Light theme default + yt-dlp search fallback
@@ -0,0 +1,52 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.studioweb.youtube_personal"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.studioweb.youtube_personal"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.␍
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.github.TeamNewPipe:NewPipeExtractor:v0.26.3")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.1.5")
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.studioweb.youtube_personal"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.studioweb.youtube_personal"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.studioweb.youtube_personal"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.studioweb.youtube_personal"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.github.TeamNewPipe:NewPipeExtractor:v0.26.3")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.1.5")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.studioweb.youtube_personal"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.studioweb.youtube_personal"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.github.TeamNewPipe:NewPipeExtractor:v0.26.3")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.1.5")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.studioweb.youtube_personal"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.studioweb.youtube_personal"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.github.TeamNewPipe:NewPipeExtractor:v0.26.3")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.1.5")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.studioweb.youtube_personal"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.studioweb.youtube_personal"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.github.TeamNewPipe:NewPipeExtractor:v0.26.3")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.1.5")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# ============================================================
|
||||
# YouTube Personal - Android Release / R8
|
||||
# Rhino / NewPipeExtractor contient des references optionnelles
|
||||
# a java.beans, API absente du runtime Android.
|
||||
# ============================================================
|
||||
|
||||
-dontwarn java.beans.**
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,74 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission
|
||||
android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:label="youtube_personal"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:resizeableActivity="true">
|
||||
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category
|
||||
android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true"
|
||||
tools:ignore="Instantiatable">
|
||||
<intent-filter>
|
||||
<action
|
||||
android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true"
|
||||
tools:ignore="Instantiatable">
|
||||
<intent-filter>
|
||||
<action
|
||||
android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action
|
||||
android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,835 @@
|
||||
package com.studioweb.youtube_personal;
|
||||
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.stream.AudioStream;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Android HD/4K extractor.
|
||||
*
|
||||
* targetHeight:
|
||||
* - 0 = Auto (meilleure qualité jusqu'à 2160p)
|
||||
* - 720 / 1080 / 1440 / 2160 = plafond demandé
|
||||
*
|
||||
* Jusqu'à 1080p, AVC/H.264 est prioritaire pour la compatibilité.
|
||||
* Au-dessus de 1080p, VP9 est prioritaire puis AV1, car les flux
|
||||
* YouTube 1440p/2160p sont généralement fournis avec ces codecs.
|
||||
*/
|
||||
public final class HdStreamExtractor {
|
||||
private static final Pattern RESOLUTION_HEIGHT_PATTERN =
|
||||
Pattern.compile("(\\d{3,4})p", Pattern.CASE_INSENSITIVE);
|
||||
private static boolean initialized = false;
|
||||
|
||||
private HdStreamExtractor() {
|
||||
}
|
||||
|
||||
private static synchronized void ensureInitialized() {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
NewPipe.init(new NewPipeDownloader());
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
public static Map<String, Object> getHdInfo(
|
||||
final String videoId,
|
||||
final int targetHeight
|
||||
) throws Exception {
|
||||
return getHdInfo(videoId, targetHeight, "auto");
|
||||
}
|
||||
|
||||
public static Map<String, Object> getHdInfo(
|
||||
final String videoId,
|
||||
final int targetHeight,
|
||||
final String preferredLanguage
|
||||
) throws Exception {
|
||||
ensureInitialized();
|
||||
|
||||
final String watchUrl =
|
||||
"https://www.youtube.com/watch?v=" + videoId;
|
||||
|
||||
final StreamInfo info =
|
||||
StreamInfo.getInfo(watchUrl);
|
||||
|
||||
final List<VideoStream> videoOnlyStreams =
|
||||
info.getVideoOnlyStreams();
|
||||
|
||||
final List<AudioStream> audioStreams =
|
||||
info.getAudioStreams();
|
||||
|
||||
final Set<String> uniqueResolutions =
|
||||
new LinkedHashSet<>();
|
||||
final Set<Integer> uniqueHeights =
|
||||
new LinkedHashSet<>();
|
||||
|
||||
for (final VideoStream stream : videoOnlyStreams) {
|
||||
if (stream == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String resolution = stream.getResolution();
|
||||
|
||||
if (resolution != null && !resolution.isBlank()) {
|
||||
uniqueResolutions.add(resolution);
|
||||
}
|
||||
|
||||
final int resolvedHeight = streamHeight(stream);
|
||||
if (resolvedHeight > 0) {
|
||||
uniqueHeights.add(resolvedHeight);
|
||||
}
|
||||
}
|
||||
|
||||
final int effectiveTarget =
|
||||
targetHeight <= 0
|
||||
? 2160
|
||||
: Math.min(targetHeight, 2160);
|
||||
|
||||
final VideoStream selectedVideo =
|
||||
selectVideo(
|
||||
videoOnlyStreams,
|
||||
effectiveTarget
|
||||
);
|
||||
|
||||
final AudioStream selectedAudio =
|
||||
selectAudio(
|
||||
audioStreams,
|
||||
preferredLanguage
|
||||
);
|
||||
|
||||
if (selectedVideo == null) {
|
||||
throw new IllegalStateException(
|
||||
"Aucun flux vidéo direct >=720p "
|
||||
+ "pour cible "
|
||||
+ effectiveTarget
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedAudio == null) {
|
||||
throw new IllegalStateException(
|
||||
"Aucun flux audio direct exploitable"
|
||||
);
|
||||
}
|
||||
|
||||
final int selectedHeight = streamHeight(selectedVideo);
|
||||
final boolean exactTargetMatched =
|
||||
targetHeight <= 0 || matchesQualityTier(selectedVideo, effectiveTarget);
|
||||
|
||||
final Map<String, Object> result =
|
||||
new LinkedHashMap<>();
|
||||
|
||||
result.put(
|
||||
"videoUrl",
|
||||
selectedVideo.getContent()
|
||||
);
|
||||
|
||||
result.put(
|
||||
"audioUrl",
|
||||
selectedAudio.getContent()
|
||||
);
|
||||
|
||||
result.put(
|
||||
"width",
|
||||
selectedVideo.getWidth()
|
||||
);
|
||||
|
||||
result.put(
|
||||
"height",
|
||||
selectedHeight
|
||||
);
|
||||
|
||||
result.put(
|
||||
"fps",
|
||||
selectedVideo.getFps()
|
||||
);
|
||||
|
||||
result.put(
|
||||
"videoCodec",
|
||||
safeString(selectedVideo.getCodec())
|
||||
);
|
||||
|
||||
result.put(
|
||||
"audioCodec",
|
||||
safeString(selectedAudio.getCodec())
|
||||
);
|
||||
|
||||
result.put(
|
||||
"videoResolution",
|
||||
selectedVideo.getResolution()
|
||||
);
|
||||
|
||||
result.put(
|
||||
"targetHeight",
|
||||
targetHeight
|
||||
);
|
||||
|
||||
result.put(
|
||||
"resolutions",
|
||||
new ArrayList<>(uniqueResolutions)
|
||||
);
|
||||
|
||||
result.put(
|
||||
"availableHeights",
|
||||
new ArrayList<>(uniqueHeights)
|
||||
);
|
||||
|
||||
result.put(
|
||||
"exactTargetMatched",
|
||||
exactTargetMatched
|
||||
);
|
||||
|
||||
result.put(
|
||||
"durationSeconds",
|
||||
info.getDuration()
|
||||
);
|
||||
|
||||
final Locale selectedAudioLocale =
|
||||
selectedAudio.getAudioLocale();
|
||||
|
||||
final String selectedAudioLanguage =
|
||||
selectedAudioLocale != null
|
||||
? safeString(selectedAudioLocale.getLanguage())
|
||||
: "und";
|
||||
|
||||
result.put(
|
||||
"audioLanguage",
|
||||
selectedAudioLanguage
|
||||
);
|
||||
|
||||
result.put(
|
||||
"audioTrackName",
|
||||
safeNullableString(
|
||||
selectedAudio.getAudioTrackName()
|
||||
)
|
||||
);
|
||||
|
||||
result.put(
|
||||
"preferredAudioMatched",
|
||||
matchesPreferredLanguage(
|
||||
selectedAudio,
|
||||
preferredLanguage
|
||||
)
|
||||
);
|
||||
|
||||
result.put(
|
||||
"availableAudioLanguages",
|
||||
collectAudioLanguages(audioStreams)
|
||||
);
|
||||
|
||||
result.put(
|
||||
"availableAudioTracks",
|
||||
collectAudioTracks(audioStreams)
|
||||
);
|
||||
|
||||
result.put(
|
||||
"originalAudioSelected",
|
||||
isOriginalAudioTrack(selectedAudio)
|
||||
);
|
||||
|
||||
result.put(
|
||||
"source",
|
||||
"NewPipeExtractor 0.26.3 / direct video+audio / original-audio-first / manual-audio-track / preferred-language / aspect-aware-1440-2160 18.7.26"
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static VideoStream selectVideo(
|
||||
final List<VideoStream> streams,
|
||||
final int targetHeight
|
||||
) {
|
||||
if (streams == null || streams.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<VideoStream> usable =
|
||||
new ArrayList<>();
|
||||
|
||||
for (final VideoStream stream : streams) {
|
||||
final int height = streamHeight(stream);
|
||||
|
||||
if (stream == null
|
||||
|| !stream.isUrl()
|
||||
|| !stream.isVideoOnly()
|
||||
|| stream.getContent() == null
|
||||
|| stream.getContent().isBlank()
|
||||
|| qualityTier(stream) < 720
|
||||
|| qualityTier(stream) > targetHeight) {
|
||||
continue;
|
||||
}
|
||||
|
||||
usable.add(stream);
|
||||
}
|
||||
|
||||
if (usable.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// YouTube peut exposer un palier 1440p/2160p avec une hauteur
|
||||
// différente sur les vidéos non 16:9 (ex. 2560x1350, 3840x2026).
|
||||
// On reconnaît donc le palier par les dimensions réelles.
|
||||
final List<VideoStream> exactTarget =
|
||||
new ArrayList<>();
|
||||
|
||||
for (final VideoStream stream : usable) {
|
||||
if (matchesQualityTier(stream, targetHeight)) {
|
||||
exactTarget.add(stream);
|
||||
}
|
||||
}
|
||||
|
||||
final List<VideoStream> candidates;
|
||||
if (!exactTarget.isEmpty()) {
|
||||
candidates = exactTarget;
|
||||
} else {
|
||||
final int bestHeight =
|
||||
usable.stream()
|
||||
.mapToInt(HdStreamExtractor::streamHeight)
|
||||
.max()
|
||||
.orElse(0);
|
||||
|
||||
candidates = new ArrayList<>();
|
||||
|
||||
for (final VideoStream stream : usable) {
|
||||
if (streamHeight(stream) == bestHeight) {
|
||||
candidates.add(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final int selectedHeight =
|
||||
candidates.stream()
|
||||
.mapToInt(HdStreamExtractor::streamHeight)
|
||||
.max()
|
||||
.orElse(0);
|
||||
|
||||
if (selectedHeight <= 1080) {
|
||||
final VideoStream avc =
|
||||
bestWithCodec(
|
||||
candidates,
|
||||
"avc"
|
||||
);
|
||||
|
||||
if (avc != null) {
|
||||
return avc;
|
||||
}
|
||||
} else {
|
||||
// 1440p/2160p YouTube sont généralement VP9 ou AV1.
|
||||
final VideoStream vp9 =
|
||||
bestWithCodec(
|
||||
candidates,
|
||||
"vp9"
|
||||
);
|
||||
|
||||
if (vp9 != null) {
|
||||
return vp9;
|
||||
}
|
||||
|
||||
final VideoStream av1 =
|
||||
bestWithCodec(
|
||||
candidates,
|
||||
"av1"
|
||||
);
|
||||
|
||||
if (av1 != null) {
|
||||
return av1;
|
||||
}
|
||||
|
||||
final VideoStream avc =
|
||||
bestWithCodec(
|
||||
candidates,
|
||||
"avc"
|
||||
);
|
||||
|
||||
if (avc != null) {
|
||||
return avc;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.stream()
|
||||
.max(videoComparator())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static int streamHeight(final VideoStream stream) {
|
||||
if (stream == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final int nativeHeight = stream.getHeight();
|
||||
final String resolution = stream.getResolution();
|
||||
|
||||
if (resolution != null && !resolution.isBlank()) {
|
||||
final Matcher matcher =
|
||||
RESOLUTION_HEIGHT_PATTERN.matcher(resolution);
|
||||
|
||||
if (matcher.find()) {
|
||||
try {
|
||||
final int parsed =
|
||||
Integer.parseInt(matcher.group(1));
|
||||
|
||||
// Certaines versions/extracteurs peuvent fournir une
|
||||
// hauteur native incohérente alors que le libellé
|
||||
// "1440p"/"2160p" est correct. Pour la sélection de
|
||||
// qualité on privilégie la hauteur explicite du libellé.
|
||||
if (parsed >= 240) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Fallback vers getHeight().
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nativeHeight;
|
||||
}
|
||||
|
||||
private static int qualityTier(final VideoStream stream) {
|
||||
if (stream == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final int width = Math.max(0, stream.getWidth());
|
||||
final int height = Math.max(0, streamHeight(stream));
|
||||
final int nativeHeight = Math.max(0, stream.getHeight());
|
||||
final int longest = Math.max(width, Math.max(height, nativeHeight));
|
||||
|
||||
if (longest >= 3800 || height >= 2000) {
|
||||
return 2160;
|
||||
}
|
||||
if (longest >= 2500 || height >= 1300) {
|
||||
return 1440;
|
||||
}
|
||||
if (longest >= 1900 || height >= 1000) {
|
||||
return 1080;
|
||||
}
|
||||
if (longest >= 1200 || height >= 650) {
|
||||
return 720;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
private static boolean matchesQualityTier(
|
||||
final VideoStream stream,
|
||||
final int targetHeight
|
||||
) {
|
||||
return qualityTier(stream) == targetHeight;
|
||||
}
|
||||
|
||||
private static VideoStream bestWithCodec(
|
||||
final List<VideoStream> streams,
|
||||
final String codecFamily
|
||||
) {
|
||||
return streams.stream()
|
||||
.filter(stream ->
|
||||
codecMatches(
|
||||
stream.getCodec(),
|
||||
codecFamily
|
||||
))
|
||||
.max(videoComparator())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static Comparator<VideoStream> videoComparator() {
|
||||
return Comparator
|
||||
.comparingInt(VideoStream::getFps)
|
||||
.thenComparingInt(VideoStream::getBitrate);
|
||||
}
|
||||
|
||||
private static AudioStream selectAudio(
|
||||
final List<AudioStream> streams,
|
||||
final String preferredLanguage
|
||||
) {
|
||||
if (streams == null || streams.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<AudioStream> usable =
|
||||
new ArrayList<>();
|
||||
|
||||
for (final AudioStream stream : streams) {
|
||||
if (stream == null
|
||||
|| !stream.isUrl()
|
||||
|| stream.getContent() == null
|
||||
|| stream.getContent().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
usable.add(stream);
|
||||
}
|
||||
|
||||
if (usable.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final String normalizedLanguage =
|
||||
normalizeLanguageCode(preferredLanguage);
|
||||
|
||||
if (!"auto".equals(normalizedLanguage)) {
|
||||
final List<AudioStream> preferred =
|
||||
new ArrayList<>();
|
||||
|
||||
for (final AudioStream stream : usable) {
|
||||
if (matchesPreferredLanguage(
|
||||
stream,
|
||||
normalizedLanguage
|
||||
)) {
|
||||
preferred.add(stream);
|
||||
}
|
||||
}
|
||||
|
||||
if (!preferred.isEmpty()) {
|
||||
return selectBestAudio(preferred);
|
||||
}
|
||||
}
|
||||
|
||||
// 18.7.25 : en mode Auto, la piste originale passe avant les
|
||||
// doublages automatiques. NewPipeExtractor expose le type de piste
|
||||
// audio sur les vidéos multilingues récentes de YouTube.
|
||||
final List<AudioStream> originals =
|
||||
new ArrayList<>();
|
||||
|
||||
for (final AudioStream stream : usable) {
|
||||
if (isOriginalAudioTrack(stream)) {
|
||||
originals.add(stream);
|
||||
}
|
||||
}
|
||||
|
||||
if (!originals.isEmpty()) {
|
||||
return selectBestAudio(originals);
|
||||
}
|
||||
|
||||
return selectBestAudio(usable);
|
||||
}
|
||||
|
||||
private static boolean isOriginalAudioTrack(
|
||||
final AudioStream stream
|
||||
) {
|
||||
if (stream == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final Object trackType = stream.getAudioTrackType();
|
||||
if (trackType != null) {
|
||||
final String typeText = trackType.toString()
|
||||
.toLowerCase(Locale.ROOT);
|
||||
if (typeText.contains("original")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (final Throwable ignored) {
|
||||
// Compatibilité défensive : le nom de piste reste un fallback.
|
||||
}
|
||||
|
||||
final String trackName = stream.getAudioTrackName();
|
||||
if (trackName == null || trackName.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final String name = trackName.toLowerCase(Locale.ROOT);
|
||||
return name.contains("original")
|
||||
|| name.contains("الأصلية")
|
||||
|| name.contains("أصلي")
|
||||
|| name.contains("اصلي");
|
||||
}
|
||||
|
||||
private static AudioStream selectBestAudio(
|
||||
final List<AudioStream> streams
|
||||
) {
|
||||
final AudioStream aac =
|
||||
streams.stream()
|
||||
.filter(stream ->
|
||||
codecMatches(
|
||||
stream.getCodec(),
|
||||
"aac"
|
||||
))
|
||||
.max(audioComparator())
|
||||
.orElse(null);
|
||||
|
||||
if (aac != null) {
|
||||
return aac;
|
||||
}
|
||||
|
||||
return streams.stream()
|
||||
.max(audioComparator())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static boolean matchesPreferredLanguage(
|
||||
final AudioStream stream,
|
||||
final String preferredLanguage
|
||||
) {
|
||||
final String normalized =
|
||||
normalizeLanguageCode(preferredLanguage);
|
||||
|
||||
if ("auto".equals(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final Locale locale = stream.getAudioLocale();
|
||||
|
||||
if (locale != null) {
|
||||
final String streamLanguage =
|
||||
normalizeLanguageCode(locale.getLanguage());
|
||||
|
||||
if (normalized.equals(streamLanguage)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
final String trackName =
|
||||
stream.getAudioTrackName();
|
||||
|
||||
if (trackName == null || trackName.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final String name =
|
||||
trackName.toLowerCase(Locale.ROOT);
|
||||
|
||||
switch (normalized) {
|
||||
case "fr":
|
||||
return name.contains("français")
|
||||
|| name.contains("francais")
|
||||
|| name.contains("french")
|
||||
|| name.contains("vf");
|
||||
|
||||
case "en":
|
||||
return name.contains("english")
|
||||
|| name.contains("anglais");
|
||||
|
||||
case "ar":
|
||||
return name.contains("arabic")
|
||||
|| name.contains("arabe")
|
||||
|| name.contains("عربي");
|
||||
|
||||
case "de":
|
||||
return name.contains("deutsch")
|
||||
|| name.contains("german")
|
||||
|| name.contains("allemand");
|
||||
|
||||
case "es":
|
||||
return name.contains("español")
|
||||
|| name.contains("espanol")
|
||||
|| name.contains("spanish")
|
||||
|| name.contains("espagnol");
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeLanguageCode(
|
||||
final String language
|
||||
) {
|
||||
if (language == null || language.isBlank()) {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
String normalized =
|
||||
language.trim().toLowerCase(Locale.ROOT);
|
||||
|
||||
final int hyphen = normalized.indexOf('-');
|
||||
if (hyphen > 0) {
|
||||
normalized = normalized.substring(0, hyphen);
|
||||
}
|
||||
|
||||
final int underscore = normalized.indexOf('_');
|
||||
if (underscore > 0) {
|
||||
normalized = normalized.substring(0, underscore);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> collectAudioTracks(
|
||||
final List<AudioStream> streams
|
||||
) {
|
||||
final Map<String, Map<String, Object>> byKey =
|
||||
new LinkedHashMap<>();
|
||||
|
||||
if (streams == null) {
|
||||
return new ArrayList<>(byKey.values());
|
||||
}
|
||||
|
||||
for (final AudioStream stream : streams) {
|
||||
if (stream == null
|
||||
|| !stream.isUrl()
|
||||
|| stream.getContent() == null
|
||||
|| stream.getContent().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final boolean original = isOriginalAudioTrack(stream);
|
||||
final Locale locale = stream.getAudioLocale();
|
||||
final String language = locale != null
|
||||
? normalizeLanguageCode(locale.getLanguage())
|
||||
: inferLanguageFromTrackName(stream.getAudioTrackName());
|
||||
|
||||
final String key;
|
||||
if (original) {
|
||||
key = "original";
|
||||
} else if (!"auto".equals(language)) {
|
||||
key = language;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (byKey.containsKey(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("key", key);
|
||||
item.put("language", "auto".equals(language) ? "und" : language);
|
||||
|
||||
final String trackName = stream.getAudioTrackName();
|
||||
if (trackName != null && !trackName.isBlank()) {
|
||||
item.put("label", trackName);
|
||||
} else if (original) {
|
||||
item.put("label", "Original");
|
||||
} else {
|
||||
item.put("label", language.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
item.put("original", original);
|
||||
byKey.put(key, item);
|
||||
}
|
||||
|
||||
return new ArrayList<>(byKey.values());
|
||||
}
|
||||
|
||||
private static String inferLanguageFromTrackName(
|
||||
final String trackName
|
||||
) {
|
||||
if (trackName == null || trackName.isBlank()) {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
final String name = trackName.toLowerCase(Locale.ROOT);
|
||||
|
||||
if (name.contains("arabic")
|
||||
|| name.contains("arabe")
|
||||
|| name.contains("عربي")) {
|
||||
return "ar";
|
||||
}
|
||||
if (name.contains("english") || name.contains("anglais")) {
|
||||
return "en";
|
||||
}
|
||||
if (name.contains("français")
|
||||
|| name.contains("francais")
|
||||
|| name.contains("french")) {
|
||||
return "fr";
|
||||
}
|
||||
if (name.contains("deutsch")
|
||||
|| name.contains("german")
|
||||
|| name.contains("allemand")) {
|
||||
return "de";
|
||||
}
|
||||
if (name.contains("español")
|
||||
|| name.contains("espanol")
|
||||
|| name.contains("spanish")
|
||||
|| name.contains("espagnol")) {
|
||||
return "es";
|
||||
}
|
||||
|
||||
return "auto";
|
||||
}
|
||||
|
||||
private static List<String> collectAudioLanguages(
|
||||
final List<AudioStream> streams
|
||||
) {
|
||||
final Set<String> languages =
|
||||
new LinkedHashSet<>();
|
||||
|
||||
if (streams == null) {
|
||||
return new ArrayList<>(languages);
|
||||
}
|
||||
|
||||
for (final AudioStream stream : streams) {
|
||||
if (stream == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final Locale locale = stream.getAudioLocale();
|
||||
final String trackName = stream.getAudioTrackName();
|
||||
|
||||
if (locale != null
|
||||
&& locale.getLanguage() != null
|
||||
&& !locale.getLanguage().isBlank()) {
|
||||
languages.add(locale.getLanguage());
|
||||
} else if (trackName != null && !trackName.isBlank()) {
|
||||
languages.add(trackName);
|
||||
}
|
||||
}
|
||||
|
||||
return new ArrayList<>(languages);
|
||||
}
|
||||
|
||||
private static Comparator<AudioStream> audioComparator() {
|
||||
return Comparator.comparingInt(
|
||||
stream -> Math.max(
|
||||
stream.getBitrate(),
|
||||
stream.getAverageBitrate()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean codecMatches(
|
||||
final String codec,
|
||||
final String family
|
||||
) {
|
||||
if (codec == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final String value =
|
||||
codec.toLowerCase(Locale.ROOT);
|
||||
|
||||
switch (family) {
|
||||
case "avc":
|
||||
return value.contains("avc")
|
||||
|| value.contains("h264")
|
||||
|| value.contains("h.264");
|
||||
|
||||
case "vp9":
|
||||
return value.contains("vp9")
|
||||
|| value.contains("vp09");
|
||||
|
||||
case "av1":
|
||||
return value.contains("av01")
|
||||
|| value.contains("av1");
|
||||
|
||||
case "aac":
|
||||
return value.contains("mp4a")
|
||||
|| value.contains("aac");
|
||||
|
||||
default:
|
||||
return value.contains(family);
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeNullableString(final String value) {
|
||||
return value == null || value.isBlank()
|
||||
? ""
|
||||
: value;
|
||||
}
|
||||
|
||||
private static String safeString(final String value) {
|
||||
return value == null || value.isBlank()
|
||||
? "inconnu"
|
||||
: value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
package com.studioweb.youtube_personal;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.PictureInPictureParams;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.Configuration;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.provider.Settings;
|
||||
import android.util.Rational;
|
||||
import android.util.Log;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.ryanheise.audioservice.AudioServiceActivity;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
||||
/**
|
||||
* MainActivity Java.
|
||||
*
|
||||
* Cette version évite de dépendre de la compilation Kotlin du module app.
|
||||
* Elle conserve:
|
||||
* - PiP Android
|
||||
* - permission notifications
|
||||
* - détection écran ON/OFF
|
||||
* - audio_service
|
||||
* - canal HD natif
|
||||
* - volume/luminosité par gestes
|
||||
* - contrôle KEEP_SCREEN_ON pour la mise en veille
|
||||
*/
|
||||
public class MainActivity extends AudioServiceActivity {
|
||||
private static final String PIP_CHANNEL_NAME =
|
||||
"com.studioweb.youtube_personal/pip";
|
||||
|
||||
private static final String HD_CHANNEL_NAME =
|
||||
"com.studioweb.youtube_personal/hd";
|
||||
|
||||
private static final String DEVICE_CHANNEL_NAME =
|
||||
"com.studioweb.youtube_personal/device";
|
||||
|
||||
private static final String CACHE_CHANNEL_NAME =
|
||||
"com.studioweb.youtube_personal/cache";
|
||||
|
||||
private static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 7201;
|
||||
|
||||
private MethodChannel pipChannel;
|
||||
private MethodChannel hdChannel;
|
||||
private MethodChannel deviceChannel;
|
||||
private MethodChannel cacheChannel;
|
||||
|
||||
private final ExecutorService hdExecutor =
|
||||
Executors.newSingleThreadExecutor();
|
||||
|
||||
private MethodChannel.Result pendingNotificationPermissionResult;
|
||||
|
||||
private boolean screenReceiverRegistered = false;
|
||||
|
||||
private final Handler screenPolicyHandler =
|
||||
new Handler(Looper.getMainLooper());
|
||||
private Runnable pauseSleepRunnable;
|
||||
|
||||
private final BroadcastReceiver screenReceiver =
|
||||
new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(
|
||||
Context context,
|
||||
Intent intent
|
||||
) {
|
||||
if (intent == null || pipChannel == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String action = intent.getAction();
|
||||
|
||||
if (Intent.ACTION_SCREEN_OFF.equals(action)) {
|
||||
pipChannel.invokeMethod(
|
||||
"screenInteractiveChanged",
|
||||
false
|
||||
);
|
||||
} else if (Intent.ACTION_SCREEN_ON.equals(action)) {
|
||||
pipChannel.invokeMethod(
|
||||
"screenInteractiveChanged",
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void configureFlutterEngine(
|
||||
@NonNull FlutterEngine flutterEngine
|
||||
) {
|
||||
super.configureFlutterEngine(flutterEngine);
|
||||
|
||||
pipChannel = new MethodChannel(
|
||||
flutterEngine
|
||||
.getDartExecutor()
|
||||
.getBinaryMessenger(),
|
||||
PIP_CHANNEL_NAME
|
||||
);
|
||||
|
||||
pipChannel.setMethodCallHandler(
|
||||
(MethodCall call, MethodChannel.Result result) -> {
|
||||
switch (call.method) {
|
||||
case "enterPip":
|
||||
enterPip(result);
|
||||
break;
|
||||
|
||||
case "requestNotificationPermission":
|
||||
requestNotificationPermission(result);
|
||||
break;
|
||||
|
||||
case "getOrientation":
|
||||
result.success(getOrientationLabel(getResources().getConfiguration()));
|
||||
break;
|
||||
|
||||
default:
|
||||
result.notImplemented();
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
hdChannel = new MethodChannel(
|
||||
flutterEngine
|
||||
.getDartExecutor()
|
||||
.getBinaryMessenger(),
|
||||
HD_CHANNEL_NAME
|
||||
);
|
||||
|
||||
hdChannel.setMethodCallHandler(
|
||||
(MethodCall call, MethodChannel.Result result) -> {
|
||||
if (!"getHdStream".equals(call.method)) {
|
||||
result.notImplemented();
|
||||
return;
|
||||
}
|
||||
|
||||
final String videoId = call.argument("videoId");
|
||||
final Integer targetHeightArgument =
|
||||
call.argument("targetHeight");
|
||||
final String preferredLanguageArgument =
|
||||
call.argument("preferredLanguage");
|
||||
|
||||
final int targetHeight =
|
||||
targetHeightArgument != null
|
||||
? targetHeightArgument
|
||||
: 1080;
|
||||
|
||||
final String preferredLanguage =
|
||||
preferredLanguageArgument != null
|
||||
&& !preferredLanguageArgument.trim().isEmpty()
|
||||
? preferredLanguageArgument.trim()
|
||||
: "auto";
|
||||
|
||||
if (videoId == null || videoId.trim().isEmpty()) {
|
||||
result.error(
|
||||
"HD_BAD_VIDEO_ID",
|
||||
"videoId manquant",
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
hdExecutor.execute(() -> {
|
||||
try {
|
||||
final Map<String, Object> hdInfo =
|
||||
HdStreamExtractor.getHdInfo(
|
||||
videoId,
|
||||
targetHeight,
|
||||
preferredLanguage
|
||||
);
|
||||
|
||||
runOnUiThread(
|
||||
() -> result.success(hdInfo)
|
||||
);
|
||||
} catch (Throwable error) {
|
||||
final String message =
|
||||
error.getMessage() != null
|
||||
? error.getMessage()
|
||||
: error.getClass()
|
||||
.getSimpleName();
|
||||
|
||||
runOnUiThread(
|
||||
() -> result.error(
|
||||
"HD_EXTRACTION_FAILED",
|
||||
message,
|
||||
error.getClass().getName()
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
deviceChannel = new MethodChannel(
|
||||
flutterEngine
|
||||
.getDartExecutor()
|
||||
.getBinaryMessenger(),
|
||||
DEVICE_CHANNEL_NAME
|
||||
);
|
||||
|
||||
deviceChannel.setMethodCallHandler(
|
||||
(MethodCall call, MethodChannel.Result result) -> {
|
||||
switch (call.method) {
|
||||
case "getSystemVolume":
|
||||
result.success(getSystemVolume());
|
||||
break;
|
||||
|
||||
case "setSystemVolume":
|
||||
final Number volume = call.argument("value");
|
||||
if (volume == null) {
|
||||
result.error(
|
||||
"BAD_VOLUME",
|
||||
"Valeur de volume manquante",
|
||||
null
|
||||
);
|
||||
break;
|
||||
}
|
||||
setSystemVolume(volume.doubleValue());
|
||||
result.success(true);
|
||||
break;
|
||||
|
||||
case "getWindowBrightness":
|
||||
result.success(getWindowBrightness());
|
||||
break;
|
||||
|
||||
case "setWindowBrightness":
|
||||
final Number brightness = call.argument("value");
|
||||
if (brightness == null) {
|
||||
result.error(
|
||||
"BAD_BRIGHTNESS",
|
||||
"Valeur de luminosité manquante",
|
||||
null
|
||||
);
|
||||
break;
|
||||
}
|
||||
setWindowBrightness(brightness.doubleValue());
|
||||
result.success(true);
|
||||
break;
|
||||
|
||||
case "resetWindowBrightness":
|
||||
resetWindowBrightness();
|
||||
result.success(true);
|
||||
break;
|
||||
|
||||
case "setKeepScreenOn":
|
||||
final Boolean keepOn = call.argument("value");
|
||||
setKeepScreenOn(keepOn != null && keepOn);
|
||||
result.success(true);
|
||||
break;
|
||||
|
||||
case "schedulePauseScreenSleep":
|
||||
final Number delayMs = call.argument("milliseconds");
|
||||
schedulePauseScreenSleep(
|
||||
delayMs != null
|
||||
? delayMs.longValue()
|
||||
: 0L
|
||||
);
|
||||
result.success(true);
|
||||
break;
|
||||
|
||||
default:
|
||||
result.notImplemented();
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
cacheChannel = new MethodChannel(
|
||||
flutterEngine
|
||||
.getDartExecutor()
|
||||
.getBinaryMessenger(),
|
||||
CACHE_CHANNEL_NAME
|
||||
);
|
||||
|
||||
cacheChannel.setMethodCallHandler(
|
||||
(MethodCall call, MethodChannel.Result result) -> {
|
||||
switch (call.method) {
|
||||
case "getCachePaths":
|
||||
result.success(getMediaCachePaths());
|
||||
break;
|
||||
|
||||
case "getCacheSize":
|
||||
final Integer sizeMode = call.argument("mode");
|
||||
result.success(
|
||||
directorySize(
|
||||
getMediaCacheDirectory(
|
||||
sizeMode != null ? sizeMode : 1
|
||||
)
|
||||
)
|
||||
);
|
||||
break;
|
||||
|
||||
case "clearCache":
|
||||
final Integer clearMode = call.argument("mode");
|
||||
clearMediaCache(
|
||||
clearMode != null ? clearMode : 1
|
||||
);
|
||||
result.success(true);
|
||||
break;
|
||||
|
||||
default:
|
||||
result.notImplemented();
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
registerScreenReceiver();
|
||||
}
|
||||
|
||||
private Map<String, String> getMediaCachePaths() {
|
||||
final Map<String, String> result = new LinkedHashMap<>();
|
||||
result.put(
|
||||
"internal",
|
||||
getMediaCacheDirectory(1).getAbsolutePath()
|
||||
);
|
||||
result.put(
|
||||
"external",
|
||||
getMediaCacheDirectory(2).getAbsolutePath()
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private File getMediaCacheDirectory(int mode) {
|
||||
File base;
|
||||
|
||||
if (mode == 2) {
|
||||
base = getExternalCacheDir();
|
||||
if (base == null) {
|
||||
base = getCacheDir();
|
||||
}
|
||||
} else {
|
||||
base = getCacheDir();
|
||||
}
|
||||
|
||||
final File directory = new File(
|
||||
base,
|
||||
"youtube_personal_media"
|
||||
);
|
||||
|
||||
if (!directory.exists()) {
|
||||
directory.mkdirs();
|
||||
}
|
||||
|
||||
return directory;
|
||||
}
|
||||
|
||||
private long directorySize(File file) {
|
||||
if (file == null || !file.exists()) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
if (file.isFile()) {
|
||||
return file.length();
|
||||
}
|
||||
|
||||
long total = 0L;
|
||||
final File[] children = file.listFiles();
|
||||
if (children == null) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
for (final File child : children) {
|
||||
total += directorySize(child);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private void clearMediaCache(int mode) {
|
||||
final File directory = getMediaCacheDirectory(mode);
|
||||
final File[] children = directory.listFiles();
|
||||
|
||||
if (children == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final File child : children) {
|
||||
deleteRecursively(child);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteRecursively(File file) {
|
||||
if (file == null || !file.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.isDirectory()) {
|
||||
final File[] children = file.listFiles();
|
||||
if (children != null) {
|
||||
for (final File child : children) {
|
||||
deleteRecursively(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file.delete();
|
||||
}
|
||||
|
||||
private double getSystemVolume() {
|
||||
final AudioManager audioManager =
|
||||
(AudioManager) getSystemService(Context.AUDIO_SERVICE);
|
||||
|
||||
if (audioManager == null) {
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
final int max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
|
||||
if (max <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (double) audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
/ (double) max;
|
||||
}
|
||||
|
||||
private void setSystemVolume(double value) {
|
||||
final AudioManager audioManager =
|
||||
(AudioManager) getSystemService(Context.AUDIO_SERVICE);
|
||||
|
||||
if (audioManager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final double clamped = Math.max(0.0, Math.min(1.0, value));
|
||||
final int max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
|
||||
final int next = (int) Math.round(clamped * max);
|
||||
|
||||
audioManager.setStreamVolume(
|
||||
AudioManager.STREAM_MUSIC,
|
||||
next,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
private double getWindowBrightness() {
|
||||
final WindowManager.LayoutParams attributes =
|
||||
getWindow().getAttributes();
|
||||
|
||||
if (attributes.screenBrightness >= 0.0f) {
|
||||
return Math.max(0.0, Math.min(1.0, attributes.screenBrightness));
|
||||
}
|
||||
|
||||
try {
|
||||
final int systemBrightness = Settings.System.getInt(
|
||||
getContentResolver(),
|
||||
Settings.System.SCREEN_BRIGHTNESS
|
||||
);
|
||||
return Math.max(0.0, Math.min(1.0, systemBrightness / 255.0));
|
||||
} catch (Settings.SettingNotFoundException ignored) {
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
private void setWindowBrightness(double value) {
|
||||
final double clamped = Math.max(0.02, Math.min(1.0, value));
|
||||
final WindowManager.LayoutParams attributes =
|
||||
getWindow().getAttributes();
|
||||
attributes.screenBrightness = (float) clamped;
|
||||
getWindow().setAttributes(attributes);
|
||||
}
|
||||
|
||||
private void resetWindowBrightness() {
|
||||
final WindowManager.LayoutParams attributes =
|
||||
getWindow().getAttributes();
|
||||
attributes.screenBrightness =
|
||||
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE;
|
||||
getWindow().setAttributes(attributes);
|
||||
}
|
||||
|
||||
private void setKeepScreenOn(boolean keepOn) {
|
||||
cancelPauseScreenSleep();
|
||||
applyKeepScreenOn(keepOn);
|
||||
}
|
||||
|
||||
private void applyKeepScreenOn(boolean keepOn) {
|
||||
if (keepOn) {
|
||||
getWindow().addFlags(
|
||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
||||
);
|
||||
getWindow().getDecorView().setKeepScreenOn(true);
|
||||
} else {
|
||||
getWindow().getDecorView().setKeepScreenOn(false);
|
||||
getWindow().clearFlags(
|
||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void schedulePauseScreenSleep(long delayMs) {
|
||||
cancelPauseScreenSleep();
|
||||
|
||||
if (delayMs <= 0L) {
|
||||
applyKeepScreenOn(true);
|
||||
Log.i(
|
||||
"YouTubePersonal",
|
||||
"[ANDROID-SCREEN][18.7.19] pause: veille désactivée"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
applyKeepScreenOn(true);
|
||||
|
||||
pauseSleepRunnable = () -> {
|
||||
applyKeepScreenOn(false);
|
||||
pauseSleepRunnable = null;
|
||||
|
||||
Log.i(
|
||||
"YouTubePersonal",
|
||||
"[ANDROID-SCREEN][18.7.19] délai pause écoulé -> keepOn=false"
|
||||
);
|
||||
|
||||
if (pipChannel != null) {
|
||||
pipChannel.invokeMethod(
|
||||
"pauseScreenSleepReleased",
|
||||
true
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
screenPolicyHandler.postDelayed(
|
||||
pauseSleepRunnable,
|
||||
delayMs
|
||||
);
|
||||
|
||||
Log.i(
|
||||
"YouTubePersonal",
|
||||
"[ANDROID-SCREEN][18.7.19] timer pause=" + delayMs + "ms"
|
||||
);
|
||||
}
|
||||
|
||||
private void cancelPauseScreenSleep() {
|
||||
if (pauseSleepRunnable == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
screenPolicyHandler.removeCallbacks(pauseSleepRunnable);
|
||||
pauseSleepRunnable = null;
|
||||
}
|
||||
|
||||
private String getOrientationLabel(Configuration configuration) {
|
||||
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
? "landscape"
|
||||
: "portrait";
|
||||
}
|
||||
|
||||
private void enterPip(MethodChannel.Result result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
result.success(false);
|
||||
return;
|
||||
}
|
||||
|
||||
final PictureInPictureParams params =
|
||||
new PictureInPictureParams.Builder()
|
||||
.setAspectRatio(new Rational(16, 9))
|
||||
.build();
|
||||
|
||||
result.success(
|
||||
enterPictureInPictureMode(params)
|
||||
);
|
||||
}
|
||||
|
||||
private void registerScreenReceiver() {
|
||||
if (screenReceiverRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
final IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(Intent.ACTION_SCREEN_OFF);
|
||||
filter.addAction(Intent.ACTION_SCREEN_ON);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(
|
||||
screenReceiver,
|
||||
filter,
|
||||
Context.RECEIVER_NOT_EXPORTED
|
||||
);
|
||||
} else {
|
||||
registerReceiver(
|
||||
screenReceiver,
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
screenReceiverRegistered = true;
|
||||
}
|
||||
|
||||
private void requestNotificationPermission(
|
||||
MethodChannel.Result result
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
result.success(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
checkSelfPermission(
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
result.success(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingNotificationPermissionResult != null) {
|
||||
result.success(false);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingNotificationPermissionResult = result;
|
||||
|
||||
requestPermissions(
|
||||
new String[] {
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
},
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(
|
||||
int requestCode,
|
||||
@NonNull String[] permissions,
|
||||
@NonNull int[] grantResults
|
||||
) {
|
||||
super.onRequestPermissionsResult(
|
||||
requestCode,
|
||||
permissions,
|
||||
grantResults
|
||||
);
|
||||
|
||||
if (
|
||||
requestCode
|
||||
!= NOTIFICATION_PERMISSION_REQUEST_CODE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
final boolean granted =
|
||||
grantResults.length > 0
|
||||
&& grantResults[0]
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
|
||||
if (pendingNotificationPermissionResult != null) {
|
||||
pendingNotificationPermissionResult.success(granted);
|
||||
pendingNotificationPermissionResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(
|
||||
@NonNull Configuration newConfig
|
||||
) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
|
||||
if (pipChannel != null) {
|
||||
final String orientation = getOrientationLabel(newConfig);
|
||||
pipChannel.invokeMethod(
|
||||
"orientationChanged",
|
||||
orientation
|
||||
);
|
||||
Log.i(
|
||||
"YouTubePersonal",
|
||||
"[ANDROID-ORIENTATION][18.7.19] " + orientation
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPictureInPictureModeChanged(
|
||||
boolean isInPictureInPictureMode,
|
||||
@NonNull Configuration newConfig
|
||||
) {
|
||||
super.onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode,
|
||||
newConfig
|
||||
);
|
||||
|
||||
if (pipChannel != null) {
|
||||
pipChannel.invokeMethod(
|
||||
"pipChanged",
|
||||
isInPictureInPictureMode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
cancelPauseScreenSleep();
|
||||
applyKeepScreenOn(false);
|
||||
resetWindowBrightness();
|
||||
if (screenReceiverRegistered) {
|
||||
try {
|
||||
unregisterReceiver(screenReceiver);
|
||||
} catch (Exception ignored) {
|
||||
// Receiver déjà détaché.
|
||||
}
|
||||
|
||||
screenReceiverRegistered = false;
|
||||
}
|
||||
|
||||
pipChannel = null;
|
||||
hdChannel = null;
|
||||
deviceChannel = null;
|
||||
|
||||
hdExecutor.shutdownNow();
|
||||
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.studioweb.youtube_personal;
|
||||
|
||||
import org.schabi.newpipe.extractor.downloader.Downloader;
|
||||
import org.schabi.newpipe.extractor.downloader.Request;
|
||||
import org.schabi.newpipe.extractor.downloader.Response;
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
/**
|
||||
* Minimal downloader required by NewPipeExtractor.
|
||||
*
|
||||
* No Google account cookie and no OAuth token is injected here.
|
||||
* The authenticated YouTube Data API path remains completely separate.
|
||||
*/
|
||||
public final class NewPipeDownloader extends Downloader {
|
||||
private static final String USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) "
|
||||
+ "Gecko/20100101 Firefox/140.0";
|
||||
|
||||
private final OkHttpClient client = new OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public Response execute(final Request request)
|
||||
throws IOException, ReCaptchaException {
|
||||
final String httpMethod = request.httpMethod();
|
||||
final String url = request.url();
|
||||
final Map<String, List<String>> headers =
|
||||
request.headers();
|
||||
final byte[] dataToSend = request.dataToSend();
|
||||
|
||||
RequestBody requestBody = null;
|
||||
if (dataToSend != null) {
|
||||
requestBody = RequestBody.create(dataToSend);
|
||||
}
|
||||
|
||||
final okhttp3.Request.Builder builder =
|
||||
new okhttp3.Request.Builder()
|
||||
.method(httpMethod, requestBody)
|
||||
.url(url)
|
||||
.addHeader("User-Agent", USER_AGENT);
|
||||
|
||||
headers.forEach((name, values) -> {
|
||||
builder.removeHeader(name);
|
||||
for (final String value : values) {
|
||||
builder.addHeader(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
try (okhttp3.Response response =
|
||||
client.newCall(builder.build()).execute()) {
|
||||
if (response.code() == 429) {
|
||||
throw new ReCaptchaException(
|
||||
"YouTube rate-limit / challenge",
|
||||
url
|
||||
);
|
||||
}
|
||||
|
||||
String responseBody = null;
|
||||
|
||||
final ResponseBody body = response.body();
|
||||
if (body != null) {
|
||||
responseBody = body.string();
|
||||
}
|
||||
|
||||
return new Response(
|
||||
response.code(),
|
||||
response.message(),
|
||||
response.headers().toMultimap(),
|
||||
responseBody,
|
||||
response.request().url().toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.studioweb.youtube_personal
|
||||
|
||||
/*
|
||||
* MainActivity a été déplacée vers:
|
||||
*
|
||||
* android/app/src/main/java/com/studioweb/youtube_personal/MainActivity.java
|
||||
*
|
||||
* Le module Android actuel n'incluait pas MainActivity.kt dans l'APK.
|
||||
* Ce fichier reste volontairement sans classe MainActivity pour éviter
|
||||
* tout doublon si la compilation Kotlin est réactivée plus tard.
|
||||
*/
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||