commit 7704cae4a0647aa05dc447897fc39d44aaceae43 Author: iliasmouhcine Date: Sat Aug 22 13:02:32 2026 +0100 Light theme default + yt-dlp search fallback diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f0d006 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..fa7b41d --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d7b523b356d15fb81e7d340bbe52b47f93937323" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + - platform: linux + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/APPLIQUER_FIX_FILE_PICKER_12.ps1 b/APPLIQUER_FIX_FILE_PICKER_12.ps1 new file mode 100644 index 0000000..855ee34 --- /dev/null +++ b/APPLIQUER_FIX_FILE_PICKER_12.ps1 @@ -0,0 +1,84 @@ +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host "=== YouTube Personal - Fix File Picker Android 18.10.1 ===" -ForegroundColor Cyan +Write-Host "Projet : $PWD" +Write-Host "" + +if (-not (Test-Path ".\pubspec.yaml")) { + throw "pubspec.yaml introuvable. Lance ce script depuis la racine du projet Flutter." +} + +if (-not (Test-Path ".\lib\services\memory_backup_service.dart")) { + throw "lib/services/memory_backup_service.dart introuvable. Extrais d'abord tout le ZIP dans la racine du projet." +} + +# Ferme uniquement l'executable Windows de l'application s'il tourne encore. +Get-Process -Name "youtube_personal" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + +$pubspecPath = ".\pubspec.yaml" +$backupPath = ".\pubspec.yaml.before_file_picker_12" +if (-not (Test-Path $backupPath)) { + Copy-Item $pubspecPath $backupPath + Write-Host "[OK] Sauvegarde pubspec : $backupPath" -ForegroundColor Green +} + +Write-Host "[1/4] Passage a file_picker 12.0.0..." -ForegroundColor Yellow +$content = Get-Content $pubspecPath -Raw + +if ($content -match '(?m)^\s*file_picker\s*:\s*.*$') { + $content = [regex]::Replace( + $content, + '(?m)^(\s*)file_picker\s*:\s*.*$', + '${1}file_picker: 12.0.0', + 1 + ) +} else { + $dependencyAnchor = '(?m)^dependencies\s*:\s*$' + if ($content -notmatch $dependencyAnchor) { + throw "Section dependencies introuvable dans pubspec.yaml." + } + $content = [regex]::Replace( + $content, + $dependencyAnchor, + "dependencies:`r`n file_picker: 12.0.0", + 1 + ) +} +Set-Content -Path $pubspecPath -Value $content -Encoding UTF8 +Write-Host "[OK] file_picker verrouille sur 12.0.0." -ForegroundColor Green + +Write-Host "[2/4] Suppression du registrant Android genere precedent..." -ForegroundColor Yellow +$registrant = ".\android\app\src\main\java\io\flutter\plugins\GeneratedPluginRegistrant.java" +if (Test-Path $registrant) { + Remove-Item $registrant -Force + Write-Host "[OK] GeneratedPluginRegistrant.java supprime. Flutter va le regenerer." -ForegroundColor Green +} else { + Write-Host "[INFO] Aucun registrant precedent a supprimer." -ForegroundColor DarkGray +} + +if (Test-Path ".\.flutter-plugins-dependencies") { + Remove-Item ".\.flutter-plugins-dependencies" -Force -ErrorAction SilentlyContinue +} + +Write-Host "[3/4] Resolution des dependances..." -ForegroundColor Yellow +flutter pub get +if ($LASTEXITCODE -ne 0) { + throw "flutter pub get a echoue." +} + +Write-Host "[4/4] Formatage et verification rapide..." -ForegroundColor Yellow +dart format ".\lib\services\memory_backup_service.dart" +if ($LASTEXITCODE -ne 0) { + throw "dart format a echoue." +} + +Write-Host "" +Write-Host "[OK] Correctif 18.10.1 applique." -ForegroundColor Green +Write-Host "" +Write-Host "Teste maintenant Android avec :" -ForegroundColor Cyan +Write-Host "flutter run -d f10778d8" +Write-Host "" +Write-Host "Puis APK release avec :" -ForegroundColor Cyan +Write-Host "flutter build apk --release" +Write-Host "" diff --git a/APPLIQUER_FIX_MEMOIRE_18_10_3.ps1 b/APPLIQUER_FIX_MEMOIRE_18_10_3.ps1 new file mode 100644 index 0000000..78771b7 --- /dev/null +++ b/APPLIQUER_FIX_MEMOIRE_18_10_3.ps1 @@ -0,0 +1,103 @@ +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host "=== YouTube Personal - Memoire 18.10.3 / Dependances stables ===" -ForegroundColor Cyan +Write-Host "Projet : $PWD" +Write-Host "" + +if (-not (Test-Path ".\pubspec.yaml")) { + throw "pubspec.yaml introuvable. Lance ce script depuis la racine du projet Flutter." +} +if (-not (Test-Path ".\lib\services\memory_backup_service.dart")) { + throw "memory_backup_service.dart introuvable. Extrais d'abord tout le ZIP dans la racine du projet." +} + +Get-Process -Name "youtube_personal" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + +$pubspecPath = ".\pubspec.yaml" +$backupPath = ".\pubspec.yaml.before_memory_18_10_3" +if (-not (Test-Path $backupPath)) { + Copy-Item $pubspecPath $backupPath + Write-Host "[OK] Sauvegarde pubspec : $backupPath" -ForegroundColor Green +} +if (Test-Path ".\pubspec.lock") { + $lockBackup = ".\pubspec.lock.before_memory_18_10_3" + if (-not (Test-Path $lockBackup)) { + Copy-Item ".\pubspec.lock" $lockBackup + Write-Host "[OK] Sauvegarde lock : $lockBackup" -ForegroundColor Green + } +} + +$content = Get-Content $pubspecPath -Raw + +Write-Host "[1/5] Suppression de file_picker..." -ForegroundColor Yellow +$content = [regex]::Replace($content, '(?m)^\s*file_picker\s*:\s*.*(?:\r?\n)?', '') + +function Set-DependencyLine { + param([string]$Name, [string]$Version) + $pattern = "(?m)^(\s*)" + [regex]::Escape($Name) + "\s*:\s*.*$" + if ($script:content -match $pattern) { + $script:content = [regex]::Replace( + $script:content, + $pattern, + '${1}' + $Name + ': ' + $Version, + 1 + ) + } else { + $anchor = '(?m)^dependencies\s*:\s*$' + if ($script:content -notmatch $anchor) { + throw "Section dependencies introuvable dans pubspec.yaml." + } + $script:content = [regex]::Replace( + $script:content, + $anchor, + "dependencies:`r`n $Name`: $Version", + 1 + ) + } +} + +Write-Host "[2/5] Versions stables et compatibles..." -ForegroundColor Yellow +Set-DependencyLine "wakelock_plus" "1.5.2" +Set-DependencyLine "package_info_plus" "9.0.1" +Set-DependencyLine "file_selector" "1.1.0" +Set-DependencyLine "file_saver" "0.3.1" +Set-Content -Path $pubspecPath -Value $content -Encoding UTF8 + +Write-Host "[OK] file_picker supprime" -ForegroundColor Green +Write-Host "[OK] wakelock_plus = 1.5.2" -ForegroundColor Green +Write-Host "[OK] package_info_plus = 9.0.1" -ForegroundColor Green +Write-Host "[OK] file_selector = 1.1.0" -ForegroundColor Green +Write-Host "[OK] file_saver = 0.3.1" -ForegroundColor Green + +Write-Host "[3/5] Nettoyage du cache de plugins..." -ForegroundColor Yellow +$registrant = ".\android\app\src\main\java\io\flutter\plugins\GeneratedPluginRegistrant.java" +if (Test-Path $registrant) { + Remove-Item $registrant -Force + Write-Host "[OK] Ancien GeneratedPluginRegistrant.java supprime." -ForegroundColor Green +} +if (Test-Path ".\.flutter-plugins-dependencies") { + Remove-Item ".\.flutter-plugins-dependencies" -Force -ErrorAction SilentlyContinue +} + +Write-Host "[4/5] Resolution des dependances..." -ForegroundColor Yellow +flutter pub get +if ($LASTEXITCODE -ne 0) { + throw "flutter pub get a echoue. Sauvegarde disponible : $backupPath" +} + +Write-Host "[5/5] Formatage du service memoire..." -ForegroundColor Yellow +dart format ".\lib\services\memory_backup_service.dart" +if ($LASTEXITCODE -ne 0) { + throw "dart format a echoue." +} + +Write-Host "" +Write-Host "[OK] Correctif Memoire 18.10.3 applique." -ForegroundColor Green +Write-Host "" +Write-Host "Teste maintenant :" -ForegroundColor Cyan +Write-Host "flutter run -d f10778d8" +Write-Host "" +Write-Host "Puis, si le test est bon :" -ForegroundColor Cyan +Write-Host "flutter build apk --release" +Write-Host "" diff --git a/APPLIQUER_FIX_MEMOIRE_18_10_4.ps1 b/APPLIQUER_FIX_MEMOIRE_18_10_4.ps1 new file mode 100644 index 0000000..97a210e --- /dev/null +++ b/APPLIQUER_FIX_MEMOIRE_18_10_4.ps1 @@ -0,0 +1,104 @@ +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host "=== YouTube Personal - Memoire 18.10.4 / Fix package_info_plus ===" -ForegroundColor Cyan +Write-Host "Projet : $PWD" +Write-Host "" + +if (-not (Test-Path ".\pubspec.yaml")) { + throw "pubspec.yaml introuvable. Lance ce script depuis la racine du projet Flutter." +} + +Get-Process -Name "youtube_personal" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + +$pubspecPath = ".\pubspec.yaml" +$backupPath = ".\pubspec.yaml.before_memory_18_10_4" +if (-not (Test-Path $backupPath)) { + Copy-Item $pubspecPath $backupPath + Write-Host "[OK] Sauvegarde pubspec : $backupPath" -ForegroundColor Green +} +if (Test-Path ".\pubspec.lock") { + $lockBackup = ".\pubspec.lock.before_memory_18_10_4" + if (-not (Test-Path $lockBackup)) { + Copy-Item ".\pubspec.lock" $lockBackup + Write-Host "[OK] Sauvegarde lock : $lockBackup" -ForegroundColor Green + } +} + +$content = Get-Content $pubspecPath -Raw + +function Set-DependencyLine { + param([string]$Name, [string]$Version) + $pattern = "(?m)^(\s*)" + [regex]::Escape($Name) + "\s*:\s*.*$" + if ($script:content -match $pattern) { + $script:content = [regex]::Replace( + $script:content, + $pattern, + '${1}' + $Name + ': ' + $Version, + 1 + ) + } else { + $anchor = '(?m)^dependencies\s*:\s*$' + if ($script:content -notmatch $anchor) { + throw "Section dependencies introuvable dans pubspec.yaml." + } + $script:content = [regex]::Replace( + $script:content, + $anchor, + "dependencies:`r`n $Name`: $Version", + 1 + ) + } +} + +Write-Host "[1/4] Versions Android compatibles..." -ForegroundColor Yellow +Set-DependencyLine "package_info_plus" "8.3.1" +Set-DependencyLine "wakelock_plus" "1.5.2" +Set-DependencyLine "file_selector" "1.1.0" +Set-DependencyLine "file_saver" "0.3.1" +$content = [regex]::Replace($content, '(?m)^\s*file_picker\s*:\s*.*(?:\r?\n)?', '') +Set-Content -Path $pubspecPath -Value $content -Encoding UTF8 + +Write-Host "[OK] package_info_plus = 8.3.1" -ForegroundColor Green +Write-Host "[OK] wakelock_plus = 1.5.2" -ForegroundColor Green +Write-Host "[OK] file_selector = 1.1.0" -ForegroundColor Green +Write-Host "[OK] file_saver = 0.3.1" -ForegroundColor Green +Write-Host "[OK] file_picker absent" -ForegroundColor Green + +Write-Host "[2/4] Nettoyage des fichiers generes Flutter..." -ForegroundColor Yellow +$registrant = ".\android\app\src\main\java\io\flutter\plugins\GeneratedPluginRegistrant.java" +if (Test-Path $registrant) { + Remove-Item $registrant -Force -ErrorAction SilentlyContinue + Write-Host "[OK] GeneratedPluginRegistrant.java supprime; Flutter le regenerera." -ForegroundColor Green +} + +foreach ($path in @(".\.flutter-plugins-dependencies", ".\.dart_tool")) { + if (Test-Path $path) { + try { + Remove-Item $path -Recurse -Force -ErrorAction Stop + Write-Host "[OK] Supprime : $path" -ForegroundColor Green + } catch { + Write-Host "[INFO] Impossible de supprimer completement $path; poursuite." -ForegroundColor DarkYellow + } + } +} + +Write-Host "[3/4] Resolution des dependances..." -ForegroundColor Yellow +flutter pub get +if ($LASTEXITCODE -ne 0) { + throw "flutter pub get a echoue. Sauvegarde disponible : $backupPath" +} + +Write-Host "[4/4] Verification des versions resolues..." -ForegroundColor Yellow +$deps = flutter pub deps --style=compact 2>&1 +$deps | Select-String -Pattern "package_info_plus|wakelock_plus|file_selector|file_saver|win32" | ForEach-Object { Write-Host $_.Line } + +Write-Host "" +Write-Host "[OK] Correctif Memoire 18.10.4 applique." -ForegroundColor Green +Write-Host "" +Write-Host "Teste Android :" -ForegroundColor Cyan +Write-Host "flutter run -d f10778d8" +Write-Host "" +Write-Host "Puis APK release :" -ForegroundColor Cyan +Write-Host "flutter build apk --release" +Write-Host "" diff --git a/APPLIQUER_FIX_MEMOIRE_18_10_5.ps1 b/APPLIQUER_FIX_MEMOIRE_18_10_5.ps1 new file mode 100644 index 0000000..0732a19 --- /dev/null +++ b/APPLIQUER_FIX_MEMOIRE_18_10_5.ps1 @@ -0,0 +1,116 @@ +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host "=== YouTube Personal - Memoire 18.10.5 / Couple de dependances compatible ===" -ForegroundColor Cyan +Write-Host "Projet : $PWD" +Write-Host "" + +if (-not (Test-Path ".\pubspec.yaml")) { + throw "pubspec.yaml introuvable. Lance ce script depuis la racine du projet Flutter." +} +if (-not (Test-Path ".\lib\services\memory_backup_service.dart")) { + throw "memory_backup_service.dart introuvable. Le patch Memoire 18.10 doit deja etre installe." +} + +$pubspecPath = ".\pubspec.yaml" +$backupPath = ".\pubspec.yaml.before_memory_18_10_5" +if (-not (Test-Path $backupPath)) { + Copy-Item $pubspecPath $backupPath + Write-Host "[OK] Sauvegarde pubspec : $backupPath" -ForegroundColor Green +} +if (Test-Path ".\pubspec.lock") { + $lockBackup = ".\pubspec.lock.before_memory_18_10_5" + if (-not (Test-Path $lockBackup)) { + Copy-Item ".\pubspec.lock" $lockBackup + Write-Host "[OK] Sauvegarde lock : $lockBackup" -ForegroundColor Green + } +} + +$content = Get-Content $pubspecPath -Raw + +function Set-DependencyLine { + param([string]$Name, [string]$Version) + $pattern = "(?m)^(\s*)" + [regex]::Escape($Name) + "\s*:\s*.*$" + if ($script:content -match $pattern) { + $script:content = [regex]::Replace( + $script:content, + $pattern, + '${1}' + $Name + ': ' + $Version, + 1 + ) + } else { + $anchor = '(?m)^dependencies\s*:\s*$' + if ($script:content -notmatch $anchor) { + throw "Section dependencies introuvable dans pubspec.yaml." + } + $script:content = [regex]::Replace( + $script:content, + $anchor, + "dependencies:`r`n $Name`: $Version", + 1 + ) + } +} + +Write-Host "[1/4] Fix du couple wakelock/package_info..." -ForegroundColor Yellow +$content = [regex]::Replace($content, '(?m)^\s*file_picker\s*:\s*.*(?:\r?\n)?', '') +Set-DependencyLine "wakelock_plus" "1.3.3" +Set-DependencyLine "package_info_plus" "8.3.1" +Set-DependencyLine "file_selector" "1.1.0" +Set-DependencyLine "file_saver" "0.3.1" +Set-Content -Path $pubspecPath -Value $content -Encoding UTF8 + +Write-Host "[OK] wakelock_plus = 1.3.3" -ForegroundColor Green +Write-Host "[OK] package_info_plus = 8.3.1" -ForegroundColor Green +Write-Host "[OK] file_selector = 1.1.0" -ForegroundColor Green +Write-Host "[OK] file_saver = 0.3.1" -ForegroundColor Green +Write-Host "[OK] file_picker absent" -ForegroundColor Green + +Write-Host "[2/4] Suppression des anciens fichiers de registration Flutter..." -ForegroundColor Yellow +$registrant = ".\android\app\src\main\java\io\flutter\plugins\GeneratedPluginRegistrant.java" +if (Test-Path $registrant) { + Remove-Item $registrant -Force -ErrorAction SilentlyContinue + Write-Host "[OK] GeneratedPluginRegistrant.java supprime." -ForegroundColor Green +} +if (Test-Path ".\.flutter-plugins-dependencies") { + Remove-Item ".\.flutter-plugins-dependencies" -Force -ErrorAction SilentlyContinue + Write-Host "[OK] .flutter-plugins-dependencies supprime." -ForegroundColor Green +} + +Write-Host "[3/4] Resolution des dependances..." -ForegroundColor Yellow +flutter pub get +if ($LASTEXITCODE -ne 0) { + throw "flutter pub get a echoue. Sauvegarde disponible : $backupPath" +} + +Write-Host "[4/4] Verification des versions resolues..." -ForegroundColor Yellow +$deps = flutter pub deps --style=compact 2>&1 | Out-String +if ($LASTEXITCODE -ne 0) { + throw "Impossible de verifier les dependances resolues." +} + +$checks = @( + @{ Name = "wakelock_plus"; Version = "1.3.3" }, + @{ Name = "package_info_plus"; Version = "8.3.1" }, + @{ Name = "file_selector"; Version = "1.1.0" }, + @{ Name = "file_saver"; Version = "0.3.1" } +) + +foreach ($check in $checks) { + $pattern = "(?m)\b" + [regex]::Escape($check.Name) + "\s+" + [regex]::Escape($check.Version) + "\b" + if ($deps -match $pattern) { + Write-Host "[OK] $($check.Name) $($check.Version) resolu" -ForegroundColor Green + } else { + Write-Host "[ATTENTION] $($check.Name) $($check.Version) non confirme dans flutter pub deps" -ForegroundColor Yellow + } +} + +Write-Host "" +Write-Host "[OK] Correctif Memoire 18.10.5 applique." -ForegroundColor Green +Write-Host "" +Write-Host "Teste Android :" -ForegroundColor Cyan +Write-Host "flutter run -d f10778d8" +Write-Host "" +Write-Host "Puis, si le test est bon :" -ForegroundColor Cyan +Write-Host "flutter build apk --release" +Write-Host "" diff --git a/APPLIQUER_FIX_MEMOIRE_18_10_6.ps1 b/APPLIQUER_FIX_MEMOIRE_18_10_6.ps1 new file mode 100644 index 0000000..ccf6ddc --- /dev/null +++ b/APPLIQUER_FIX_MEMOIRE_18_10_6.ps1 @@ -0,0 +1,193 @@ +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host "=== YouTube Personal - Memoire 18.10.6 / Bridge Kotlin AGP 9 ===" -ForegroundColor Cyan +Write-Host "Projet : $PWD" +Write-Host "" + +$root = (Get-Location).Path +$pubspec = Join-Path $root "pubspec.yaml" +$gradleProps = Join-Path $root "android\gradle.properties" +$rootGradle = Join-Path $root "android\build.gradle.kts" + +foreach ($path in @($pubspec, $gradleProps, $rootGradle)) { + if (-not (Test-Path $path)) { + throw "Fichier introuvable : $path" + } +} + +function Backup-Once([string]$Path, [string]$Suffix) { + $backup = "$Path.$Suffix" + if (-not (Test-Path $backup)) { + Copy-Item $Path $backup + Write-Host "[OK] Sauvegarde : $backup" -ForegroundColor Green + } +} + +Backup-Once $pubspec "before_memory_18_10_6" +Backup-Once $gradleProps "before_memory_18_10_6" +Backup-Once $rootGradle "before_memory_18_10_6" +if (Test-Path (Join-Path $root "pubspec.lock")) { + Backup-Once (Join-Path $root "pubspec.lock") "before_memory_18_10_6" +} + +Write-Host "[1/6] Verification des dependances stables..." -ForegroundColor Yellow +$content = Get-Content $pubspec -Raw + +function Set-DependencyLine { + param([string]$Name, [string]$Version) + $pattern = "(?m)^(\s*)" + [regex]::Escape($Name) + "\s*:\s*.*$" + if ($script:content -match $pattern) { + $script:content = [regex]::Replace( + $script:content, + $pattern, + '${1}' + $Name + ': ' + $Version, + 1 + ) + } else { + $anchor = '(?m)^dependencies\s*:\s*$' + if ($script:content -notmatch $anchor) { + throw "Section dependencies introuvable dans pubspec.yaml." + } + $script:content = [regex]::Replace( + $script:content, + $anchor, + "dependencies:`r`n $Name`: $Version", + 1 + ) + } +} + +# file_picker reste supprime. On conserve la pile memoire 18.10.3+. +$content = [regex]::Replace($content, '(?m)^\s*file_picker\s*:\s*.*(?:\r?\n)?', '') +Set-DependencyLine "wakelock_plus" "1.3.3" +Set-DependencyLine "package_info_plus" "8.3.1" +Set-DependencyLine "file_selector" "1.1.0" +Set-DependencyLine "file_saver" "0.3.1" +Set-Content -Path $pubspec -Value $content -Encoding UTF8 + +Write-Host "[OK] wakelock_plus = 1.3.3" -ForegroundColor Green +Write-Host "[OK] package_info_plus = 8.3.1" -ForegroundColor Green +Write-Host "[OK] file_selector = 1.1.0" -ForegroundColor Green +Write-Host "[OK] file_saver = 0.3.1" -ForegroundColor Green + +Write-Host "[2/6] Activation explicite du mode Kotlin legacy compatible AGP 9..." -ForegroundColor Yellow +$props = Get-Content $gradleProps -Raw + +function Set-GradleProperty { + param([string]$Name, [string]$Value) + $pattern = '(?m)^\s*' + [regex]::Escape($Name) + '\s*=.*$' + $line = "$Name=$Value" + if ($script:props -match $pattern) { + $script:props = [regex]::Replace($script:props, $pattern, $line, 1) + } else { + if (-not $script:props.EndsWith("`n")) { + $script:props += "`r`n" + } + $script:props += "$line`r`n" + } +} + +# Flutter 3.44 utilise ce mode comme pont temporaire pour les plugins KGP. +Set-GradleProperty "android.builtInKotlin" "false" +Set-GradleProperty "android.newDsl" "false" +Set-Content -Path $gradleProps -Value $props -Encoding UTF8 +Write-Host "[OK] android.builtInKotlin=false" -ForegroundColor Green +Write-Host "[OK] android.newDsl=false" -ForegroundColor Green + +Write-Host "[3/6] Ajout du bridge Kotlin aux plugins qui perdaient leurs classes..." -ForegroundColor Yellow +$gradle = Get-Content $rootGradle -Raw +$beginMarker = "// BEGIN YOUTUBE_PERSONAL_AGP9_KOTLIN_BRIDGE_18_10_6" +$endMarker = "// END YOUTUBE_PERSONAL_AGP9_KOTLIN_BRIDGE_18_10_6" + +# Retire une ancienne occurrence du meme bridge s'il y en a une. +$markerPattern = '(?ms)^\s*// BEGIN YOUTUBE_PERSONAL_AGP9_KOTLIN_BRIDGE_18_10_6.*?// END YOUTUBE_PERSONAL_AGP9_KOTLIN_BRIDGE_18_10_6\s*' +$gradle = [regex]::Replace($gradle, $markerPattern, '') + +$bridge = @' + +// BEGIN YOUTUBE_PERSONAL_AGP9_KOTLIN_BRIDGE_18_10_6 +// Flutter 3.44 + AGP 9 peut fonctionner temporairement avec +// android.builtInKotlin=false. Certains plugins regardent uniquement la +// version d'AGP et n'appliquent alors plus KGP : leurs sources .kt ne sont +// jamais compilees et GeneratedPluginRegistrant ne trouve plus les classes. +val youtubePersonalLegacyKotlinPlugins = setOf( + "package_info_plus", + "wakelock_plus", +) + +subprojects { + if (name in youtubePersonalLegacyKotlinPlugins) { + val pluginProject = this + pluginManager.withPlugin("com.android.library") { + if (!pluginProject.pluginManager.hasPlugin("org.jetbrains.kotlin.android")) { + pluginProject.pluginManager.apply("org.jetbrains.kotlin.android") + } + } + } +} +// END YOUTUBE_PERSONAL_AGP9_KOTLIN_BRIDGE_18_10_6 +'@ + +$gradle = $gradle.TrimEnd() + "`r`n" + $bridge + "`r`n" +Set-Content -Path $rootGradle -Value $gradle -Encoding UTF8 +Write-Host "[OK] Bridge ajoute dans android/build.gradle.kts" -ForegroundColor Green + +Write-Host "[4/6] Arret des processus pouvant verrouiller le build..." -ForegroundColor Yellow +Get-Process youtube_personal -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +$gradlew = Join-Path $root "android\gradlew.bat" +if (Test-Path $gradlew) { + Push-Location (Join-Path $root "android") + try { + & .\gradlew.bat --stop | Out-Host + } catch { + Write-Host "[INFO] Aucun daemon Gradle a arreter." -ForegroundColor DarkGray + } + Pop-Location +} + +Write-Host "[5/6] Nettoyage Flutter et resolution des dependances..." -ForegroundColor Yellow +$registrant = Join-Path $root "android\app\src\main\java\io\flutter\plugins\GeneratedPluginRegistrant.java" +if (Test-Path $registrant) { + Remove-Item $registrant -Force -ErrorAction SilentlyContinue +} + +# On tente un clean complet; si Windows conserve un verrou, on nettoie les +# fichiers de metadata essentiels et Flutter regenerera le reste. +flutter clean +if ($LASTEXITCODE -ne 0) { + Write-Host "[ATTENTION] flutter clean incomplet; nettoyage manuel des metadonnees." -ForegroundColor Yellow +} + +foreach ($path in @( + (Join-Path $root ".dart_tool"), + (Join-Path $root ".flutter-plugins-dependencies") +)) { + if (Test-Path $path) { + Remove-Item $path -Recurse -Force -ErrorAction SilentlyContinue + } +} + +flutter pub get +if ($LASTEXITCODE -ne 0) { + throw "flutter pub get a echoue. Les sauvegardes .before_memory_18_10_6 permettent de revenir en arriere." +} + +Write-Host "[6/6] Build Android Debug de verification..." -ForegroundColor Yellow +flutter build apk --debug +if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "Le bridge a ete applique, mais le build a encore echoue." -ForegroundColor Red + Write-Host "Copie uniquement la nouvelle erreur a partir de 'FAILURE: Build failed'." -ForegroundColor Yellow + exit 1 +} + +Write-Host "" +Write-Host "[OK] Build Android Debug reussi avec le bridge Kotlin AGP 9." -ForegroundColor Green +Write-Host "" +Write-Host "Tu peux maintenant lancer :" -ForegroundColor Cyan +Write-Host " flutter run -d f10778d8" +Write-Host "" +Write-Host "Puis creer l'APK Release :" -ForegroundColor Cyan +Write-Host " flutter build apk --release" +Write-Host "" diff --git a/APPLIQUER_FIX_MEMOIRE_18_10_7.ps1 b/APPLIQUER_FIX_MEMOIRE_18_10_7.ps1 new file mode 100644 index 0000000..14435c2 --- /dev/null +++ b/APPLIQUER_FIX_MEMOIRE_18_10_7.ps1 @@ -0,0 +1,141 @@ +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host "=== YouTube Personal - Memoire 18.10.7 / Memoire Gradle-D8 ===" -ForegroundColor Cyan +Write-Host "Projet : $PWD" +Write-Host "" + +$root = (Get-Location).Path +$gradleProps = Join-Path $root "android\gradle.properties" +$rootGradle = Join-Path $root "android\build.gradle.kts" + +foreach ($path in @($gradleProps, $rootGradle)) { + if (-not (Test-Path $path)) { + throw "Fichier introuvable : $path" + } +} + +# Verifie que le bridge 18.10.6 est bien present avant de continuer. +$gradleText = Get-Content $rootGradle -Raw +if ($gradleText -notmatch 'YOUTUBE_PERSONAL_AGP9_KOTLIN_BRIDGE_18_10_6') { + throw "Le bridge Kotlin 18.10.6 est absent. Applique d'abord le correctif 18.10.6." +} +Write-Host "[OK] Bridge Kotlin 18.10.6 detecte." -ForegroundColor Green + +$backup = "$gradleProps.before_memory_18_10_7" +if (-not (Test-Path $backup)) { + Copy-Item $gradleProps $backup + Write-Host "[OK] Sauvegarde : $backup" -ForegroundColor Green +} + +# Choisit une heap prudente en fonction de la RAM physique disponible. +$totalGb = 0 +try { + $totalGb = [math]::Floor((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB) +} catch { + $totalGb = 16 +} + +if ($totalGb -ge 24) { + $heap = "8g" + $meta = "1536m" + $kotlinHeap = "2048m" +} elseif ($totalGb -ge 16) { + $heap = "6g" + $meta = "1024m" + $kotlinHeap = "1536m" +} elseif ($totalGb -ge 12) { + $heap = "4g" + $meta = "768m" + $kotlinHeap = "1024m" +} else { + $heap = "3g" + $meta = "512m" + $kotlinHeap = "768m" +} + +Write-Host "[INFO] RAM detectee : environ $totalGb Go" -ForegroundColor DarkGray +Write-Host "[INFO] Heap Gradle choisie : $heap" -ForegroundColor DarkGray + +$props = Get-Content $gradleProps -Raw + +function Set-GradleProperty { + param([string]$Name, [string]$Value) + $pattern = '(?m)^\s*' + [regex]::Escape($Name) + '\s*=.*$' + $line = "$Name=$Value" + if ($script:props -match $pattern) { + $script:props = [regex]::Replace($script:props, $pattern, $line, 1) + } else { + if (-not $script:props.EndsWith("`n")) { + $script:props += "`r`n" + } + $script:props += "$line`r`n" + } +} + +Write-Host "[1/5] Configuration memoire Gradle / Kotlin..." -ForegroundColor Yellow +Set-GradleProperty "org.gradle.jvmargs" "-Xmx$heap -XX:MaxMetaspaceSize=$meta -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8" +Set-GradleProperty "kotlin.daemon.jvmargs" "-Xmx$kotlinHeap -Dfile.encoding=UTF-8" +Set-GradleProperty "org.gradle.parallel" "false" +Set-GradleProperty "org.gradle.workers.max" "2" +Set-GradleProperty "org.gradle.daemon" "true" + +# Conserve les deux proprietes requises par le bridge 18.10.6. +Set-GradleProperty "android.builtInKotlin" "false" +Set-GradleProperty "android.newDsl" "false" + +Set-Content -Path $gradleProps -Value $props -Encoding UTF8 +Write-Host "[OK] org.gradle.jvmargs configure sur -Xmx$heap" -ForegroundColor Green +Write-Host "[OK] org.gradle.workers.max=2" -ForegroundColor Green +Write-Host "[OK] org.gradle.parallel=false" -ForegroundColor Green + +Write-Host "[2/5] Arret du programme et des anciens daemons Gradle..." -ForegroundColor Yellow +Get-Process youtube_personal -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +Push-Location (Join-Path $root "android") +try { + & .\gradlew.bat --stop | Out-Host +} catch { + Write-Host "[INFO] Aucun daemon Gradle actif." -ForegroundColor DarkGray +} +Pop-Location + +Write-Host "[3/5] Nettoyage des sorties de build uniquement..." -ForegroundColor Yellow +$buildDir = Join-Path $root "build" +if (Test-Path $buildDir) { + try { + Remove-Item $buildDir -Recurse -Force -ErrorAction Stop + Write-Host "[OK] build supprime." -ForegroundColor Green + } catch { + Write-Host "[ATTENTION] build partiellement verrouille; Gradle reconstruira ce qui est necessaire." -ForegroundColor Yellow + } +} + +Write-Host "[4/5] Resolution des dependances..." -ForegroundColor Yellow +flutter pub get +if ($LASTEXITCODE -ne 0) { + throw "flutter pub get a echoue." +} + +Write-Host "[5/5] Build Debug ARM64 de verification..." -ForegroundColor Yellow +Write-Host " (ARM64 uniquement pour reduire fortement le pic memoire D8/CMake)" -ForegroundColor DarkGray +flutter build apk --debug --target-platform android-arm64 +if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "Le correctif memoire est applique mais le build a encore echoue." -ForegroundColor Red + Write-Host "Copie la nouvelle erreur a partir de 'FAILURE:' ou 'Error:'." -ForegroundColor Yellow + exit 1 +} + +$apk = Join-Path $root "build\app\outputs\flutter-apk\app-debug.apk" +Write-Host "" +Write-Host "[OK] Build Android Debug ARM64 reussi." -ForegroundColor Green +if (Test-Path $apk) { + Write-Host "APK Debug : $apk" -ForegroundColor Cyan +} +Write-Host "" +Write-Host "Teste maintenant sur le smartphone :" -ForegroundColor Cyan +Write-Host " flutter run -d f10778d8" +Write-Host "" +Write-Host "Puis APK Release ARM64 :" -ForegroundColor Cyan +Write-Host " flutter build apk --release --target-platform android-arm64" +Write-Host "" diff --git a/APPLIQUER_FIX_R8.ps1 b/APPLIQUER_FIX_R8.ps1 new file mode 100644 index 0000000..8a10f91 --- /dev/null +++ b/APPLIQUER_FIX_R8.ps1 @@ -0,0 +1,110 @@ +param( + [string]$ProjectRoot = (Get-Location).Path +) + +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host "=== YouTube Personal - Correctif R8 Release v2 ===" -ForegroundColor Cyan +Write-Host "Projet : $ProjectRoot" +Write-Host "" + +$androidApp = Join-Path $ProjectRoot "android\app" +$kts = Join-Path $androidApp "build.gradle.kts" +$groovy = Join-Path $androidApp "build.gradle" +$proguard = Join-Path $androidApp "proguard-rules.pro" + +if (-not (Test-Path $androidApp)) { + throw "Dossier Android introuvable : $androidApp" +} + +# 1) Cree/complete proguard-rules.pro +$rule = '-dontwarn java.beans.**' +if (-not (Test-Path $proguard)) { + Set-Content -Path $proguard -Value $rule -Encoding UTF8 + Write-Host "[OK] proguard-rules.pro cree." -ForegroundColor Green +} else { + $ptext = Get-Content $proguard -Raw + if ($ptext -notmatch '(?m)^\s*-dontwarn\s+java\.beans\.\*\*\s*$') { + Add-Content -Path $proguard -Value "`r`n# Rhino / NewPipeExtractor`r`n$rule" -Encoding UTF8 + Write-Host "[OK] Regle java.beans ajoutee." -ForegroundColor Green + } else { + Write-Host "[OK] Regle java.beans deja presente." -ForegroundColor Green + } +} + +function Backup-Once([string]$Path) { + $backup = "$Path.before_r8_fix_v2" + if (-not (Test-Path $backup)) { + Copy-Item $Path $backup + Write-Host "[OK] Sauvegarde : $backup" -ForegroundColor DarkGray + } +} + +# 2) Branche proguard-rules.pro dans le build type release. +# Contrairement a la v1, ce script ne suppose PAS la presence de +# isMinifyEnabled = true. Flutter/AGP peut lancer R8 sans cette ligne +# explicite dans le fichier utilisateur. +if (Test-Path $kts) { + Backup-Once $kts + $text = Get-Content $kts -Raw + + if ($text -match 'proguard-rules\.pro') { + Write-Host "[OK] build.gradle.kts utilise deja proguard-rules.pro." -ForegroundColor Green + } else { + $pattern = '(?m)^(\s*)release\s*\{\s*(?://.*)?$' + $m = [regex]::Match($text, $pattern) + if (-not $m.Success) { + throw "Bloc release { introuvable dans android/app/build.gradle.kts. Aucune modification Gradle effectuee." + } + + $indent = $m.Groups[1].Value + ' ' + $line = $indent + 'proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")' + $replacement = $m.Value + "`r`n" + $line + $rx = [regex]::new($pattern) + $text = $rx.Replace($text, $replacement, 1) + Set-Content -Path $kts -Value $text -Encoding UTF8 + Write-Host "[OK] proguard-rules.pro branche dans release (Kotlin DSL)." -ForegroundColor Green + } +} +elseif (Test-Path $groovy) { + Backup-Once $groovy + $text = Get-Content $groovy -Raw + + if ($text -match 'proguard-rules\.pro') { + Write-Host "[OK] build.gradle utilise deja proguard-rules.pro." -ForegroundColor Green + } else { + $pattern = '(?m)^(\s*)release\s*\{\s*(?://.*)?$' + $m = [regex]::Match($text, $pattern) + if (-not $m.Success) { + throw "Bloc release { introuvable dans android/app/build.gradle. Aucune modification Gradle effectuee." + } + + $indent = $m.Groups[1].Value + ' ' + $line = $indent + "proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'" + $replacement = $m.Value + "`r`n" + $line + $rx = [regex]::new($pattern) + $text = $rx.Replace($text, $replacement, 1) + Set-Content -Path $groovy -Value $text -Encoding UTF8 + Write-Host "[OK] proguard-rules.pro branche dans release (Groovy)." -ForegroundColor Green + } +} +else { + throw "Aucun android/app/build.gradle.kts ni android/app/build.gradle trouve." +} + +Write-Host "" +Write-Host "=== Verification ===" -ForegroundColor Cyan +if (Test-Path $kts) { + Select-String -Path $kts -Pattern 'release\s*\{|proguardFiles|isMinifyEnabled' | ForEach-Object { Write-Host $_.Line } +} elseif (Test-Path $groovy) { + Select-String -Path $groovy -Pattern 'release\s*\{|proguardFiles|minifyEnabled' | ForEach-Object { Write-Host $_.Line } +} + +Write-Host "" +Write-Host "Correctif applique." -ForegroundColor Green +Write-Host "Lance maintenant :" -ForegroundColor Yellow +Write-Host " flutter clean" +Write-Host " flutter pub get" +Write-Host " flutter build apk --release" +Write-Host "" diff --git a/APPLIQUER_FIX_WAKELOCK.ps1 b/APPLIQUER_FIX_WAKELOCK.ps1 new file mode 100644 index 0000000..8280f85 --- /dev/null +++ b/APPLIQUER_FIX_WAKELOCK.ps1 @@ -0,0 +1,83 @@ +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host "=== YouTube Personal - Fix Android Wakelock 18.10.2 ===" -ForegroundColor Cyan +Write-Host "Projet : $PWD" +Write-Host "" + +if (-not (Test-Path ".\pubspec.yaml")) { + throw "pubspec.yaml introuvable. Lance ce script depuis la racine du projet Flutter." +} + +# Fermer l'application Windows si elle tourne et peut verrouiller build/.dart_tool. +Get-Process -Name "youtube_personal" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + +$pubspecPath = ".\pubspec.yaml" +$backupPath = ".\pubspec.yaml.before_wakelock_18_10_2" +if (-not (Test-Path $backupPath)) { + Copy-Item $pubspecPath $backupPath + Write-Host "[OK] Sauvegarde pubspec : $backupPath" -ForegroundColor Green +} + +if (Test-Path ".\pubspec.lock") { + $lockBackup = ".\pubspec.lock.before_wakelock_18_10_2" + if (-not (Test-Path $lockBackup)) { + Copy-Item ".\pubspec.lock" $lockBackup + Write-Host "[OK] Sauvegarde lock : $lockBackup" -ForegroundColor Green + } +} + +Write-Host "[1/3] Verrouillage de wakelock_plus sur 1.5.2..." -ForegroundColor Yellow +$content = Get-Content $pubspecPath -Raw + +if ($content -match '(?m)^\s*wakelock_plus\s*:\s*.*$') { + $content = [regex]::Replace( + $content, + '(?m)^(\s*)wakelock_plus\s*:\s*.*$', + '${1}wakelock_plus: 1.5.2', + 1 + ) +} else { + $dependencyAnchor = '(?m)^dependencies\s*:\s*$' + if ($content -notmatch $dependencyAnchor) { + throw "Section dependencies introuvable dans pubspec.yaml." + } + $content = [regex]::Replace( + $content, + $dependencyAnchor, + "dependencies:`r`n wakelock_plus: 1.5.2", + 1 + ) +} +Set-Content -Path $pubspecPath -Value $content -Encoding UTF8 +Write-Host "[OK] wakelock_plus = 1.5.2" -ForegroundColor Green + +Write-Host "[2/3] Resolution des dependances..." -ForegroundColor Yellow +flutter pub get +if ($LASTEXITCODE -ne 0) { + throw "flutter pub get a echoue." +} + +Write-Host "[3/3] Verification des versions resolues..." -ForegroundColor Yellow +$deps = flutter pub deps --style=compact 2>&1 | Out-String +$wakeline = ($deps -split "`r?`n" | Where-Object { $_ -match 'wakelock_plus\s' } | Select-Object -First 1) +$fileline = ($deps -split "`r?`n" | Where-Object { $_ -match 'file_picker\s' } | Select-Object -First 1) +if ($wakeline) { Write-Host "[INFO] $wakeline" -ForegroundColor DarkGray } +if ($fileline) { Write-Host "[INFO] $fileline" -ForegroundColor DarkGray } + +if ($deps -notmatch 'wakelock_plus\s+1\.5\.2') { + throw "wakelock_plus 1.5.2 n'est pas la version resolue. Verifie pubspec.yaml / dependency_overrides." +} +if ($deps -notmatch 'file_picker\s+12\.0\.0') { + Write-Host "[ATTENTION] file_picker 12.0.0 n'apparait pas dans les dependances resolues." -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "[OK] Correctif 18.10.2 applique." -ForegroundColor Green +Write-Host "" +Write-Host "Teste Android avec :" -ForegroundColor Cyan +Write-Host "flutter run -d f10778d8" +Write-Host "" +Write-Host "Puis APK release avec :" -ForegroundColor Cyan +Write-Host "flutter build apk --release" +Write-Host "" diff --git a/APPLIQUER_HD_ANDROID.ps1 b/APPLIQUER_HD_ANDROID.ps1 new file mode 100644 index 0000000..46ee3cb --- /dev/null +++ b/APPLIQUER_HD_ANDROID.ps1 @@ -0,0 +1,114 @@ +param( + [string]$ProjectRoot = $PSScriptRoot +) + +$ErrorActionPreference = "Stop" + +$androidRoot = Join-Path $ProjectRoot "android" +$rootGradle = Join-Path $androidRoot "build.gradle.kts" +$appGradle = Join-Path $androidRoot "app\build.gradle.kts" + +if (-not (Test-Path $rootGradle)) { + throw "Introuvable: $rootGradle" +} + +if (-not (Test-Path $appGradle)) { + throw "Introuvable: $appGradle" +} + +function Backup-Once([string]$Path) { + $backup = "$Path.before_hd" + if (-not (Test-Path $backup)) { + Copy-Item $Path $backup + Write-Host "Backup: $backup" + } +} + +Backup-Once $rootGradle +Backup-Once $appGradle + +$rootText = Get-Content $rootGradle -Raw + +if ($rootText -notmatch 'https://jitpack\.io') { + $needle = 'mavenCentral()' + + if (-not $rootText.Contains($needle)) { + throw "Impossible de trouver mavenCentral() dans android/build.gradle.kts" + } + + $replacement = @' +mavenCentral() + maven { url = uri("https://jitpack.io") } +'@ + + $rootText = $rootText.Replace( + $needle, + $replacement + ) + + Set-Content -Path $rootGradle -Value $rootText -Encoding UTF8 + Write-Host "JitPack ajoute." +} else { + Write-Host "JitPack deja present." +} + +$appText = Get-Content $appGradle -Raw + +# Nettoie les blocs kotlin/compilerOptions qui ont pu être ajoutés par +# les premières versions du script HD. Ils ne sont pas nécessaires ici: +# AGP 9 fait hériter le jvmTarget de compileOptions.targetCompatibility. +$kotlinCompilerBlock = '(?ms)[ \t]*kotlin\s*\{\s*compilerOptions\s*\{\s*jvmTarget\s*=\s*org\.jetbrains\.kotlin\.gradle\.dsl\.JvmTarget\.JVM_17\s*\}\s*\}\s*' + +$appText = [regex]::Replace( + $appText, + $kotlinCompilerBlock, + '' +) + +if ( + $appText -notmatch 'isCoreLibraryDesugaringEnabled\s*=\s*true' +) { + $targetNeedle = + 'targetCompatibility = JavaVersion.VERSION_17' + + if (-not $appText.Contains($targetNeedle)) { + throw "Impossible de trouver targetCompatibility Java 17 dans android/app/build.gradle.kts" + } + + $targetReplacement = @' +targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true +'@ + + $appText = $appText.Replace( + $targetNeedle, + $targetReplacement + ) + + Write-Host "Core library desugaring active." +} + +if ($appText -notmatch 'NewPipeExtractor:v0\.26\.3') { + $dependencyBlock = @' + +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") +} +'@ + + $appText = $appText.TrimEnd() + "`r`n" + $dependencyBlock + "`r`n" + Write-Host "Dependances HD ajoutees." +} else { + Write-Host "Dependances HD deja presentes." +} + +Set-Content -Path $appGradle -Value $appText -Encoding UTF8 + +Write-Host "" +Write-Host "Configuration HD Android appliquee." +Write-Host "Lance ensuite:" +Write-Host " flutter clean" +Write-Host " flutter pub get" +Write-Host " flutter run -d f10778d8" diff --git a/APPLIQUER_MEMOIRE_V1.ps1 b/APPLIQUER_MEMOIRE_V1.ps1 new file mode 100644 index 0000000..f9e3094 --- /dev/null +++ b/APPLIQUER_MEMOIRE_V1.ps1 @@ -0,0 +1,111 @@ +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host "=== YouTube Personal - Memoire Export / Import V1 ===" -ForegroundColor Cyan +Write-Host "Projet : $PWD" +Write-Host "" + +if (-not (Test-Path ".\pubspec.yaml")) { + throw "pubspec.yaml introuvable. Lance ce script depuis la racine du projet Flutter." +} + +$settingsPath = ".\lib\screens\settings_page.dart" +if (-not (Test-Path $settingsPath)) { + throw "lib/screens/settings_page.dart introuvable." +} + +$backupPath = "$settingsPath.before_memory_v1" +if (-not (Test-Path $backupPath)) { + Copy-Item $settingsPath $backupPath + Write-Host "[OK] Sauvegarde settings_page : $backupPath" -ForegroundColor Green +} else { + Write-Host "[INFO] Sauvegarde settings_page deja presente." -ForegroundColor DarkGray +} + +Write-Host "[1/4] Ajout de file_picker..." -ForegroundColor Yellow +flutter pub add "file_picker:^11.0.3" +if ($LASTEXITCODE -ne 0) { + throw "flutter pub add file_picker a echoue." +} + +Write-Host "[2/4] Integration dans Reglages..." -ForegroundColor Yellow +$content = Get-Content $settingsPath -Raw + +$importLine = "import '../widgets/memory_transfer_settings.dart';" +if ($content -notmatch [regex]::Escape($importLine)) { + $anchorImport = "import '../services/preferred_language_service.dart';" + if ($content.Contains($anchorImport)) { + $content = $content.Replace( + $anchorImport, + "$anchorImport`r`n$importLine" + ) + } else { + $firstFlutterImport = "import 'package:flutter/material.dart';" + if (-not $content.Contains($firstFlutterImport)) { + throw "Impossible de trouver un point d'insertion pour l'import. Le fichier n'a pas ete modifie." + } + $content = $content.Replace( + $firstFlutterImport, + "$firstFlutterImport`r`n`r`n$importLine" + ) + } + Write-Host "[OK] Import MemoryTransferSettings ajoute." -ForegroundColor Green +} else { + Write-Host "[OK] Import deja present." -ForegroundColor Green +} + +if ($content -notmatch "const MemoryTransferSettings\(\)") { + $marker = @" + SizedBox(height: 14), + _SettingsSection( + title: 'Navigation', +"@ + + $replacement = @" + SizedBox(height: 14), + const MemoryTransferSettings(), + SizedBox(height: 14), + _SettingsSection( + title: 'Navigation', +"@ + + if (-not $content.Contains($marker)) { + # Variante CRLF normalisee par Get-Content -Raw / environnements differents. + $regex = "(?ms)(\s*SizedBox\(height:\s*14\),\s*)(_SettingsSection\(\s*title:\s*'Navigation',)" + if ($content -notmatch $regex) { + throw "Section Navigation introuvable. Restauration du fichier d'origine." + } + $content = [regex]::Replace( + $content, + $regex, + "`r`n SizedBox(height: 14),`r`n const MemoryTransferSettings(),`r`n SizedBox(height: 14),`r`n `$2", + 1 + ) + } else { + $content = $content.Replace($marker, $replacement) + } + Write-Host "[OK] Bloc Memoire ajoute dans Reglages." -ForegroundColor Green +} else { + Write-Host "[OK] Bloc Memoire deja present." -ForegroundColor Green +} + +Set-Content -Path $settingsPath -Value $content -Encoding UTF8 + +Write-Host "[3/4] Formatage Dart..." -ForegroundColor Yellow +dart format ".\lib\services\memory_backup_service.dart" ".\lib\widgets\memory_transfer_settings.dart" $settingsPath +if ($LASTEXITCODE -ne 0) { + throw "dart format a echoue." +} + +Write-Host "[4/4] Dependances..." -ForegroundColor Yellow +flutter pub get +if ($LASTEXITCODE -ne 0) { + throw "flutter pub get a echoue." +} + +Write-Host "" +Write-Host "[OK] Memoire Export / Import V1 installee." -ForegroundColor Green +Write-Host "" +Write-Host "Test Windows : flutter run -d windows" +Write-Host "Test Android : flutter run -d " +Write-Host "" diff --git a/BASELINE_PROTEGEE_18_7_5.md b/BASELINE_PROTEGEE_18_7_5.md new file mode 100644 index 0000000..821dbed --- /dev/null +++ b/BASELINE_PROTEGEE_18_7_5.md @@ -0,0 +1,61 @@ +# YouTube Personal — Baseline protégée 18.7.5 + +## Statut + +Cette version est la référence fonctionnelle validée pour le démarrage vidéo Windows. +Elle ne doit pas être remplacée par une réécriture du pipeline de démarrage sans test comparatif et possibilité de retour arrière. + +## Fichiers protégés + +- `lib/screens/video_player_page.dart` +- `lib/services/windows_hd_service.dart` + +## Comportement à préserver + +1. Au clic sur une vidéo sous Windows, tenter d'abord le démarrage rapide 360p muxé. +2. Ne pas bloquer le premier affichage sur le téléchargement complet de la vidéo et de l'audio. +3. En cas d'échec du chemin rapide, conserver un fallback yt-dlp robuste. +4. Si la qualité cible est supérieure à 360p, préparer la HD en arrière-plan puis basculer à la position courante. +5. Si 360p est explicitement sélectionné, ne pas lancer de préparation HD inutile. +6. Une bascule de qualité ne doit pas incrémenter le compteur personnel de vues. +7. Ne pas réintroduire le relais Range `yt-dlp HttpFD` de la 18.7.4 comme chemin principal : il a provoqué des HTTP 403 et des timeouts de 30 s. + +## Règles obligatoires pour les évolutions futures + +Avant toute modification touchant le lecteur Windows : + +1. Conserver cette archive intacte comme point de retour arrière. +2. Faire une modification minimale et localisée. +3. Ne pas réécrire `WindowsHdService` ou le démarrage du `VideoPlayerPage` si l'évolution demandée ne concerne pas directement le pipeline vidéo. +4. Vérifier le diff contre cette baseline avant livraison. +5. Tester au minimum une vidéo non mise en cache. +6. Vérifier que la première image apparaît avant de valider la modification. +7. Vérifier les logs `[HD-WINDOWS]`, `[HD-PROGRESSIVE]` et `[YT-PLAYER]` en cas de changement du pipeline. +8. Si une régression apparaît, restaurer immédiatement cette baseline avant de poursuivre. +9. Toute livraison doit contenir uniquement les fichiers modifiés/ajoutés avec chemins relatifs au projet. + +## Régressions connues à ne pas réintroduire + +### 18.7.2 / ancien chemin lent +Le lecteur pouvait attendre le téléchargement complet d'environ 20 MiB de vidéo + 10 MiB d'audio avant de commencer la lecture 360p. + +### 18.7.4 +Le relais Range basé sur `yt-dlp HttpFD` a produit : + +```text +HTTP Error 403: Forbidden +0 octet fourni +Timeout 30 secondes +``` + +Ce chemin ne doit pas redevenir le démarrage principal. + +## Restauration + +Depuis le dossier extrait de cette baseline : + +```powershell +powershell -ExecutionPolicy Bypass -File .\restore_18_7_5.ps1 -ProjectPath "D:\07-Logiciels\23-youtube_personal" +``` + +Le script crée d'abord une sauvegarde des fichiers actuels avant de remettre les deux fichiers protégés. diff --git a/BUILD_APK_RELEASE.ps1 b/BUILD_APK_RELEASE.ps1 new file mode 100644 index 0000000..e0be045 --- /dev/null +++ b/BUILD_APK_RELEASE.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference = "Stop" +$ProjectRoot = $PSScriptRoot +Set-Location $ProjectRoot +Write-Host "=== Build APK Release ===" -ForegroundColor Cyan +flutter clean +flutter pub get +flutter build apk --release +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +$apk = Join-Path $ProjectRoot "build\app\outputs\flutter-apk\app-release.apk" +if (Test-Path $apk) { + Write-Host "" + Write-Host "[OK] APK : $apk" -ForegroundColor Green +} else { + throw "Build termine mais APK introuvable : $apk" +} diff --git a/CREER_INSTALLATEUR_WINDOWS.ps1 b/CREER_INSTALLATEUR_WINDOWS.ps1 new file mode 100644 index 0000000..f16a6c5 --- /dev/null +++ b/CREER_INSTALLATEUR_WINDOWS.ps1 @@ -0,0 +1,164 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $ProjectRoot + +Write-Host "" +Write-Host "=== YouTube Personal - Creation installateur Windows V2 ===" -ForegroundColor Cyan +Write-Host "Projet : $ProjectRoot" +Write-Host "" + +if (-not (Get-Command flutter -ErrorAction SilentlyContinue)) { + throw "Flutter est introuvable dans le PATH. Ouvre le terminal Flutter habituel puis relance ce script." +} + +$PubspecPath = Join-Path $ProjectRoot "pubspec.yaml" +if (-not (Test-Path $PubspecPath)) { + throw "pubspec.yaml introuvable. Copie ce pack a la racine de ton projet Flutter." +} + +$Pubspec = Get-Content $PubspecPath -Raw +$Version = "1.0.0" +if ($Pubspec -match '(?m)^version:\s*([^\s+]+)(?:\+\S+)?\s*$') { + $Version = $Matches[1].Trim() +} +Write-Host "[INFO] Version : $Version" -ForegroundColor DarkCyan + +Write-Host "" +Write-Host "[1/3] Compilation Windows Release..." -ForegroundColor Yellow +flutter pub get +if ($LASTEXITCODE -ne 0) { throw "flutter pub get a echoue." } + +flutter build windows --release +if ($LASTEXITCODE -ne 0) { throw "flutter build windows --release a echoue." } + +$ReleaseDir = Join-Path $ProjectRoot "build\windows\x64\runner\Release" +$ExePath = Join-Path $ReleaseDir "youtube_personal.exe" +if (-not (Test-Path $ExePath)) { + throw "Executable introuvable apres compilation : $ExePath" +} +Write-Host "[OK] Application Windows : $ExePath" -ForegroundColor Green + +function Find-InnoSetupCompiler { + # 1. PATH + $Cmd = Get-Command ISCC.exe -ErrorAction SilentlyContinue + if ($Cmd) { return $Cmd.Source } + + # 2. Emplacements standards Windows, y compris Program Files (x86) + $PF64 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) + $PF86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) + + $Candidates = @() + if ($PF86) { + $Candidates += (Join-Path $PF86 "Inno Setup 6\ISCC.exe") + $Candidates += (Join-Path $PF86 "Inno Setup 5\ISCC.exe") + } + if ($PF64) { + $Candidates += (Join-Path $PF64 "Inno Setup 6\ISCC.exe") + $Candidates += (Join-Path $PF64 "Inno Setup 5\ISCC.exe") + } + if ($env:LOCALAPPDATA) { + $Candidates += (Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe") + $Candidates += (Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 5\ISCC.exe") + } + + foreach ($Path in $Candidates) { + if ($Path -and (Test-Path $Path)) { return $Path } + } + + # 3. Registre: installations machine/utilisateur, 32 et 64 bits + $RegistryRoots = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + + foreach ($Root in $RegistryRoots) { + try { + $Apps = Get-ItemProperty $Root -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -like "Inno Setup*" } + foreach ($App in $Apps) { + if ($App.InstallLocation) { + $Path = Join-Path $App.InstallLocation "ISCC.exe" + if (Test-Path $Path) { return $Path } + } + } + } catch { } + } + + # 4. Dernier recours: recherche ciblee dans les dossiers Inno Setup uniquement + $Roots = @($PF86, $PF64, (Join-Path $env:LOCALAPPDATA "Programs")) | + Where-Object { $_ -and (Test-Path $_) } + foreach ($Root in $Roots) { + try { + $Dirs = Get-ChildItem -Path $Root -Directory -Filter "Inno Setup*" -ErrorAction SilentlyContinue + foreach ($Dir in $Dirs) { + $Path = Join-Path $Dir.FullName "ISCC.exe" + if (Test-Path $Path) { return $Path } + } + } catch { } + } + + return $null +} + +Write-Host "" +Write-Host "[2/3] Detection de Inno Setup..." -ForegroundColor Yellow +$ISCC = Find-InnoSetupCompiler + +if (-not $ISCC) { + $Winget = Get-Command winget -ErrorAction SilentlyContinue + if ($Winget) { + Write-Host "[INFO] ISCC.exe non trouve. Tentative d'installation/reparation avec winget..." + # Ne pas considerer 'deja installe / aucune mise a jour' comme une erreur fatale. + & winget install --id JRSoftware.InnoSetup -e --accept-package-agreements --accept-source-agreements --silent + $WingetExit = $LASTEXITCODE + Write-Host "[INFO] winget termine avec le code $WingetExit. Nouvelle detection..." + $ISCC = Find-InnoSetupCompiler + } +} + +if (-not $ISCC) { + Write-Host "" + Write-Host "Inno Setup semble installe mais ISCC.exe reste introuvable." -ForegroundColor Red + Write-Host "Teste cette commande pour le localiser :" -ForegroundColor Yellow + Write-Host 'Get-ChildItem "C:\Program Files*" -Filter ISCC.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName' -ForegroundColor Cyan + throw "ISCC.exe introuvable." +} + +Write-Host "[OK] Inno Setup : $ISCC" -ForegroundColor Green + +Write-Host "" +Write-Host "[3/3] Creation du Setup.exe..." -ForegroundColor Yellow +$InstallerDir = Join-Path $ProjectRoot "installer\windows" +$IssFile = Join-Path $InstallerDir "youtube_personal.iss" +$DistDir = Join-Path $ProjectRoot "dist" +New-Item -ItemType Directory -Force -Path $DistDir | Out-Null + +if (-not (Test-Path $IssFile)) { + throw "Fichier Inno Setup introuvable : $IssFile" +} + +Push-Location $InstallerDir +try { + & $ISCC "/DAppVersion=$Version" "/DSourceDir=$ReleaseDir" "/DOutputDir=$DistDir" $IssFile + if ($LASTEXITCODE -ne 0) { throw "La compilation Inno Setup a echoue avec le code $LASTEXITCODE." } +} +finally { + Pop-Location +} + +$SetupPath = Join-Path $DistDir "YouTube_Personal_Setup_$Version.exe" +if (-not (Test-Path $SetupPath)) { + throw "Le Setup n'a pas ete trouve a l'emplacement attendu : $SetupPath" +} + +Write-Host "" +Write-Host "=============================================" -ForegroundColor Green +Write-Host " INSTALLATEUR WINDOWS CREE AVEC SUCCES" -ForegroundColor Green +Write-Host "=============================================" -ForegroundColor Green +Write-Host "" +Write-Host $SetupPath -ForegroundColor Cyan +Write-Host "" +Start-Process explorer.exe -ArgumentList $DistDir diff --git a/ETAPE10_SCREEN_LOCK_PIP_TEST.md b/ETAPE10_SCREEN_LOCK_PIP_TEST.md new file mode 100644 index 0000000..63230b7 --- /dev/null +++ b/ETAPE10_SCREEN_LOCK_PIP_TEST.md @@ -0,0 +1,55 @@ +# Correctif écran verrouillé lorsque le PiP est actif + +Le log précédent montrait : + +```text +[BACKGROUND] lifecycle=AppLifecycleState.hidden pip=true playing=true +[BACKGROUND] lifecycle=AppLifecycleState.paused pip=true playing=true +... +AudioTrack ... stop +``` + +Le problème était que le code interprétait `pip=true` comme +« la vidéo est toujours visible » et refusait donc d'activer le mode +audio-only. + +Or, lors du verrouillage, Android peut conserver l'état PiP pendant que +l'écran s'éteint et que la Surface vidéo disparaît. + +## Nouveau comportement + +`MainActivity.kt` écoute maintenant directement : + +- `ACTION_SCREEN_OFF` +- `ACTION_SCREEN_ON` + +Quand `ACTION_SCREEN_OFF` arrive : + +1. Flutter reçoit `screenInteractiveChanged=false`. +2. La piste vidéo est désactivée avec `VideoTrack.no()`, même si `pip=true`. +3. Le Player garde l'audio et la MediaSession. +4. Le foreground service reste actif. + +Quand l'écran revient : + +1. Flutter reçoit `screenInteractiveChanged=true`. +2. Il attend 450 ms que la Surface Android revienne. +3. `VideoTrack.auto()` restaure l'image. + +## Test utile + +Lance Android en debug, démarre une vidéo, passe éventuellement en PiP, +puis verrouille le téléphone. + +Cherche ces lignes : + +```text +[BACKGROUND] native screen interactive=false +[BACKGROUND] disable video track +[BACKGROUND] audio-only active +``` + +Si ensuite Android affiche malgré tout `AudioTrack ... stop`, cela voudra +dire que `media_kit` arrête son moteur audio indépendamment de la piste vidéo. +Dans ce cas, la prochaine étape sera de déplacer la lecture audio de fond +dans un Player séparé géré par le service Android. diff --git a/ETAPE10_SCREEN_LOCK_TEST.md b/ETAPE10_SCREEN_LOCK_TEST.md new file mode 100644 index 0000000..b3ed17e --- /dev/null +++ b/ETAPE10_SCREEN_LOCK_TEST.md @@ -0,0 +1,34 @@ +# Correctif écran verrouillé Android + +Ce correctif ne change pas le PiP. + +Quand Android met l'application en arrière-plan ou verrouille l'écran : + +1. la position et l'état lecture/pause sont conservés ; +2. la piste vidéo de `media_kit` est désactivée avec `VideoTrack.no()` ; +3. l'audio du même flux 360p continue dans la MediaSession / foreground service ; +4. au retour dans l'application, `VideoTrack.auto()` restaure l'image. + +Cela évite de maintenir le décodage vidéo / Surface Android lorsque l'écran est éteint. + +## Test + +Lance : + +```powershell +flutter run -d f10778d8 +``` + +Puis : + +1. démarre une vidéo ; +2. vérifie qu'elle joue ; +3. verrouille le téléphone sans mettre la vidéo en pause ; +4. attends 20 secondes ; +5. le son doit continuer ; +6. déverrouille ; +7. l'image doit revenir à la position audio actuelle. + +Les logs utiles commencent par : + +`[BACKGROUND]` diff --git a/ETAPE10_TEST.md b/ETAPE10_TEST.md new file mode 100644 index 0000000..7776b4d --- /dev/null +++ b/ETAPE10_TEST.md @@ -0,0 +1,35 @@ +# Étape 10 — PiP Android + lecture écran verrouillé + +## Ce qui change + +- `audio_service` maintient une MediaSession Android. +- Android dispose donc de contrôles système : + - retour 10 s + - lecture / pause + - avance 10 s +- Ces contrôles sont utilisés par : + - la notification média + - l'écran verrouillé + - les boutons casque / Bluetooth + - le mode Picture-in-Picture Android lorsqu'on touche la fenêtre PiP. +- Le service passe en foreground pendant la lecture afin que le système + ne suspende pas le moteur de lecture quand l'écran s'éteint. + +## Premier lancement Android + +Android 13+ peut demander l'autorisation d'afficher les notifications. +Accepte-la pour voir les contrôles média dans la notification et sur +l'écran verrouillé. + +## Test conseillé + +1. Lance une vidéo. +2. Passe en PiP. +3. Touche une fois la fenêtre PiP : + les contrôles système doivent apparaître. +4. Teste pause / lecture. +5. Reviens dans l'application. +6. Lance la vidéo et verrouille le téléphone. +7. Attends 15 à 30 secondes. +8. Vérifie que le son continue et que la position avance. +9. Sur l'écran verrouillé, teste lecture / pause et +/- 10 s. diff --git a/ETAPE11_BACKGROUND_PLAYER_TEST.md b/ETAPE11_BACKGROUND_PLAYER_TEST.md new file mode 100644 index 0000000..221f432 --- /dev/null +++ b/ETAPE11_BACKGROUND_PLAYER_TEST.md @@ -0,0 +1,65 @@ +# Étape 11 — Player audio séparé pour écran verrouillé + +Les logs précédents montrent que le Player vidéo est arrêté AVANT que +`ACTION_SCREEN_OFF` arrive : + +- `playing=true` +- `AudioTrack ... stop` +- puis seulement `screenInteractive=false` + +Le correctif précédent ne pouvait donc pas empêcher l'arrêt. + +## Nouvelle architecture Android + +Quand l'écran s'éteint : + +1. on conserve l'URL 360p déjà résolue ; +2. on relève la dernière position connue ; +3. `audio_service` démarre un DEUXIÈME `media_kit.Player` ; +4. ce Player n'a aucun `VideoController` ; +5. `PlayerConfiguration(vo: 'null')` désactive la sortie vidéo ; +6. `VideoTrack.no()` désactive explicitement la piste vidéo ; +7. il seek à la position courante et reprend uniquement l'audio ; +8. les contrôles notification / lockscreen pilotent ce Player. + +Quand l'écran se rallume : + +1. on lit la position réelle du Player audio de fond ; +2. on le détruit ; +3. on seek le Player vidéo à cette position ; +4. on remet la même vitesse ; +5. on reprend la vidéo si l'audio était encore en lecture. + +## Test + +```powershell +flutter clean +flutter pub get +flutter run -d f10778d8 +``` + +Puis : + +1. démarre une vidéo et attends au moins 10 secondes ; +2. verrouille le téléphone ; +3. accepte une très courte coupure de transition lors de ce premier test ; +4. le son doit reprendre et continuer écran éteint ; +5. utilise play/pause et +/-10 s sur l'écran verrouillé ; +6. rallume et déverrouille ; +7. la vidéo doit revenir à la position atteinte par l'audio. + +Logs attendus : + +```text +[BACKGROUND] start dedicated audio ... +[BACKGROUND-AUDIO] switching to dedicated player ... +[BACKGROUND-AUDIO] dedicated player ready ... playing=true +[BACKGROUND] dedicated audio active +``` + +Au retour : + +```text +[BACKGROUND-AUDIO] returning to video player ... +[BACKGROUND] video restored ... +``` diff --git a/ETAPE11_FIX_PLAY_INTENT_SEEK.md b/ETAPE11_FIX_PLAY_INTENT_SEEK.md new file mode 100644 index 0000000..d3b4b31 --- /dev/null +++ b/ETAPE11_FIX_PLAY_INTENT_SEEK.md @@ -0,0 +1,53 @@ +# Correctif étape 11 — état lecture + seek du Player de fond + +Les nouveaux logs montraient deux problèmes précis : + +```text +lifecycle=hidden ... playing=true +lifecycle=paused ... playing=true +AudioTrack ... stop +screen interactive=false ... playing=false +start dedicated audio ... recentlyPlaying=false +switching ... shouldPlay=false +dedicated player ready at 0 ms playing=false +``` + +Donc : + +1. Android arrête le Player vidéo AVANT `ACTION_SCREEN_OFF`. +2. Notre code perdait l'information qu'il était en lecture. +3. Le Player audio de fond recevait `shouldPlay=false`. +4. Son `seek` à ~111 s était envoyé trop tôt et restait à 0 s. + +## Correctif + +### Intention de lecture + +Au callback lifecycle `inactive/hidden/paused`, tant que le Player vaut encore +`playing=true`, on mémorise `continueOnLock=true`. + +Quand `ACTION_SCREEN_OFF` arrive plus tard, cette valeur est utilisée même si +Android a déjà mis le Player vidéo à `playing=false`. + +### Seek du Player audio + +Le Player de fond attend désormais que la durée du média soit connue, puis : + +1. seek à la position courante ; +2. vérification de la position ; +3. jusqu'à 3 tentatives ; +4. seulement ensuite `play()` ; +5. confirmation que `playing=true`. + +## Logs attendus + +```text +[BACKGROUND] lifecycle=AppLifecycleState.hidden ... continueOnLock=true +[BACKGROUND] native screen interactive=false ... playing=false +[BACKGROUND] start dedicated audio ... continueOnLock=true +[BACKGROUND-AUDIO] switching ... shouldPlay=true +[BACKGROUND-AUDIO] seek attempt 1 -> ... +[BACKGROUND-AUDIO] seek confirmed ... +[BACKGROUND-AUDIO] dedicated player ready at ... playing=true +[BACKGROUND] dedicated audio active +``` diff --git a/ETAPE12_BACKGROUND_FINITION.md b/ETAPE12_BACKGROUND_FINITION.md new file mode 100644 index 0000000..1ea60cc --- /dev/null +++ b/ETAPE12_BACKGROUND_FINITION.md @@ -0,0 +1,17 @@ +# Étape 12 — finition lecture écran verrouillé + +Cette étape ne change pas l'architecture qui fonctionne déjà. + +Elle ajoute deux sécurités : + +1. Pendant la lecture écran verrouillé, la position est sauvegardée localement + toutes les 5 secondes. Si Android tue l'application ou si elle est fermée + pendant que l'écran est verrouillé, la reprise reste donc à jour. +2. Au déverrouillage, le Player vidéo ne fait plus un simple `seek()`. + Il utilise la même logique de `seek + confirmation` que la reprise + automatique déjà validée, pour garantir que l'image revient réellement + à la position du Player audio. + +Les boutons du lockscreen `-10 s / pause-play / +10 s` continuent à piloter +le Player audio de fond, et un seek fait depuis le lockscreen est maintenant +persisté immédiatement. diff --git a/ETAPE12_FIX_PAUSE_LOCK.md b/ETAPE12_FIX_PAUSE_LOCK.md new file mode 100644 index 0000000..2966755 --- /dev/null +++ b/ETAPE12_FIX_PAUSE_LOCK.md @@ -0,0 +1,26 @@ +# Correctif pause + verrouillage Android + +Le bug venait du délai `recentlyPlaying` de 10 secondes. + +Si la vidéo venait d'être en lecture puis était mise en pause juste avant +le verrouillage, le code considérait encore `recentlyPlaying=true` et +démarrait volontairement le Player audio de fond. + +Ce correctif supprime complètement cette heuristique. + +Nouveau comportement : + +- vidéo en lecture au verrouillage -> audio de fond démarre ; +- vidéo déjà en pause au verrouillage -> elle reste en pause ; +- un événement Android `paused=false` tardif ne peut plus écraser un état + `playing=true` capturé juste avant l'arrêt système de l'AudioTrack. + +Test : + +1. lance une vidéo ; +2. mets-la en pause ; +3. attends 1 seconde ; +4. verrouille le téléphone ; +5. aucun son ne doit démarrer ; +6. déverrouille : la vidéo doit toujours être en pause ; +7. refais le test avec la vidéo en lecture : l'audio doit continuer. diff --git a/ETAPE13_FIX_403_DIAGNOSTIC.md b/ETAPE13_FIX_403_DIAGNOSTIC.md new file mode 100644 index 0000000..79d5f11 --- /dev/null +++ b/ETAPE13_FIX_403_DIAGNOSTIC.md @@ -0,0 +1,19 @@ +# Correctif diagnostic 403 YouTube Data API + +La capture Google Cloud confirme que YouTube Data API v3 est activée. + +L'ancien code transformait TOUS les HTTP 403 en : +"Vérifie que YouTube Data API v3 est activée", ce qui masquait la vraie cause. + +Ce correctif lit maintenant le champ `reason` renvoyé par Google, par exemple : + +- insufficientPermissions +- subscriptionForbidden +- quotaExceeded +- accessNotConfigured + +Le terminal affiche aussi une ligne : + +[YT-ACCOUNT][API-ERROR] status=403 reason=... message=... + +Aucun jeton OAuth n'est affiché dans les logs. diff --git a/ETAPE13_FIX_DIALOG_OAUTH.md b/ETAPE13_FIX_DIALOG_OAUTH.md new file mode 100644 index 0000000..272cd9b --- /dev/null +++ b/ETAPE13_FIX_DIALOG_OAUTH.md @@ -0,0 +1,26 @@ +# Correctif crash écran OAuth + +Le crash `_dependents.isEmpty` venait de la gestion de la boîte de dialogue +OAuth dans `subscriptions_page.dart`. + +Avant, le code : + +- créait les `TextEditingController` dans la page parente ; +- modifiait le `ValueNotifier` de configuration pendant que le dialogue + était encore monté dans l'Overlay ; +- reconstruisait la page parente ; +- fermait ensuite le dialogue et détruisait immédiatement les contrôleurs. + +Cette combinaison pouvait provoquer une désactivation incohérente dans +l'arbre Flutter en debug. + +Le correctif : + +- utilise maintenant un vrai `StatefulWidget` pour le dialogue ; +- les contrôleurs appartiennent au dialogue et sont détruits dans son + `dispose()` ; +- aucune configuration n'est enregistrée tant que le dialogue n'est pas + complètement fermé ; +- la page est mise à jour seulement après le retour de `showDialog()`. + +Aucun changement OAuth, Google Sign-In, lecture, PiP ou arrière-plan. diff --git a/ETAPE14_CHAINES_VIDEOS.md b/ETAPE14_CHAINES_VIDEOS.md new file mode 100644 index 0000000..a617b67 --- /dev/null +++ b/ETAPE14_CHAINES_VIDEOS.md @@ -0,0 +1,21 @@ +# Étape 14 — chaînes abonnements + vidéos récentes + +Ajouts : + +- champ de recherche dans les abonnements ; +- clic sur une chaîne ; +- nouvelle page avec les 30 vidéos récentes ; +- clic sur une vidéo -> lecteur YouTube Personal existant ; +- durée affichée grâce à `videos.list`. + +Pour éviter de gaspiller le quota YouTube Data API, cette étape n'utilise +PAS `search.list`. + +Pour une chaîne ouverte, elle fait seulement : + +1. `channels.list(part=contentDetails)` pour trouver la playlist `uploads`; +2. `playlistItems.list` pour lire jusqu'à 30 mises en ligne ; +3. `videos.list(part=contentDetails,status)` pour récupérer les durées. + +Les données du compte restent lues avec le token OAuth officiel. +Le token OAuth n'est toujours pas transmis au lecteur vidéo. diff --git a/ETAPE14_FIX_ANDROID_CARDS.md b/ETAPE14_FIX_ANDROID_CARDS.md new file mode 100644 index 0000000..1972561 --- /dev/null +++ b/ETAPE14_FIX_ANDROID_CARDS.md @@ -0,0 +1,20 @@ +# Correctif affichage Android — cartes d'abonnements + +Le log Flutter montrait : + +- `A RenderFlex overflowed by 21 pixels on the bottom` +- `subscriptions_page.dart` +- contrainte de carte d'environ `145.7 x 98.6` + +La carte était trop basse pour contenir à la fois : +avatar + titre + icône/texte "Voir les vidéos". + +Le correctif rend la grille responsive : + +- Android / largeur < 560 px : hauteur de carte fixe de 178 px ; +- avatar légèrement réduit ; +- padding et espacements réduits ; +- titre et ligne d'action utilisent `Flexible` ; +- desktop conserve l'affichage actuel. + +Aucun changement OAuth, API YouTube, lecteur, PiP ou arrière-plan. diff --git a/ETAPE14_FIX_ANDROID_LIST.md b/ETAPE14_FIX_ANDROID_LIST.md new file mode 100644 index 0000000..a754b27 --- /dev/null +++ b/ETAPE14_FIX_ANDROID_LIST.md @@ -0,0 +1,19 @@ +# Correctif Android — liste au lieu de petites cartes + +Les nouveaux logs ne montrent plus l'exception `RenderFlex overflow`. +Donc le problème d'origine de débordement est corrigé. + +Pour rendre l'affichage Android robuste, cette version n'utilise plus +la grille 2 colonnes sur petit écran. + +Sur Android / largeur < 600 px : +- une chaîne = une ligne ; +- avatar à gauche ; +- nom au centre ; +- icône "Voir les vidéos" visible ; +- chevron à droite. + +Sur Windows / tablette : +- la grille existante reste inchangée. + +Aucun changement OAuth, API YouTube, lecteur, PiP ou arrière-plan. diff --git a/ETAPE14_FIX_COMPILE_MOBILE.md b/ETAPE14_FIX_COMPILE_MOBILE.md new file mode 100644 index 0000000..2efd2bf --- /dev/null +++ b/ETAPE14_FIX_COMPILE_MOBILE.md @@ -0,0 +1,17 @@ +# Correctif compilation mobile + +Erreur corrigée : + +`The getter 'mobile' isn't defined for the type '_SubscriptionsPageState'` + +Cause : +une modification précédente avait remplacé par erreur un `SizedBox` +dans `_buildSignedOut()`, une méthode où la variable locale `mobile` +n'existe pas. + +Correctif : +- `_buildSignedOut()` revient à `const SizedBox(width: 12)`; +- le `SizedBox(width: mobile ? 10 : 12)` est placé uniquement dans + `_buildSignedIn()`, là où `mobile` est bien défini. + +Aucun autre comportement n'est modifié. diff --git a/ETAPE14_FIX_HOME_OVERFLOW.md b/ETAPE14_FIX_HOME_OVERFLOW.md new file mode 100644 index 0000000..c4af55d --- /dev/null +++ b/ETAPE14_FIX_HOME_OVERFLOW.md @@ -0,0 +1,21 @@ +# Correctif overflow Accueil Android + +Le dernier log ne montre plus d'erreur dans `subscriptions_page.dart`. + +La seule exception Flutter restante est : + +`A RenderFlex overflowed by 0.188 pixels on the bottom` + +et elle vient de : + +`lib/screens/home_page.dart:270` + +La carte de l'accueil avait une hauteur mobile de 220 px. Le calcul du +thumbnail 16:9 + textes + espacements aboutissait à une différence +fractionnaire sur cet appareil. + +Correctif : +- hauteur mobile de la section horizontale : 220 -> 226 px ; +- desktop reste à 255 px. + +Aucun changement dans les abonnements, OAuth, lecteur, PiP ou arrière-plan. diff --git a/ETAPE14_FIX_MOBILE_HEADER_NAV.md b/ETAPE14_FIX_MOBILE_HEADER_NAV.md new file mode 100644 index 0000000..e5f4134 --- /dev/null +++ b/ETAPE14_FIX_MOBILE_HEADER_NAV.md @@ -0,0 +1,19 @@ +# Correctif mobile : en-tête et navigation + +La vidéo montre que les avatars des chaînes se chargent correctement. + +Les deux problèmes visibles sont surtout des problèmes de place sur 411 dp : + +- le titre `Abonnements (37)` passe sur deux lignes ; +- le libellé `Abonnements` de la barre de navigation passe aussi sur deux lignes. + +Correctif : + +- titre forcé sur une seule ligne sur mobile ; +- taille du titre légèrement réduite sur mobile ; +- actions `Configuration OAuth` et `Déconnexion` regroupées dans un menu `⋮` ; +- le bouton `Actualiser` reste directement visible ; +- libellé mobile `Abonnements` raccourci en `Abos` ; +- desktop conserve `Abonnements` dans la NavigationRail. + +Aucun changement OAuth, API YouTube, lecteur, PiP ou arrière-plan. diff --git a/ETAPE15_FIX_QUOTA_LOOP.md b/ETAPE15_FIX_QUOTA_LOOP.md new file mode 100644 index 0000000..7d15bea --- /dev/null +++ b/ETAPE15_FIX_QUOTA_LOOP.md @@ -0,0 +1,29 @@ +# Étape 15 — correctif boucle OAuth / quota YouTube + +Le nouveau log montre la vraie cause : + +`reason=quotaExceeded` + +La boucle venait de l'interaction entre deux morceaux du code : + +1. `_loadSubscriptions()` appelait `getAccessToken()`. +2. Sur Android, `getAccessToken()` réécrivait systématiquement `session.value`. +3. Le listener `_onSessionChanged()` voyait cette écriture. +4. Il rappelait immédiatement `_loadSubscriptions()`. +5. La boucle recommençait et envoyait des centaines de requêtes à + YouTube Data API jusqu'à épuiser le quota du projet. + +Correctif : + +- `getAccessToken()` ne notifie plus `session.value` si le token est inchangé ; +- `_onSessionChanged()` ne relance jamais les abonnements si une requête + est déjà en cours ; +- `_loadSubscriptions()` possède maintenant un verrou anti-réentrance ; +- une connexion initiale continue de charger automatiquement les abonnements. + +Important : +le quota actuellement épuisé est un quota du projet YouTube Data API, +pas un bannissement du compte Google/YouTube. + +Après remplacement des fichiers, redémarre l'application. Le flot de +centaines de lignes `[YT-ACCOUNT][API-ERROR]` doit disparaître. diff --git a/ETAPE16_1_FIX_NULLABLE_HEIGHT.md b/ETAPE16_1_FIX_NULLABLE_HEIGHT.md new file mode 100644 index 0000000..88c2600 --- /dev/null +++ b/ETAPE16_1_FIX_NULLABLE_HEIGHT.md @@ -0,0 +1,27 @@ +# Étape 16.1 - correctif nullable height + +Erreur corrigée : + +- `_player.state.height` est de type `int?` +- `_player.stream.height` émet également des `int?` + +Le code HD comparait ces valeurs directement avec des `int`, ce qui +empêchait la compilation Dart. + +Correctif : + +- `state.height ?? 0` +- `.whereType()` sur le stream de hauteur +- `state.width ?? 0` pour le diagnostic de résolution + +Aucun changement dans l'extraction NewPipe, Gradle, PiP, arrière-plan +ou OAuth. + +Après extraction : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d f10778d8 +``` + +Un `flutter clean` n'est pas nécessaire pour ce correctif Dart. diff --git a/ETAPE16_1_FIX_STREAM_HEIGHT.md b/ETAPE16_1_FIX_STREAM_HEIGHT.md new file mode 100644 index 0000000..e5a4b06 --- /dev/null +++ b/ETAPE16_1_FIX_STREAM_HEIGHT.md @@ -0,0 +1,22 @@ +# Étape 16.1 - correctif Stream + +Erreur corrigée : + +`Stream` ne possède pas la méthode `whereType()` dans cette +configuration Dart. + +Le code utilise maintenant directement : + +- `firstWhere((height) => (height ?? 0) >= minimumHeight)` +- puis `streamedHeight ?? 0` + +Aucun autre changement n'est effectué dans le lecteur HD. + +Après extraction : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d f10778d8 +``` + +Pas besoin de `flutter clean` ni de `flutter pub get`. diff --git a/ETAPE16_1_HD_DIRECT.md b/ETAPE16_1_HD_DIRECT.md new file mode 100644 index 0000000..663ba67 --- /dev/null +++ b/ETAPE16_1_HD_DIRECT.md @@ -0,0 +1,53 @@ +# Étape 16.1 - HD directe vidéo + audio séparés + +Le log précédent confirme deux choses importantes : + +1. NewPipeExtractor arrive bien à voir les qualités HD : + 2160p60, 1440p60, 1080p60, 720p60, etc. +2. Le manifest HLS ne devient jamais prêt dans media_kit Android au bout + de 9 secondes, donc le code retourne automatiquement au 360p. + +Cette étape n'utilise donc plus HLS comme source principale. + +Nouvelle stratégie : + +- NewPipeExtractor choisit un flux vidéo-only direct ; +- priorité AVC/H.264 ; +- plafond volontaire à 1080p pour ce premier test ; +- choix d'un flux audio direct séparé ; +- priorité AAC/mp4a ; +- media_kit ouvre la vidéo et charge l'audio via `AudioTrack.uri(...)` ; +- le Player vérifie ensuite la vraie hauteur décodée ; +- si la HD échoue, fallback automatique vers le 360p stable. + +Le lecteur écran verrouillé reste sur le flux progressif 360p stable. + +## Test + +Après extraction à la racine du projet : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d f10778d8 +``` + +Pas besoin de modifier Gradle et ne relance pas les scripts Gradle précédents. + +## Logs recherchés + +Succès attendu : + +```text +[HD] paire directe sélectionnée: 1080p60 ... +[HD] lecture confirmée: 1920x1080 ... +``` + +Si la paire directe échoue : + +```text +[HD] paire directe non lisible, retour 360p: ... +``` + +L'interface affiche aussi maintenant la qualité réellement confirmée +par media_kit, et pas seulement la qualité annoncée par l'extracteur. diff --git a/ETAPE16_2_QUALITES_4K.md b/ETAPE16_2_QUALITES_4K.md new file mode 100644 index 0000000..72ee174 --- /dev/null +++ b/ETAPE16_2_QUALITES_4K.md @@ -0,0 +1,80 @@ +# Étape 16.2 - Sélecteur qualité + 4K Android + +Cette étape part de la version Android 1080p réellement validée. + +## Nouveau sélecteur + +Le lecteur Android propose maintenant : + +- Auto +- 360p +- 720p +- 1080p +- 1440p +- 2160p (4K) + +La qualité par défaut reste 1080p, car c'est la qualité déjà validée. + +## Changement en cours de lecture + +Changer la qualité recharge le flux en conservant : + +- la position actuelle ; +- l'état lecture/pause ; +- la vitesse de lecture. + +## Codecs + +Jusqu'à 1080p : +- priorité AVC/H.264 + +Pour 1440p et 2160p : +- priorité VP9 +- puis AV1 +- puis meilleur flux restant + +Le son reste une piste séparée, avec priorité AAC. + +## Fallback + +Pour Auto, 1440p et 2160p : + +1. tentative dans la qualité demandée ; +2. si le téléphone ne peut pas la lire, tentative 1080p AVC ; +3. si cela échoue aussi, retour au 360p stable. + +Le Player audio écran verrouillé continue d'utiliser le flux progressif +360p stable. + +## Windows + +Windows reste volontairement en 360p dans cette étape. Le sélecteur +Windows sera ajouté après validation du 4K Android. + +## Test + +Après extraction à la racine du projet : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d f10778d8 +``` + +Aucun script Gradle à relancer. + +Pour tester la 4K, choisir `2160p (4K)`. + +Succès attendu : + +```text +[HD] paire directe sélectionnée: 2160p... +[HD] lecture confirmée: 3840x2160 ... +``` + +Si le téléphone refuse le flux 4K : + +```text +[HD] cible=2160 non lisible: ... +[HD] essai de secours 1080p AVC +``` diff --git a/ETAPE16_FIX_GRADLE_AGP9.md b/ETAPE16_FIX_GRADLE_AGP9.md new file mode 100644 index 0000000..e509c07 --- /dev/null +++ b/ETAPE16_FIX_GRADLE_AGP9.md @@ -0,0 +1,20 @@ +# Correctif Gradle Flutter 3.44 / AGP 9 + +Erreur observée : + +- `kotlin { ... }` interprété au niveau projet ; +- `compilerOptions` et `jvmTarget` non résolus. + +Avec le Kotlin intégré d'AGP 9 utilisé par les versions Flutter récentes, +le bloc Kotlin de l'application doit se trouver dans `android { ... }`. + +Le script `REPARER_HD_ANDROID_GRADLE.ps1` : + +1. sauvegarde `android/app/build.gradle.kts` ; +2. retire le bloc Kotlin invalide au niveau projet ; +3. l'insère dans `android { ... }` ; +4. conserve le desugaring ; +5. conserve les dépendances NewPipeExtractor/OkHttp. + +Le fichier `APPLIQUER_HD_ANDROID.ps1` est également remplacé par une version +corrigée pour éviter que le même problème revienne. diff --git a/ETAPE16_FIX_KOTLIN_DSL.md b/ETAPE16_FIX_KOTLIN_DSL.md new file mode 100644 index 0000000..aa29bf6 --- /dev/null +++ b/ETAPE16_FIX_KOTLIN_DSL.md @@ -0,0 +1,24 @@ +# Étape 16 - correctif Kotlin DSL / AGP 9 + +Le correctif précédent a déplacé `kotlin { compilerOptions { ... } }` +dans `android {}`, mais ce bloc n'est pas reconnu dans la configuration +actuelle du projet. + +Le correctif actuel retire complètement ce bloc. + +Pourquoi c'est sûr ici : + +- `android.compileOptions.targetCompatibility` est déjà Java 17 ; +- avec le Kotlin intégré d'AGP 9, `jvmTarget` hérite par défaut de + `android.compileOptions.targetCompatibility` ; +- on n'a donc pas besoin de déclarer `jvmTarget` séparément. + +Le script conserve : + +- `isCoreLibraryDesugaringEnabled = true` ; +- NewPipeExtractor 0.26.3 ; +- OkHttp 4.12.0 ; +- desugar_jdk_libs_nio 2.1.5. + +Le fichier `APPLIQUER_HD_ANDROID.ps1` est aussi remplacé pour qu'il ne +réintroduise plus ce bloc lors d'un futur ré-essai de l'étape HD. diff --git a/ETAPE16_FIX_MAINACTIVITY_JAVA.md b/ETAPE16_FIX_MAINACTIVITY_JAVA.md new file mode 100644 index 0000000..853d306 --- /dev/null +++ b/ETAPE16_FIX_MAINACTIVITY_JAVA.md @@ -0,0 +1,34 @@ +# Étape 16 - correctif MainActivity absente de l'APK + +Le build Gradle passe maintenant, mais Android plante immédiatement avec : + +`ClassNotFoundException: com.studioweb.youtube_personal.MainActivity` + +Cela signifie que le manifeste trouve bien le nom de l'activité, mais que +la classe Kotlin `MainActivity` n'a pas été empaquetée dans l'APK. + +Pour ne plus dépendre de la configuration Kotlin du module app pendant +l'expérimentation HD, `MainActivity` est désormais en Java. + +Fonctions conservées : + +- `AudioServiceActivity` +- PiP Android +- notification permission Android 13+ +- détection écran ON/OFF +- callbacks PiP vers Flutter +- canal MethodChannel HD +- extraction HD sur thread dédié + +L'ancien `MainActivity.kt` est remplacé par un fichier sans classe afin de +ne pas créer de doublon si Kotlin est réactivé plus tard. + +Après extraction : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d f10778d8 +``` + +Pas besoin de relancer les scripts Gradle précédents. diff --git a/ETAPE16_HD_ANDROID.md b/ETAPE16_HD_ANDROID.md new file mode 100644 index 0000000..f2f25d8 --- /dev/null +++ b/ETAPE16_HD_ANDROID.md @@ -0,0 +1,86 @@ +# Étape 16 - HD Android expérimentale + +## Pourquoi cette nouvelle approche + +Les essais précédents avec `youtube_explode_dart` ont réellement atteint +le blocage YouTube : + +`Sign in to confirm you're not a bot` + +et les flux adaptatifs 720p/1080p ne démarraient pas. + +En 2026, YouTube applique de plus en plus SABR et les PO Tokens. +NewPipe 0.28.8 a publié un contournement spécifique du problème +"seulement MP4 360p", via NewPipeExtractor 0.26.3. + +Cette étape teste une voie différente : + +- extraction native Android via NewPipeExtractor 0.26.3 ; +- récupération du manifest HLS Apple/VisionOS ; +- un seul Player `media_kit`, donc pas de synchronisation manuelle + audio + vidéo ; +- fallback automatique vers le 360p stable si HD échoue ; +- Windows reste volontairement en 360p stable pour cette étape. + +## Séparation du compte Google + +Le code `NewPipeDownloader` n'injecte : + +- aucun cookie Google ; +- aucun token OAuth ; +- aucun identifiant du compte Google connecté. + +La connexion Google reste exclusivement utilisée pour YouTube Data API +(abonnements). Le chemin de lecture HD reste anonyme. + +## Lecture écran verrouillé + +Même si l'image visible utilise HLS HD, le Player dédié écran verrouillé +continue d'utiliser l'URL progressive 360p stable. Cela évite de casser +la lecture en arrière-plan déjà validée. + +## Installation + +Après extraction du ZIP à la racine du projet : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +powershell -ExecutionPolicy Bypass -File .\APPLIQUER_HD_ANDROID.ps1 +flutter clean +flutter pub get +flutter run -d f10778d8 +``` + +Le script fait une sauvegarde automatique : + +- `android/build.gradle.kts.before_hd` +- `android/app/build.gradle.kts.before_hd` + +## Logs attendus + +Succès : + +```text +[HD] demande native pour ... +[HD] HLS disponible (...) +``` + +Fallback : + +```text +[HD] extraction indisponible, fallback 360p: ... +``` + +ou : + +```text +[HD] HLS non prêt, retour 360p: ... +``` + +En cas de fallback, le lecteur 360p doit continuer à fonctionner. + +## Licence + +NewPipeExtractor est sous GPL-3.0-or-later. Pour un test/personnel cela +ne change pas l'installation locale. Si l'application est distribuée +plus tard, il faudra traiter correctement les obligations de licence. diff --git a/ETAPE17_10_LOCAL_CHUNK_RELAY.md b/ETAPE17_10_LOCAL_CHUNK_RELAY.md new file mode 100644 index 0000000..8a5a654 --- /dev/null +++ b/ETAPE17_10_LOCAL_CHUNK_RELAY.md @@ -0,0 +1,94 @@ +# Étape 17.10 - Relais HTTP local chunké pour Windows + +## Ce que le test yt-dlp vient de prouver + +Le test direct `yt-dlp -f 299 --test` réussit réellement : + +```text +[info] ... Downloading 1 format(s): 299 +[download] Destination: ... +[download] 100% of 10.00KiB +``` + +Donc : + +- le client `mweb` fonctionne ; +- le PO Token est généré ; +- le serveur `googlevideo.com` accepte le flux ; +- l'IP et les headers sont valides ; +- le problème restant est le transport direct libmpv -> googlevideo. + +yt-dlp fournit aussi `downloader_options.http_chunk_size` pour YouTube +(typiquement 10 MiB). La documentation yt-dlp signale que YouTube peut +throttler les requêtes HTTP dont les chunks dépassent 10 MiB. + +## Nouvelle architecture Windows + +```text +yt-dlp + | + | URL vidéo/audio + PO Token + headers + http_chunk_size + v +Relais HTTP local Dart (127.0.0.1 uniquement) + | + | requêtes Range vers googlevideo <= 10 MiB + v +Googlevideo + +libmpv/media_kit + | + +----> http://127.0.0.1:/video + +----> http://127.0.0.1:/audio +``` + +libmpv ne contacte donc plus directement Google. + +Le relais : + +- écoute uniquement sur `127.0.0.1` ; +- transmet les headers exacts yt-dlp ; +- force `Accept-Encoding: identity` ; +- respecte les requêtes `Range` de mpv ; +- découpe l'upstream en blocs au maximum égaux au chunk yt-dlp ; +- permet le seek ; +- relaie séparément vidéo et audio. + +Android n'est pas modifié. + +## Installation + +Extraire ce ZIP à la racine du projet. + +Aucune nouvelle dépendance. +Aucun nouveau script PowerShell. + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d windows +``` + +Tester 1080p d'abord. + +## Logs attendus + +```text +[HD-WINDOWS][17.10] yt-dlp -> relais local -> libmpv +[HD-WINDOWS][17.9] extraction mweb + PO Token provider local +[HD-WINDOWS][17.10] transport yt-dlp: ... chunk=10485760o ... +[HD-RELAY][17.10] actif sur 127.0.0.1:... +[HD-RELAY] /video GET bytes=... +``` + +Succès : + +```text +[HD-WINDOWS] vidéo relayée confirmée: 1920x1080 +[HD-WINDOWS] lecture confirmée: 1920x1080 (relais local) +``` + +Si le relais reçoit un refus upstream, le log affichera directement : + +```text +[HD-RELAY] /video échec: googlevideo HTTP 403 ... +``` diff --git a/ETAPE17_11_AUDIO_RANGE_FIX.md b/ETAPE17_11_AUDIO_RANGE_FIX.md new file mode 100644 index 0000000..4fca75e --- /dev/null +++ b/ETAPE17_11_AUDIO_RANGE_FIX.md @@ -0,0 +1,76 @@ +# Étape 17.11 - Son + stabilité du relais Range Windows + +## Diagnostic du log 17.10 + +La vidéo fonctionne réellement en 1920x1080 : + +```text +1920 1080 +[HD-WINDOWS] vidéo relayée confirmée: 1920x1080 +``` + +Le son manque parce que la toute première requête audio reçoit : + +```text +googlevideo HTTP 403 pour bytes=0-10485759 +``` + +La vidéo finit elle aussi par recevoir un 403 exactement sur des requêtes +de 10 MiB. + +## Cause importante + +Notre relais 17.10 envoyait systématiquement des chunks de : + +```text +10485760 octets = 10 MiB +``` + +Le downloader HTTP officiel de yt-dlp ne fait pas cela. Lorsqu'un +`http_chunk_size` est configuré, il randomise chaque nouvelle requête entre +95 % et 100 % de la taille cible. + +La FAQ yt-dlp avertit également que YouTube peut throttler/refuser des +requêtes dont la taille dépasse 10 MB. + +## Correctif 17.11 + +Le relais : + +- plafonne maintenant les requêtes à 9 900 000 octets ; +- randomise chaque Range entre 95 % et 100 % ; +- force une nouvelle connexion logique par Range ; +- en cas de 403/429, retente jusqu'à 4 fois en divisant progressivement le + chunk ; +- flush le downstream après chaque Range pour éviter le gros buffering lors + d'un seek ; +- journalise séparément le client et la présence du `pot=` pour vidéo/audio. + +## Test + +Extraire le ZIP à la racine puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d windows +``` + +Tester uniquement le 1080p avec le son. + +Logs importants : + +```text +[HD-WINDOWS][17.11] GVS: videoClient=MWEB videoPOT=true audioClient=MWEB audioPOT=true +[HD-RELAY][17.11] upstream OK ... +``` + +Puis : + +```text +[HD-WINDOWS] vidéo relayée confirmée: 1920x1080 +[HD-WINDOWS] lecture confirmée: 1920x1080 +``` + +Si un 403 survient encore, la 17.11 affichera désormais les retries et la +taille exacte du chunk qui a été refusé. diff --git a/ETAPE17_12_YTDLP_NATIVE_RELAY.md b/ETAPE17_12_YTDLP_NATIVE_RELAY.md new file mode 100644 index 0000000..fd317fe --- /dev/null +++ b/ETAPE17_12_YTDLP_NATIVE_RELAY.md @@ -0,0 +1,101 @@ +# Étape 17.12 - Relais local piloté par le downloader natif yt-dlp + +## Ce que le log 17.11 a prouvé + +La résolution n'est plus le problème : + +```text +[HD-WINDOWS] vidéo relayée confirmée: 1920x1080 +``` + +Les deux URLs sont bien `MWEB` et portent bien un PO Token : + +```text +videoClient=MWEB videoPOT=true +audioClient=MWEB audioPOT=true +``` + +Mais le `HttpClient` Dart reçoit encore des 403 : + +- audio : dès l'offset 0, même avec un Range réduit à ~1,18 Mo ; +- vidéo : après environ 31 Mo, puis systématiquement au même offset. + +Cela invalide l'hypothèse "chunk trop gros" comme cause principale. + +## Différence importante avec yt-dlp + +Le downloader HTTP officiel de yt-dlp ne se limite pas à ajouter les mêmes +headers. Il utilise son propre stack réseau et sa propre logique de reprise +Range. + +L'étape 17.12 arrête donc d'essayer de reproduire ce transport en Dart. + +## Nouvelle architecture + +```text +libmpv + | + | HTTP Range + v +127.0.0.1 + | + | lance yt-dlp --load-info-json -o - + v +yt-dlp HttpFD + | + | URL MWEB + PO Token + headers + Range + v +googlevideo +``` + +Pour chaque connexion `/video` ou `/audio`, le relais crée temporairement un +info-json local contenant : + +- l'URL signée déjà extraite ; +- les headers exacts ; +- le Range demandé par mpv ; +- le `http_chunk_size` yt-dlp ; +- la taille totale. + +Puis il demande au `yt-dlp.exe` déjà installé de télécharger cette plage vers +stdout. Le relais retransmet stdout directement à libmpv. + +Ainsi Google n'est plus contacté par le `HttpClient` Dart. Le transport +googlevideo est effectué par le même moteur yt-dlp qui a réussi le test manuel. + +## Seek + +Lors d'un seek, mpv ferme/ouvre une nouvelle requête Range. Le relais lance un +nouveau process yt-dlp à l'offset demandé. Le process précédent est stoppé +lorsque sa connexion n'est plus utile. + +## Installation + +Aucune dépendance ou script supplémentaire. + +Extraire ce ZIP à la racine puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d windows +``` + +Tester 1080p + son. + +## Logs attendus + +```text +[HD-WINDOWS][17.12] yt-dlp -> relais yt-dlp natif -> libmpv +[HD-RELAY][17.12] actif sur 127.0.0.1:... +[HD-RELAY][17.12] video: yt-dlp démarre Range=... +[HD-RELAY][17.12] audio: yt-dlp démarre Range=... +``` + +Succès : + +```text +[HD-RELAY][17.12] video: yt-dlp OK ... +[HD-RELAY][17.12] audio: yt-dlp OK ... +[HD-WINDOWS] lecture confirmée: 1920x1080 +``` diff --git a/ETAPE17_13_WEB_SAFARI_HLS.md b/ETAPE17_13_WEB_SAFARI_HLS.md new file mode 100644 index 0000000..06a9662 --- /dev/null +++ b/ETAPE17_13_WEB_SAFARI_HLS.md @@ -0,0 +1,82 @@ +# Étape 17.13 - Windows 1080p avec HLS pré-muxé web_safari + +## Diagnostic après le test 17.12 + +Le test confirme : + +- `1920x1080` est réellement décodé ; +- vidéo et audio mweb possèdent tous les deux un PO Token ; +- malgré cela, yt-dlp lui-même reçoit encore `HTTP Error 403` sur les URLs + DASH/GVS mweb ; +- l'audio échoue immédiatement ; +- la vidéo fonctionne environ 30 Mo puis est refusée. + +Ce comportement correspond à des problèmes mweb + PO Token actuellement +rapportés côté yt-dlp/YouTube : un format peut être exposé avec un PO Token +valide mais son URL média retourner tout de même HTTP 403. + +## Nouveau chemin prioritaire pour 720/1080p + +yt-dlp documente `web_safari` comme pouvant exposer des formats HLS +pré-muxés : + +```text +vidéo + audio dans UNE seule URL m3u8 +``` + +Le code Windows essaie donc maintenant : + +```text +web_safari + ↓ +HLS muxé 720/1080 + ↓ +media_kit/libmpv +``` + +Cela supprime complètement : + +- le flux audio DASH séparé ; +- `AudioTrack.uri()` ; +- le relais audio ; +- le GVS PO Token mweb pour le chemin 1080p. + +Si YouTube ne fournit pas de HLS web_safari pour la session courante, le code +retombe automatiquement sur le pipeline mweb existant afin que le diagnostic +reste fonctionnel. + +## Limitation actuelle + +Le HLS `web_safari` est destiné à stabiliser 720/1080p. Il ne constitue pas +notre chemin 1440p/4K. + +Depuis juillet 2026, YouTube ne renvoie ces HLS que pour certaines sessions +jugées "trusted". Le log dira donc immédiatement s'ils sont disponibles sur +cette machine/session. + +## Test + +Extraire le ZIP à la racine : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d windows +``` + +Tester 1080p. + +Succès attendu : + +```text +[HD-WINDOWS][17.13] tentative HLS muxé web_safari cible=1080p +[HD-WINDOWS][17.13] HLS muxé trouvé: ... audio=... +[HD-WINDOWS][17.13] HLS AV confirmé: 1920x1080 (vidéo + audio muxés) +``` + +Si le HLS n'est pas disponible : + +```text +[HD-WINDOWS][17.13] aucun HLS muxé web_safari ... +[HD-WINDOWS][17.13] HLS muxé indisponible/illisible -> secours mweb adaptatif +``` diff --git a/ETAPE17_14_WEB_EMBEDDED_DIRECT.md b/ETAPE17_14_WEB_EMBEDDED_DIRECT.md new file mode 100644 index 0000000..e9ce234 --- /dev/null +++ b/ETAPE17_14_WEB_EMBEDDED_DIRECT.md @@ -0,0 +1,100 @@ +# Étape 17.14 - Priorité web_embedded Windows + +Le test manuel avec le yt-dlp nightly 2026.08.18.122307 est décisif. + +Pour la vidéo de validation, `web_embedded` expose : + +```text +251 webm audio only opus +299 mp4 1920x1080 60 AVC +308 webm 2560x1440 60 VP9 +315 webm 3840x2160 60 VP9 +``` + +Et surtout le test utilisateur a téléchargé intégralement le format audio 251 : + +```text +[download] 100% of 10.88MiB +``` + +## Architecture 17.14 + +Le chemin prioritaire Windows devient : + +```text +yt-dlp nightly + ↓ +client web_embedded + ↓ +URL vidéo adaptative + URL audio adaptative + ↓ +headers exacts yt-dlp + ↓ +media_kit / libmpv +``` + +Aucun PO Token n'est utilisé sur ce chemin. + +Le format audio 251 Opus est préféré explicitement car c'est celui qui a été +validé intégralement en ligne de commande. + +Vidéo : + +- <=1080p : AVC/H.264 prioritaire, donc typiquement format 299 en 1080p60 ; +- 1440p/2160p : VP9 prioritaire, donc typiquement 308/315. + +## Fallback + +Si la vidéo n'est pas embeddable ou si libmpv refuse les URLs directes : + +1. tentative HLS web_safari pour <=1080p ; +2. ancien pipeline mweb + PO Token/relais ; +3. fallback stable 360p existant. + +Android n'est pas modifié. + +## Test + +Extraire le ZIP à la racine puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d windows +``` + +Tester 1080p d'abord. + +Logs attendus : + +```text +[HD-WINDOWS][17.14] priorité web_embedded directe ... +[HD-WINDOWS][17.14] paire embedded: 1920x1080 60fps video=299/... audio=251/opus +[HD-WINDOWS][17.14] vidéo embedded confirmée: 1920x1080 +[HD-WINDOWS][17.14] audio embedded attaché: format=251 codec=opus +[HD-WINDOWS][17.14] lecture AV embedded confirmée: 1920x1080 +``` + +Ensuite seulement, tester 2160p : + +```text +video=315/vp9 +... +lecture AV embedded confirmée: 3840x2160 +``` + + +## PREPARER_HD_WINDOWS.ps1 mis à jour + +Le script de préparation est inclus dans cette étape afin d'éviter de +réinstaller accidentellement l'ancien yt-dlp stable 2026.07.04. + +Il télécharge toujours ce stable comme bootstrap vérifié par SHA-256 puis +exécute automatiquement : + +```text +yt-dlp.exe --update-to nightly +``` + +Ainsi les futures préparations Windows conservent le canal nightly requis par +ce chemin `web_embedded`. diff --git a/ETAPE17_15_WEB_EMBEDDED_GROWING_CACHE.md b/ETAPE17_15_WEB_EMBEDDED_GROWING_CACHE.md new file mode 100644 index 0000000..4f6ca2b --- /dev/null +++ b/ETAPE17_15_WEB_EMBEDDED_GROWING_CACHE.md @@ -0,0 +1,91 @@ +# Étape 17.15 - Cache progressif yt-dlp web_embedded + +## Diagnostic confirmé + +Le test manuel complet a réussi : + +```text +format 299 +361.06 MiB +100 % +24 secondes +``` + +Le format audio 251 avait déjà réussi intégralement : + +```text +10.88 MiB +100 % +``` + +Cela prouve que le téléchargement `web_embedded` fonctionne réellement pour +les deux pistes avec yt-dlp nightly. + +En revanche, le log 17.14 montre que libmpv échoue dès qu'on lui donne +directement l'URL googlevideo. + +## Architecture 17.15 + +On ne donne donc plus aucune URL googlevideo `web_embedded` à libmpv. + +```text +yt-dlp nightly web_embedded + ↓ +fichier vidéo temporaire qui grandit +fichier audio temporaire qui grandit + ↓ +serveur 127.0.0.1 intégré + ↓ +media_kit / libmpv +``` + +Les deux téléchargements yt-dlp démarrent en parallèle. + +Le lecteur n'attend pas forcément le téléchargement complet : +le serveur local lit les fichiers pendant qu'ils grandissent. + +Si mpv demande une zone située plus loin lors d'un seek, le serveur attend +simplement que yt-dlp ait téléchargé cette zone. + +## Formats + +Pour la vidéo de validation : + +```text +1080p60 : vidéo 299 AVC + audio 251 Opus +1440p60 : vidéo 308 VP9 + audio 251 Opus +2160p60 : vidéo 315 VP9 + audio 251 Opus +``` + +## Test + +Extraire le ZIP à la racine puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d windows +``` + +Tester 1080p + son. + +Logs attendus : + +```text +[HD-WINDOWS][17.15] priorité web_embedded via cache yt-dlp progressif +[HD-CACHE][17.15] lancement yt-dlp video format=299 +[HD-CACHE][17.15] lancement yt-dlp audio format=251 +[HD-CACHE][17.15] actif sur 127.0.0.1:... +[HD-WINDOWS][17.15] vidéo cache confirmée: 1920x1080 +[HD-WINDOWS][17.15] audio cache attaché: format=251 codec=opus +[HD-WINDOWS][17.15] lecture AV cache confirmée: 1920x1080 +``` + +Le téléchargement peut démarrer avec quelques secondes d'attente imposées par +YouTube/yt-dlp, comme dans le test manuel où yt-dlp a affiché : + +```text +Sleeping 6.00 seconds as required by the site... +``` + +Une fois le 1080p + son validé, tester le 2160p. diff --git a/ETAPE17_16_FULL_DOWNLOAD_LOCAL.md b/ETAPE17_16_FULL_DOWNLOAD_LOCAL.md new file mode 100644 index 0000000..6de513f --- /dev/null +++ b/ETAPE17_16_FULL_DOWNLOAD_LOCAL.md @@ -0,0 +1,72 @@ +# Étape 17.16 - Mode robuste Windows : téléchargement complet puis lecture locale + +Le cache progressif 17.15 a échoué parce que libmpv ouvre le serveur local +avant que le fichier vidéo soit suffisamment disponible. Le log montre : + +```text +[HD-CACHE][17.15] /video GET ... +[MPV-ERROR] Failed to open http://127.0.0.1:.../video +``` + +En revanche, l'audio 251 a bien été téléchargé complètement dans le même test. + +Nous arrêtons donc les expériences de streaming/relais pour stabiliser enfin +le chemin Windows. + +## Ce qui est déjà prouvé + +Les tests manuels utilisateur ont validé avec `web_embedded` : + +```text +audio 251 : 10.88 MiB -> 100 % +vidéo 299 : 361.06 MiB -> 100 % en ~24 s +``` + +## Architecture 17.16 + +```text +yt-dlp web_embedded + ├── vidéo -> téléchargement complet local + └── audio -> téléchargement complet local + (les deux en parallèle) + ↓ + fichiers temporaires complets + ↓ + media_kit / libmpv +``` + +Aucune URL googlevideo n'est utilisée par libmpv. +Aucun serveur HTTP local n'est utilisé pour ce chemin. + +Le démarrage 1080p prendra donc typiquement autour de 20-30 secondes avec la +vidéo de test actuelle. Ce n'est pas encore l'expérience finale, mais c'est le +chemin de référence destiné à obtenir une lecture HD + son totalement stable. + +Une fois ce socle validé, l'optimisation du démarrage pourra être faite sans +remettre en cause le transport réseau. + +## Test + +Extraire le ZIP à la racine puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d windows +``` + +Tester 1080p et attendre la fin du téléchargement. + +Logs attendus : + +```text +[HD-WINDOWS][17.16] mode robuste: téléchargement complet web_embedded +[HD-DOWNLOAD][17.16] téléchargement parallèle video=299 audio=251 +[HD-DOWNLOAD][17.16] audio terminé: ... +[HD-DOWNLOAD][17.16] video terminé: ... +[HD-DOWNLOAD][17.16] prêts: ... +[HD-WINDOWS][17.16] fichiers locaux prêts -> ouverture media_kit +[HD-WINDOWS][17.16] vidéo locale confirmée: 1920x1080 +[HD-WINDOWS][17.16] audio local attaché: format=251 codec=opus +[HD-WINDOWS][17.16] lecture AV locale confirmée: 1920x1080 +``` diff --git a/ETAPE17_17_1_FIX_COMPILE_CACHE.md b/ETAPE17_17_1_FIX_COMPILE_CACHE.md new file mode 100644 index 0000000..828d1b0 --- /dev/null +++ b/ETAPE17_17_1_FIX_COMPILE_CACHE.md @@ -0,0 +1,23 @@ +# Étape 17.17.1 - Correctif compilation Windows + +Le build 17.17 échouait sur : + +```text +The method 'setLastModified' isn't defined for the type 'Directory' +``` + +Correction : + +- suppression de l'appel invalide sur `Directory` ; +- ajout d'un fichier marqueur `.last_access` dans chaque dossier de cache ; +- le nettoyage LRU lit désormais la date de ce marqueur ; +- aucun changement du lecteur, de yt-dlp ou du choix AV1. + +Test : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +`flutter clean` n'est pas nécessaire. diff --git a/ETAPE17_17_FAST_LOCAL_CACHE.md b/ETAPE17_17_FAST_LOCAL_CACHE.md new file mode 100644 index 0000000..13b97ca --- /dev/null +++ b/ETAPE17_17_FAST_LOCAL_CACHE.md @@ -0,0 +1,94 @@ +# Étape 17.17 - Démarrage Windows accéléré sans changer le transport validé + +La 17.16 est notre première base Windows réellement validée : + +```text +vidéo locale confirmée: 1920x1080 +audio local attaché: format=251 codec=opus +lecture AV locale confirmée: 1920x1080 +``` + +On ne remplace donc PAS cette architecture. + +## Optimisation 1 : format vidéo beaucoup plus compact + +La vidéo de test expose en 1080p60 : + +```text +299 AVC : ~361 MiB +303 VP9 : ~275 MiB +399 AV1 : ~172 MiB +``` + +La 17.16 choisissait 299. + +La 17.17 choisit maintenant le codec efficace pour la résolution demandée, +donc typiquement 399 AV1 en 1080p60. + +À débit réseau identique, cela divise approximativement par deux le volume +vidéo du premier chargement. + +Même principe : + +```text +1440p60 : AV1 400 préféré +2160p60 : AV1 401 préféré +``` + +## Optimisation 2 : cache persistant + +Les fichiers complets validés ne sont plus supprimés à la fermeture du player. + +Emplacement : + +```text +%LOCALAPPDATA%\YouTubePersonal\media_cache +``` + +Au prochain lancement de la même vidéo/qualité : + +```text +[HD-CACHE][17.17] video=HIT audio=HIT +``` + +et media_kit peut ouvrir immédiatement les fichiers locaux sans les +retélécharger. + +Le cache conserve automatiquement les 5 vidéos les plus récentes. + +## Optimisation 3 : progression visible + +Pendant le premier chargement, l'écran affiche maintenant l'avancement : + +```text +Préparation HD • vidéo 45% • audio 100% +``` + +L'application n'a donc plus l'air figée. + +## Test + +Extraire le ZIP à la racine. + +Un `flutter clean` n'est pas nécessaire à chaque test. Faire simplement : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Tester 1080p. + +Le premier lancement doit sélectionner typiquement : + +```text +[HD-WINDOWS][17.17] format rapide sélectionné: 399 1920x1080 60fps av01... +``` + +Puis, au deuxième lancement de la même vidéo : + +```text +[HD-CACHE][17.17] video=HIT audio=HIT +``` + +Le chargement réseau doit alors disparaître presque totalement. diff --git a/ETAPE17_18_INSTANT_360_BACKGROUND_HD.md b/ETAPE17_18_INSTANT_360_BACKGROUND_HD.md new file mode 100644 index 0000000..aa42b64 --- /dev/null +++ b/ETAPE17_18_INSTANT_360_BACKGROUND_HD.md @@ -0,0 +1,104 @@ +# Étape 17.18 - Lecture immédiate puis bascule HD en arrière-plan + +La 17.17 a montré deux choses : + +1. Le format AV1 399 réduit bien la taille (environ 180 Mo au lieu de 379 Mo), + mais l'audio externe n'était plus audible sur la machine de test. +2. Même avec un fichier deux fois plus petit, attendre le téléchargement + complet AVANT de démarrer donne encore l'impression d'un lecteur lent. + +On change donc l'expérience au lieu de continuer à réduire quelques secondes. + +## Nouveau comportement + +```text +clic vidéo + ↓ +360p muxé stable démarre immédiatement (image + son) + ↓ +en parallèle : +yt-dlp télécharge la HD + l'audio dans le cache + ↓ +quand la HD est prête : +position actuelle capturée + ↓ +bascule automatique vers la HD locale + ↓ +seek à la même position +``` + +L'utilisateur ne regarde donc plus un écran de chargement pendant les 20-30 s +du premier téléchargement. + +## Audio + +Pour 1080p Windows, on revient volontairement à la paire déjà VALIDÉE : + +```text +vidéo 299 AVC 1080p60 +audio 251 Opus +``` + +C'est exactement la paire qui avait donné du son en 17.16. + +L'AV1 399 n'est plus prioritaire en 1080p Windows pour le moment. + +## Cache + +Le cache persistant 17.17 reste actif. + +Première lecture : + +```text +360p immédiat +↓ +téléchargement HD en arrière-plan +↓ +bascule 1080p +``` + +Deuxième lecture de la même vidéo : + +```text +360p immédiat +↓ +cache HIT +↓ +bascule 1080p presque immédiate +``` + +## Test + +Extraire le ZIP à la racine puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. + +Logs attendus au premier lancement : + +```text +[HD-WINDOWS][17.18] lecture immédiate 360p pendant préparation cible=1080 +[HD-WINDOWS][17.18] préparation HD arrière-plan cible=1080 +[HD-WINDOWS][17.18] HD arrière-plan: 1920x1080 60fps video=299/... audio=251/opus +``` + +La vidéo 360p doit déjà jouer avec le son pendant ces lignes. + +Quand le téléchargement est terminé : + +```text +[HD-WINDOWS][17.18] bascule 360p -> HD à ... ms +[HD-WINDOWS][17.18] bascule HD confirmée: 1920x1080 audio=251/opus +``` + +Au deuxième lancement : + +```text +[HD-CACHE][17.17] video=HIT audio=HIT +``` + +et la bascule doit être très rapide. diff --git a/ETAPE17_19_PROGRESSIVE_PREBUFFER.md b/ETAPE17_19_PROGRESSIVE_PREBUFFER.md new file mode 100644 index 0000000..4127eef --- /dev/null +++ b/ETAPE17_19_PROGRESSIVE_PREBUFFER.md @@ -0,0 +1,107 @@ +# Étape 17.19 - Vrai prébuffer HD progressif Windows + +Oui : c'est désormais exactement l'architecture utilisée. + +La 17.18 attendait encore les 378 Mo complets du format 299 avant de basculer. +La 17.19 ne fait plus cela. + +## Fonctionnement + +```text +clic + ↓ +360p muxé démarre immédiatement + ↓ +yt-dlp démarre 1080p 299 + audio 251 + ↓ +on surveille la taille réellement téléchargée + ↓ +dès qu'on a environ le timestamp courant + 20 s de réserve : + BASCULE HD + ↓ +yt-dlp continue à télécharger le reste pendant la lecture +``` + +Exemple avec une reprise autour de 60 secondes : + +```text +on ne demande plus 378 Mo +on attend seulement assez d'octets pour couvrir environ 0 -> 80 s ++ une marge de sécurité +``` + +Avec le débit observé lors du test manuel, cela devrait être beaucoup plus +court que le téléchargement intégral. + +## Pourquoi pas `--download-sections` + +`yt-dlp --download-sections` passe par ffmpeg pour les plages temporelles. +Pour notre lecteur, on veut au contraire conserver le fichier DASH continu +qui grandit pendant la lecture et laisser mpv faire ses Range/seeks. + +## Correction du flash au début + +La 17.18 faisait : + +```text +open HD au début +puis seek vers 94.8 s +``` + +La 17.19 utilise directement : + +```dart +Media( + hdUrl, + start: switchPosition, +) +``` + +Donc mpv prépare le nouveau média au timestamp courant avant la première frame +HD rendue. + +## Audio + +On conserve la paire validée : + +```text +1080p60 : 299 AVC +audio : 251 Opus +``` + +L'audio étant petit, la bascule attend qu'il soit entièrement disponible afin +de ne pas perdre le son. + +## Cache + +Le cache existant est réutilisé : + +```text +%LOCALAPPDATA%\YouTubePersonal\media_cache +``` + +Si 299 + 251 sont déjà complets, la bascule peut être quasi immédiate. + +## Test conseillé + +Après extraction : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. + +Pour voir le comportement progressif réel, tester aussi UNE AUTRE vidéo qui +n'est pas déjà en cache. + +Logs attendus : + +```text +[HD-WINDOWS][17.19] lecture immédiate 360p ... +[HD-PROGRESSIVE][17.19] session prête video=STREAM audio=DOWNLOAD +[HD-WINDOWS][17.19] buffer suffisant: video=... ≈...s audio=true +[HD-WINDOWS][17.19] bascule vers HD bufferisée à ... ms +[HD-WINDOWS][17.19] HD progressive confirmée: 1920x1080 audio=251/opus +``` diff --git a/ETAPE17_1_FIX_DENO_CHECKSUM.md b/ETAPE17_1_FIX_DENO_CHECKSUM.md new file mode 100644 index 0000000..3fc5af2 --- /dev/null +++ b/ETAPE17_1_FIX_DENO_CHECKSUM.md @@ -0,0 +1,24 @@ +# Étape 17.1 - Correctif checksum Deno + +Le téléchargement de Deno était correct, mais le script lisait mal le fichier +`.sha256sum` et interprétait le mot `path` comme s'il s'agissait du hash. + +Le correctif extrait maintenant explicitement l'unique SHA-256 de 64 caractères +hexadécimaux avant de comparer le fichier téléchargé. + +Après extraction de ce ZIP à la racine du projet : + +```powershell +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +.\PREPARER_HD_WINDOWS.ps1 +``` + +Si la préparation termine correctement : + +```powershell +flutter clean +flutter pub get +flutter run -d windows +``` + +Ensuite tester 1080p puis 2160p (4K). diff --git a/ETAPE17_20_FULLSCREEN_KEYBOARD.md b/ETAPE17_20_FULLSCREEN_KEYBOARD.md new file mode 100644 index 0000000..8b9dbec --- /dev/null +++ b/ETAPE17_20_FULLSCREEN_KEYBOARD.md @@ -0,0 +1,57 @@ +# Étape 17.20 - Raccourcis clavier en plein écran + +Ajout demandé pour le mode plein écran Windows. + +## Raccourcis + +```text +Espace = Lecture / Pause +Flèche gauche = Reculer de 10 secondes +Flèche droite = Avancer de 10 secondes +Flèche haut = Volume +5 % +Flèche bas = Volume -5 % +F = Entrer / quitter le plein écran +Échap = Quitter le plein écran +``` + +Les flèches gauche/droite utilisent 10 secondes pour rester cohérentes avec +les boutons -10/+10 déjà présents dans l'application. + +## Important + +Les raccourcis personnalisés sont appliqués uniquement au thème plein écran +desktop. Le comportement normal hors plein écran n'est donc pas perturbé. + +`media_kit_video` permet officiellement de personnaliser les raccourcis avec +`MaterialDesktopVideoControlsThemeData.keyboardShortcuts`. + +## Test + +Extraire le ZIP à la racine puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. + +Passer la vidéo en plein écran puis tester : + +```text +Espace +← +→ +↑ +↓ +F +Échap +``` + +Des logs apparaissent aussi pour les actions seek/volume : + +```text +[FULLSCREEN-KEYS] seek -10s +[FULLSCREEN-KEYS] seek +10s +[FULLSCREEN-KEYS] volume=55% +``` diff --git a/ETAPE17_21_STRICT_4K_AVAILABILITY.md b/ETAPE17_21_STRICT_4K_AVAILABILITY.md new file mode 100644 index 0000000..d170eeb --- /dev/null +++ b/ETAPE17_21_STRICT_4K_AVAILABILITY.md @@ -0,0 +1,86 @@ +# Étape 17.21 - Qualités 1440p / 4K strictes sous Windows + +Le log explique exactement pourquoi le bouton 4K restait en 1080p. + +Lors du clic 4K : + +```text +[HD] changement qualité -> 2160p (4K) +[HD-WINDOWS][17.14] extraction web_embedded cible=2160 +[HD-WINDOWS][17.14] paire embedded: 1920x1080 ... video=303/vp9 +``` + +Puis la lecture était confirmée en : + +```text +1920x1080 +``` + +Même chose pour 1440p : l'extracteur renvoyait encore 1920x1080. + +Cela signifie que, pour cette vidéo/session web_embedded, la liste yt-dlp +utilisée par l'application ne contient pas 1440p/2160p. L'ancien code +acceptait silencieusement la meilleure qualité inférieure, donc 1080p. + +## Correction 17.21 + +Sur Windows, pour 1440p et 2160p : + +1. l'application vérifie d'abord les formats web_embedded sans interrompre + la vidéo en cours ; +2. si la résolution demandée existe, le passage de qualité continue ; +3. si elle n'existe pas, le Player actuel reste intact ; +4. une notification explique la résolution maximale disponible ; +5. la qualité absente est ensuite désactivée dans le menu pour cette vidéo. + +Exemple : + +```text +2160p (4K) n'est pas disponible pour cette vidéo. +Cette vidéo est disponible ici jusqu'à 1080p. +``` + +Et dans le menu : + +```text +1440p • indisponible +2160p (4K) • indisponible +``` + +## Important + +Ce correctif ne bloque PAS la vraie 4K. + +Si web_embedded expose réellement 2160p pour une vidéo, `_selectVideo` +sélectionne toujours la résolution la plus haute <= cible. Le chemin +progressif 17.19 continuera alors avec le flux 2160p réel. + +Le service journalise maintenant aussi la liste réelle : + +```text +[HD-WINDOWS][17.21] cible 2160p absente pour web_embedded; +disponibles=[360, 480, 720, 1080] +``` + +## Test + +Extraire le ZIP à la racine puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. + +Tester d'abord la même vidéo. Le clic 4K ne doit plus faire : +1080p -> 360p -> 1080p. + +Ensuite tester une vidéo dont la source est réellement disponible en 4K. +Si yt-dlp expose 2160p, les logs doivent montrer : + +```text +paire embedded: 3840x2160 ... +flux progressif: 3840x2160 ... +HD progressive confirmée: 3840x2160 ... +``` diff --git a/ETAPE17_2_FIX_MPV_YTDL_HOOK.md b/ETAPE17_2_FIX_MPV_YTDL_HOOK.md new file mode 100644 index 0000000..fb289d0 --- /dev/null +++ b/ETAPE17_2_FIX_MPV_YTDL_HOOK.md @@ -0,0 +1,74 @@ +# Étape 17.2 - Correctif HD/4K Windows via mpv ytdl_hook + +## Diagnostic confirmé par le log précédent + +yt-dlp fonctionnait parfaitement : + +- 1080p trouvé : `1920x1080 60fps avc1` +- 4K trouvée : `3840x2160 60fps vp9` + +Mais après avoir transmis les URLs `googlevideo` directement à media_kit, +aucune image HD n'arrivait dans les 12 secondes. Le lecteur revenait donc en +640x360. + +## Nouvelle stratégie + +Windows n'ouvre plus manuellement les URLs directes yt-dlp. + +On laisse maintenant **le ytdl_hook natif de mpv** : + +1. appeler le `yt-dlp.exe` déjà empaqueté ; +2. utiliser Deno ; +3. choisir la qualité demandée ; +4. appliquer lui-même les paramètres/headers nécessaires ; +5. associer lui-même la piste vidéo et la piste audio ; +6. fournir le résultat directement à media_kit/libmpv. + +Android n'est pas modifié et conserve le chemin NewPipe direct déjà validé. + +## Installation + +Les outils Windows sont déjà installés. Il n'est pas nécessaire de relancer +`PREPARER_HD_WINDOWS.ps1`. + +Extraire ce ZIP à la racine du projet puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter pub get +flutter run -d windows +``` + +Tester d'abord 1080p puis 2160p. + +## Logs attendus + +Au démarrage du flux : + +```text +[HD-WINDOWS] préparation mpv/ytdl_hook ... +[HD-WINDOWS] configuration ytdl_hook ... +[HD-WINDOWS] ouverture via ytdl_hook ... +``` + +Succès 1080p : + +```text +[HD-WINDOWS] lecture confirmée: 1920x1080 ... +``` + +Succès 4K : + +```text +[HD-WINDOWS] lecture confirmée: 3840x2160 ... +``` + +Si mpv/yt-dlp renvoie une erreur, elle est maintenant remontée avec : + +```text +[HD-WINDOWS][MPV-ERROR] ... +``` + +Le délai de validation Windows passe aussi de 12 à 30 secondes, car mpv lance +désormais yt-dlp + Deno lui-même avant d'ouvrir les pistes. diff --git a/ETAPE17_3_FORCE_YTDL_SCHEME.md b/ETAPE17_3_FORCE_YTDL_SCHEME.md new file mode 100644 index 0000000..d2f1838 --- /dev/null +++ b/ETAPE17_3_FORCE_YTDL_SCHEME.md @@ -0,0 +1,66 @@ +# Étape 17.3 - Forcer réellement le ytdl_hook Windows + +Le log précédent a montré ceci : + +```text +[HD-WINDOWS] ouverture via ytdl_hook ... +[HD-WINDOWS][MPV] cplayer: Failed to recognize file format. +``` + +Le hook n'avait donc pas pris possession de l'URL avant que mpv tente +d'interpréter la page HTML YouTube comme un fichier vidéo. + +## Correctif + +L'URL ouverte par mpv devient maintenant : + +```text +ytdl://https://www.youtube.com/watch?v=... +``` + +Le préfixe `ytdl://` force explicitement le hook intégré de mpv à traiter +l'URL avec yt-dlp. + +Le chemin absolu de notre `yt-dlp.exe` reste fourni via : + +```text +ytdl_hook-ytdl_path=... +``` + +Deno reste fourni à yt-dlp avec `ytdl-raw-options`. + +## Diagnostic ajouté + +Après l'ouverture, l'application journalise également : + +```text +[HD-WINDOWS] ytdl_hook path=... +``` + +Si le hook est bien exécuté, ce chemin doit pointer vers le `yt-dlp.exe` +empaqueté dans `flutter_assets`. + +## Installation + +Aucun script de préparation à relancer. + +Extraire le ZIP à la racine puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter pub get +flutter run -d windows +``` + +Tester d'abord 1080p. + +Succès attendu : + +```text +[HD-WINDOWS] ouverture FORCÉE via ytdl:// ... +[HD-WINDOWS] ytdl_hook path=...yt-dlp.exe +[HD-WINDOWS] lecture confirmée: 1920x1080 ... +``` + +Puis tester 2160p. diff --git a/ETAPE17_5_FIX_MEDIA_KIT_LOADFILE.md b/ETAPE17_5_FIX_MEDIA_KIT_LOADFILE.md new file mode 100644 index 0000000..0550684 --- /dev/null +++ b/ETAPE17_5_FIX_MEDIA_KIT_LOADFILE.md @@ -0,0 +1,72 @@ +# Étape 17.5 - Correctif exact media_kit : loadfile au lieu de loadlist + +## Ce que le log 17.4 montre + +mpv bloque encore : + +```text +Refusing to load potentially unsafe URL from a playlist. +``` + +Le problème n'est plus yt-dlp. + +## Cause confirmée dans media_kit + +`Player.open(Media(...))` écrit le média dans une playlist temporaire puis +utilise la commande mpv `loadlist`. + +Or `ytdl://` est un protocole spécial. mpv refuse les protocoles spéciaux +lorsqu'ils proviennent d'une playlist. + +Dans le code de media_kit, `Player.add(Media(...))` passe au contraire par +`loadfile` directement. + +## Correctif 17.5 + +Pour Windows HD/4K uniquement : + +1. `player.stop()` +2. `player.add(Media("ytdl://..."))` +3. `player.play()` + +Cela utilise `loadfile` directement et ne nécessite plus de désactiver +globalement la sécurité des playlists. + +Le réglage `load-unsafe-playlists=yes` ajouté en 17.4 est donc supprimé. + +## Installation + +Extraire ce ZIP à la racine du projet puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d windows +``` + +Pas besoin de relancer `PREPARER_HD_WINDOWS.ps1`. +Pas besoin de `flutter pub get`. + +## Log attendu + +Au minimum : + +```text +[HD-WINDOWS][17.5] mode loadfile direct actif +[HD-WINDOWS] ouverture FORCÉE via ytdl:// ... +[HD-WINDOWS] ytdl:// chargé via loadfile direct (pas via loadlist) +``` + +L'erreur suivante doit avoir disparu : + +```text +Refusing to load potentially unsafe URL from a playlist. +``` + +Puis, si yt-dlp/mpv ouvre bien le flux : + +```text +[HD-WINDOWS] lecture confirmée: 1920x1080 ... +``` + +Ensuite tester 2160p. diff --git a/ETAPE17_6_DIRECT_HEADERS.md b/ETAPE17_6_DIRECT_HEADERS.md new file mode 100644 index 0000000..3e5ea9d --- /dev/null +++ b/ETAPE17_6_DIRECT_HEADERS.md @@ -0,0 +1,44 @@ +# Étape 17.6 - Windows direct avec headers yt-dlp + +## Ce que le test 17.5 a confirmé + +Le chemin `ytdl://` ne déclenche toujours aucun hook yt-dlp dans le libmpv +embarqué par media_kit. Plutôt que de continuer à dépendre de ce hook, cette +étape revient aux URLs directes yt-dlp, mais corrige les deux points qui +manquaient au premier essai Windows. + +## Changements + +1. Les headers HTTP renvoyés par yt-dlp sont récupérés. +2. Le User-Agent / Referer / Cookie / X-Forwarded-For sont appliqués à libmpv. +3. La vidéo HD est ouverte et validée AVANT d'ajouter la piste audio externe. +4. La piste audio est ajoutée seulement après confirmation de la résolution. +5. Les erreurs HTTP/FFmpeg/libavformat sont journalisées. + +Android n'est pas modifié. + +## Installation + +Extraire le ZIP à la racine du projet : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d windows +``` + +Pas besoin de relancer `PREPARER_HD_WINDOWS.ps1`. +Pas besoin de `flutter pub get`. + +## Logs attendus en 1080p + +```text +[HD-WINDOWS][17.6] extraction directe + headers ... +[HD-WINDOWS] flux sélectionné: 1920x1080 ... +[HD-WINDOWS] headers appliqués à libmpv: ... +[HD-WINDOWS] vidéo seule confirmée: 1920x1080 +[HD-WINDOWS] lecture confirmée: 1920x1080 +``` + +Si Googlevideo refuse la requête, les logs doivent maintenant afficher par +exemple `403`, `Forbidden` ou `HTTP error`, ce qui donnera la cause exacte. diff --git a/ETAPE17_7_FIX_MPV_GLOBAL_HEADERS.md b/ETAPE17_7_FIX_MPV_GLOBAL_HEADERS.md new file mode 100644 index 0000000..7206f16 --- /dev/null +++ b/ETAPE17_7_FIX_MPV_GLOBAL_HEADERS.md @@ -0,0 +1,75 @@ +# Étape 17.7 - Correction exacte des headers mpv Windows + +L'autre analyse a mis le doigt sur un point important, et la vérification du +code/documentation mpv/media_kit confirme deux choses : + +1. `Media(..., httpHeaders: ...)` couvre bien le média principal. +2. `AudioTrack.uri(...)` ne prend pas de `httpHeaders`. + +Il faut donc aussi appliquer les headers globalement à l'instance libmpv avant +`open()`, pour que la piste audio externe les hérite. + +## Bug dans notre étape 17.6 + +Notre code utilisait : + +```text +options/user-agent +options/http-header-fields +``` + +pour `NativePlayer.setProperty`. + +C'était le mauvais niveau de propriété pour cette API runtime. + +La 17.7 utilise maintenant directement : + +```text +user-agent +http-header-fields +``` + +## Autre correction + +La 17.6 n'envoyait globalement que quelques headers. + +La 17.7 transmet maintenant TOUS les headers renvoyés par yt-dlp, sauf +`User-Agent` qui utilise la propriété mpv dédiée. + +Les virgules à l'intérieur des valeurs sont échappées avant de construire la +string-list `http-header-fields`. + +## Lecture + +L'ordre reste : + +1. yt-dlp extrait vidéo + audio + headers +2. headers globaux mpv +3. `Media(videoUrl, httpHeaders: headers)` +4. démarrage et validation de la vidéo HD +5. réapplication des headers globaux +6. `AudioTrack.uri(audioUrl)` +7. confirmation finale de la résolution + +## Test + +Extraire à la racine puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d windows +``` + +Pas besoin de relancer `PREPARER_HD_WINDOWS.ps1`. +Pas besoin de `flutter pub get`. + +Logs attendus : + +```text +[HD-WINDOWS][17.7] extraction directe + headers globaux mpv ... +[HD-WINDOWS][17.7] headers globaux mpv appliqués: UA=true, champs=... +[HD-WINDOWS][17.7] propriétés mpv posées AVANT open() +[HD-WINDOWS] vidéo seule confirmée: 1920x1080 +[HD-WINDOWS] lecture confirmée: 1920x1080 +``` diff --git a/ETAPE17_8_FORCE_TV_CLIENT.md b/ETAPE17_8_FORCE_TV_CLIENT.md new file mode 100644 index 0000000..96d9a4f --- /dev/null +++ b/ETAPE17_8_FORCE_TV_CLIENT.md @@ -0,0 +1,75 @@ +# Étape 17.8 - Forcer le client YouTube TV dans yt-dlp + +Le log 17.7 a donné une information décisive : l'URL HD sélectionnée contenait : + +```text +c=ANDROID_VR +``` + +Les headers sont désormais bien appliqués à libmpv, mais l'URL issue de ce +client échoue encore à l'ouverture. + +## Changement 17.8 + +yt-dlp est maintenant lancé avec : + +```text +--extractor-args +youtube:player_client=tv +``` + +L'objectif est d'éviter complètement le client `ANDROID_VR`. + +Tous les correctifs 17.7 sont conservés : + +- extraction locale yt-dlp ; +- Deno ; +- URLs vidéo/audio séparées ; +- `Media.httpHeaders` pour la vidéo ; +- `user-agent` global mpv ; +- `http-header-fields` global mpv ; +- réapplication des headers avant `AudioTrack.uri`. + +## Diagnostic supplémentaire + +Le code lit maintenant le paramètre `c=` de l'URL sélectionnée sans afficher +l'URL signée complète. + +Log attendu : + +```text +[HD-WINDOWS][17.8] client yt-dlp forcé: tv +[HD-WINDOWS] flux sélectionné: 1920x1080 ... client=TV +``` + +La valeur exacte peut être `TV` ou une variante utilisée par YouTube, mais elle +ne doit plus être `ANDROID_VR`. + +## Installation + +Extraire ce ZIP à la racine du projet puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter run -d windows +``` + +Pas besoin de relancer `PREPARER_HD_WINDOWS.ps1`. +Pas besoin de `flutter pub get`. + +Tester d'abord 1080p. + +Succès attendu : + +```text +[HD-WINDOWS][17.8] client yt-dlp forcé: tv +[HD-WINDOWS] flux sélectionné: 1920x1080 ... client=TV +[HD-WINDOWS] vidéo seule confirmée: 1920x1080 +[HD-WINDOWS] lecture confirmée: 1920x1080 +``` + +Si le client indiqué reste `ANDROID_VR`, le paramètre `extractor-args` n'a pas +été pris en compte. Si le client est `TV` mais l'ouverture échoue encore, le +prochain log permettra de séparer clairement un blocage serveur d'un problème +libmpv. diff --git a/ETAPE17_9_MWEB_PO_TOKEN.md b/ETAPE17_9_MWEB_PO_TOKEN.md new file mode 100644 index 0000000..31cfc32 --- /dev/null +++ b/ETAPE17_9_MWEB_PO_TOKEN.md @@ -0,0 +1,45 @@ +# Étape 17.9 - mweb + PO Token provider local + +Le test 17.8 montre que le client `tv` n'est pas exploitable pour cette vidéo : + +```text +ERROR: [youtube] ... The page needs to be reloaded. +``` + +Cette étape suit donc la voie recommandée actuellement par yt-dlp : +client `mweb` + fournisseur automatique de PO Token. + +Le provider est local à la machine et est invoqué à la demande via Deno. +Aucun cookie Google de l'application n'est transmis à yt-dlp. + +## Installation une seule fois + +Extraire le ZIP à la racine puis : + +```powershell +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +.\PREPARER_PO_TOKEN_WINDOWS.ps1 +``` + +Puis : + +```powershell +flutter clean +flutter run -d windows +``` + +## Logs attendus + +```text +[HD-WINDOWS][17.9] extraction mweb + PO Token provider local +[HD-WINDOWS] flux sélectionné: 1920x1080 ... client=MWEB +``` + +Puis : + +```text +[HD-WINDOWS] vidéo seule confirmée: 1920x1080 +[HD-WINDOWS] lecture confirmée: 1920x1080 +``` + +Le correctif global des headers mpv de la 17.7 reste en place. diff --git a/ETAPE17_HD_4K_WINDOWS.md b/ETAPE17_HD_4K_WINDOWS.md new file mode 100644 index 0000000..5ea1ed1 --- /dev/null +++ b/ETAPE17_HD_4K_WINDOWS.md @@ -0,0 +1,66 @@ +# Étape 17 - HD / 4K Windows + +Cette étape ajoute au lecteur Windows le même sélecteur : + +- Auto +- 360p +- 720p +- 1080p +- 1440p +- 2160p (4K) + +## Architecture Windows + +Android continue d'utiliser NewPipeExtractor. + +Windows utilise localement : +- yt-dlp.exe +- Deno +- media_kit / libmpv pour lire la vidéo et l'audio séparés. + +Aucun serveur PC, backend distant personnel ou compte Google n'est nécessaire. +Le chemin OAuth officiel YouTube Data API reste séparé : aucun cookie Google ou +jeton OAuth n'est transmis à yt-dlp. + +## Installation des outils Windows + +Après extraction du ZIP à la racine : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +.\PREPARER_HD_WINDOWS.ps1 +``` + +Le script télécharge les releases officielles et vérifie leur SHA-256. + +Puis : + +```powershell +flutter clean +flutter pub get +flutter run -d windows +``` + +## Logs attendus + +1080p : + +```text +[HD-WINDOWS] flux sélectionné: 1920x1080 ... +[HD] lecture confirmée: 1920x1080 ... +``` + +4K : + +```text +[HD-WINDOWS] flux sélectionné: 3840x2160 ... +[HD] lecture confirmée: 3840x2160 ... +``` + +Si l'extraction ou le décodage 1440p/4K échoue, l'application tente 1080p puis +revient au flux 360p stable. + +## Important + +La première préparation Windows télécharge deux exécutables. Une fois ceux-ci +placés dans `assets/windows_hd/`, Flutter les empaquette dans l'application. diff --git a/ETAPE18_1_FIX_RADAR_CONST.md b/ETAPE18_1_FIX_RADAR_CONST.md new file mode 100644 index 0000000..c3ce199 --- /dev/null +++ b/ETAPE18_1_FIX_RADAR_CONST.md @@ -0,0 +1,26 @@ +# Étape 18.1 - Correctif compilation Radar + +Erreur corrigée : + +```text +lib/services/radar_service.dart(...): Constant evaluation error +``` + +Cause : +le `const Set _stopWords` contenait des valeurs en double (`the` et `que`). + +Dart refuse les doublons dans un `const Set` à l'évaluation de compilation. + +Correction : +- suppression des doublons uniquement ; +- aucun changement du fonctionnement du Radar ; +- aucun changement de la partie HD. + +Test : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. diff --git a/ETAPE18_2_ANDROID_PARITY.md b/ETAPE18_2_ANDROID_PARITY.md new file mode 100644 index 0000000..16825fc --- /dev/null +++ b/ETAPE18_2_ANDROID_PARITY.md @@ -0,0 +1,105 @@ +# Étape 18.2 - Parité Android + +Cette étape met Android au même niveau fonctionnel pour les nouveautés +récentes, sans remplacer son moteur HD natif qui fonctionne déjà mieux que +le chemin Windows. + +## Ce qui est commun Windows + Android + +Le Radar V1 est du Flutter partagé, donc il fonctionne maintenant sur les +deux plateformes : + +```text +Pour toi maintenant +Continuer ce sujet +Quelque chose de différent +À redécouvrir +Pourquoi cette vidéo ? +Pas maintenant +Moins de cette chaîne +Mélanger mon Radar +``` + +Modes de session : + +```text +Comme d'habitude +Je veux du nouveau +Apprendre +Détente +Courtes vidéos +Long format +``` + +Historique, favoris et feedback du Radar utilisent les mêmes préférences +locales de l'application. + +## Android : commandes plein écran tactiles + +Les raccourcis clavier Windows n'ont pas de sens sur téléphone. Leur +équivalent tactile est ajouté : + +```text +Tap simple = lecture / pause +Double tap moitié gauche = -10 secondes +Double tap moitié droite = +10 secondes +Glisser vertical à droite = volume +``` + +Les boutons physiques de volume Android continuent naturellement à +fonctionner. + +## HD Android + +AUCUN des relais/cache yt-dlp Windows n'est porté sur Android. + +Android garde le chemin natif déjà validé : + +```text +360p +720p +1080p +1440p +2160p / 4K +``` + +avec NewPipeExtractor / MediaCodec. + +C'est volontaire : importer le mécanisme Windows sur Android serait une +régression. + +## Conservé + +```text +PiP Android +lecture écran verrouillé / arrière-plan +reprise de lecture +vitesse +favoris +historique +abonnements Google +qualités jusqu'à 4K +``` + +## Test Android + +Après extraction à la racine : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d f10778d8 +``` + +Il n'est pas nécessaire de faire `flutter clean`. + +Tester : + +1. Accueil -> le Radar doit apparaître. +2. Changer de mode de session. +3. Ouvrir "Pourquoi cette vidéo ?". +4. Ouvrir une vidéo. +5. Passer en plein écran. +6. Double tap gauche puis droite. +7. Glisser verticalement sur la moitié droite pour le volume. +8. Vérifier 1080p puis 2160p/4K. +9. Vérifier PiP et lecture écran verrouillé. diff --git a/ETAPE18_3_CHAINES_CATEGORIES.md b/ETAPE18_3_CHAINES_CATEGORIES.md new file mode 100644 index 0000000..60eb08f --- /dev/null +++ b/ETAPE18_3_CHAINES_CATEGORIES.md @@ -0,0 +1,155 @@ +# Étape 18.3 - Chaînes + catégories Radar + +Trois ajouts principaux, communs à Windows et Android. + +## 1. Cliquer sur la chaîne depuis une vidéo + +Le nom de l'auteur sous le lecteur est maintenant cliquable. + +```text +Vidéo + ↓ +nom de la chaîne + ↓ +page de la chaîne + ↓ +toutes ses vidéos publiques +``` + +Cela ne dépend pas du compte Google. Même une vidéo venant d'une recherche, +du Radar, de l'historique ou des favoris peut résoudre sa chaîne via +youtube_explode_dart. + +Les anciennes vidéos déjà enregistrées dans l'historique restent compatibles : +si elles n'ont pas encore `channelId`, l'application le résout à partir du +videoId au clic. + +## 2. Page chaîne = catalogue complet progressif + +La page d'une chaîne charge les uploads progressivement au lieu d'attendre +tout le catalogue. + +Tri disponible : + +```text +Plus récentes +Plus vues +Plus anciennes +``` + +Chaque vidéo récupère maintenant aussi : + +```text +channelId +viewCount +uploadDate +``` + +## 3. Nouveau menu "Chaînes" + +Navigation : + +```text +Accueil +Recherche +Abonnements +Chaînes +Historique +Favoris +``` + +Le menu Chaînes construit un catalogue à partir : + +```text +historique +favoris +Radar actuel +``` + +Puis enrichit les anciennes vidéos qui n'avaient pas encore les métadonnées +de chaîne/vues. + +Classement principal : + +```text +somme des vues publiques des vidéos connues pour chaque chaîne +``` + +Exemple : + +```text +#1 Chaîne A 85 M vues cumulées +#2 Chaîne B 42 M vues cumulées +#3 Chaîne C 18 M vues cumulées +``` + +Le texte "vidéos mesurées" est volontaire : ce n'est pas la somme de TOUTES +les vues historiques de toute la chaîne, seulement des vidéos que YouTube +Personal connaît déjà via tes sources personnelles. + +## 4. Radar trié par catégories + +Nouvelle zone : + +```text +Explorer par catégorie +``` + +Catégories V1 : + +```text +Jeux vidéo +Culture & histoire +OVNI & mystères +Technologie & IA +Science & espace +Auto & transport +Musique +Société & actualité +Tutoriels & apprentissage +Divers +``` + +Le classement est local et transparent, à partir du titre + nom de chaîne. +Une vidéo est placée dans une catégorie principale pour éviter les doublons. + +La liste des catégories n'est pas figée visuellement : seules les catégories +qui ont réellement des propositions apparaissent. + +## Important + +Le système HD Windows/Android n'est pas modifié dans cette étape. + +Les gestes plein écran Android et raccourcis Windows de l'étape 18.2 restent +dans `video_player_page.dart`. + +## Test + +Extraire le ZIP à la racine puis : + +Windows : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Android : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d f10778d8 +``` + +Pas besoin de `flutter clean`. + +Scénario de test : + +1. Ouvrir une vidéo. +2. Cliquer le nom de la chaîne. +3. Vérifier que ses vidéos commencent à apparaître. +4. Tester le tri "Plus vues". +5. Revenir et ouvrir le menu "Chaînes". +6. Vérifier le classement par vues. +7. Accueil -> "Explorer par catégorie". +8. Tester "Jeux vidéo", "Culture & histoire", "OVNI & mystères". diff --git a/ETAPE18_4_CHAINES_PERSONNELLES_SPONSORBLOCK.md b/ETAPE18_4_CHAINES_PERSONNELLES_SPONSORBLOCK.md new file mode 100644 index 0000000..84009d1 --- /dev/null +++ b/ETAPE18_4_CHAINES_PERSONNELLES_SPONSORBLOCK.md @@ -0,0 +1,156 @@ +# Étape 18.4 - Chaînes personnelles + chargement vidéos + SponsorBlock + +Cette étape corrige les trois points remontés après la capture du menu +Chaînes. + +## 1. Les vidéos d'une chaîne se chargent réellement + +La page chaîne essaie maintenant en priorité le chemin officiel déjà présent +dans l'application : + +```text +Google OAuth youtube.readonly + ↓ +channels.list -> playlist uploads + ↓ +playlistItems.list (pagination de 50) + ↓ +videos.list (durée + vues + date) + ↓ +affichage progressif +``` + +Elle ne s'arrête plus aux 30 vidéos de l'ancienne version et suit +`nextPageToken` jusqu'à la fin de la playlist d'uploads. + +Si le compte Google n'est pas disponible, elle bascule sur le chemin public +`youtube_explode_dart.getUploadsFromPage()`. + +## 2. Le classement Chaînes utilise MES vues + +Le classement précédent utilisait les vues publiques YouTube. C'était une +mauvaise interprétation de la demande. + +Nouveau classement : + +```text +#1 chaîne avec le plus de lectures dans YouTube Personal +#2 ... +#3 ... +``` + +Chaque ouverture réelle d'une vidéo incrémente un compteur local : + +```text +youtube_personal.watch_counts.v1 +``` + +Un changement 1080p -> 4K ne compte PAS comme une nouvelle vue. + +Les anciennes entrées de l'historique sont migrées avec au moins 1 vue par +vidéo, car l'application ne peut pas reconstruire les répétitions passées +qu'elle n'enregistrait pas encore. + +Carte exemple : + +```text +12 de mes vues • 4 vidéos différentes +La plus regardée par moi (5×) : ... +``` + +## 3. Une chaîne ne m'intéresse pas + +Menu `...` d'une chaîne : + +```text +Cette chaîne ne m'intéresse pas +``` + +La chaîne est alors : + +```text +- retirée du menu Chaînes +- exclue des prochaines recommandations Radar +- mémorisée localement +``` + +Un bouton ANNULER apparaît immédiatement après le masquage. + +La page complète de la chaîne possède aussi : + +```text +Ne plus me proposer cette chaîne +``` + +L'historique existant n'est pas détruit. + +## 4. Sponsors intégrés aux vidéos + +Ajout de SponsorBlock, catégorie `sponsor` uniquement. + +YouTube Personal récupère les segments connus et, lorsque la lecture entre +dans l'un d'eux : + +```text +lecture normale + ↓ +début sponsor détecté + ↓ +seek automatique à la fin du sponsor + ↓ +lecture continue +``` + +Notification courte : + +```text +Sponsor ignoré automatiquement • 34s +``` + +Le mécanisme fonctionne dans le lecteur Windows/Android et aussi dans le +Player audio Android lorsque l'écran est verrouillé. + +### Vie privée SponsorBlock + +Le client utilise l'endpoint k-anonymity : il calcule SHA-256(videoId) et +n'envoie au serveur que les 4 premiers caractères du hash. Il filtre ensuite +localement la réponse pour retrouver la vidéo exacte. + +Seule la catégorie `sponsor` est ignorée. Les intros, outros, rappels de +s'abonner et autres catégories ne sont pas sautés. + +## Test + +Extraire le ZIP à la racine puis : + +Windows : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Android : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d f10778d8 +``` + +Pas besoin de `flutter clean`. + +### Vérifications + +1. Menu Chaînes : le texte doit afficher `mes vues`, plus les millions de vues + publiques. +2. Ouvrir deux fois une vidéo, revenir dans Chaînes : le compteur augmente. +3. Changer seulement la qualité : le compteur ne doit pas augmenter. +4. Cliquer une chaîne : les vidéos doivent commencer à apparaître. +5. Tester `Plus vues sur YouTube` dans la page de la chaîne. +6. Masquer une chaîne puis vérifier qu'elle disparaît du Radar. +7. Sur une vidéo connue de SponsorBlock, chercher le log : + +```text +[SPONSORBLOCK] ... segment(s) sponsor trouvé(s) +[SPONSORBLOCK] skip ... +``` diff --git a/ETAPE18_5_1_INFOS_CHAINE_SANS_GOOGLE.md b/ETAPE18_5_1_INFOS_CHAINE_SANS_GOOGLE.md new file mode 100644 index 0000000..844e338 --- /dev/null +++ b/ETAPE18_5_1_INFOS_CHAINE_SANS_GOOGLE.md @@ -0,0 +1,101 @@ +# Étape 18.5.1 - Infos chaîne visibles sans compte Google + +Le log a révélé la cause : + +```text +[CHANNEL] Data API indisponible -> fallback public: +Bad state: Aucune session Google active. +``` + +La 18.5 récupérait la fiche statistique détaillée uniquement après obtention +d'un access token Google. Le catalogue avait un fallback public, mais pas la +fiche d'informations. + +La 18.5.1 corrige cela. + +## Sur la page vidéo + +Sous le nom de la chaîne apparaissent maintenant directement : + +```text +2,4 M abonnés +486 M vues chaîne +Créée 14 mars 2016 +84 k likes vidéo +``` + +Ces données sont chargées sans quitter la vidéo et sans nécessiter Google +OAuth. + +`likes vidéo` désigne explicitement les likes de la vidéo actuelle, car +YouTube ne fournit pas un compteur global de likes au niveau Channel. + +## Quand on ouvre la chaîne + +La fiche complète est maintenant alimentée en deux niveaux : + +```text +fallback public youtube_explode_dart + ↓ +si Google OAuth existe : +YouTube Data API remplace/enrichit les données +``` + +Donc même déconnecté du compte Google, la fiche peut afficher : + +```text +nom +photo +description +pays +date de création / date d'arrivée +abonnés +vues totales de la chaîne +``` + +Les likes des vidéos sont également récupérés par le chemin public et sont +additionnés progressivement dans la fiche : + +```text +Likes chargés + ↓ +Likes cumulés +``` + +## Important sur les likes + +Il n'existe pas de `statistics.likeCount` global pour une ressource Channel. + +Le total affiché dans la fiche chaîne est donc calculé par YouTube Personal à +partir des `likeCount` publics des vidéos chargées. + +## Test + +Après extraction : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. + +Test immédiat : + +```text +ouvrir une vidéo +↓ +regarder sous le nom de la chaîne +``` + +Les petites pastilles de statistiques doivent apparaître sans avoir besoin de +cliquer sur la chaîne. + +Puis : + +```text +cliquer sur la chaîne +``` + +La fiche complète doit apparaître même si le log indique qu'aucune session +Google n'est active. diff --git a/ETAPE18_5_2_FIX_STATS_CHAINE_WINDOWS.md b/ETAPE18_5_2_FIX_STATS_CHAINE_WINDOWS.md new file mode 100644 index 0000000..089656e --- /dev/null +++ b/ETAPE18_5_2_FIX_STATS_CHAINE_WINDOWS.md @@ -0,0 +1,105 @@ +# Étape 18.5.2 - Correctif réel des statistiques de chaîne Windows + +La capture 18.5.1 montre que seule cette donnée arrivait : + +```text +45 k likes vidéo +``` + +Cela prouve que la métadonnée de la vidéo fonctionne, mais que les champs de +la chaîne (abonnés / vues / date) revenaient vides par le chemin public +`youtube_explode_dart`. + +## Nouveau fallback Windows + +YouTube Personal réutilise maintenant le `yt-dlp.exe` déjà fourni avec le +lecteur HD. + +Pour une chaîne : + +```text +youtube_explode_dart + ↓ +si champs manquants + ↓ +yt-dlp sur /channel//videos + ↓ +si nécessaire /about + ↓ +fusion des métadonnées +``` + +Champs récupérés quand YouTube les expose : + +```text +channel_follower_count -> abonnés +view_count -> vues chaîne +playlist_count -> vidéos +channel/title -> nom +description -> description +thumbnails -> avatar +``` + +## Logs + +Le test doit maintenant écrire une ligne très claire : + +```text +[CHANNEL-YTDLP][18.5.2] channel=UC... subs=... views=... videos=... +[CHANNEL-META][18.5.2] id=UC... subs=... views=... videos=... date=... +``` + +Cela évite désormais les échecs silencieux. + +## Sous la vidéo + +La zone peut maintenant afficher : + +```text +2,4 M abonnés +486 M vues chaîne +312 vidéos +45 k likes vidéo +``` + +Si YouTube ne fournit réellement aucune statistique de chaîne, l'interface +affiche explicitement : + +```text +Stats chaîne indisponibles +``` + +au lieu de faire croire que le code n'a pas été exécuté. + +## Date + +La vraie date de création reste utilisée dès qu'elle est fournie par la page +À propos ou par YouTube Data API. + +Si cette date n'est pas publiquement extractible mais que le catalogue complet +est chargé, la page chaîne affiche à la place une donnée honnête : + +```text +Plus ancienne vidéo : 14/03/2017 +``` + +Elle n'est PAS présentée comme la date de création de la chaîne. + +## Test + +Extraire le ZIP à la racine puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. + +Ouvrir la même vidéo Gamerawi et regarder : + +```text +sous le nom de la chaîne +``` + +Puis regarder les lignes `[CHANNEL-YTDLP][18.5.2]` dans PowerShell. diff --git a/ETAPE18_5_INFOS_CHAINE.md b/ETAPE18_5_INFOS_CHAINE.md new file mode 100644 index 0000000..27f6389 --- /dev/null +++ b/ETAPE18_5_INFOS_CHAINE.md @@ -0,0 +1,105 @@ +# Étape 18.5 - Informations détaillées de chaîne + +Ajout d'une vraie fiche d'informations en haut de la page d'une chaîne. + +## Informations affichées + +```text +Nom +Photo +Description +Pays +Date de création +Nombre d'abonnés +Nombre total de vues de la chaîne +Nombre de vidéos publiques +Likes cumulés des vidéos chargées +Commentaires cumulés des vidéos chargées +``` + +Les statistiques générales de chaîne viennent de `channels.list` avec : + +```text +part=snippet,statistics +``` + +La date affichée est la date de création/publication de la chaîne +(`snippet.publishedAt`). + +## Likes + +YouTube Data API n'expose pas de `likeCount` global dans les statistiques +d'une chaîne. + +Le champ `likeCount` existe par contre pour les vidéos. + +YouTube Personal additionne donc : + +```text +likes vidéo 1 ++ likes vidéo 2 ++ likes vidéo 3 ++ ... +``` + +Au cours du chargement, l'interface affiche : + +```text +Likes chargés +``` + +Quand le catalogue est terminé : + +```text +Likes cumulés +``` + +avec le nombre de vidéos réellement mesurées. + +Même principe pour les commentaires. + +## Vidéos + +Chaque VideoItem peut maintenant mémoriser : + +```text +viewCount +likeCount +commentCount +uploadDate +``` + +La ligne sous une vidéo de chaîne peut donc afficher : + +```text +1,2 M vues YouTube • 84 k likes • 3,2 k commentaires • 15/08/2026 +``` + +## Test + +Après extraction : + +Windows : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Android : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d f10778d8 +``` + +Pas besoin de `flutter clean`. + +Ouvrir : + +```text +Chaînes +→ une chaîne +``` + +La fiche statistique doit apparaître au-dessus du tri des vidéos. diff --git a/ETAPE18_6_1_FIX_WHITE45.md b/ETAPE18_6_1_FIX_WHITE45.md new file mode 100644 index 0000000..7f391e9 --- /dev/null +++ b/ETAPE18_6_1_FIX_WHITE45.md @@ -0,0 +1,35 @@ +# Étape 18.6.1 - Correctif compilation `Colors.white45` + +Erreur corrigée : + +```text +lib/widgets/video_result_card.dart(...): +Member not found: 'white45' +``` + +Cause : + +Flutter ne fournit pas de constante `Colors.white45`. + +Correction : + +```dart +Colors.white45 +``` + +devient : + +```dart +const Color(0x73FFFFFF) +``` + +Cela conserve environ 45 % d'opacité sans changer le reste de l'étape 18.6. + +Test : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. diff --git a/ETAPE18_6_2_NAVIGATION_LISTES.md b/ETAPE18_6_2_NAVIGATION_LISTES.md new file mode 100644 index 0000000..6666d6a --- /dev/null +++ b/ETAPE18_6_2_NAVIGATION_LISTES.md @@ -0,0 +1,62 @@ +# Étape 18.6.2 - Navigation des listes horizontales + +La capture montrait que les cartes continuaient hors écran mais qu'il n'y +avait aucun contrôle visible pour accéder à la suite avec une souris. + +Toutes les rangées de l'accueil utilisent maintenant le même composant. + +## Windows / desktop + +Chaque rangée affiche automatiquement : + +```text +← → +``` + +quand il y a du contenu caché à gauche ou à droite. + +Le bouton droit avance d'environ 82 % de la largeur visible, ce qui donne un +effet de carrousel et laisse un peu de contexte avec la page précédente. + +Ajout aussi de : + +```text +barre de défilement horizontale +glisser à la souris +trackpad +``` + +Les flèches disparaissent automatiquement lorsqu'on atteint le début ou la +fin. + +## Sections corrigées + +```text +Pour toi maintenant +Continuer ce sujet +Quelque chose de différent +Explorer par catégorie +Continuer à regarder +À redécouvrir +Vues récemment +Favoris récents +``` + +## Android + +Pas de boutons superposés sur petit écran. Le swipe horizontal natif reste +utilisé. + +## Test + +Après extraction : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. + +Sur `Pour toi maintenant`, une flèche `>` doit apparaître à droite dès que +la rangée contient plus de cartes que la largeur de l'écran. diff --git a/ETAPE18_6_PREVIEWS_DESCRIPTION.md b/ETAPE18_6_PREVIEWS_DESCRIPTION.md new file mode 100644 index 0000000..1c3a495 --- /dev/null +++ b/ETAPE18_6_PREVIEWS_DESCRIPTION.md @@ -0,0 +1,62 @@ +# Étape 18.6 - Infos dans les aperçus + description vidéo + +La fiche vidéo affichait déjà les statistiques de chaîne, mais les cartes du +Radar n'en montraient pas assez. La description de la vidéo manquait aussi. + +## Cartes Radar + +Sous la chaîne : + +```text +1,9 M abonnés • 245 k vues • 40 k likes +``` + +Puis, quand la recherche YouTube fournit le texte : + +```text +extrait de la description de la vidéo… +``` + +Le nombre d'abonnés utilise un chemin léger mis en cache. Le Radar ne lance +pas yt-dlp pour chaque miniature. + +## Page vidéo + +Sous les statistiques chaîne : + +```text +245 k vues • 40 k likes • 1,2 k commentaires • 19/08/2026 + +Description de la vidéo... +Afficher plus +``` + +Trois lignes par défaut, puis bouton `Afficher plus`. + +## Recherche et page chaîne + +Les cartes classiques affichent désormais aussi : + +```text +vues • likes +description sur 2 lignes +``` + +## Persistance + +`VideoItem` mémorise maintenant `description`. Les anciens historiques restent +compatibles avec une description vide. + +## Correctif + +Le badge `0 vidéos` n'est plus affiché lorsqu'yt-dlp n'a pas réellement fourni +le nombre de vidéos de la chaîne. + +## Test + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. diff --git a/ETAPE18_7_1_FIX_RATE_LIMIT_OVERFLOW.md b/ETAPE18_7_1_FIX_RATE_LIMIT_OVERFLOW.md new file mode 100644 index 0000000..2c95e7a --- /dev/null +++ b/ETAPE18_7_1_FIX_RATE_LIMIT_OVERFLOW.md @@ -0,0 +1,139 @@ +# Étape 18.7.1 - Fix blocage / rate-limit / overflow + +Le log a révélé deux problèmes différents. + +## 1. Rate limit YouTube + +Erreur : + +```text +RequestLimitExceededException +Failed to perform an HTTP request to YouTube because of rate limiting. +``` + +La cause venait de la 18.7 : pour une chaîne de 30 vidéos, elle lançait +`videos.get()` sur chaque vidéo, par lots. Cela crée une rafale de requêtes +vers les pages watch YouTube. + +Correction : + +```text +AVANT +liste chaîne +→ videos.get(video1) +→ videos.get(video2) +→ videos.get(video3) +→ ... +→ IP rate-limitée +``` + +devient : + +```text +MAINTENANT +1 requête catalogue de chaîne ++ +1 processus yt-dlp flat-playlist Windows +→ métadonnées groupées +``` + +Le chemin par vidéo a été supprimé. + +Log attendu : + +```text +[CHANNEL-VIDEOS][18.7.1] ... mode=NO_PER_VIDEO_WATCH_REQUESTS +[CHANNEL-CATALOG][18.7.1] bulk start ... +[CHANNEL-CATALOG][18.7.1] bulk ready ... videos=... +``` + +## 2. Le lecteur faisait aussi une requête de trop + +À l'ouverture d'une vidéo, la page avait déjà un `VideoItem` issu du Radar, +de la recherche ou de la chaîne, mais faisait quand même un nouveau +`videos.get(videoId)` uniquement pour récupérer description/statistiques. + +Cette requête supplémentaire est supprimée. + +La page utilise maintenant immédiatement `widget.video`. + +## 3. Tri + +En fallback public, changer : + +```text +Plus récentes +Plus vues +Plus anciennes +``` + +relance maintenant l'ordre natif de la chaîne. + +Donc même si certaines métadonnées de vue sont absentes, le tri n'est plus +un simple tri local de valeurs nulles/0. + +`Plus aimées` reste un tri local quand des likes sont réellement disponibles. + +## 4. Métadonnées groupées Windows + +Un nouveau service utilise le yt-dlp déjà fourni : + +```text +--flat-playlist --dump-json +/channel//videos +``` + +Il tente de récupérer en un seul passage : + +```text +durée +vues +likes si exposés +commentaires si exposés +date +description si exposée +``` + +Puis fusionne ces données avec les cartes déjà visibles. + +Si yt-dlp ne fournit pas une valeur, la page ne montre plus un faux +`0 vues`. + +## 5. Overflow accueil + +Erreur : + +```text +A RenderFlex overflowed by 5.1 pixels on the bottom +``` + +La barre de défilement horizontale prenait quelques pixels sur la hauteur des +cartes ordinaires. La hauteur du rail a été augmentée pour laisser la place. + +## Important + +Le rate-limit est imposé côté YouTube. Ce correctif arrête d'en créer de +nouveaux, mais un blocage déjà actif peut mettre un peu de temps à disparaître +côté YouTube. + +Le chemin HD yt-dlp n'a pas été modifié. + +## Test + +Extraire le ZIP puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. + +Ensuite : + +1. ouvrir `Chaînes` ; +2. ouvrir Gamerawi ; +3. vérifier les logs `18.7.1` ; +4. changer `Plus récentes` → `Plus vues` → `Plus anciennes` ; +5. ouvrir ensuite une vidéo et vérifier que le log ne produit plus une rafale + de `RequestLimitExceededException`. diff --git a/ETAPE18_7_2_RESTORE_PLAYBACK_WINDOWS.md b/ETAPE18_7_2_RESTORE_PLAYBACK_WINDOWS.md new file mode 100644 index 0000000..9eb4919 --- /dev/null +++ b/ETAPE18_7_2_RESTORE_PLAYBACK_WINDOWS.md @@ -0,0 +1,58 @@ +# Étape 18.7.2 - Restauration lecture Windows + +Le log montre que le lecteur Windows échoue AVANT le pipeline yt-dlp : + +```text +[YT-PLAYER] RequestLimitExceededException +Request: GET https://www.youtube.com/watch?v=... +``` + +## Cause + +`_loadVideoInternal()` appelait toujours d'abord : + +```text +youtube_explode_dart -> getPlayableMuxedUrl() -> /watch +``` + +Comme l'IP est temporairement rate-limitée sur ce endpoint, le code quittait +avant même d'essayer le chemin Windows yt-dlp/web_embedded. + +## Correction + +Windows démarre maintenant directement : + +```text +WindowsHdService +-> yt-dlp web_embedded +-> cache progressif local +-> petit prébuffer +-> media_kit +``` + +Le chemin Windows initial ne dépend donc plus du flux 360 de +`youtube_explode_dart`. + +Android conserve son chemin actuel. + +Les informations de chaîne sont aussi lancées APRÈS la tentative de lecture +Windows pour ne pas concurrencer le démarrage vidéo. + +## Logs attendus + +```text +[HD-WINDOWS][18.7.2] yt-dlp-only start ... +[HD-WINDOWS][18.7.2] prébuffer prêt ... +[HD-WINDOWS][18.7.2] lecture confirmée ... +``` + +## Test + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. + +Tester en premier la même vidéo Gamerawi qui échoue actuellement. diff --git a/ETAPE18_7_PAGE_CHAINE_RICHE_TRI.md b/ETAPE18_7_PAGE_CHAINE_RICHE_TRI.md new file mode 100644 index 0000000..cae3bd5 --- /dev/null +++ b/ETAPE18_7_PAGE_CHAINE_RICHE_TRI.md @@ -0,0 +1,128 @@ +# Étape 18.7 - Page chaîne riche + tri réellement fonctionnel + +La capture montrait deux problèmes distincts : + +```text +durée 0:00 +0 vues +``` + +et une grande zone vide à droite. + +Le tri local utilisait donc des valeurs identiques (0 vues / dates absentes), +ce qui explique pourquoi "Plus récentes", "Plus vues" et "Plus anciennes" +semblaient ne rien changer. + +## Correction des métadonnées + +La liste de chaîne donne d'abord les IDs des uploads. + +YouTube Personal enrichit maintenant chaque vidéo avec : + +```text +youtube.videos.get(videoId) +``` + +pour récupérer la vraie fiche : + +```text +durée +vues +likes +date de publication +description +``` + +L'enrichissement se fait par lots de 6 pour ne pas lancer toutes les requêtes +en même temps. + +## Tri + +Le menu devient : + +```text +Plus récentes +Plus vues +Plus aimées +Plus anciennes +``` + +Les tris utilisent désormais les vraies métadonnées. + +Pour le fallback public, la demande de chaîne utilise également le tri natif : + +```text +VideoSorting.newest +VideoSorting.popularity +VideoSorting.oldest +``` + +## Nouvelle présentation desktop + +Au lieu d'une petite ligne collée à gauche avec beaucoup de vide : + +```text +[ miniature ] texte [ grand vide ] +``` + +la page utilise maintenant une grille responsive : + +```text +[ vidéo 1 ] [ vidéo 2 ] [ vidéo 3 ] [ vidéo 4 ] +[ vidéo 5 ] [ vidéo 6 ] [ vidéo 7 ] [ vidéo 8 ] +``` + +Selon la largeur : + +```text +>= 1500 px : 4 colonnes +>= 1080 px : 3 colonnes +>= 720 px : 2 colonnes +mobile : 1 colonne +``` + +Chaque carte affiche : + +```text +miniature +durée +titre +vues +likes +commentaires si disponibles +date de publication +description +``` + +## Résumé du catalogue + +Au-dessus du tri apparaissent maintenant : + +```text +Vidéos chargées +Vues cumulées +Moyenne vues / vidéo +Likes cumulés +Durée totale chargée +Période des vidéos +``` + +## Test + +Après extraction : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. + +Dans PowerShell, le fallback public doit aussi afficher : + +```text +[CHANNEL-VIDEOS][18.7] channel=UC... sort=newest uploads=... +``` + +Puis vérifier visuellement que `Plus vues` place réellement les vidéos les +plus vues en premier et que `Plus anciennes` inverse clairement les dates. diff --git a/ETAPE18_8_1_FINANCE_CATEGORIE.md b/ETAPE18_8_1_FINANCE_CATEGORIE.md new file mode 100644 index 0000000..fd52308 --- /dev/null +++ b/ETAPE18_8_1_FINANCE_CATEGORIE.md @@ -0,0 +1,118 @@ +# Étape 18.8.1 - Finance devient une catégorie du Radar + +Correction d'interprétation de l'étape 18.8. + +## Besoin réel + +Finance est un centre d'intérêt souhaité. + +Il doit donc : + +```text +rester dans le profil du Radar ++ +pouvoir apparaître dans Signal actuel ++ +générer des recommandations ++ +avoir sa propre catégorie +``` + +Il ne doit PAS être bloqué. + +## Nouvelle catégorie + +Ajout : + +```text +Finance +``` + +avec détection de termes comme : + +```text +finance +investissement +bourse +actions +ETF +IPO +dividendes +trading +forex +crypto +bitcoin +banque +épargne +portefeuille +Nasdaq +S&P 500 +CAC 40 +MASI +BVC +... +``` + +et plusieurs équivalents arabes. + +## Ce qui est conservé de la 18.8 + +Le bouton `×` sur les centres d'intérêt reste utile. + +Il permet toujours d'exclure MANUELLEMENT un sujet qui ne t'intéresse pas. + +Exemple : + +```text +[gamer ×] [ovni ×] [football ×] +``` + +Mais Finance n'est plus une exclusion par défaut. + +## Migration + +Si la version 18.8 avait laissé un terme Finance dans les exclusions pendant +les essais, la 18.8.1 nettoie automatiquement le vocabulaire Finance de cette +liste. + +Aucun historique, favori ou compteur de vues n'est supprimé. + +## Résultat attendu + +Dans `Explorer par catégorie`, Finance peut apparaître comme : + +```text +[Finance (7)] +``` + +et regrouper uniquement les recommandations financières détectées. + +Le `Signal actuel` peut également contenir à nouveau des termes comme : + +```text +finance +bourse +investissement +ETF +``` + +si l'historique et les favoris montrent réellement cet intérêt. + +## Test + +Après extraction : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Puis : + +```text +Accueil +→ Mélanger mon Radar +``` + +Finance doit maintenant être traitée comme un intérêt positif et non comme un +sujet interdit. diff --git a/ETAPE18_8_2_FIX_COMPILE_FINANCE.md b/ETAPE18_8_2_FIX_COMPILE_FINANCE.md new file mode 100644 index 0000000..b792dcc --- /dev/null +++ b/ETAPE18_8_2_FIX_COMPILE_FINANCE.md @@ -0,0 +1,64 @@ +# Étape 18.8.2 - Correctif compilation Finance + +Erreur corrigée : + +```text +radar_service.dart(41,...) +A named parameter that doesn't refer to an instance variable can't start with an underscore +Type 'await' not found +No named parameter with the name 'mode' +``` + +## Cause + +L'appel : + +```dart +await _restoreFinanceInterestIfNeeded(); +``` + +avait été inséré par erreur **dans la liste des paramètres nommés** de : + +```dart +generate({...}) +``` + +au lieu d'être placé dans le corps de la méthode. + +## Correction + +La signature redevient : + +```dart +Future generate({ + required RadarSessionMode mode, + bool force = false, +}) async { + await _restoreFinanceInterestIfNeeded(); + ... +} +``` + +Le paramètre `mode` redevient donc valide et les erreurs en cascade de +`home_page.dart` disparaissent également. + +## Migration Finance améliorée + +Le nettoyage des exclusions Finance provenant de la mauvaise étape 18.8 est +maintenant exécuté **une seule fois**. + +Cela évite qu'un futur clic manuel sur le `×` de Finance soit automatiquement +annulé à chaque rafraîchissement du Radar. + +La catégorie Finance reste bien présente et positive. + +## Test + +Extraire le ZIP à la racine puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. diff --git a/ETAPE18_9_1_RETOUR_CENTRES_INTERET.md b/ETAPE18_9_1_RETOUR_CENTRES_INTERET.md new file mode 100644 index 0000000..bc05002 --- /dev/null +++ b/ETAPE18_9_1_RETOUR_CENTRES_INTERET.md @@ -0,0 +1,93 @@ +# Étape 18.9.1 - Retour aux centres d'intérêt fixes en haut + +Cette version annule les modifications 18.8 / 18.9 qui avaient transformé +les mots extraits des titres en pseudo-centres d'intérêt. + +## Base restaurée + +Les deux fichiers concernés repartent de la dernière base stable avant cette +régression : + +```text +radar_service.dart -> base 18.4 +home_page.dart -> base 18.7.1 +``` + +Aucun fichier du lecteur, de la HD Windows, d'Android, des chaînes ou de +SponsorBlock n'est remplacé. + +## Barre des centres d'intérêt + +La rangée fixe est de nouveau en haut : + +```text +Tous +Politique +Finance +Technologie & IA +Jeux vidéo +Culture & histoire +Science & espace +OVNI & mystères +Auto & transport +Musique +Société & actualité +Tutoriels & apprentissage +``` + +Les termes bruts tels que : + +```text +morocco +sky +yann +arthus +2026 +``` + +ne sont plus affichés comme centres d'intérêt. + +## Fonctionnement + +`Tous` conserve le Radar personnalisé général. + +Quand une catégorie est choisie, le Radar ajoute une recherche explicite liée +à ce centre d'intérêt. + +Exemple : + +```text +Finance +→ finance investissement bourse ETF marchés financiers + +Politique +→ politique géopolitique actualité gouvernement élections +``` + +Le moteur garde aussi une partie du profil historique afin de personnaliser +les résultats à l'intérieur du thème choisi. + +## Catégories + +`Politique` est séparée de `Société & actualité`. + +`Finance` est une catégorie positive dédiée. + +## Interface + +Le bloc dynamique inférieur `Explorer par catégorie` est retiré afin d'éviter +d'avoir deux systèmes de catégories différents sur le même écran. + +La rangée du haut redevient le point de contrôle principal des centres +d'intérêt. + +## Test + +Après extraction : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. diff --git a/ETAPE18_9_RESTORE_CENTRES_INTERET.md b/ETAPE18_9_RESTORE_CENTRES_INTERET.md new file mode 100644 index 0000000..d020474 --- /dev/null +++ b/ETAPE18_9_RESTORE_CENTRES_INTERET.md @@ -0,0 +1,171 @@ +# Étape 18.9 - Restauration des vrais centres d'intérêt du Radar + +La capture montrait une régression importante : + +```text +morocco +sky +yann +arthus +bertrand +2026 +``` + +étaient affichés comme des « centres d'intérêt ». + +Ce ne sont pas des centres d'intérêt : ce sont seulement des mots extraits de +titres. + +## Séparation restaurée + +Le Radar a maintenant deux niveaux distincts : + +```text +CENTRES D'INTÉRÊT SÉMANTIQUES + ↓ +Jeux vidéo +Culture & histoire +OVNI & mystères +Technologie & IA +Science & espace +Auto & transport +Musique +Finance +Société & actualité +Tutoriels & apprentissage + +MOTS-CLÉS FINS INTERNES + ↓ +morocco +playstation +bitcoin +android +roswell +etc. +``` + +Les mots-clés fins restent utiles au calcul, mais ils ne sont plus présentés +comme le profil principal de l'utilisateur. + +## Signal visible + +La zone en haut de l'accueil devient : + +```text +Centres d’intérêt détectés : +[Culture & histoire ×] +[Technologie & IA ×] +[Finance ×] +[Jeux vidéo ×] +... +``` + +et non : + +```text +[morocco ×] [sky ×] [yann ×] [2026 ×] +``` + +## Calcul du profil + +Chaque vidéo de l'historique reçoit : + +```text +poids de récence ++ +classification sémantique +``` + +Les favoris reçoivent un poids plus fort. + +Les catégories sont ensuite classées selon leur poids cumulé. + +## Radar + +Les recherches de nouvelles vidéos partent désormais d'abord des centres +d'intérêt. + +Exemple : + +```text +Finance +→ finance investissement bourse ETF marchés financiers + +Culture & histoire +→ documentaire culture histoire civilisation + +Technologie & IA +→ technologie intelligence artificielle informatique +``` + +Une seule partie des recherches utilise encore des mots précis issus des +titres récents. + +Cela évite qu'une série de vidéos contenant « Morocco » ou le nom d'un auteur +transforme tout le Radar en recommandations sur ce seul mot. + +## Scoring + +Le score d'une recommandation comprend maintenant explicitement : + +```text +Centre d’intérêt +Détails +Habitudes +Favoris +Découverte +Session +``` + +Le centre d'intérêt sémantique peut apporter jusqu'à 28 points. + +Les mots bruts ont été abaissés à un maximum de 22 points afin qu'un nom +propre répété ne domine plus le profil. + +## Classification + +La détection utilise maintenant : + +```text +titre ++ +nom de chaîne ++ +description +``` + +au lieu de seulement titre + chaîne. + +## Exclusion avec × + +Le `×` agit maintenant sur toute la catégorie. + +Exemple : + +```text +[Finance ×] +``` + +exclut les vidéos classifiées Finance du profil et des nouvelles +recommandations. + +Finance reste toutefois une catégorie positive par défaut. + +## Test + +Après extraction : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Puis : + +```text +Accueil +→ Mélanger mon Radar +``` + +La ligne du haut doit afficher des catégories sémantiques et plus les mots +bruts `morocco / sky / yann / 2026`. diff --git a/ETAPE18_RADAR_V1.md b/ETAPE18_RADAR_V1.md new file mode 100644 index 0000000..dc40ae9 --- /dev/null +++ b/ETAPE18_RADAR_V1.md @@ -0,0 +1,114 @@ +# Étape 18 - Radar V1 + +Première version du système original de vidéos proposées. + +## Principe + +Le classement est calculé localement à partir de : + +- historique ; +- favoris ; +- chaînes déjà regardées ; +- sujets extraits des titres ; +- durée de la vidéo ; +- mode de session choisi ; +- feedback `Pas maintenant` ; +- feedback `Moins de cette chaîne`. + +YouTube sert seulement à fournir des vidéos candidates via les recherches +déjà utilisées par l'application. Le score final et l'ordre sont calculés par +YouTube Personal. + +## Nouveautés de l'accueil + +### Radar personnel + +Sections : + +```text +Pour toi maintenant +Continuer ce sujet +Quelque chose de différent +À redécouvrir +Continuer à regarder +Vues récemment +Favoris récents +``` + +### Modes de session + +```text +Comme d'habitude +Je veux du nouveau +Apprendre +Détente +Courtes vidéos +Long format +``` + +Le mode ne modifie pas définitivement le profil. Il influence seulement la +génération actuelle du Radar. + +### Mélanger mon Radar + +Force une nouvelle combinaison de candidats et de classement. + +### Pourquoi cette vidéo ? + +Chaque proposition possède un score sur 100 et une explication : + +```text +Proche de ce que tu regardes maintenant +Thèmes communs : ... +Chaîne proche de tes habitudes +Sujet proche de tes favoris +Nouvelle chaîne pour toi +``` + +La fenêtre affiche aussi la contribution du score : + +```text +Sujet +... +Habitudes +... +Favoris +... +Découverte +... +Session +... +Feedback -... +``` + +### Feedback + +Menu `...` de chaque proposition : + +```text +Pourquoi cette vidéo ? +Pas maintenant +Moins de cette chaîne +``` + +`Pas maintenant` masque la vidéo pendant 30 jours. + +`Moins de cette chaîne` ajoute un malus persistant à cette chaîne. + +## Fichiers + +```text +lib/models/radar_recommendation.dart +lib/services/radar_service.dart +lib/screens/home_page.dart +``` + +## Test + +Extraire le ZIP à la racine : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +Pas besoin de `flutter clean`. + +Le premier chargement du Radar peut prendre quelques secondes car il lance +plusieurs recherches candidates. Les générations suivantes sont gardées en +cache mémoire tant que l'historique/favoris/feedback ne changent pas. diff --git a/GOOGLE_OAUTH_SETUP.md b/GOOGLE_OAUTH_SETUP.md new file mode 100644 index 0000000..29ba1f0 --- /dev/null +++ b/GOOGLE_OAUTH_SETUP.md @@ -0,0 +1,143 @@ +# Google OAuth + abonnements YouTube + +Cette étape permet de connecter un compte Google et de charger les +abonnements YouTube dans l'onglet **Abonnements**. + +L'application utilise uniquement le scope : + +`https://www.googleapis.com/auth/youtube.readonly` + +Aucun mot de passe Google n'est demandé ou stocké par l'application. + +--- + +## 1. Créer le projet Google Cloud + +Dans Google Cloud Console : + +1. crée ou sélectionne un projet ; +2. ouvre **APIs & Services > Library** ; +3. active **YouTube Data API v3** ; +4. configure l'écran de consentement OAuth ; +5. si l'application est encore en mode **Testing**, ajoute ton propre + compte Google dans **Test users**. + +--- + +## 2. Android + +Package Android : + +`com.studioweb.youtube_personal` + +### Trouver le SHA-1 debug + +Dans PowerShell : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal\android" +.\gradlew signingReport +``` + +Cherche : + +```text +Variant: debug +SHA1: ... +``` + +### Créer les identifiants + +Dans **Google Cloud > APIs & Services > Credentials** : + +1. crée un **OAuth client ID > Android** ; +2. Package name : + `com.studioweb.youtube_personal` +3. SHA-1 : + colle le SHA-1 obtenu avec `signingReport`. + +Ensuite crée aussi : + +**OAuth client ID > Web application** + +Copie le Client ID qui se termine normalement par : + +`apps.googleusercontent.com` + +### Dans YouTube Personal + +1. ouvre **Abonnements** ; +2. clique **Configurer** ; +3. colle le **Client ID Web** ; +4. clique **Enregistrer** ; +5. clique **Se connecter avec Google**. + +Le Client ID du client Android n'est pas collé dans l'application. +Il sert à autoriser le package + la signature APK côté Google. + +--- + +## 3. Windows + +Dans **Credentials** crée : + +**OAuth client ID > Desktop app** + +Dans YouTube Personal : + +1. ouvre **Abonnements** ; +2. clique **Configurer** ; +3. colle le **Client ID Desktop** ; +4. colle le Client Secret si Google en fournit un ; +5. enregistre ; +6. clique **Se connecter avec Google**. + +Le navigateur système s'ouvre. Après autorisation, Google revient sur +une adresse temporaire locale `127.0.0.1`. Aucun serveur externe n'est +nécessaire. + +--- + +## 4. Si Google affiche une erreur + +### Android : erreur de configuration / annulation après choix du compte + +Vérifie en priorité : + +- package exact : `com.studioweb.youtube_personal` +- SHA-1 debug correct +- client OAuth Android créé dans le même projet +- Client ID Web correct dans l'application +- ton compte présent dans **Test users** si l'écran OAuth est en Testing + +### Erreur YouTube 403 + +Vérifie que **YouTube Data API v3** est activée dans le même projet. + +### Windows : redirect_uri_mismatch + +Utilise bien un client OAuth de type **Desktop app** et non Web +application. + +--- + +## 5. Alternative avec dart-define + +Les anciens paramètres restent compatibles : + +### Android + +```powershell +flutter run -d f10778d8 ` + --dart-define=GOOGLE_WEB_CLIENT_ID="TON_CLIENT_ID_WEB.apps.googleusercontent.com" +``` + +### Windows + +```powershell +flutter run -d windows ` + --dart-define=GOOGLE_DESKTOP_CLIENT_ID="TON_CLIENT_ID_DESKTOP.apps.googleusercontent.com" ` + --dart-define=GOOGLE_DESKTOP_CLIENT_SECRET="TON_CLIENT_SECRET" +``` + +Les valeurs `dart-define` ont priorité sur celles enregistrées dans l'app. diff --git a/ICON_PREVIEW.png b/ICON_PREVIEW.png new file mode 100644 index 0000000..813f5bb Binary files /dev/null and b/ICON_PREVIEW.png differ diff --git a/INSTALLATION_ICONE.md b/INSTALLATION_ICONE.md new file mode 100644 index 0000000..3151b0f --- /dev/null +++ b/INSTALLATION_ICONE.md @@ -0,0 +1,48 @@ +# YouTube Personal — étape 18.9.2 — Icône application + +Ce patch conserve les fichiers de la version 18.9.1 (barre Tous / Politique / Finance / Technologie & IA...) et ajoute une icône d'application pour Windows et Android. + +## Fichiers ajoutés +- `windows/runner/resources/app_icon.ico` : icône de l'exécutable Windows +- `android/app/src/main/res/mipmap-*/ic_launcher.png` : icône Android +- `android/app/src/main/res/mipmap-*/ic_launcher_round.png` : variante ronde Android +- `assets/app_icon/app_icon_1024.png` : source haute résolution +- `ICON_PREVIEW.png` : aperçu + +## Installation du patch +Extraire ce ZIP directement à la racine du projet `D:\07-Logiciels\23-youtube_personal` et accepter le remplacement des fichiers. + +## Windows +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter clean +flutter pub get +flutter run -d windows +``` + +Pour générer une version Windows release : +```powershell +flutter build windows --release +``` + +## Android — téléphone branché en USB avec débogage USB activé +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter devices +flutter clean +flutter pub get +flutter run -d f10778d8 +``` + +Si l'identifiant du téléphone a changé, remplace `f10778d8` par l'identifiant affiché par `flutter devices`. + +Pour fabriquer et installer l'APK release : +```powershell +flutter build apk --release +adb install -r "build\app\outputs\flutter-apk\app-release.apk" +``` + +Si `adb` n'est pas reconnu, tu peux installer avec Flutter : +```powershell +flutter install -d f10778d8 +``` diff --git a/INSTALLER_SMARTPHONE.ps1 b/INSTALLER_SMARTPHONE.ps1 new file mode 100644 index 0000000..243e39f --- /dev/null +++ b/INSTALLER_SMARTPHONE.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = "Stop" +$ProjectRoot = $PSScriptRoot +$apk = Join-Path $ProjectRoot "build\app\outputs\flutter-apk\app-release.apk" +if (-not (Test-Path $apk)) { + throw "APK introuvable. Lance d'abord .\BUILD_APK_RELEASE.ps1" +} +Write-Host "=== Appareils ADB ===" -ForegroundColor Cyan +adb devices +Write-Host "" +Write-Host "=== Installation ===" -ForegroundColor Cyan +adb install -r "$apk" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +Write-Host "[OK] Application installee." -ForegroundColor Green diff --git a/INSTALLER_WINDOWS.ps1 b/INSTALLER_WINDOWS.ps1 new file mode 100644 index 0000000..59be052 --- /dev/null +++ b/INSTALLER_WINDOWS.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference = "Stop" +$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$Pubspec = Join-Path $ProjectRoot "pubspec.yaml" +$Version = "1.0.0" +if (Test-Path $Pubspec) { + $Text = Get-Content $Pubspec -Raw + if ($Text -match '(?m)^version:\s*([^\s+]+)(?:\+\S+)?\s*$') { + $Version = $Matches[1].Trim() + } +} +$Setup = Join-Path $ProjectRoot "dist\YouTube_Personal_Setup_$Version.exe" +if (-not (Test-Path $Setup)) { + throw "Installateur introuvable : $Setup`nLance d'abord .\CREER_INSTALLATEUR_WINDOWS.ps1" +} +Start-Process -FilePath $Setup -Verb RunAs diff --git a/OAUTH_SETUP.md b/OAUTH_SETUP.md new file mode 100644 index 0000000..910d897 --- /dev/null +++ b/OAUTH_SETUP.md @@ -0,0 +1,78 @@ +# Configuration Google / YouTube – Abonnements + +L'application n'embarque volontairement aucun identifiant OAuth personnel. + +## 1. Google Cloud + +1. Crée ou sélectionne un projet Google Cloud. +2. Active **YouTube Data API v3**. +3. Configure l'écran de consentement OAuth. +4. Si l'application est encore en mode **Testing**, ajoute ton compte Google + dans les utilisateurs de test. + +L'application demande uniquement : + +`https://www.googleapis.com/auth/youtube.readonly` + +Ce scope permet de lire les données de ton compte YouTube, notamment les +abonnements. + +--- + +## 2. Android + +Identifiant de package : + +`com.studioweb.youtube_personal` + +### Récupérer le SHA-1 debug + +Dans PowerShell, depuis le projet : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal\android" +.\gradlew signingReport +``` + +Repère `Variant: debug` puis copie la valeur `SHA1`. + +### Créer les clients Google + +Dans Google Cloud > APIs & Services > Credentials : + +1. Crée un client OAuth **Android** : + - package : `com.studioweb.youtube_personal` + - SHA-1 : celui récupéré ci-dessus. +2. Crée aussi un client OAuth **Web application**. +3. Copie le **Client ID du client Web**. + +Lance ensuite Android avec : + +```powershell +flutter run -d f10778d8 --dart-define=GOOGLE_WEB_CLIENT_ID="TON_CLIENT_ID_WEB.apps.googleusercontent.com" +``` + +--- + +## 3. Windows + +Dans Google Cloud > Credentials, crée un client OAuth : + +**Desktop app** + +Copie son Client ID et, s'il est fourni, son Client Secret. + +Lance Windows avec : + +```powershell +flutter run -d windows ` + --dart-define=GOOGLE_DESKTOP_CLIENT_ID="TON_CLIENT_ID_DESKTOP.apps.googleusercontent.com" ` + --dart-define=GOOGLE_DESKTOP_CLIENT_SECRET="TON_CLIENT_SECRET" +``` + +Sur Windows, le bouton Connexion ouvre le navigateur système. Google redirige +ensuite vers un serveur local temporaire `127.0.0.1` lancé uniquement pendant +la connexion. Il n'y a aucun backend permanent. + +Dans cette première version, le jeton Windows n'est pas conservé après la +fermeture de l'application : il faudra se reconnecter à la prochaine session. diff --git a/PATCH_18_7_10_ANDROID_1440_4K.md b/PATCH_18_7_10_ANDROID_1440_4K.md new file mode 100644 index 0000000..d58dc40 --- /dev/null +++ b/PATCH_18_7_10_ANDROID_1440_4K.md @@ -0,0 +1,17 @@ +# YouTube Personal 18.7.10 — Android 1440p / 4K exact + +## Portée +Correctif strictement Android. Aucun service Windows n'est modifié. + +## Correctifs +- Le sélecteur natif Android utilise la hauteur du libellé de résolution (`1440p`, `2160p`) si nécessaire. +- Pour une demande 1440p ou 2160p, la hauteur exacte est prioritaire lorsqu'elle existe. +- Une demande 1440p/2160p ne peut plus être validée silencieusement par un flux 1080p. +- La bascule Android attend maintenant que MediaCodec publie exactement la résolution sélectionnée avant de confirmer la qualité. +- Si la vidéo ne propose pas la qualité demandée, la lecture actuelle continue et un message indique la résolution maximale disponible. +- Ajout de logs `[HD-ANDROID][18.7.10]` avec les hauteurs disponibles et la hauteur réellement sélectionnée. + +## Windows +- `windows_hd_service.dart` absent du ZIP. +- Aucun code du pipeline Windows 18.7.5 n'est modifié. +- Les changements Dart sont derrière `Platform.isAndroid`. diff --git a/PATCH_18_7_11A_FIX_PREFERRED_LANGUAGE.md b/PATCH_18_7_11A_FIX_PREFERRED_LANGUAGE.md new file mode 100644 index 0000000..56a3592 --- /dev/null +++ b/PATCH_18_7_11A_FIX_PREFERRED_LANGUAGE.md @@ -0,0 +1,18 @@ +# Patch 18.7.11a — Correctif compilation langue préférée + +## Cause +Le patch 18.7.11 avait inclus par erreur une version incomplète de `preferred_language_service.dart`. + +## Correctif +Restauration des fonctions déjà utilisées par le projet : +- `labelFor` +- `nativeSearchHint` +- `dubbingSearchHint` +- `scoreVideo` +- `isLikelyDubbed` +- `rankVideos` + +## Impact +- Navigation 18.7.11 conservée. +- Android 18.7.10 conservé. +- Aucun fichier du lecteur Windows n'est modifié. diff --git a/PATCH_18_7_11B_UI_OVERFLOW_FIX.md b/PATCH_18_7_11B_UI_OVERFLOW_FIX.md new file mode 100644 index 0000000..c2917b8 --- /dev/null +++ b/PATCH_18_7_11B_UI_OVERFLOW_FIX.md @@ -0,0 +1,11 @@ +# Patch 18.7.11b — Correction des overflows d'affichage + +## Corrections +- Le mode desktop/rail n'est plus activé sur un téléphone simplement en paysage. +- La page Bibliothèque adapte maintenant le nombre de colonnes (1 / 2 / 3). +- Les cartes Bibliothèque utilisent une hauteur fixe adaptée et un layout interne non débordant. +- Le rail affiche seulement le label sélectionné pour réduire l'encombrement vertical. + +## Fichiers modifiés +- lib/main.dart +- lib/screens/library_pages.dart diff --git a/PATCH_18_7_11C_CHANNELS_MATERIAL_FIX.md b/PATCH_18_7_11C_CHANNELS_MATERIAL_FIX.md new file mode 100644 index 0000000..55a77a1 --- /dev/null +++ b/PATCH_18_7_11C_CHANNELS_MATERIAL_FIX.md @@ -0,0 +1,15 @@ +# Patch 18.7.11c — Chaînes / Material ancestor + +Correction partagée Windows + Android. + +Cause: +`ChannelsPage` était utilisée comme route depuis la nouvelle Bibliothèque sans `Scaffold` / `Material` propre. +Le `TextField` de recherche levait alors `No Material widget found`. + +Correction: +- `ChannelsPage` possède désormais son propre `Scaffold`. +- Aucun service vidéo ni lecteur n'est modifié. +- Aucun fichier Windows HD / Android HD n'est modifié. + +Fichier modifié: +- lib/screens/channels_page.dart diff --git a/PATCH_18_7_11D_WINDOWS_BACK_NAV.md b/PATCH_18_7_11D_WINDOWS_BACK_NAV.md new file mode 100644 index 0000000..71ba3dc --- /dev/null +++ b/PATCH_18_7_11D_WINDOWS_BACK_NAV.md @@ -0,0 +1,15 @@ +# Patch 18.7.11d — Navigation retour des sous-pages + +## Problème +Après le regroupement dans Bibliothèque, Chaînes, Historique et Favoris sont ouverts via `Navigator.push()`. Sur Windows, aucune commande de retour n'était visible. + +## Correction +- Bouton Retour visible en haut de Chaînes quand la page peut être dépilée. +- Bouton Retour visible en haut de Historique et Favoris quand la page peut être dépilée. +- Le bouton n'apparaît pas sur une page racine. +- Le correctif Material de Chaînes (18.7.11c) est conservé. +- Aucun lecteur vidéo ou service HD n'est modifié. + +## Fichiers modifiés +- lib/screens/channels_page.dart +- lib/screens/library_pages.dart diff --git a/PATCH_18_7_11E_SNACKBAR_AUTOCLOSE.md b/PATCH_18_7_11E_SNACKBAR_AUTOCLOSE.md new file mode 100644 index 0000000..c1bdd4b --- /dev/null +++ b/PATCH_18_7_11E_SNACKBAR_AUTOCLOSE.md @@ -0,0 +1,11 @@ +# Patch 18.7.11e — Fermeture automatique du message Chaînes + +Correction: +- Le message "ne sera plus proposée" disparaît automatiquement après 3 secondes. +- Le bouton ANNULER reste fonctionnel pendant ce délai. +- Une fermeture forcée via le contrôleur du SnackBar évite les cas où Windows/Flutter laisse le message persistant. + +Impact: +- UI Chaînes uniquement. +- Aucun changement dans le lecteur Windows. +- Aucun changement dans le lecteur Android. diff --git a/PATCH_18_7_11_LIBRARY_BOTTOM_NAV.md b/PATCH_18_7_11_LIBRARY_BOTTOM_NAV.md new file mode 100644 index 0000000..d5682ac --- /dev/null +++ b/PATCH_18_7_11_LIBRARY_BOTTOM_NAV.md @@ -0,0 +1,29 @@ +# Patch 18.7.11 — Refonte du menu bas (Version 1) + +## Objectif +Passer de 7 onglets serrés à 5 onglets plus propres sur mobile. + +## Nouveau menu bas +- Accueil +- Recherche +- Abos +- Bibliothèque +- Réglages + +## Bibliothèque +Le nouvel écran **Bibliothèque** regroupe : +- Historique +- Favoris +- Chaînes + +Il affiche aussi des cartes d'accès rapide et des aperçus récents. + +## Impact Windows +Aucun impact sur le lecteur Windows ni sur `windows_hd_service.dart`. + +## Fichiers modifiés +- lib/main.dart +- lib/screens/library_pages.dart +- lib/screens/settings_page.dart +- lib/services/app_settings_service.dart +- lib/services/preferred_language_service.dart diff --git a/PATCH_18_7_12A_STRICT_FILTER_ALL_HOME.md b/PATCH_18_7_12A_STRICT_FILTER_ALL_HOME.md new file mode 100644 index 0000000..d216c3c --- /dev/null +++ b/PATCH_18_7_12A_STRICT_FILTER_ALL_HOME.md @@ -0,0 +1,23 @@ +# Patch 18.7.12a — Filtre Strict appliqué à tout l'accueil + +## Cause +Le filtre 18.7.12 était appliqué aux candidats du Radar, mais pas à toutes les sources visibles de l'accueil. +Des vidéos déjà présentes dans l'historique ou un feed en mémoire pouvaient donc rester visibles dans : +- Continuer à regarder +- Continuer ce sujet +- Découverte +- catégories Radar +- redécouverte + +## Correction +- filtrage local juste avant affichage dans HomePage +- filtrage de "Continuer à regarder" +- filtrage de "Continuer ce sujet", "Découverte" et catégories, même avec un ancien feed en mémoire +- filtrage des vidéos qui servent à construire le profil Radar +- ajout de plusieurs chaînes/marques typiquement très jeunes au niveau Strict, dont VLAD ARA + +## Non modifié +- recherche manuelle +- lecteur Windows +- lecteur Android +- pipeline HD diff --git a/PATCH_18_7_12_YOUNG_AUDIENCE_FILTER.md b/PATCH_18_7_12_YOUNG_AUDIENCE_FILTER.md new file mode 100644 index 0000000..665debd --- /dev/null +++ b/PATCH_18_7_12_YOUNG_AUDIENCE_FILTER.md @@ -0,0 +1,41 @@ +# Patch 18.7.12 — Filtre contenu très jeune + +## Nouveau réglage +Paramètres > Filtre contenu très jeune + +Niveaux : +- Désactivé +- Modéré +- Strict + +## Portée +Le filtre agit sur le Radar et les recommandations automatiques. +Il ne masque pas les résultats d'une recherche explicite de l'utilisateur. + +## Détection +Le filtre utilise uniquement les métadonnées textuelles disponibles : +- nom de chaîne +- titre vidéo +- description +- marqueurs textuels multilingues (kids, enfants, bébés, jouets, comptines, etc.) +- quelques marques/chaînes typiquement orientées jeune public + +Aucune analyse du visage ou estimation d'âge à partir des miniatures. + +## Comportement +- Modéré : exige des signaux forts/explicites. +- Strict : seuil plus bas et donc filtrage plus agressif. +- Toute modification du réglage reconstruit automatiquement le Radar. + +## Plateformes +- Windows : oui, recommandations uniquement +- Android : oui, recommandations uniquement +- Lecteur Windows : non modifié +- Lecteur Android : non modifié + +## Fichiers +- lib/screens/home_page.dart +- lib/screens/settings_page.dart +- lib/services/app_settings_service.dart +- lib/services/radar_service.dart +- lib/services/young_audience_filter_service.dart diff --git a/PATCH_18_7_13_CHANNEL_AVATAR.md b/PATCH_18_7_13_CHANNEL_AVATAR.md new file mode 100644 index 0000000..2ed81f7 --- /dev/null +++ b/PATCH_18_7_13_CHANNEL_AVATAR.md @@ -0,0 +1,9 @@ +# Patch 18.7.13 — Logo de chaîne dans le lecteur + +- Affiche le vrai avatar/logo de la chaîne à gauche du nom sur la page vidéo. +- Utilise `ChannelDetails.thumbnailUrl`, déjà récupéré par les métadonnées inline. +- Fallback automatique vers l'icône générique si l'URL est vide ou si le chargement échoue. +- Aucun changement du pipeline de lecture Windows ou Android. + +Fichier modifié : +- `lib/screens/video_player_page.dart` diff --git a/PATCH_18_7_14_ANDROID_GESTURES_SLEEP.md b/PATCH_18_7_14_ANDROID_GESTURES_SLEEP.md new file mode 100644 index 0000000..05f3f80 --- /dev/null +++ b/PATCH_18_7_14_ANDROID_GESTURES_SLEEP.md @@ -0,0 +1,37 @@ +# YouTube Personal 18.7.14 — Android gestes + veille + +## Parité Android +Les fonctions Flutter partagées restent communes Windows / Android : +- dates et métadonnées +- page Bibliothèque / navigation +- filtre contenu très jeune +- langue préférée +- résolution par défaut +- avatar réel de chaîne dans le lecteur + +## Gestes Android +Sur la surface vidéo : +- glissement vertical côté droit : volume système média +- glissement vertical côté gauche : luminosité de la fenêtre +- pincement à deux doigts : zoom 1.0x à 3.0x +- feedback central : Volume / Luminosité / Zoom +- double tap -10/+10 s existant conservé en plein écran + +## Mise en veille Android +Le wakelock automatique de `media_kit_video` est désactivé uniquement sur Android et remplacé par une gestion contrôlée : +- lecture : écran maintenu allumé +- vidéo terminée : veille système immédiatement autorisée +- pause : veille autorisée après délai configurable +- valeurs : 2, 5, 10 minutes ou Jamais +- défaut : 5 minutes +- sortie de la page : maintien écran retiré et luminosité rendue au système + +## Fichiers modifiés +- lib/screens/video_player_page.dart +- lib/screens/settings_page.dart +- lib/services/app_settings_service.dart +- android/app/src/main/java/com/studioweb/youtube_personal/MainActivity.java + +## Protection Windows +Aucun fichier Windows HD n'est fourni dans ce patch. +`windows_hd_service.dart` et le pipeline 18.7.5 ne sont pas modifiés. diff --git a/PATCH_18_7_15_CACHE_CONTROL_AND_ANDROID_COMPILE_FIX.md b/PATCH_18_7_15_CACHE_CONTROL_AND_ANDROID_COMPILE_FIX.md new file mode 100644 index 0000000..20c4c06 --- /dev/null +++ b/PATCH_18_7_15_CACHE_CONTROL_AND_ANDROID_COMPILE_FIX.md @@ -0,0 +1,30 @@ +# YouTube Personal 18.7.15 +## Cache Windows + Android et correctif compilation Android + +### Correctif compilation Android +`Colors.black72` n'existe pas dans Flutter. +Le fond de l'indicateur des gestes utilise maintenant `Color(0xB8000000)`. + +### Windows +- cache média persistant limité à 5 Go par défaut +- nettoyage LRU automatique des vidéos les moins récemment utilisées +- nettoyage lancé au démarrage de l'application +- emplacement du cache configurable +- détection et suppression séparée de l'ancien cache `%LOCALAPPDATA%\YouTubePersonal\media_cache` +- fallback au chemin par défaut si un disque personnalisé est indisponible +- aucun changement dans `windows_hd_service.dart` + +### Android +- cache démuxeur media_kit limité en mémoire, 64 Mo par défaut +- choix 32 / 64 / 128 Mo +- cache disque vidéo désactivé par défaut +- option de cache disque temporaire dans le stockage interne privé de l'app +- option de cache disque temporaire dans le stockage externe privé de l'app +- affichage du chemin et de la taille du cache temporaire +- bouton pour vider le cache +- les restes d'une session interrompue sont nettoyés avant réutilisation +- les paramètres de cache s'appliquent aux nouvelles pages vidéo + +### Sécurité de la lecture +Le cache est une optimisation. Une erreur de configuration du cache Android ne doit pas empêcher la lecture. +Le pipeline Windows protégé n'est pas réécrit. diff --git a/PATCH_18_7_16_ANDROID_GESTURES_SLEEP_FIX.md b/PATCH_18_7_16_ANDROID_GESTURES_SLEEP_FIX.md new file mode 100644 index 0000000..adaab32 --- /dev/null +++ b/PATCH_18_7_16_ANDROID_GESTURES_SLEEP_FIX.md @@ -0,0 +1,27 @@ +# YouTube Personal 18.7.16 — Android gestures + pause sleep fix + +## Correction des gestes Android +La détection `GestureDetector.onScale...` est remplacée par un `Listener` +de pointeurs bruts sur Android. + +- moitié droite, glissement vertical : volume système média +- moitié gauche, glissement vertical : luminosité de la fenêtre +- deux doigts : zoom 1.0x à 3.0x +- seuil avant activation pour ne pas confondre un tap avec un glissement +- double tap existant conservé +- indicateur visuel Volume / Luminosité / Zoom conservé + +Le Listener brut continue à recevoir les PointerEvents même lorsque les +contrôles media_kit participent au GestureArena. + +## Correction de la veille après pause +Ajout d'un watchdog Android toutes les secondes : +- lecture / chargement : maintien écran actif +- pause : mémorisation du début réel de la pause +- après le délai choisi : FLAG_KEEP_SCREEN_ON retiré +- fin de vidéo : retrait immédiat, comportement déjà fonctionnel conservé +- "Jamais" : maintien de l'écran + +## Windows +Aucun service Windows n'est modifié. Les nouvelles branches sont protégées +par `Platform.isAndroid`. diff --git a/PATCH_18_7_17_ANDROID_CONTROLS_GESTURES_FIX.md b/PATCH_18_7_17_ANDROID_CONTROLS_GESTURES_FIX.md new file mode 100644 index 0000000..65e7c76 --- /dev/null +++ b/PATCH_18_7_17_ANDROID_CONTROLS_GESTURES_FIX.md @@ -0,0 +1,26 @@ +# YouTube Personal 18.7.17 — Android controls + gestures + +## Régression corrigée +La 18.7.16 plaçait un `Listener` opaque AU-DESSUS de `Video`. +Les swipes volume/luminosité fonctionnaient, mais les taps n'atteignaient +plus correctement les contrôles `media_kit`. + +## Nouvelle architecture Android +Le `Listener` brut est maintenant un PARENT du widget `Video`. + +Conséquences : +- swipe vertical droite : volume conservé +- swipe vertical gauche : luminosité conservée +- pinch : zoom conservé +- contrôles media_kit reçoivent à nouveau les taps +- play/pause restauré +- barre/seek restaurée +- volume du lecteur restauré +- plein écran restauré + +La couche `GestureDetector` qui recouvrait la vidéo n'est plus utilisée sur +Android. + +## Windows +Aucun service Windows n'est modifié. +Le changement est protégé par `Platform.isAndroid`. diff --git a/PATCH_18_7_18_ANDROID_FULLSCREEN_GESTURES.md b/PATCH_18_7_18_ANDROID_FULLSCREEN_GESTURES.md new file mode 100644 index 0000000..f2e8a14 --- /dev/null +++ b/PATCH_18_7_18_ANDROID_FULLSCREEN_GESTURES.md @@ -0,0 +1,29 @@ +# YouTube Personal 18.7.18 — Gestes Android en plein écran + +## Cause +En 18.7.17, les gestes Android étaient attachés autour du widget `Video` +de la page normale. Or media_kit crée une nouvelle route `Video` lorsqu'il +entre en plein écran. Cette nouvelle surface n'héritait donc pas du Listener. + +## Correction +Les gestes sont maintenant attachés au `controls` builder Android : +- media_kit recopie ce builder dans sa route fullscreen +- les mêmes PointerEvents sont donc observés en mode normal et plein écran +- les contrôles MaterialVideoControls restent enfants du Listener et continuent + à recevoir les taps normalement + +## Gestes +- moitié droite, swipe vertical : volume système média +- moitié gauche, swipe vertical : luminosité de la fenêtre +- pinch à deux doigts : zoom 1.0x à 3.0x + +## Zoom +Le zoom passe maintenant par la propriété native mpv `video-zoom`, donc il +s'applique à la sortie vidéo elle-même et reste valable en plein écran. + +## Feedback +Les indicateurs `Volume`, `Luminosité` et `Zoom` sont intégrés au builder de +contrôles et apparaissent donc aussi en plein écran. + +## Windows +Aucun service Windows n'est modifié. Le changement est limité au chemin Android. diff --git a/PATCH_18_7_19_ANDROID_SLEEP_ROTATION.md b/PATCH_18_7_19_ANDROID_SLEEP_ROTATION.md new file mode 100644 index 0000000..de1f8a2 --- /dev/null +++ b/PATCH_18_7_19_ANDROID_SLEEP_ROTATION.md @@ -0,0 +1,42 @@ +# YouTube Personal 18.7.19 — Veille pause native + rotation plein écran + +## 1. Mise en veille après pause +Le délai de pause n'est plus géré par un Timer Flutter. + +Le timer est maintenant dans MainActivity Android : +- lecture -> KEEP_SCREEN_ON actif, timer pause annulé +- pause -> timer natif de 2 / 5 / 10 minutes +- expiration -> KEEP_SCREEN_ON retiré +- reprise -> timer annulé et écran maintenu allumé +- fin de vidéo -> KEEP_SCREEN_ON retiré immédiatement +- fonctionne en vue normale et dans la route fullscreen media_kit +- option Jamais conservée + +Un callback vers Flutter synchronise l'état lorsque le timer natif expire. + +Logs : +[ANDROID-SCREEN][18.7.19] timer pause=... +[ANDROID-SCREEN][18.7.19] délai pause écoulé -> keepOn=false + +## 2. Rotation automatique Android +MainActivity envoie les changements d'orientation à la page vidéo. + +Comportement : +- rotation physique vers paysage -> entrée plein écran +- rotation physique vers portrait -> sortie plein écran +- debounce 250 ms pour éviter les doubles transitions +- après transition, l'orientation est rendue au capteur Android pour que le + retour portrait reste possible + +Logs : +[ANDROID-ORIENTATION][18.7.19] landscape +[ANDROID-ORIENTATION][18.7.19] paysage -> plein écran +[ANDROID-ORIENTATION][18.7.19] portrait -> sortie plein écran + +## Préservé +- gestes volume/luminosité en normal et fullscreen +- pinch-to-zoom natif mpv +- contrôles media_kit +- qualité Android 1440p / 4K +- cache 18.7.15 +- aucun service Windows modifié diff --git a/PATCH_18_7_20_CHANNEL_LOAD_MORE.md b/PATCH_18_7_20_CHANNEL_LOAD_MORE.md new file mode 100644 index 0000000..2d0ec89 --- /dev/null +++ b/PATCH_18_7_20_CHANNEL_LOAD_MORE.md @@ -0,0 +1,30 @@ +# YouTube Personal 18.7.20 — Pagination des vidéos de chaîne + +## Problème +Le fallback public affichait seulement la première page du catalogue de chaîne +(environ 30 vidéos) et ne proposait pas de charger la suite. + +## Correction +- première page rapide : ~30 vidéos +- bouton permanent en bas : `Charger 30 vidéos de plus` +- chaque clic appelle la continuation YouTube suivante +- les nouvelles vidéos sont ajoutées sans écraser les précédentes +- doublons filtrés +- le bouton disparaît quand il n'y a plus de page +- le compteur indique `N chargées` tant que le catalogue n'est pas complet +- changement de tri = nouvelle pagination propre +- actualiser = pagination remise à zéro + +## Windows +Le catalogue yt-dlp groupé déjà en cache est réutilisé pour enrichir les nouvelles +vidéos, sans lancer une requête watch par vidéo. + +## Android +La pagination publique fonctionne aussi sur Android via youtube_explode_dart. + +## Non modifié +- video_player_page.dart +- MainActivity.java +- pipeline HD Windows +- pipeline qualité Android +- gestes / zoom / veille / rotation Android diff --git a/PATCH_18_7_21A_THEME_COMPILE_FIX.md b/PATCH_18_7_21A_THEME_COMPILE_FIX.md new file mode 100644 index 0000000..850b4f8 --- /dev/null +++ b/PATCH_18_7_21A_THEME_COMPILE_FIX.md @@ -0,0 +1,19 @@ +# YouTube Personal 18.7.21a — Correctif compilation thème + +## Problème +`lib/theme/app_theme.dart` utilisait `Theme.of(context)` dans des getters statiques, +sans `BuildContext` disponible. Cela provoquait les erreurs : +- Undefined name 'context' +- Couldn't find constructor 'Theme.of' + +## Correction +Le thème sombre utilise maintenant des couleurs explicites : +- scaffold: #0F0F0F +- surface: #181818 +- container: #202020 +- container high: #252525 +- container highest: #303030 + +Le thème clair reste inchangé. + +Aucun autre fichier n'est modifié. diff --git a/PATCH_18_7_21_LIGHT_DARK_THEME.md b/PATCH_18_7_21_LIGHT_DARK_THEME.md new file mode 100644 index 0000000..c30f347 --- /dev/null +++ b/PATCH_18_7_21_LIGHT_DARK_THEME.md @@ -0,0 +1,45 @@ +# YouTube Personal 18.7.21 — Thème sombre / clair + +## Réglages > Apparence +Ajout de trois modes : +- Système +- Sombre +- Clair + +Le choix est enregistré avec SharedPreferencesAsync et appliqué immédiatement. +Le mode Système suit le thème de Windows ou Android. + +## Interface adaptée +Les couleurs neutres codées en dur ont été remplacées par le ColorScheme +Material sur les principaux écrans partagés : +- Accueil / Radar +- Recherche +- Abonnements +- Bibliothèque +- Chaînes +- Page de chaîne / catalogue +- Réglages +- cartes de résultats +- métadonnées et contrôles sous le lecteur + +Le thème sombre conserve volontairement les teintes historiques +#0F0F0F / #181818 / #202020 / #252525. + +Le thème clair utilise des surfaces blanches et gris très clair avec +textes foncés et bordures adaptées. + +## Lecteur vidéo +La surface vidéo, les gradients, badges de durée et overlays de contrôle +restent noirs/blancs afin de conserver le contraste sur les images. + +Les fonctions Android 18.7.18/18.7.19 sont conservées : +- gestes volume / luminosité +- pinch-to-zoom +- gestes plein écran +- veille après pause +- rotation paysage <-> plein écran + +La pagination de chaîne 18.7.20 est conservée. + +## Windows +Aucun service du pipeline HD Windows n'est modifié. diff --git a/PATCH_18_7_22_CHANNEL_INFO_COLLAPSIBLE.md b/PATCH_18_7_22_CHANNEL_INFO_COLLAPSIBLE.md new file mode 100644 index 0000000..bddf042 --- /dev/null +++ b/PATCH_18_7_22_CHANNEL_INFO_COLLAPSIBLE.md @@ -0,0 +1,20 @@ +# YouTube Personal 18.7.22 — Informations de chaîne repliables + +## Objectif +Sur Android, la fiche complète de chaîne prenait trop de hauteur et repoussait +le catalogue vidéo sous l'écran. + +## Nouveau comportement +- Android : les informations détaillées de la chaîne sont repliées par défaut. +- Windows : les informations restent ouvertes par défaut. +- Bouton compact `Infos de la chaîne` pour ouvrir la fiche. +- Bouton `Masquer les infos` pour la refermer. +- Le bloc principal et le résumé statistique sont repliés ensemble. +- Le tri et les vidéos restent toujours visibles. +- Animation courte lors de l'ouverture/fermeture. + +## Préservé +- thème clair / sombre 18.7.21 +- pagination `Charger 30 vidéos de plus` 18.7.20 +- lecteur Windows +- fonctions Android gestes / zoom / veille / rotation diff --git a/PATCH_18_7_23A_HIGH_TECH_CLASSIFICATION.md b/PATCH_18_7_23A_HIGH_TECH_CLASSIFICATION.md new file mode 100644 index 0000000..f84d356 --- /dev/null +++ b/PATCH_18_7_23A_HIGH_TECH_CLASSIFICATION.md @@ -0,0 +1,48 @@ +# YouTube Personal 18.7.23a — Classification High-tech renforcée + +## Problème observé +La vidéo arabe `العودة السحرية .. بلاك بيري !!` était visible dans `Tous` +mais absente du profil `High-tech`. + +Cause : +le classificateur 18.7.23 ne connaissait pas encore `بلاك بيري / BlackBerry`. +La vidéo avait donc été classée dans le bucket technique `Autres`. +Une note donnée depuis `Tous` était alors mémorisée dans ce mauvais bucket. + +## Correction +Le vocabulaire High-tech reconnaît maintenant notamment : +- BlackBerry / بلاك بيري / بلاكبيري +- Samsung / Galaxy / سامسونج +- Apple / iPhone / آيفون / ايفون +- Xiaomi / Redmi / Poco / شاومي +- Huawei / هواوي +- Honor / هونر +- Google Pixel +- Android / أندرويد / اندرويد +- smartphone / phone / mobile / هاتف / هواتف / هاتف ذكي +- Qualcomm / Snapdragon / MediaTek +- NVIDIA / GeForce / AMD / Radeon +- OLED / AMOLED +- Bluetooth / Wi-Fi / USB +- IA / AI / ChatGPT / OpenAI +- autres termes technologiques courants + +## Migration automatique +Au chargement, les anciens feedbacks stockés dans `Autres` sont reclassés +si le nouveau classificateur identifie maintenant une catégorie connue. + +Important : +- seuls les anciens éléments `Autres` sont migrés automatiquement +- les catégories que l'utilisateur pouvait sélectionner explicitement ne sont + jamais déplacées automatiquement +- la note, `Mauvaise qualité` et `Pas intéressé` sont conservés + +Exemple : +`BlackBerry 5★` dans `Autres` +devient +`BlackBerry 5★` dans `High-tech`. + +## Fichier modifié +- lib/services/interest_profile_service.dart + +Aucun lecteur vidéo ni pipeline Windows/Android n'est modifié. diff --git a/PATCH_18_7_23B_CLASSIFIER_FALSE_POSITIVES.md b/PATCH_18_7_23B_CLASSIFIER_FALSE_POSITIVES.md new file mode 100644 index 0000000..868e62c --- /dev/null +++ b/PATCH_18_7_23B_CLASSIFIER_FALSE_POSITIVES.md @@ -0,0 +1,39 @@ +# YouTube Personal 18.7.23b — Faux positifs du classificateur + +## Intrus observé +`25 Races de Chevaux de Trait Les Plus Puissantes au Monde` +apparaissait dans le profil High-tech. + +## Cause +Le classificateur utilisait `haystack.contains(keyword)`. + +Le mot-clé High-tech court `ai` correspondait donc aussi à des fragments de +mots, notamment : + +- `trait` +- `maison` +- d'autres mots contenant les lettres `ai` + +La vidéo de chevaux pouvait alors recevoir artificiellement des points +High-tech. + +## Correction +Les mots-clés et expressions doivent maintenant correspondre à des frontières +de mots après normalisation. + +Exemples : + +- `AI` correspond à `AI` +- `AI` ne correspond plus à `trait` +- `PC` correspond à `PC` +- `BlackBerry` / `بلاك بيري` restent reconnus +- les expressions comme `artificial intelligence` restent reconnues + +## Impact +Le correctif s'applique à toutes les catégories et réduit les faux positifs +causés par les mots-clés courts. + +## Fichier modifié +- lib/services/interest_profile_service.dart + +Aucun autre composant n'est modifié. diff --git a/PATCH_18_7_23C_HORROR_CATEGORY.md b/PATCH_18_7_23C_HORROR_CATEGORY.md new file mode 100644 index 0000000..2ec80ca --- /dev/null +++ b/PATCH_18_7_23C_HORROR_CATEGORY.md @@ -0,0 +1,53 @@ +# YouTube Personal 18.7.23c — Catégorie Horreur + +## Nouvelle catégorie +Ajout de `Horreur` dans le Centre d'intérêt actif. + +Elle possède sa propre mémoire : +- notes 1 à 5 étoiles +- Mauvaise qualité +- Pas intéressé +- exemples positifs/négatifs +- scoring indépendant des autres catégories + +## Détection +Exemples FR/EN : +- horreur / horror +- épouvante +- scary / creepy +- slasher +- jumpscare +- creepypasta +- analog horror +- found footage +- paranormal +- haunted / ghost +- possession / démon / exorcisme +- film d'horreur / horror movie + +Exemples jeux d'horreur : +- Resident Evil +- Silent Hill +- Outlast +- Amnesia +- jeu d'horreur / horror game + +Exemples arabes : +- رعب +- مرعب / مرعبة +- مخيف / مخيفة +- قصة رعب / قصص رعب +- أشباح / شبح +- مسكون / مسكونة + +## Recherche Radar +Le profil Horreur utilise aussi une requête dédiée : +`horreur horror paranormal haunted scary` + +## Fichier modifié +- lib/services/interest_profile_service.dart + +La liste des catégories de l'accueil est dynamique, donc aucun changement de +HomePage n'est nécessaire. + +Aucun lecteur vidéo ni pipeline Windows/Android n'est modifié. diff --git a/PATCH_18_7_23D_PARANORMAL_RENAME.md b/PATCH_18_7_23D_PARANORMAL_RENAME.md new file mode 100644 index 0000000..acc5748 --- /dev/null +++ b/PATCH_18_7_23D_PARANORMAL_RENAME.md @@ -0,0 +1,39 @@ +# YouTube Personal 18.7.23d — Horreur devient Paranormal + +## Renommage +La catégorie `Horreur` est remplacée par `Paranormal`. + +## Migration automatique +Au prochain lancement : +- centre actif `Horreur` -> `Paranormal` +- notes 1–5★ conservées +- `Mauvaise qualité` conservé +- `Pas intéressé` conservé +- exemples positifs/négatifs conservés + +## Détection renforcée Darija / arabe +Ajout notamment de : +- ملعون / ملعونة / لعنة +- مسحور / مسحورة +- عالم آخر +- عالم الجن / الجن +- دوار مهجور +- قرية مهجورة +- مكان مهجور +- اختفى / اختفاء +- مكان غريب +- قصة مخيفة +- قصة مرعبة + +La détection Horreur existante reste incluse dans Paranormal : +paranormal, haunted, ghost, creepypasta, analog horror, found footage, +Resident Evil, Silent Hill, etc. + +## Recherche Radar +Le profil utilise désormais : +`paranormal surnaturel horreur haunted ghost scary` + +## Fichier modifié +- lib/services/interest_profile_service.dart + +Aucun lecteur vidéo ni pipeline Windows/Android n'est modifié. diff --git a/PATCH_18_7_23_INTEREST_PROFILES_RATING.md b/PATCH_18_7_23_INTEREST_PROFILES_RATING.md new file mode 100644 index 0000000..0005cf3 --- /dev/null +++ b/PATCH_18_7_23_INTEREST_PROFILES_RATING.md @@ -0,0 +1,127 @@ +# YouTube Personal 18.7.23 — Profils d'intérêt + notation qualité + +## Objectif + +Séparer deux questions différentes : + +1. Quel sujet veux-tu regarder maintenant ? +2. Quel niveau/type de contenu considères-tu comme bon ou mauvais dans ce sujet ? + +Une mauvaise vidéo Gaming ne doit donc pas diminuer l'intérêt pour le Gaming. +Elle doit seulement apprendre au Radar à éviter les vidéos de qualité/style +similaire dans le profil Gaming. + +## Profils disponibles + +- Tous +- Politique +- Jeux vidéo +- Documentaires +- Islam +- Football +- High-tech + +Le profil actif est mémorisé. + +Quand un profil précis est actif, l'accueil est focalisé sur ce profil : +- Radar +- Continuer à regarder +- À redécouvrir +- Vues récemment +- Favoris récents + +La recherche manuelle n'est pas filtrée. + +## Notes + +Chaque vidéo peut recevoir de 1 à 5 étoiles. + +- 1★ : très mauvaise +- 2★ : mauvaise +- 3★ : neutre +- 4★ : très bonne +- 5★ : excellente + +Les étoiles sont disponibles : +- sur les recommandations Radar +- dans Continuer à regarder +- dans Vues récemment +- dans Favoris / À redécouvrir + +Cela permet de noter une vidéo après l'avoir regardée. + +## Feedbacks supplémentaires + +Dans le menu `...` d'une recommandation : + +### Mauvaise qualité +- cache la vidéo exacte +- conserve le sujet/catégorie +- crée un exemple négatif de qualité +- les vidéos ressemblant à cet exemple perdent des points +- la chaîne peut également perdre des points dans cette catégorie + +### Pas intéressé +- cache la vidéo exacte +- apprend aussi que ce sous-sujet précis doit être moins proposé + +`Pas maintenant` reste séparé et temporaire. + +## Scoring V1 + +Le score existant du Radar est conservé puis enrichi par : + +- proximité avec les vidéos 4–5★ : bonus jusqu'à +28 +- proximité avec les vidéos 1–2★ / Mauvaise qualité : malus jusqu'à -36 +- réputation personnelle de la chaîne dans cette catégorie : environ -18 à +14 +- `Pas intéressé` : malus de proximité thématique + +Dans `Pourquoi ?`, le détail peut afficher : + +`Qualité perso +18` + +ou : + +`Qualité perso -24` + +## Isolation des catégories + +Les exemples sont enregistrés séparément. + +Exemple : +- une vidéo Football 1★ n'affecte pas Jeux vidéo +- une vidéo Jeux vidéo 5★ n'affecte pas Politique +- en mode `Tous`, chaque candidate est comparée uniquement aux exemples de sa + propre catégorie détectée + +## Réinitialisation + +Lorsque des signaux existent, le bloc `Centre d'intérêt actif` propose +`Réinitialiser`. + +Cela supprime seulement : +- notes +- Mauvaise qualité +- Pas intéressé + +Historique et favoris restent intacts. + +## Test conseillé + +1. Choisir `Jeux vidéo`. +2. Noter 2 ou 3 vidéos de bonne qualité en 5★. +3. Noter 2 vidéos médiocres en 1★ ou `Mauvaise qualité`. +4. Appuyer sur `Mélanger`. +5. Ouvrir `Pourquoi ?` sur les nouvelles propositions. +6. Vérifier la ligne `Qualité perso`. +7. Passer à `Politique` et vérifier que les signaux Gaming n'interviennent pas. +8. Revenir à `Tous`. + +## Fichiers modifiés + +- lib/screens/home_page.dart +- lib/services/radar_service.dart +- lib/services/interest_profile_service.dart + +Aucun fichier du lecteur Windows ou Android n'est modifié. +Aucun pipeline vidéo n'est modifié. diff --git a/PATCH_18_7_24_ASPECT_AWARE_1440_4K.md b/PATCH_18_7_24_ASPECT_AWARE_1440_4K.md new file mode 100644 index 0000000..5a56d3c --- /dev/null +++ b/PATCH_18_7_24_ASPECT_AWARE_1440_4K.md @@ -0,0 +1,48 @@ +# YouTube Personal 18.7.24 — 1440p / 4K avec ratios non 16:9 + +## Cause confirmée par les logs + +La vidéo `wIj00Tzr2MA` expose notamment : +- 1920x1012 +- 2560x1350 +- 3840x2026 + +YouTube les rattache aux paliers : +- 1080p +- 1440p +- 2160p / 4K + +L'application exigeait auparavant une hauteur littérale de 1440 ou 2160. +Elle rejetait donc à tort 2560x1350 et 3840x2026. + +## Correction Windows + +La validation stricte 1440p / 2160p reconnaît désormais le palier à partir +des dimensions réelles : + +- palier 1440p : dimension longue >= ~2500 px +- palier 2160p / 4K : dimension longue >= ~3800 px + +Ainsi : +- 2560x1350 est accepté comme 1440p +- 3840x2026 est accepté comme 2160p / 4K +- 1920x1012 reste 1080p et ne peut pas se faire passer pour 1440p/4K + +La protection du pipeline Windows 18.7.5 reste intacte : +`windows_hd_service.dart` n'est pas modifié. + +## Correction Android + +Le même principe est appliqué dans `HdStreamExtractor.java`. + +La sélection ne dépend plus d'une hauteur exacte : +- vidéos cinéma / ultrawide +- vidéos portrait +- ratios non 16:9 + +restent correctement rattachés à leur classe YouTube 720/1080/1440/2160. + +## Fichiers modifiés + +- lib/screens/video_player_page.dart +- android/app/src/main/java/com/studioweb/youtube_personal/HdStreamExtractor.java diff --git a/PATCH_18_7_25_MANUAL_CATEGORIES_PLAYER_RATING.md b/PATCH_18_7_25_MANUAL_CATEGORIES_PLAYER_RATING.md new file mode 100644 index 0000000..7d10605 --- /dev/null +++ b/PATCH_18_7_25_MANUAL_CATEGORIES_PLAYER_RATING.md @@ -0,0 +1,85 @@ +# YouTube Personal 18.7.25 — Catégorisation manuelle + étoiles dans le lecteur + +## Pourquoi + +Le classificateur automatique reste utile, mais aucun dictionnaire de mots-clés +ne peut couvrir parfaitement tous les titres FR / EN / AR / Darija. + +L'utilisateur peut maintenant corriger directement la catégorie d'une vidéo +ou d'une chaîne. + +## Priorité de catégorisation + +1. Catégorie manuelle de la vidéo +2. Catégorie manuelle de la chaîne +3. Classification automatique + +Une correction humaine est donc toujours prioritaire. + +## Page de lecture + +Ajout sous le titre : + +### Ta note +- 1 à 5 étoiles +- utilise exactement le même stockage que le Radar +- la note est immédiatement prise en compte dans le scoring +- 1–2★ restent des signaux de mauvaise qualité +- 4–5★ restent des exemples positifs + +### Catégorie +Affichage de : +- catégorie effective +- source : automatique / chaîne / vidéo +- bouton `Modifier` + +## Dialogue Catégoriser + +Deux réglages indépendants : + +### Cette vidéo +- Automatique +- Politique +- Jeux vidéo +- Documentaires +- Paranormal +- Islam +- Football +- High-tech + +### Toute la chaîne +Même choix. + +`Automatique` supprime la correction manuelle correspondante. + +Exemple : +- chaîne entière = High-tech +- une vidéo particulière de cette chaîne = Documentaires + +La vidéo particulière reste alors Documentaires. + +## Conservation des notes + +Quand une vidéo ou une chaîne change de catégorie, les notes et feedbacks +existants sont déplacés vers la nouvelle catégorie afin de ne pas perdre +l'apprentissage déjà effectué. + +## Chaînes + +L'association est mémorisée avec le channelId lorsqu'il est disponible et +également avec le nom normalisé de la chaîne comme fallback. Elle s'applique +donc aussi aux futures vidéos de cette chaîne. + +## Exemple actuel + +`صندوق الذكريات المرعب 🗃️ | قصة صباح` +est déjà détectée automatiquement comme `Paranormal` grâce à `مرعب`. + +## Fichiers modifiés + +- lib/services/interest_profile_service.dart +- lib/screens/video_player_page.dart + +Aucun pipeline vidéo Windows ou Android n'est modifié. +Les gestes, zoom, veille, rotation, thèmes et pagination de chaînes sont +conservés. diff --git a/PATCH_18_7_25_ORIGINAL_AUDIO_FIRST.md b/PATCH_18_7_25_ORIGINAL_AUDIO_FIRST.md new file mode 100644 index 0000000..01f96cf --- /dev/null +++ b/PATCH_18_7_25_ORIGINAL_AUDIO_FIRST.md @@ -0,0 +1,39 @@ +# YouTube Personal 18.7.25 — Piste audio originale en priorité + +## Problème +Sur certaines vidéos arabes disposant de doublages YouTube, l'application +pouvait choisir automatiquement la piste anglaise. + +Windows choisissait surtout le meilleur format audio (par exemple 251/Opus) +sans tenir suffisamment compte de la piste originale. + +Android privilégiait la langue préférée lorsqu'elle était explicite, mais en +mode Auto retombait ensuite sur le meilleur codec disponible. + +## Nouveau comportement + +### Mode Auto +1. piste explicitement marquée `original` par YouTube / l'extracteur +2. à défaut, langue originale estimée à partir des métadonnées fiables + (notamment alphabet arabe -> `ar`) +3. à défaut, meilleur flux audio disponible + +### Langue préférée explicite +Si Réglages contient une langue explicite (ar/fr/en/de/es), elle reste +prioritaire. Si elle est absente, l'application revient à la piste originale. + +## Windows +- sélection par langue/original AVANT le classement 251/140/Opus/AAC +- prise en charge des variantes de format `251-0`, `251-1`, etc. +- HLS muxé : même préférence de langue lorsqu'une métadonnée de piste existe +- pipeline HD 18.7.5 conservé ; seule la sélection audio est modifiée + +## Android +- en mode Auto, `AudioTrackType` ORIGINAL de NewPipeExtractor est prioritaire +- fallback sur le nom de piste contenant `original` +- langue préférée explicite conservée + +## Fichiers modifiés +- lib/screens/video_player_page.dart +- lib/services/windows_hd_service.dart +- android/app/src/main/java/com/studioweb/youtube_personal/HdStreamExtractor.java diff --git a/PATCH_18_7_26_AUDIO_TRACK_PICKER.md b/PATCH_18_7_26_AUDIO_TRACK_PICKER.md new file mode 100644 index 0000000..6310ba6 --- /dev/null +++ b/PATCH_18_7_26_AUDIO_TRACK_PICKER.md @@ -0,0 +1,67 @@ +# YouTube Personal 18.7.26 — Choix manuel de la piste audio + +## Nouveau contrôle dans la page vidéo + +Un nouveau bloc `Piste audio` apparaît sous `Qualité vidéo`. + +En l'ouvrant, l'application interroge les pistes réellement exposées par +YouTube pour la vidéo courante et propose uniquement celles détectées, par +exemple : + +- Auto (selon les réglages) +- Originale +- Arabe +- Anglais +- Français +- Allemand +- Espagnol + +Le choix est local à la vidéo courante et ne modifie pas la préférence globale +de langue dans Réglages. + +## Comportement + +- Auto : reprend le réglage global de langue. +- Originale : force la piste marquée originale quand YouTube l'expose. +- Une langue explicite : force cette langue pour la vidéo courante. +- Retour à Auto : réactive immédiatement le comportement global. + +Après sélection, la vidéo reprend au même timestamp et conserve son état +lecture/pause. + +## Qualité vidéo + +Le changement audio ne modifie pas la qualité choisie : +- Auto reste Auto +- 720p reste 720p +- 1080p reste 1080p +- 1440p reste 1440p +- 2160p/4K reste 2160p/4K + +Exception : 360p est un flux muxé audio+vidéo. Pour changer de piste, le lecteur +passe automatiquement en 720p avec vidéo et audio séparés. + +## Windows + +`windows_hd_service.dart` expose maintenant : +- piste sélectionnée +- langue sélectionnée +- indication piste originale +- liste des pistes/langues disponibles + +La logique de transport HD existante n'est pas réécrite. + +## Android + +`HdStreamExtractor.java` expose maintenant la liste des pistes disponibles à +partir de NewPipeExtractor et conserve : +- piste originale prioritaire en Auto +- choix explicite d'une langue +- qualité 1440p / 4K +- gestes, zoom, veille et rotation déjà présents dans l'application + +## Fichiers modifiés + +- lib/screens/video_player_page.dart +- lib/services/windows_hd_service.dart +- android/app/src/main/java/com/studioweb/youtube_personal/HdStreamExtractor.java diff --git a/PATCH_18_7_27_RATED_SEED_DISCOVERY.md b/PATCH_18_7_27_RATED_SEED_DISCOVERY.md new file mode 100644 index 0000000..c637d57 --- /dev/null +++ b/PATCH_18_7_27_RATED_SEED_DISCOVERY.md @@ -0,0 +1,76 @@ +# YouTube Personal 18.7.27 — Découverte à partir des vidéos 4–5★ + +## Changement principal + +Avant, une note 4–5★ influençait surtout le classement d'un ensemble de vidéos +déjà trouvé par le Radar. + +Désormais, les vidéos 4–5★ deviennent de vraies **graines de recherche** : +leurs titres et sous-thèmes déclenchent directement de nouvelles recherches +YouTube. + +## Comme d'habitude + +Priorité visuelle : +- environ 5 places sur 8 pour les chaînes déjà connues, si le pool le permet +- jusqu'à 3 places pour des découvertes proches des vidéos 4–5★ + +Les chaînes habituelles conservent leur bonus de réputation. + +## Je veux du nouveau + +Priorité visuelle : +- jusqu'à 7 places sur 8 réservées aux nouvelles chaînes +- les chaînes déjà connues subissent un malus dans ce mode +- les nouvelles chaînes proches d'une vidéo 4–5★ reçoivent un fort bonus + +Le Radar utilise jusqu'à 4 vidéos 4–5★ comme graines et jusqu'à 6 requêtes +distinctes par génération. + +## Isolation des centres d'intérêt + +Le profil actif reste prioritaire. + +Une vidéo nouvelle peut néanmoins être acceptée même si son titre ne contient +pas littéralement les mots génériques de la catégorie, à condition que : +- elle ressemble suffisamment à une vidéo 4–5★ du profil actif +- son score positif soit supérieur à son score négatif + +Cela permet par exemple à une nouvelle chaîne Paranormal arabe d'apparaître +sans avoir le mot `paranormal` dans son titre. + +## Mauvaises notes + +Les 1–2★ et `Mauvaise qualité` continuent à produire un malus. +`Pas intéressé` réduit également les sous-thèmes similaires. + +Une nouvelle vidéo proche d'un bon exemple mais également très proche d'un +contre-exemple sera donc repoussée. + +## Interface + +Quand le profil possède des notes 4–5★, l'accueil indique : +`N vidéos 4–5★ utilisées comme graines de découverte.` + +En mode `Je veux du nouveau`, le bloc principal indique que les nouvelles +chaînes sont recherchées à partir des meilleures notes. + +Dans `Pourquoi ?`, une nouvelle chaîne peut afficher : +`Nouvelle chaîne proche de tes vidéos 4–5★`. + +## Logs de diagnostic + +Exemples : +`[RADAR-DISCOVERY][18.7.27] interest=Paranormal mode=discovery positives=2 ...` +`[RADAR-DISCOVERY][18.7.27] candidats=86 retenus=27 ...` + +Ces lignes permettent de voir immédiatement si les graines produisent des +candidats. + +## Fichiers modifiés + +- lib/services/radar_service.dart +- lib/screens/home_page.dart + +Aucun fichier du lecteur Windows ou Android n'est modifié. +Aucun pipeline vidéo/audio n'est modifié. diff --git a/PATCH_18_7_28_RESTORE_EDITABLE_CATEGORY.md b/PATCH_18_7_28_RESTORE_EDITABLE_CATEGORY.md new file mode 100644 index 0000000..cfb60e7 --- /dev/null +++ b/PATCH_18_7_28_RESTORE_EDITABLE_CATEGORY.md @@ -0,0 +1,68 @@ +# YouTube Personal 18.7.28 — Restauration catégorie modifiable dans le lecteur + +## Problème + +Le bloc `Ta note / Catégorie / Modifier` avait été ajouté auparavant, puis +`video_player_page.dart` a été remplacé par les patchs 4K et piste audio. +Le service de catégorisation existait toujours, mais son interface avait disparu. + +## Correction + +Sous le titre de la vidéo, le lecteur affiche de nouveau : + +- `Ta note` avec 1 à 5 étoiles +- la catégorie effective +- la source de la catégorie : + - Détection automatique + - Catégorie héritée de la chaîne + - Catégorie choisie pour cette vidéo +- bouton `Modifier` + +## Dialogue Modifier + +Deux niveaux indépendants : + +### Cette vidéo +Permet de choisir : +- Automatique +- Politique +- Jeux vidéo +- Documentaires +- Paranormal +- Islam +- Football +- High-tech + +### Toute la chaîne +Le même choix peut être appliqué à la chaîne entière. + +Priorité : +`vidéo > chaîne > détection automatique`. + +## Scoring et notes + +Lorsqu'une catégorie est modifiée, les feedbacks déjà enregistrés pour la vidéo +(note, mauvaise qualité, pas intéressé) sont déplacés vers la nouvelle catégorie. + +Exemple : +une vidéo détectée Documentaires, notée 4★, puis corrigée en Paranormal +devient une graine 4★ du profil Paranormal pour le moteur 18.7.27. + +## Préservé + +Le fichier lecteur est basé sur la 18.7.26 et conserve : +- choix manuel de piste audio +- piste originale prioritaire +- correction 1440p / 4K ratios non 16:9 +- gestes Android volume/luminosité +- zoom Android +- veille Android +- rotation paysage / plein écran +- thème clair / sombre + +Le moteur de découverte 18.7.27 n'est pas remplacé. + +## Fichiers + +- lib/screens/video_player_page.dart +- lib/services/interest_profile_service.dart diff --git a/PATCH_18_7_29_DISCOVERY_AND_DURATION_FIX.md b/PATCH_18_7_29_DISCOVERY_AND_DURATION_FIX.md new file mode 100644 index 0000000..f447a4e --- /dev/null +++ b/PATCH_18_7_29_DISCOVERY_AND_DURATION_FIX.md @@ -0,0 +1,80 @@ +# YouTube Personal 18.7.29 — Découverte élargie + durées strictes + +## Problèmes corrigés + +### 1. "Je veux du nouveau" ne proposait parfois qu'une vidéo +La première vague de recherche pouvait ramener surtout les mêmes chaînes. +Puis les filtres de catégorie et de scoring réduisaient encore le pool. + +### 2. "Courtes vidéos" pouvait afficher des vidéos très longues +La durée était seulement un bonus/malus de scoring. +Une vidéo de 1h15 pouvait donc survivre si son score thématique était élevé. + +## Nouveau moteur de découverte + +### Première vague +Le Radar utilise toujours les vidéos 4–5★ comme graines. + +En mode `Je veux du nouveau`, jusqu'à 8 requêtes initiales sont utilisées. + +### Deuxième vague automatique +Si la première vague ne contient pas suffisamment : +- de candidats du centre d'intérêt +- ou de nouvelles chaînes + +le Radar lance automatiquement jusqu'à 10 requêtes plus larges. + +Exemple Paranormal avec graines arabes : +- قصص رعب حقيقية +- قصص جن حقيقية +- أماكن مسكونة قصص +- مغامرات رعب عربية +- قصص غامضة مرعبة +- حكايات رعب واقعية +- paranormal true stories +- haunted places scary stories +- ghost stories investigation + +Les autres centres d'intérêt disposent aussi de variantes de découverte adaptées. + +### Diversité de chaînes +`Je veux du nouveau` : +- maximum 1 vidéo par chaîne dans le pool diversifié +- priorité aux chaînes absentes de l'historique +- seuil minimum de scoring légèrement assoupli uniquement pour la découverte + +`Comme d'habitude` : +- maximum 2 vidéos par chaîne +- chaînes connues toujours prioritaires + +## Durées strictes + +### Courtes vidéos +Une recommandation doit avoir une durée connue et être : +`<= 15 minutes` + +Une vidéo de 1h15 ne peut plus apparaître dans `Pour toi maintenant`. + +### Long format +Une recommandation doit avoir une durée connue et être : +`>= 30 minutes` + +Les vidéos dont la durée est inconnue sont exclues de ces deux modes, afin de +ne pas promettre une durée que l'application ne peut pas vérifier. + +## Interface +Sous `Pour toi maintenant` : +- Courtes vidéos : `Uniquement des vidéos de 15 minutes maximum.` +- Long format : `Uniquement des vidéos de 30 minutes ou plus.` + +## Logs +Nouvelles lignes utiles : +`[RADAR-DISCOVERY][18.7.29] expansion mode=discovery viable=... newAuthors=...` +`[RADAR-DISCOVERY][18.7.29] candidats=... retenus=...` + +## Fichiers modifiés +- lib/services/radar_service.dart +- lib/screens/home_page.dart + +Aucun fichier lecteur Windows/Android n'est modifié. +Le patch 18.7.28 catégorie modifiable reste intact. diff --git a/PATCH_18_7_30_HOME_RADAR_COLLAPSIBLE.md b/PATCH_18_7_30_HOME_RADAR_COLLAPSIBLE.md new file mode 100644 index 0000000..33e2b9d --- /dev/null +++ b/PATCH_18_7_30_HOME_RADAR_COLLAPSIBLE.md @@ -0,0 +1,55 @@ +# YouTube Personal 18.7.30 — Radar d'accueil repliable + +## Objectif + +Sur Android, la partie supérieure de l'accueil occupait presque tout l'écran : +- YouTube Personal +- Radar personnel +- historique / favoris +- Signal actuel +- Centre d'intérêt +- Humeur de la session + +Les recommandations arrivaient donc trop bas. + +## Nouveau comportement + +### Android +Le bloc Radar est replié par défaut. + +Restent visibles : +- YouTube Personal +- bouton Mélanger +- Radar personnel +- résumé compact : + - centre d'intérêt actif + - humeur active + - nombre de graines 4–5★, si disponible +- bouton `Filtres` + +Appuyer sur `Filtres` affiche : +- historique / favoris +- Signal actuel +- Centre d'intérêt +- Réinitialiser +- nombre de graines +- Humeur de cette session + +Le bouton devient `Masquer` pour replier de nouveau le bloc. + +### Windows / grands écrans +Le Radar reste ouvert par défaut, comme avant. +Un bouton `Masquer les filtres` permet aussi de le replier. + +## Préservé +- découverte 18.7.29 +- Courtes vidéos <= 15 min +- Long format >= 30 min +- profils d'intérêt +- notation 1–5★ +- catégorie modifiable +- thème clair / sombre +- lecteurs Windows / Android inchangés + +## Fichier modifié +- lib/screens/home_page.dart diff --git a/PATCH_18_7_31_FIX2.md b/PATCH_18_7_31_FIX2.md new file mode 100644 index 0000000..40575af --- /dev/null +++ b/PATCH_18_7_31_FIX2.md @@ -0,0 +1,10 @@ +# PATCH 18_7_31_FIX2 + +## Correction compilation Windows + +Fichier modifié : +- lib/screens/library_pages.dart + +Correction : +- Correction d'une chaîne Dart non échappée dans le tooltip de suppression historique. +- Remplacement des apostrophes simples par des guillemets doubles pour éviter l'erreur String starting with ' must end with '. diff --git a/PATCH_18_7_31_THEME_RADAR_HISTORY_FIXES.md b/PATCH_18_7_31_THEME_RADAR_HISTORY_FIXES.md new file mode 100644 index 0000000..8dd939b --- /dev/null +++ b/PATCH_18_7_31_THEME_RADAR_HISTORY_FIXES.md @@ -0,0 +1,11 @@ +# PATCH 18.7.31 - Theme auto, Radar centres d’intérêt et historique + +## Changements +- Ajout du mode thème Auto jour/nuit. +- Préparation du filtrage Radar/continuer à regarder par centre d’intérêt. +- Suppression historique vidéo individuelle ajoutée. +- Correction thème global via MaterialApp pour écrans bibliothèque. + +## Tests +- Vérifier Android/Windows. +- Vérifier clair, sombre et auto. diff --git a/PATCH_18_7_8_ANDROID_PARITY.md b/PATCH_18_7_8_ANDROID_PARITY.md new file mode 100644 index 0000000..2e9019b --- /dev/null +++ b/PATCH_18_7_8_ANDROID_PARITY.md @@ -0,0 +1,18 @@ +# YouTube Personal 18.7.8 — Parité Android + +Cette évolution intègre les évolutions récentes dans la partie Android sans modifier le pipeline Windows protégé 18.7.5. + +## Android + +- résolution par défaut : le réglage partagé continue de piloter le lecteur Android ; +- langue préférée : transmise au moteur natif Android ; +- pistes audio multilingues : si NewPipe/YouTube expose une piste correspondant à la langue préférée, elle est choisie en priorité ; +- fallback : si aucune piste correspondante n’est disponible, le meilleur flux audio compatible existant est conservé ; +- le lecteur affiche la langue audio réellement choisie quand elle est connue ; +- les dates, informations de chaîne, Radar et classement par langue restent issus du code Flutter partagé. + +## Protection Windows + +`lib/services/windows_hd_service.dart` reste strictement identique à la baseline 18.7.5 (SHA-256 c4173bcafc9345a57ee84cdc105a05f5d9fa3eee6b7a688613c7c7cba6785ea6). + +Dans `video_player_page.dart`, les changements 18.7.8 sont limités au chemin Android de sélection audio et au MethodChannel Android. Le chemin de démarrage Windows 18.7.5 n’est pas modifié. diff --git a/PATCH_18_7_9_ANDROID_QUALITY_SWITCH.md b/PATCH_18_7_9_ANDROID_QUALITY_SWITCH.md new file mode 100644 index 0000000..318995f --- /dev/null +++ b/PATCH_18_7_9_ANDROID_QUALITY_SWITCH.md @@ -0,0 +1,22 @@ +# YouTube Personal 18.7.9 — Correctif bascule qualité Android + +## Portée + +- Android uniquement pour le comportement. +- Windows : aucune modification de `windows_hd_service.dart` et aucune modification du pipeline Windows 18.7.5. + +## Problème observé + +Le flux 720p était correctement extrait et MediaCodec confirmait 1280x720, mais la bascule repassait par `_loadVideoInternal`, rechargeait des éléments inutiles, ouvrait d'abord le média à 0 puis effectuait un seek et pouvait laisser `_loading` actif indéfiniment. + +## Correctif + +- voie dédiée `_changeAndroidQuality` ; +- préparation native du flux HD pendant que la vidéo courante continue ; +- ouverture du nouveau média avec `Media(start: positionCourante)` ; +- pas de second cycle complet de chargement ; +- timeouts Android sur les opérations natives de bascule ; +- `_loading=false` dès confirmation réelle de la résolution ; +- aucun nouvel enregistrement d'historique / compteur de vue lors du changement de qualité ; +- restauration 360p si la bascule HD échoue ; +- retour 360p sans repasser par le pipeline HD. diff --git a/PREPARER_HD_WINDOWS.ps1 b/PREPARER_HD_WINDOWS.ps1 new file mode 100644 index 0000000..07e1ee7 --- /dev/null +++ b/PREPARER_HD_WINDOWS.ps1 @@ -0,0 +1,144 @@ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +$Assets = Join-Path $Root "assets\windows_hd" + +New-Item -ItemType Directory -Force -Path $Assets | Out-Null + +# Version stable utilisée uniquement comme bootstrap vérifié par SHA-256. +# Le script passe ensuite automatiquement au canal nightly. +$YtVersion = "2026.07.04" +$YtBase = "https://github.com/yt-dlp/yt-dlp/releases/download/$YtVersion" +$YtExe = Join-Path $Assets "yt-dlp.exe" +$YtSums = Join-Path $env:TEMP "youtube-personal-yt-dlp-sha256.txt" + +$DenoVersion = "2.9.4" +$DenoBase = "https://github.com/denoland/deno/releases/download/v$DenoVersion" +$DenoZipName = "deno-x86_64-pc-windows-msvc.zip" +$DenoZip = Join-Path $env:TEMP "youtube-personal-deno.zip" +$DenoSum = Join-Path $env:TEMP "youtube-personal-deno.sha256sum" +$DenoExtract = Join-Path $env:TEMP "youtube-personal-deno-extract" +$DenoExe = Join-Path $Assets "deno.exe" + +function Assert-Sha256 { + param( + [Parameter(Mandatory = $true)][string]$File, + [Parameter(Mandatory = $true)][string]$Expected + ) + + $Actual = (Get-FileHash -Algorithm SHA256 -Path $File).Hash.ToLowerInvariant() + $ExpectedNormalized = $Expected.Trim().ToLowerInvariant() + + if ($Actual -ne $ExpectedNormalized) { + Remove-Item -Force -ErrorAction SilentlyContinue $File + throw "SHA-256 incorrect pour $File`nAttendu: $ExpectedNormalized`nObtenu: $Actual" + } +} + +Write-Host "" +Write-Host "=== YouTube Personal - Preparation HD/4K Windows ===" -ForegroundColor Cyan +Write-Host "" + +Write-Host "[1/5] Telechargement bootstrap de yt-dlp stable $YtVersion..." +Invoke-WebRequest -UseBasicParsing ` + -Uri "$YtBase/yt-dlp.exe" ` + -OutFile $YtExe + +Invoke-WebRequest -UseBasicParsing ` + -Uri "$YtBase/SHA2-256SUMS" ` + -OutFile $YtSums + +$YtLine = Get-Content $YtSums | + Where-Object { $_ -match "\syt-dlp\.exe$" } | + Select-Object -First 1 + +if (-not $YtLine) { + throw "Impossible de lire le SHA-256 officiel de yt-dlp.exe." +} + +$YtExpected = ($YtLine -split "\s+")[0] +Assert-Sha256 -File $YtExe -Expected $YtExpected + +Write-Host "[2/5] yt-dlp stable verifie." -ForegroundColor Green + +Write-Host "[3/5] Mise a jour de yt-dlp vers le canal nightly..." +& $YtExe --update-to nightly +if ($LASTEXITCODE -ne 0) { + throw "La mise a jour yt-dlp nightly a echoue." +} + +$YtNightlyVersion = (& $YtExe --version).Trim() +if (-not $YtNightlyVersion) { + throw "Impossible de lire la version yt-dlp nightly." +} + +Write-Host "yt-dlp nightly actif: $YtNightlyVersion" -ForegroundColor Green + +Write-Host "[4/5] Telechargement de Deno $DenoVersion..." +Invoke-WebRequest -UseBasicParsing ` + -Uri "$DenoBase/$DenoZipName" ` + -OutFile $DenoZip + +Invoke-WebRequest -UseBasicParsing ` + -Uri "$DenoBase/$DenoZipName.sha256sum" ` + -OutFile $DenoSum + +# Le fichier .sha256sum de Deno n'utilise pas forcément le format +# " ". Certaines releases utilisent un petit format +# structuré avec des champs comme "path" et "hash". +# +# On extrait donc explicitement l'unique SHA-256 (64 caractères hexadécimaux) +# au lieu de supposer que le premier mot de la ligne est le hash. +$DenoChecksumText = Get-Content $DenoSum -Raw + +$DenoHashMatches = [regex]::Matches( + $DenoChecksumText, + '(?i)(?.exe`. + +Pour lancer ensuite l'installateur : + +```powershell +.\INSTALLER_WINDOWS.ps1 +``` diff --git a/README_INSTALLATION.txt b/README_INSTALLATION.txt new file mode 100644 index 0000000..31c44eb --- /dev/null +++ b/README_INSTALLATION.txt @@ -0,0 +1,11 @@ +Patch Android mémoire debug + +Fichiers concernés : +- android/gradle.properties + +Ce patch augmente la mémoire Gradle et limite les workers pour éviter +les erreurs D8/R8 Java heap space. + +Le build.gradle.kts n'est pas inclus automatiquement car sa structure +peut varier selon la version actuelle du projet. Il doit être ajusté +uniquement si la désactivation minify debug est nécessaire. diff --git a/README_JSON_MEMORY.md b/README_JSON_MEMORY.md new file mode 100644 index 0000000..e3eb2db --- /dev/null +++ b/README_JSON_MEMORY.md @@ -0,0 +1,19 @@ +# YouTube Personal - Mémoire JSON + +Ce patch remplace l'export mémoire `.ypmemory` par un fichier JSON standard. + +Modifications: +- Export: `YouTube_Personal_Memory_DATE.json` +- MIME: `application/json` +- Import accepte `.json` (et garde la compatibilité `.ypmemory`) + +Installation: +1. Copier `lib/services/memory_backup_service.dart` dans le projet. +2. Lancer: + flutter clean + flutter pub get + flutter run -d windows + ou + flutter run -d + +Le format reste versionné avec schemaVersion. diff --git a/README_MEMOIRE_V1.md b/README_MEMOIRE_V1.md new file mode 100644 index 0000000..fc0edac --- /dev/null +++ b/README_MEMOIRE_V1.md @@ -0,0 +1,80 @@ +# YouTube Personal — Mémoire Export / Import V1 + +## Objectif + +Ajouter dans **Réglages** un bloc **Mémoire & transfert** permettant de déplacer le profil appris par l'application entre Windows, Android et, plus tard, Linux. + +## Données exportées + +- historique ; +- favoris ; +- positions de reprise ; +- compteurs personnels de vues ; +- chaînes masquées ; +- recommandations Radar masquées / auteurs réduits ; +- notes et feedback d'apprentissage ; +- catégories corrigées manuellement ; +- langue préférée des recommandations ; +- filtre jeune public. + +## Données volontairement exclues + +- compte / session Google ; +- access tokens, OAuth, secrets et credentials ; +- chemins du cache média ; +- données temporaires ; +- réglages spécifiques à un appareil lorsqu'ils ne concernent pas l'apprentissage. + +## Format + +Extension : `.ypmemory` + +Le fichier est un JSON versionné avec : + +- `format` ; +- `schemaVersion` ; +- date UTC d'export ; +- plateforme source ; +- identifiant stable de l'appareil source ; +- préférences mémoire typées. + +Ce format constitue la base de la future synchronisation multi-appareils. + +## Installation + +Extraire le ZIP dans la racine du projet : + +`D:\07-Logiciels\23-youtube_personal` + +Puis : + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +Set-ExecutionPolicy -Scope Process Bypass +.\APPLIQUER_MEMOIRE_V1.ps1 +``` + +## Test Windows + +```powershell +flutter run -d windows +``` + +## Test Android + +```powershell +flutter devices +flutter run -d +``` + +## Utilisation + +1. Windows > Réglages > Mémoire & transfert > **Exporter la mémoire**. +2. Copier/envoyer le fichier `.ypmemory` vers le téléphone. +3. Android > Réglages > Mémoire & transfert > **Importer une mémoire**. +4. Choisir : + - **Fusionner** : conserve les blocs locaux qui ne sont pas présents dans la sauvegarde ; la sauvegarde remplace les blocs portant la même clé ; + - **Remplacer** : efface la mémoire comportementale locale puis restaure exactement la sauvegarde. +5. Fermer complètement puis rouvrir l'application après l'import. + +Le redémarrage est nécessaire car plusieurs services de l'application gardent une copie de leur état en mémoire pendant l'exécution. diff --git a/REPARER_HD_ANDROID_GRADLE.ps1 b/REPARER_HD_ANDROID_GRADLE.ps1 new file mode 100644 index 0000000..f69dbbb --- /dev/null +++ b/REPARER_HD_ANDROID_GRADLE.ps1 @@ -0,0 +1,120 @@ +param( + [string]$ProjectRoot = $PSScriptRoot +) + +$ErrorActionPreference = "Stop" + +$appGradle = Join-Path $ProjectRoot "android\app\build.gradle.kts" + +if (-not (Test-Path $appGradle)) { + throw "Introuvable: $appGradle" +} + +$backup = "$appGradle.before_hd_agp9_fix" + +if (-not (Test-Path $backup)) { + Copy-Item $appGradle $backup + Write-Host "Backup: $backup" +} + +$text = Get-Content $appGradle -Raw + +# Flutter 3.44 / AGP 9 utilise le Kotlin intégré à AGP. +# Le bloc kotlin { compilerOptions { ... } } doit être dans android { ... }. +$topLevelKotlinPattern = '(?ms)^[ \t]*kotlin\s*\{\s*compilerOptions\s*\{\s*jvmTarget\s*=\s*org\.jetbrains\.kotlin\.gradle\.dsl\.JvmTarget\.JVM_17\s*\}\s*\}\s*' + +if ($text -match $topLevelKotlinPattern) { + $text = [regex]::Replace( + $text, + $topLevelKotlinPattern, + '', + 1 + ) + + Write-Host "Bloc kotlin {} invalide retiré du niveau projet." +} + +$androidKotlinBlock = @' + kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } + } + +'@ + +# Insère le bloc Kotlin juste après compileOptions {} dans android {}. +if ( + $text -notmatch '(?ms)android\s*\{.*?kotlin\s*\{\s*compilerOptions' +) { + $compileOptionsPattern = '(?ms)([ \t]*compileOptions\s*\{\s*sourceCompatibility\s*=\s*JavaVersion\.VERSION_17\s*targetCompatibility\s*=\s*JavaVersion\.VERSION_17(?:\s*isCoreLibraryDesugaringEnabled\s*=\s*true)?\s*\}\s*)' + + $match = [regex]::Match( + $text, + $compileOptionsPattern + ) + + if (-not $match.Success) { + throw @" +Impossible de trouver compileOptions Java 17 dans: +$appGradle + +Envoie-moi ce fichier et je te ferai un correctif exact. +"@ + } + + $replacement = + $match.Groups[1].Value + + "`r`n" + + $androidKotlinBlock + + $text = + $text.Substring(0, $match.Index) + + $replacement + + $text.Substring( + $match.Index + $match.Length + ) + + Write-Host "Bloc kotlin {} déplacé dans android {}." +} + +# NewPipe utilise des API Java qui nécessitent le desugaring sur Android. +if ( + $text -notmatch 'isCoreLibraryDesugaringEnabled\s*=\s*true' +) { + $text = $text.Replace( + 'targetCompatibility = JavaVersion.VERSION_17', + "targetCompatibility = JavaVersion.VERSION_17`r`n isCoreLibraryDesugaringEnabled = true" + ) + + Write-Host "Core library desugaring activé." +} + +# Vérifie que les dépendances HD sont toujours présentes. +if ( + $text -notmatch 'com\.github\.TeamNewPipe:NewPipeExtractor:v0\.26\.3' +) { + $dependencyBlock = @' + +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") +} +'@ + + $text = $text.TrimEnd() + "`r`n" + $dependencyBlock + "`r`n" + + Write-Host "Dépendances HD restaurées." +} + +Set-Content -Path $appGradle -Value $text -Encoding UTF8 + +Write-Host "" +Write-Host "Correctif Gradle AGP 9 appliqué." +Write-Host "Fichier: $appGradle" +Write-Host "" +Write-Host "Lance maintenant:" +Write-Host " flutter clean" +Write-Host " flutter pub get" +Write-Host " flutter run -d f10778d8" diff --git a/REPARER_HD_ANDROID_KOTLIN_DSL.ps1 b/REPARER_HD_ANDROID_KOTLIN_DSL.ps1 new file mode 100644 index 0000000..296313a --- /dev/null +++ b/REPARER_HD_ANDROID_KOTLIN_DSL.ps1 @@ -0,0 +1,93 @@ +param( + [string]$ProjectRoot = $PSScriptRoot +) + +$ErrorActionPreference = "Stop" + +$appGradle = Join-Path $ProjectRoot "android\app\build.gradle.kts" + +if (-not (Test-Path $appGradle)) { + throw "Introuvable: $appGradle" +} + +$backup = "$appGradle.before_hd_kotlin_dsl_fix" + +if (-not (Test-Path $backup)) { + Copy-Item $appGradle $backup + Write-Host "Backup: $backup" +} + +$text = Get-Content $appGradle -Raw + +# Avec l'AGP 9 Kotlin intégré, jvmTarget hérite déjà de +# android.compileOptions.targetCompatibility. Notre bloc kotlin {} +# ajouté pour la HD est donc inutile et, sur cette combinaison +# Flutter/Gradle, n'a pas d'accesseur Kotlin DSL disponible. +# +# On retire uniquement le bloc précis que notre correctif précédent +# avait ajouté, qu'il soit au niveau racine ou dans android {}. +$kotlinCompilerBlock = '(?ms)[ \t]*kotlin\s*\{\s*compilerOptions\s*\{\s*jvmTarget\s*=\s*org\.jetbrains\.kotlin\.gradle\.dsl\.JvmTarget\.JVM_17\s*\}\s*\}\s*' + +$count = [regex]::Matches( + $text, + $kotlinCompilerBlock +).Count + +if ($count -gt 0) { + $text = [regex]::Replace( + $text, + $kotlinCompilerBlock, + '' + ) + + Write-Host "Bloc kotlin/compilerOptions retire ($count occurrence(s))." +} else { + Write-Host "Aucun bloc kotlin/compilerOptions HD a retirer." +} + +# On conserve Java 17 et le desugaring nécessaire à NewPipe. +if ( + $text -notmatch 'isCoreLibraryDesugaringEnabled\s*=\s*true' +) { + $needle = + 'targetCompatibility = JavaVersion.VERSION_17' + + if (-not $text.Contains($needle)) { + throw "targetCompatibility Java 17 introuvable dans $appGradle" + } + + $text = $text.Replace( + $needle, + "targetCompatibility = JavaVersion.VERSION_17`r`n isCoreLibraryDesugaringEnabled = true" + ) + + Write-Host "Core library desugaring active." +} + +# Vérifie que les dépendances de l'étape HD existent toujours. +if ( + $text -notmatch 'com\.github\.TeamNewPipe:NewPipeExtractor:v0\.26\.3' +) { + $dependencyBlock = @' + +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") +} +'@ + + $text = $text.TrimEnd() + "`r`n" + $dependencyBlock + "`r`n" + Write-Host "Dependances HD restaurees." +} + +Set-Content -Path $appGradle -Value $text -Encoding UTF8 + +Write-Host "" +Write-Host "Correctif Kotlin DSL applique." +Write-Host "Le bloc kotlin {} ajoute par l'etape precedente a ete retire." +Write-Host "" +Write-Host "Lance maintenant:" +Write-Host " flutter clean" +Write-Host " flutter pub get" +Write-Host " flutter run -d f10778d8" diff --git a/SHA256SUMS.txt b/SHA256SUMS.txt new file mode 100644 index 0000000..a296051 --- /dev/null +++ b/SHA256SUMS.txt @@ -0,0 +1,2 @@ +881f8283cc55bcdfd55798d09b0e720c515cccc3b92e0ff81e5c0c4460df8984 lib/screens/video_player_page.dart +c4173bcafc9345a57ee84cdc105a05f5d9fa3eee6b7a688613c7c7cba6785ea6 lib/services/windows_hd_service.dart diff --git a/YOUTUBE_PERSONAL_SPECIFICATIONS_FONCTIONNELLES_TECHNIQUES.md b/YOUTUBE_PERSONAL_SPECIFICATIONS_FONCTIONNELLES_TECHNIQUES.md new file mode 100644 index 0000000..e43ab03 --- /dev/null +++ b/YOUTUBE_PERSONAL_SPECIFICATIONS_FONCTIONNELLES_TECHNIQUES.md @@ -0,0 +1,1860 @@ +# YouTube Personal — Spécifications fonctionnelles et techniques + +> **Document de référence du projet** +> Version de référence : **étape 18.7.3** +> Plateformes cibles : **Windows 11** et **Android** +> Type de projet : application Flutter personnelle de lecture YouTube sans publicité classique, avec recommandations locales, gestion de bibliothèque, lecture HD/4K et enrichissement des métadonnées. + +--- + +## 1. Objectif du projet + +**YouTube Personal** est une application Flutter personnelle destinée à proposer une expérience YouTube plus maîtrisée, plus légère et plus personnalisable que le client officiel. + +L’application vise à : + +- rechercher et lire des vidéos YouTube ; +- supprimer l’exposition aux publicités classiques de la plateforme dans le lecteur ; +- détecter et ignorer automatiquement les segments sponsorisés intégrés aux vidéos lorsqu’ils sont connus de SponsorBlock ; +- proposer un moteur de recommandations local nommé **Radar personnel** ; +- gérer l’historique, les favoris, la reprise de lecture et les chaînes ; +- permettre l’accès aux abonnements YouTube officiels via OAuth ; +- proposer une lecture HD/4K sur Android ; +- proposer une lecture progressive haute définition sur Windows ; +- rester utilisable sans dépendre d’un backend personnel ; +- fonctionner de façon autonome sur Android ; +- éviter autant que possible les dépendances à une session Google pour la lecture vidéo ; +- séparer strictement les usages OAuth officiels de l’extraction non officielle des flux vidéo. + +--- + +# 2. Périmètre fonctionnel + +## 2.1 Plateformes + +### Windows + +Plateforme desktop principale. + +Fonctions spécifiques : + +- interface large avec `NavigationRail` ; +- lecture vidéo via `media_kit` / libmpv ; +- raccourcis clavier ; +- Picture-in-Picture sous forme de fenêtre toujours au premier plan ; +- extraction HD via `yt-dlp` ; +- runtime JavaScript `Deno` embarqué ; +- cache média local ; +- relais HTTP local progressif ; +- démarrage rapide 360p puis bascule HD progressive. + +### Android + +Plateforme mobile autonome. + +Fonctions spécifiques : + +- `NavigationBar` mobile ; +- lecteur direct Android ; +- HD jusqu’à 4K ; +- décodage matériel MediaCodec ; +- lecture en arrière-plan ; +- lecture écran verrouillé ; +- Picture-in-Picture natif ; +- gestes tactiles ; +- aucune dépendance à un PC ou serveur local. + +--- + +# 3. Navigation principale + +L’application comporte les sections principales suivantes : + +```text +Accueil +Recherche +Abonnements +Chaînes +Historique +Favoris +``` + +Sur desktop, ces sections utilisent un rail latéral. + +Sur Android, elles utilisent une barre de navigation adaptée au mobile. + +--- + +# 4. Accueil — Radar personnel + +## 4.1 But + +Le Radar personnel est le moteur de recommandations propre à YouTube Personal. + +Il ne doit pas simplement recopier les recommandations de YouTube. + +Il doit construire localement un profil de session à partir de : + +- l’historique ; +- les favoris ; +- les chaînes déjà regardées ; +- les thèmes récurrents ; +- les mots-clés des titres ; +- les catégories détectées ; +- les choix faits dans le Radar ; +- les vidéos ignorées ; +- les chaînes masquées. + +--- + +## 4.2 Sections du Radar + +L’accueil peut contenir notamment : + +```text +Pour toi maintenant +Continuer à regarder +Vues récemment +Favoris récents +Explorer par catégorie +À redécouvrir +Quelque chose de différent +``` + +Le contenu exact peut évoluer, mais le Radar doit toujours distinguer les vidéos purement historiques des nouvelles recommandations. + +--- + +## 4.3 Humeur de session + +L’utilisateur peut modifier temporairement le comportement du Radar avec une humeur de session. + +Options prévues : + +```text +Comme d’habitude +Je veux du nouveau +Apprendre +Détente +Courtes vidéos +Long format +``` + +Ces choix influencent le classement uniquement pour la session ou la période définie par l’implémentation. + +--- + +## 4.4 Score de recommandation + +Chaque recommandation peut afficher un pourcentage de proximité, par exemple : + +```text +84 % +76 % +68 % +``` + +Ce score est un score local de pertinence. + +Il ne correspond pas à une probabilité calculée par YouTube. + +--- + +## 4.5 Explication « Pourquoi ? » + +Chaque recommandation peut expliquer pourquoi elle a été proposée. + +Exemples : + +```text +Proche de ce que tu regardes maintenant +Même chaîne que plusieurs vidéos récentes +Même thème que tes favoris +Sujet fréquent dans ton historique +Vidéo différente pour casser la routine +``` + +--- + +## 4.6 Actions sur les recommandations + +L’utilisateur doit pouvoir : + +- ouvrir la vidéo ; +- demander pourquoi elle est proposée ; +- masquer une recommandation ; +- réduire la présence d’un auteur ; +- masquer définitivement une chaîne des recommandations. + +--- + +## 4.7 Navigation horizontale desktop + +Les rangées horizontales doivent être réellement navigables sur desktop. + +Comportement attendu : + +- bouton précédent si du contenu existe à gauche ; +- bouton suivant si du contenu existe à droite ; +- scrollbar horizontale ; +- glisser à la souris ; +- trackpad ; +- swipe sur écran tactile. + +Les boutons disparaissent automatiquement quand le début ou la fin est atteint. + +--- + +# 5. Catégorisation des vidéos + +Le Radar classe automatiquement les vidéos dans des catégories locales. + +Catégories prévues : + +```text +Jeux vidéo +Culture & histoire +OVNI & mystères +Technologie & IA +Science & espace +Auto & transport +Musique +Société & actualité +Tutoriels & apprentissage +Divers +``` + +La détection est basée sur les métadonnées textuelles disponibles : + +- titre ; +- description ; +- chaîne ; +- mots-clés détectés localement. + +Exemples : + +```text +UFO, UAP, Roswell, OVNI → OVNI & mystères +Fortnite, Roblox, Minecraft → Jeux vidéo +GPU, PC, IA, Android → Technologie & IA +``` + +--- + +# 6. Recherche + +## 6.1 Recherche vidéo + +L’utilisateur peut saisir une requête et obtenir des résultats YouTube réels. + +Chaque carte peut afficher : + +- miniature ; +- titre ; +- chaîne ; +- durée ; +- vues ; +- likes si disponibles ; +- description courte ; +- date si disponible. + +--- + +## 6.2 Ouverture + +Un clic ou tap ouvre le lecteur interne de YouTube Personal. + +La lecture ne doit pas ouvrir le navigateur externe par défaut. + +--- + +# 7. Historique + +L’historique est stocké localement. + +Clé persistante : + +```text +youtube_personal.history.v1 +``` + +Caractéristiques : + +- maximum historique actuel : environ 100 vidéos ; +- déduplication par vidéo ; +- la dernière ouverture remonte la vidéo ; +- conservation locale via `SharedPreferencesAsync`. + +--- + +# 8. Favoris + +L’utilisateur peut ajouter et retirer une vidéo des favoris. + +Clé persistante : + +```text +youtube_personal.favorites.v1 +``` + +Les favoris participent au Radar. + +--- + +# 9. Reprise de lecture + +Les positions sont stockées localement. + +Clé : + +```text +youtube_personal.positions.v1 +``` + +Règles : + +- ne pas mémoriser une position inférieure à environ 5 secondes ; +- considérer la vidéo terminée lorsque la fin est proche ; +- effacer une position lorsqu’il reste environ 15 secondes ou moins ; +- reprendre à la dernière position pertinente ; +- éviter d’afficher l’image de départ avant un seek de reprise lorsque cela est techniquement possible. + +--- + +# 10. Comptage personnel des vues + +YouTube Personal distingue les vues publiques YouTube des vues personnelles dans l’application. + +Clé : + +```text +youtube_personal.watch_counts.v1 +``` + +Une « vue personnelle » correspond à une vraie ouverture de vidéo. + +Ne doivent pas incrémenter le compteur : + +```text +changement 1080p → 4K +changement 4K → 720p +seek +changement de vitesse +pause/reprise +``` + +Le menu **Chaînes** utilise ces vues personnelles pour classer les chaînes. + +--- + +# 11. Gestion des chaînes + +## 11.1 Menu Chaînes + +Le menu Chaînes liste les chaînes rencontrées par l’utilisateur. + +Le classement principal est basé sur : + +```text +nombre de mes vues sur cette chaîne +``` + +et non sur les vues publiques cumulées de la chaîne. + +Exemple : + +```text +#1 Gamerawi +12 de mes vues +4 vidéos différentes + +#2 Aburob +7 de mes vues +2 vidéos différentes +``` + +--- + +## 11.2 Masquage d’une chaîne + +Action : + +```text +Cette chaîne ne m’intéresse pas +``` + +Effets : + +- retirer la chaîne de la liste principale ; +- l’exclure du Radar ; +- conserver l’historique existant ; +- mémoriser le choix localement ; +- proposer une action Annuler immédiatement après. + +--- + +## 11.3 Ouverture d’une chaîne + +Depuis : + +- le lecteur vidéo ; +- la page Chaînes ; +- éventuellement une carte recommandée. + +L’ouverture d’une chaîne affiche : + +- avatar ; +- nom ; +- description ; +- pays si disponible ; +- nombre d’abonnés ; +- vues de chaîne si disponibles ; +- nombre de vidéos publiques si disponible ; +- date de création ou date d’arrivée si disponible ; +- statistiques calculées sur les vidéos chargées ; +- catalogue vidéo. + +--- + +# 12. Page détaillée d’une chaîne + +## 12.1 En-tête + +Informations possibles : + +```text +Nom +Avatar +Description +Pays +Abonnés +Vues chaîne +Vidéos publiques +Date de création +``` + +Toutes les informations ne sont pas garanties par les chemins publics non authentifiés. + +L’interface ne doit pas inventer une donnée indisponible. + +--- + +## 12.2 Likes de chaîne + +YouTube ne fournit pas de compteur officiel global de likes pour une chaîne. + +YouTube Personal calcule donc : + +```text +likes cumulés des vidéos chargées +``` + +Ce compteur doit être clairement présenté comme une agrégation locale. + +--- + +## 12.3 Commentaires cumulés + +Même principe pour les commentaires lorsque `commentCount` est disponible. + +--- + +## 12.4 Résumé du catalogue + +La page peut afficher : + +```text +Vidéos chargées +Vues cumulées +Moyenne de vues par vidéo +Likes cumulés +Durée totale chargée +Période couverte par les vidéos +``` + +--- + +## 12.5 Catalogue vidéo + +Présentation desktop recommandée : + +```text +[ vidéo 1 ] [ vidéo 2 ] [ vidéo 3 ] [ vidéo 4 ] +[ vidéo 5 ] [ vidéo 6 ] [ vidéo 7 ] [ vidéo 8 ] +``` + +Responsive : + +```text +>= 1500 px : 4 colonnes +>= 1080 px : 3 colonnes +>= 720 px : 2 colonnes +mobile : 1 colonne +``` + +--- + +## 12.6 Informations sur les cartes de chaîne + +Chaque vidéo peut afficher : + +- miniature ; +- durée ; +- titre ; +- vues ; +- likes ; +- commentaires ; +- date de publication ; +- description courte. + +Une valeur non connue ne doit pas être affichée comme `0` si `0` signifie en réalité « donnée inconnue ». + +--- + +# 13. Tri des vidéos d’une chaîne + +Tri prévu : + +```text +Plus récentes +Plus vues +Plus aimées +Plus anciennes +``` + +### Plus récentes + +Date de publication décroissante. + +### Plus anciennes + +Date de publication croissante. + +### Plus vues + +Nombre de vues publiques décroissant. + +Quand le fallback public sait appliquer un tri natif de popularité, celui-ci doit être privilégié. + +### Plus aimées + +Nombre de likes publics décroissant lorsque cette donnée est disponible. + +--- + +# 14. Authentification Google + +## 14.1 But + +OAuth sert uniquement aux fonctions officielles du compte YouTube. + +Scope : + +```text +youtube.readonly +``` + +--- + +## 14.2 Fonctions OAuth + +OAuth permet notamment : + +- lire les abonnements ; +- récupérer les chaînes suivies ; +- utiliser certaines données YouTube Data API officielles. + +--- + +## 14.3 Séparation sécurité + +Règle stricte : + +```text +OAuth Google officiel +≠ +extraction vidéo non officielle +``` + +Les éléments OAuth ne doivent pas être injectés automatiquement dans : + +- yt-dlp ; +- l’extracteur de flux ; +- les URLs vidéo ; +- les requêtes PO Token ; +- les flux `googlevideo`. + +Ne pas automatiser : + +- likes ; +- commentaires ; +- abonnements ; +- vues artificielles. + +--- + +# 15. Abonnements YouTube + +L’utilisateur connecté peut voir ses abonnements réels. + +Une chaîne d’abonnement peut ouvrir son catalogue vidéo. + +Le chargement via Data API utilise idéalement : + +```text +channels.list +→ contentDetails.relatedPlaylists.uploads +→ playlistItems.list +→ videos.list +``` + +avec pagination. + +Objectif : + +- éviter `search.list` lorsque cela n’est pas nécessaire ; +- réduire l’utilisation du quota API ; +- récupérer durée, statistiques et date via `videos.list`. + +--- + +# 16. Lecteur vidéo — fonctions générales + +## 16.1 Commandes + +Le lecteur doit fournir : + +```text +Play / Pause +-10 secondes ++10 secondes +Recommencer +Picture-in-Picture +Sélecteur de qualité +Sélecteur de vitesse +``` + +--- + +## 16.2 Clic vidéo + +Un clic ou tap sur la surface vidéo bascule Play/Pause. + +--- + +## 16.3 Vitesse + +Plage : + +```text +0.50x → 2.00x +``` + +Pas fin : + +```text +0.05x +``` + +Presets rapides : + +```text +0.50x +1.00x +1.50x +2.00x +``` + +--- + +# 17. Raccourcis plein écran Windows + +En mode plein écran : + +```text +Espace → pause / lecture +Flèche gauche → recul +Flèche droite → avance +Flèche haut → volume + +Flèche bas → volume - +``` + +Le lecteur doit rester utilisable au clavier sans afficher de contrôles supplémentaires obligatoires. + +--- + +# 18. Android — gestes tactiles + +Le lecteur Android conserve des gestes adaptés au mobile. + +Exemples : + +- double tap gauche → recul ; +- double tap droite → avance ; +- autres gestes définis dans le lecteur mobile selon la version. + +--- + +# 19. Picture-in-Picture + +## Windows + +Utiliser une fenêtre compacte : + +```text +480 × 300 environ +always-on-top +``` + +via `window_manager`. + +## Android + +Utiliser le Picture-in-Picture natif Android. + +--- + +# 20. Lecture en arrière-plan Android + +Android doit continuer la lecture audio lorsque : + +- l’application passe en arrière-plan ; +- l’écran est verrouillé. + +Architecture : + +```text +Player vidéo foreground ++ +Player audio-only background ++ +audio_service +``` + +Le système doit : + +- synchroniser la position ; +- conserver seek/pause ; +- éviter un décalage important ; +- restaurer le Player principal lorsque l’utilisateur revient. + +--- + +# 21. SponsorBlock + +## 21.1 But + +Détecter et ignorer automatiquement les segments sponsorisés intégrés au contenu. + +Catégorie actuellement visée : + +```text +sponsor +``` + +Ne pas ignorer automatiquement par défaut : + +```text +intro +outro +self-promo +interaction reminder +preview +``` + +--- + +## 21.2 Comportement + +```text +lecture normale +→ entrée dans segment sponsor +→ seek à la fin +→ reprise lecture +``` + +Notification possible : + +```text +Sponsor ignoré automatiquement • 34s +``` + +--- + +## 21.3 Confidentialité + +Utiliser le mode k-anonymity de SponsorBlock. + +Principe : + +```text +SHA-256(videoId) +→ envoyer seulement le préfixe court +→ filtrer localement la réponse +``` + +Le système doit rester non bloquant. + +Si SponsorBlock échoue, la lecture normale continue. + +--- + +# 22. Modèle de données vidéo + +Modèle principal : + +```text +VideoItem +``` + +Champs fonctionnels : + +```text +id +title +author +channelId +thumbnailUrl +duration +viewCount +likeCount +commentCount +description +uploadDate +``` + +Toutes les données doivent être optionnelles lorsqu’elles ne sont pas garanties par la source. + +--- + +# 23. Métadonnées chaîne + +Modèle : + +```text +ChannelDetails +``` + +Champs possibles : + +```text +channelId +title +description +thumbnailUrl +publishedAt +joinDateLabel +viewCount +subscriberCount +hiddenSubscriberCount +videoCount +country +``` + +--- + +# 24. Sources de métadonnées + +YouTube Personal utilise plusieurs chemins, avec des rôles distincts. + +## 24.1 YouTube Data API officielle + +Utilisation : + +- abonnements ; +- statistiques officielles chaîne ; +- uploads d’une chaîne lorsque OAuth est actif ; +- durée / statistiques / date de vidéos. + +Avantage : + +- métadonnées structurées. + +Inconvénient : + +- quota ; +- session Google nécessaire. + +--- + +## 24.2 youtube_explode_dart + +Utilisation : + +- recherche ; +- métadonnées vidéo publiques ; +- chaînes publiques ; +- liste publique d’uploads ; +- fallback stable Android selon le contexte. + +Limite connue : + +YouTube peut appliquer du rate limiting sur les pages `/watch`. + +L’application doit éviter les rafales de requêtes par vidéo. + +--- + +## 24.3 yt-dlp + +Utilisation Windows : + +- extraction HD ; +- extraction `web_embedded` ; +- récupération groupée de certaines métadonnées de chaîne ; +- catalogue public en mode bulk ; +- fallback lorsque `youtube_explode_dart` est limité. + +Le binaire est embarqué dans : + +```text +assets/windows_hd/yt-dlp.exe +``` + +Runtime JS : + +```text +assets/windows_hd/deno.exe +``` + +--- + +# 25. Architecture lecture Windows + +La lecture Windows est la partie la plus spécifique du projet. + +Objectifs : + +- démarrage rapide ; +- éviter les 403 historiques des URLs directes ; +- éviter le rate limit du `/watch` public ; +- permettre 1080p, 1440p et 2160p lorsque disponibles ; +- conserver le son ; +- reprendre au bon timestamp ; +- utiliser le cache. + +--- + +# 26. Historique des contraintes Windows + +Plusieurs approches ont été abandonnées ou conservées comme fallback : + +```text +URLs googlevideo directes vers mpv +→ instables / timeouts / 403 + +mweb + PO Token +→ téléchargements partiels / 403 + +web_safari HLS +→ disponibilité trop variable + +relay Dart simple +→ limitations + +yt-dlp natif par URL signée +→ 403 selon les URLs + +growing file direct +→ ouverture mpv instable + +download complet local +→ robuste mais lent +``` + +La solution actuelle privilégie un mécanisme progressif local. + +--- + +# 27. Windows — comportement cible étape 18.7.3 + +La lecture doit suivre : + +```text +clic vidéo + ↓ +yt-dlp web_embedded 360p + ↓ +petit prébuffer + ↓ +lecture 360p + ↓ +préparation HD en arrière-plan + ↓ +bascule à la position courante +``` + +Le 360p de démarrage ne doit pas utiliser le endpoint public `/watch` de `youtube_explode_dart`. + +--- + +# 28. Prébuffer Windows 360p + +Objectif de démarrage : + +```text +vidéo : environ 2 MiB +audio : environ 512 KiB +``` + +Le service HTTP local sert les fichiers pendant leur croissance. + +Routes : + +```text +/video +/audio +``` + +Cela permet à `media_kit` de commencer à lire avant la fin du téléchargement. + +--- + +# 29. Deux relais Windows séparés + +Deux instances de `WindowsEmbeddedProgressiveService` : + +```text +Relais startup 360p +Relais HD +``` + +Le cache startup doit être isolé afin d’éviter : + +- suppression du fichier en cours de lecture ; +- concurrence de fichiers ; +- réécriture de l’audio ; +- erreurs Windows de type fichier utilisé par un autre processus. + +Après confirmation HD : + +```text +arrêt du relais 360p +``` + +--- + +# 30. HD Windows + +Qualités cibles : + +```text +360p +720p +1080p +1440p +2160p +Auto +``` + +Auto vise généralement une qualité haute adaptée, historiquement 1080p comme cible de démarrage HD. + +--- + +## 30.1 web_embedded + +Client principal : + +```text +web_embedded +``` + +Formats observés possibles : + +```text +137 → AVC 1080p30 +299 → AVC 1080p60 +399 → AV1 1080p60 +308 → VP9 1440p60 +400 → AV1 1440p60 +315 → VP9 2160p60 +401 → AV1 2160p60 +251 → Opus audio +``` + +La sélection exacte dépend de la vidéo. + +--- + +## 30.2 Codecs + +Préférences possibles selon les branches : + +```text +AV1 +VP9 +AVC +``` + +Toute optimisation de taille doit prévoir un fallback codec si le décodage local échoue. + +--- + +# 31. Cache Windows + +Répertoire : + +```text +%LOCALAPPDATA%\YouTubePersonal\media_cache\ +``` + +Structure approximative : + +```text +media_cache/ + / + video_. + audio_. + startup360_video_... + startup360_audio_... +``` + +Objectifs : + +- réutiliser les médias déjà téléchargés ; +- accélérer les relectures ; +- éviter les requêtes réseau inutiles. + +--- + +# 32. Politique de cache + +Le cache doit : + +- valider qu’un fichier existe et est exploitable ; +- éviter de considérer comme valide un fichier incomplet ; +- réutiliser l’audio si compatible ; +- limiter progressivement l’espace disque ; +- conserver les éléments récents ; +- pouvoir supprimer les caches les plus anciens. + +Une stratégie LRU via fichier `.last_access` est utilisée ou prévue. + +--- + +# 33. Gestion des erreurs Windows + +Le lecteur doit produire des logs lisibles. + +Préfixes historiques : + +```text +[HD-WINDOWS] +[HD-PROGRESSIVE] +[HD-CACHE] +[HD-DOWNLOAD] +[YT-PLAYER] +``` + +Exemples attendus étape 18.7.3 : + +```text +[HD-WINDOWS][18.7.3] démarrage rapide 360p ... +[HD-WINDOWS][18.7.3] 360p prêt ... +[HD-WINDOWS][18.7.3] lecture 360p confirmée ... +[HD-WINDOWS][18.7.3] 360p en lecture -> préparation HD ... +[HD-WINDOWS][17.19] HD progressive confirmée ... +[HD-WINDOWS][18.7.3] relais 360p arrêté après bascule HD +``` + +--- + +# 34. Rate limiting YouTube + +Problème connu : + +```text +RequestLimitExceededException +``` + +Cause typique : + +- trop de requêtes aux pages watch ; +- récupération individuelle des métadonnées de nombreuses vidéos. + +Règle technique : + +**ne pas lancer une requête watch par vidéo pour tout un catalogue.** + +Préférer : + +```text +1 requête de catalogue ++ +1 extraction bulk yt-dlp +``` + +--- + +# 35. Catalogue de chaîne Windows + +Architecture récente : + +```text +liste publique légère ++ +yt-dlp --flat-playlist --dump-json +``` + +Objectif : + +- obtenir un grand catalogue sans lancer une page watch pour chaque vidéo ; +- réduire le risque de rate-limit ; +- enrichir les cartes en masse. + +Les données non présentes dans `flat-playlist` ne doivent pas être inventées. + +--- + +# 36. Android — HD et 4K + +Android utilise un chemin natif spécifique. + +L’application a déjà validé : + +```text +1080p AVC + AAC +``` + +et : + +```text +3840 × 2160 VP9 +``` + +Décodage observé : + +```text +c2.qti.vp9.decoder +``` + +Le 4K Android est considéré comme fonctionnel sur le terminal testé. + +--- + +# 37. Sélecteur qualité Android + +Qualités : + +```text +Auto +360 +720 +1080 +1440 +2160 +``` + +Défaut historique : + +```text +1080p +``` + +La disponibilité réelle dépend des formats fournis par la vidéo. + +--- + +# 38. Interface et design + +## 38.1 Thème + +Thème sombre. + +Palette dominante : + +- noir / gris très foncé ; +- accents rouges / saumon ; +- textes blancs / gris. + +--- + +## 38.2 Responsive + +Desktop : + +- densité plus élevée ; +- cartes multiples ; +- grilles ; +- contrôles souris/clavier. + +Mobile : + +- cartes plus compactes ; +- swipe ; +- gestures ; +- NavigationBar. + +--- + +# 39. Description vidéo + +La page vidéo doit afficher : + +```text +vues +likes +commentaires +date +description +``` + +Comportement : + +- 3 lignes environ par défaut ; +- bouton `Afficher plus` ; +- bouton `Afficher moins`. + +Les aperçus peuvent afficher 1 à 2 lignes. + +--- + +# 40. Informations chaîne sous une vidéo + +Sous le lecteur, la zone chaîne peut afficher : + +```text +Nom de chaîne +Abonnés +Vues chaîne si disponible +Nombre de vidéos si disponible +Date si disponible +Likes de la vidéo +``` + +Une donnée inconnue ne doit pas être remplacée par `0`. + +--- + +# 41. Persistance locale + +Technologie : + +```text +shared_preferences +SharedPreferencesAsync +``` + +Clés principales : + +```text +youtube_personal.history.v1 +youtube_personal.favorites.v1 +youtube_personal.positions.v1 +youtube_personal.watch_counts.v1 +youtube_personal.hidden_channels.v1 +``` + +D’autres clés Radar peuvent être ajoutées pour : + +- dismissals ; +- auteurs réduits ; +- préférences locales. + +--- + +# 42. Confidentialité + +Principes : + +- aucune vente ou partage de données ; +- application personnelle ; +- préférences conservées localement ; +- OAuth utilisé uniquement pour les appels officiels Google ; +- aucune URL signée sensible ne doit être enregistrée dans un document utilisateur ; +- aucun secret OAuth ne doit être exposé dans les logs ou la documentation ; +- pas de cookies compte injectés automatiquement dans yt-dlp ; +- SponsorBlock utilisé en k-anonymity. + +--- + +# 43. Sécurité fonctionnelle + +Ne pas automatiser : + +```text +like +commentaire +abonnement +désabonnement +vue artificielle +interaction sociale +``` + +Le projet reste un lecteur et un gestionnaire de contenu personnel. + +--- + +# 44. Dépendances Flutter principales + +Référence du projet : + +```yaml +youtube_explode_dart: ^3.1.0 +media_kit: ^1.2.6 +media_kit_video: ^2.0.1 +media_kit_libs_video: ^1.0.7 +shared_preferences: ^2.5.5 +window_manager: ^0.5.2 +google_sign_in: ^7.2.0 +http: ^1.5.0 +crypto: ^3.0.6 +url_launcher: ^6.3.2 +audio_service: ^0.18.19 +``` + +--- + +# 45. Environnement de développement de référence + +Projet : + +```text +D:\07-Logiciels\23-youtube_personal +``` + +Flutter : + +```text +D:\07-Logiciels\00-Flutter\flutter +``` + +Android SDK : + +```text +C:\Users\abmou\AppData\Local\Android\sdk +``` + +Java Android Studio : + +```text +D:\Program Files\Android\Android Studio\jbr +``` + +--- + +# 46. Identité du projet + +Nom Flutter : + +```text +youtube_personal +``` + +Package Android : + +```text +com.studioweb.youtube_personal +``` + +Version historique de base : + +```text +1.0.0+1 +``` + +--- + +# 47. Commandes de lancement + +## Windows + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d windows +``` + +## Android + +```powershell +cd "D:\07-Logiciels\23-youtube_personal" +flutter run -d f10778d8 +``` + +`flutter clean` ne doit être utilisé que lorsqu’il est réellement nécessaire. + +--- + +# 48. Structure logique du code + +Structure principale : + +```text +lib/ + main.dart + + models/ + video_item.dart + subscription_channel.dart + channel_details.dart + channel_rank_item.dart + + screens/ + home_page.dart + search_page.dart + subscriptions_page.dart + channel_videos_page.dart + channels_page.dart + video_player_page.dart + library pages... + + services/ + youtube_service.dart + library_service.dart + radar_service.dart + google_auth_service.dart + google_oauth_config_service.dart + youtube_account_service.dart + background_playback_service.dart + hidden_channels_service.dart + channel_catalog_service.dart + channel_preview_metadata_service.dart + sponsor_block_service.dart + windows_hd_service.dart + windows_embedded_download_service.dart + windows_embedded_progressive_service.dart + windows_channel_metadata_service.dart + windows_channel_video_catalog_service.dart + windows relay / cache services... + + widgets/ + video_result_card.dart +``` + +--- + +# 49. Responsabilités des principaux services + +## `YoutubeService` + +Responsabilités : + +- recherche ; +- métadonnées vidéo ; +- métadonnées chaîne publiques ; +- uploads publics ; +- fallback non authentifié. + +--- + +## `LibraryService` + +Responsabilités : + +- historique ; +- favoris ; +- positions ; +- compteur personnel de vues ; +- persistance locale. + +--- + +## `RadarService` + +Responsabilités : + +- construire le profil local ; +- calculer les recommandations ; +- catégoriser ; +- expliquer les recommandations ; +- gérer dismissals et réduction d’auteurs ; +- exclure les chaînes masquées. + +--- + +## `GoogleAuthService` + +Responsabilités : + +- session Google ; +- access token officiel ; +- renouvellement lorsque prévu. + +--- + +## `YoutubeAccountService` + +Responsabilités : + +- appels YouTube Data API ; +- abonnements ; +- playlists uploads ; +- statistiques officielles. + +--- + +## `SponsorBlockService` + +Responsabilités : + +- récupération des segments sponsor ; +- cache de segments ; +- k-anonymity ; +- non-blocage du lecteur. + +--- + +## `WindowsHdService` + +Responsabilités : + +- interrogation yt-dlp ; +- choix des formats ; +- qualité cible ; +- métadonnées HD. + +--- + +## `WindowsEmbeddedProgressiveService` + +Responsabilités : + +- téléchargement progressif ; +- relais HTTP local ; +- Range requests ; +- surveillance du cache en croissance ; +- prébuffer ; +- lecture pendant téléchargement. + +--- + +# 50. Critères d’acceptation fonctionnels + +## Accueil + +- les recommandations s’affichent ; +- la liste est navigable ; +- la fin de la liste reste accessible ; +- les chaînes masquées n’apparaissent plus ; +- les catégories fonctionnent. + +## Recherche + +- une requête retourne des vidéos ; +- une carte ouvre le lecteur ; +- les métadonnées disponibles sont visibles. + +## Lecteur + +- lecture/pause ; +- seek ; +- vitesse ; +- qualité ; +- reprise ; +- description ; +- chaîne cliquable ; +- PiP. + +## Windows + +- une vidéo non en cache doit commencer en 360p rapidement ; +- la HD se prépare ensuite ; +- la bascule conserve la position courante ; +- si 360p est explicitement sélectionné, aucune HD automatique ne doit être lancée ; +- les erreurs de téléchargement ne doivent pas geler toute l’application. + +## Android + +- lecture autonome ; +- HD/4K ; +- PiP ; +- arrière-plan ; +- écran verrouillé. + +## Chaînes + +- classement selon vues personnelles ; +- suppression/masquage ; +- ouverture catalogue ; +- tri réel ; +- cartes riches ; +- aucune fausse statistique à zéro. + +## SponsorBlock + +- segment sponsor connu → seek automatique ; +- segment inconnu → lecture normale ; +- erreur réseau SponsorBlock → lecture normale. + +--- + +# 51. Contraintes techniques importantes + +1. **Ne pas casser le pipeline HD Windows qui fonctionne.** +2. **Éviter les requêtes watch en rafale.** +3. **Ne pas utiliser OAuth pour l’extraction vidéo.** +4. **Android doit rester autonome.** +5. **Les données inconnues doivent rester inconnues.** +6. **La lecture doit être prioritaire sur les métadonnées secondaires.** +7. **Les optimisations doivent conserver les fallbacks robustes.** +8. **Une modification de cache ne doit pas supprimer un fichier encore utilisé par media_kit.** +9. **Une bascule de qualité ne doit pas incrémenter les vues personnelles.** +10. **Les nouveaux fichiers ou modifications du projet doivent être livrés dans un ZIP avec chemins relatifs au projet.** + +--- + +# 52. Limitations connues actuelles + +## YouTube rate limiting + +Le endpoint public `/watch` peut être temporairement limité. + +Conséquence : + +```text +RequestLimitExceededException +``` + +La stratégie actuelle consiste à réduire le nombre de requêtes et à privilégier yt-dlp Windows. + +--- + +## Informations chaîne incomplètes + +Les chemins publics peuvent fournir : + +```text +abonnés +``` + +mais pas toujours : + +```text +vues chaîne +date création +nombre vidéos +``` + +Le fallback yt-dlp est utilisé lorsque possible. + +--- + +## 360p Windows + HD + +La logique 18.7.3 est conçue pour restaurer : + +```text +360p rapide → HD +``` + +tout en évitant le endpoint rate-limité. + +Cette partie doit être validée en conditions réelles sur vidéo non mise en cache. + +--- + +## Téléchargement progressif Windows + +Des erreurs historiques observées peuvent inclure : + +```text +yt-dlp exit code négatif +fichier en cours d’utilisation sous Windows +cache incomplet +download interrompu +``` + +Le système doit isoler les caches et ne pas considérer un fichier partiel comme complet. + +--- + +# 53. Évolutions possibles + +Évolutions compatibles avec le projet : + +- page de réglages Radar ; +- poids par catégorie ; +- bouton « moins de vidéos comme celle-ci » ; +- fréquence maximale par chaîne ; +- déduplication avancée des recommandations ; +- import/export des préférences ; +- gestion taille maximale du cache ; +- nettoyage cache manuel ; +- file d’attente ; +- playlist locale ; +- historique par session ; +- recherche dans une chaîne ; +- filtre durée ; +- filtre année ; +- filtre popularité ; +- indicateur vidéo déjà regardée ; +- indicateur progression dans les cartes ; +- statistiques personnelles avancées ; +- dashboard « temps regardé par chaîne » ; +- dashboard « catégories regardées » ; +- bouton SponsorBlock configurable par catégorie. + +--- + +# 54. Règles de développement du projet + +Pour les évolutions futures : + +- partir de la dernière version fonctionnelle validée ; +- ne pas réécrire une architecture stable sans nécessité ; +- préserver les fonctionnalités Windows et Android existantes ; +- diagnostiquer les logs avant de changer le pipeline vidéo ; +- ne pas demander à l’utilisateur d’éditer manuellement le code ; +- livrer chaque modification sous forme de ZIP contenant uniquement les fichiers modifiés ou ajoutés ; +- conserver les chemins relatifs à la racine du projet ; +- ne jamais exposer les secrets OAuth, URLs signées, tokens, POT ou données sensibles dans les réponses ou documents. + +--- + +# 55. Résumé d’architecture + +```text + ┌──────────────────────┐ + │ Interface Flutter │ + └──────────┬───────────┘ + │ + ┌──────────────────────┼──────────────────────┐ + │ │ │ + ▼ ▼ ▼ + Radar local Bibliothèque Compte Google + │ │ │ + ▼ ▼ ▼ + RadarService LibraryService GoogleAuthService + │ │ + │ ▼ + │ YoutubeAccountService + │ + └──────────────┐ + ▼ + YoutubeService + │ + ┌────────────┴────────────┐ + │ │ + ▼ ▼ + Android Windows + │ │ + │ ▼ + │ WindowsHdService + │ │ + │ ▼ + │ yt-dlp + │ │ + │ ▼ + │ Progressive local cache + │ │ + │ ▼ + │ HTTP local + │ │ + └──────────────► media_kit +``` + +--- + +# 56. État de référence + +La version de référence fonctionnelle actuelle est basée sur la série : + +```text +17.x : lecture HD Windows +18.x : Radar, chaînes, métadonnées, SponsorBlock +18.7.x : enrichissement chaîne, réduction rate-limit, restauration startup 360p +18.7.3 : 360p progressif rapide puis HD +``` + +Ce document doit être mis à jour à chaque évolution importante de l’architecture ou des spécifications. + diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..85f6774 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,37 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..c908258 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..a16b37f --- /dev/null +++ b/android/app/build.gradle.kts @@ -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") +} + + + + diff --git a/android/app/build.gradle.kts.before_hd b/android/app/build.gradle.kts.before_hd new file mode 100644 index 0000000..1bffec4 --- /dev/null +++ b/android/app/build.gradle.kts.before_hd @@ -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 = "../.." +} diff --git a/android/app/build.gradle.kts.before_hd_agp9_fix b/android/app/build.gradle.kts.before_hd_agp9_fix new file mode 100644 index 0000000..9fc3250 --- /dev/null +++ b/android/app/build.gradle.kts.before_hd_agp9_fix @@ -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") +} + diff --git a/android/app/build.gradle.kts.before_hd_kotlin_dsl_fix b/android/app/build.gradle.kts.before_hd_kotlin_dsl_fix new file mode 100644 index 0000000..1b00c01 --- /dev/null +++ b/android/app/build.gradle.kts.before_hd_kotlin_dsl_fix @@ -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") +} + + diff --git a/android/app/build.gradle.kts.before_r8_fix b/android/app/build.gradle.kts.before_r8_fix new file mode 100644 index 0000000..7327965 --- /dev/null +++ b/android/app/build.gradle.kts.before_r8_fix @@ -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") +} + + + diff --git a/android/app/build.gradle.kts.before_r8_fix_v2 b/android/app/build.gradle.kts.before_r8_fix_v2 new file mode 100644 index 0000000..7327965 --- /dev/null +++ b/android/app/build.gradle.kts.before_r8_fix_v2 @@ -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") +} + + + diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..432ea83 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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.** diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..8ffe024 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bd69945 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/studioweb/youtube_personal/HdStreamExtractor.java b/android/app/src/main/java/com/studioweb/youtube_personal/HdStreamExtractor.java new file mode 100644 index 0000000..2aa4635 --- /dev/null +++ b/android/app/src/main/java/com/studioweb/youtube_personal/HdStreamExtractor.java @@ -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 getHdInfo( + final String videoId, + final int targetHeight + ) throws Exception { + return getHdInfo(videoId, targetHeight, "auto"); + } + + public static Map 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 videoOnlyStreams = + info.getVideoOnlyStreams(); + + final List audioStreams = + info.getAudioStreams(); + + final Set uniqueResolutions = + new LinkedHashSet<>(); + final Set 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 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 streams, + final int targetHeight + ) { + if (streams == null || streams.isEmpty()) { + return null; + } + + final List 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 exactTarget = + new ArrayList<>(); + + for (final VideoStream stream : usable) { + if (matchesQualityTier(stream, targetHeight)) { + exactTarget.add(stream); + } + } + + final List 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 streams, + final String codecFamily + ) { + return streams.stream() + .filter(stream -> + codecMatches( + stream.getCodec(), + codecFamily + )) + .max(videoComparator()) + .orElse(null); + } + + private static Comparator videoComparator() { + return Comparator + .comparingInt(VideoStream::getFps) + .thenComparingInt(VideoStream::getBitrate); + } + + private static AudioStream selectAudio( + final List streams, + final String preferredLanguage + ) { + if (streams == null || streams.isEmpty()) { + return null; + } + + final List 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 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 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 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> collectAudioTracks( + final List streams + ) { + final Map> 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 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 collectAudioLanguages( + final List streams + ) { + final Set 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 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; + } +} diff --git a/android/app/src/main/java/com/studioweb/youtube_personal/MainActivity.java b/android/app/src/main/java/com/studioweb/youtube_personal/MainActivity.java new file mode 100644 index 0000000..e39271b --- /dev/null +++ b/android/app/src/main/java/com/studioweb/youtube_personal/MainActivity.java @@ -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 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 getMediaCachePaths() { + final Map 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(); + } +} diff --git a/android/app/src/main/java/com/studioweb/youtube_personal/NewPipeDownloader.java b/android/app/src/main/java/com/studioweb/youtube_personal/NewPipeDownloader.java new file mode 100644 index 0000000..ca12af5 --- /dev/null +++ b/android/app/src/main/java/com/studioweb/youtube_personal/NewPipeDownloader.java @@ -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> 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() + ); + } + } +} diff --git a/android/app/src/main/kotlin/com/studioweb/youtube_personal/MainActivity.kt b/android/app/src/main/kotlin/com/studioweb/youtube_personal/MainActivity.kt new file mode 100644 index 0000000..ce81148 --- /dev/null +++ b/android/app/src/main/kotlin/com/studioweb/youtube_personal/MainActivity.kt @@ -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. + */ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..1cb7aa2 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..8403758 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..eb46ff1 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..9767a87 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..396080a Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..40b64f6 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..067f35f Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8c754c6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..91fa351 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..fe1eb3c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..bb89ab8 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..606c001 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..360a160 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..5fac679 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..8ffe024 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..04c10aa --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,48 @@ +allprojects { + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} + +// BEGIN YOUTUBE_PERSONAL_AGP9_KOTLIN_BRIDGE_18_10_6 +// Flutter 3.44 + AGP 9 peut fonctionner temporairement avec +// android.builtInKotlin=false. Certains plugins regardent uniquement la +// version d'AGP et n'appliquent alors plus KGP : leurs sources .kt ne sont +// jamais compilees et GeneratedPluginRegistrant ne trouve plus les classes. +val youtubePersonalLegacyKotlinPlugins = setOf( + "package_info_plus", + "wakelock_plus", +) + +subprojects { + if (name in youtubePersonalLegacyKotlinPlugins) { + val pluginProject = this + pluginManager.withPlugin("com.android.library") { + if (!pluginProject.pluginManager.hasPlugin("org.jetbrains.kotlin.android")) { + pluginProject.pluginManager.apply("org.jetbrains.kotlin.android") + } + } + } +} +// END YOUTUBE_PERSONAL_AGP9_KOTLIN_BRIDGE_18_10_6 + diff --git a/android/build.gradle.kts.before_hd b/android/build.gradle.kts.before_hd new file mode 100644 index 0000000..1f88145 --- /dev/null +++ b/android/build.gradle.kts.before_hd @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/build.gradle.kts.before_memory_18_10_6 b/android/build.gradle.kts.before_memory_18_10_6 new file mode 100644 index 0000000..4182e44 --- /dev/null +++ b/android/build.gradle.kts.before_memory_18_10_6 @@ -0,0 +1,26 @@ +allprojects { + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} + diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..1393ba7 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..1482435 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,11 @@ +# YouTube Personal - Android memory build fix +org.gradle.jvmargs=-Xmx16384m -XX:MaxMetaspaceSize=4096m -Dfile.encoding=UTF-8 + +org.gradle.daemon=true +org.gradle.parallel=false +org.gradle.caching=true +org.gradle.workers.max=4 +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle.properties.before_memory_18_10_6 b/android/gradle.properties.before_memory_18_10_6 new file mode 100644 index 0000000..118d7d0 --- /dev/null +++ b/android/gradle.properties.before_memory_18_10_6 @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/android/gradle.properties.before_memory_18_10_7 b/android/gradle.properties.before_memory_18_10_7 new file mode 100644 index 0000000..151eeb5 --- /dev/null +++ b/android/gradle.properties.before_memory_18_10_7 @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false + diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..913c864 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..6101f5f --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/assets/app_icon/app_icon_1024.png b/assets/app_icon/app_icon_1024.png new file mode 100644 index 0000000..0644e64 Binary files /dev/null and b/assets/app_icon/app_icon_1024.png differ diff --git a/assets/windows_hd/README.txt b/assets/windows_hd/README.txt new file mode 100644 index 0000000..778a1a0 --- /dev/null +++ b/assets/windows_hd/README.txt @@ -0,0 +1,14 @@ +Ce dossier reçoit les exécutables locaux nécessaires à la HD/4K Windows. + +Lancer à la racine du projet : + + .\PREPARER_HD_WINDOWS.ps1 + +Le script télécharge depuis les releases officielles : +- yt-dlp.exe +- deno.exe + +Il vérifie les SHA-256 officiels avant utilisation. + +Ne pas supprimer ce fichier avant le premier `flutter pub get`, car le dossier +est déclaré comme dossier d'assets dans pubspec.yaml. diff --git a/assets/windows_hd/deno.exe b/assets/windows_hd/deno.exe new file mode 100644 index 0000000..51d7d0a Binary files /dev/null and b/assets/windows_hd/deno.exe differ diff --git a/assets/windows_hd/yt-dlp.exe b/assets/windows_hd/yt-dlp.exe new file mode 100644 index 0000000..f15484e Binary files /dev/null and b/assets/windows_hd/yt-dlp.exe differ diff --git a/flutter_01.png b/flutter_01.png new file mode 100644 index 0000000..8887448 Binary files /dev/null and b/flutter_01.png differ diff --git a/flutter_02.png b/flutter_02.png new file mode 100644 index 0000000..e69de29 diff --git a/flutter_03.png b/flutter_03.png new file mode 100644 index 0000000..e69de29 diff --git a/flutter_04.png b/flutter_04.png new file mode 100644 index 0000000..e69de29 diff --git a/flutter_05.png b/flutter_05.png new file mode 100644 index 0000000..e69de29 diff --git a/installer.iss b/installer.iss new file mode 100644 index 0000000..374de0b --- /dev/null +++ b/installer.iss @@ -0,0 +1,19 @@ +[Setup] +AppName=YouTube Personal +AppVersion=1.0.0 +DefaultDirName={autopf}\YouTube Personal +DefaultGroupName=YouTube Personal +OutputDir=installer_output +OutputBaseFilename=YouTube_Personal_Setup +Compression=lzma +SolidCompression=yes + +[Files] +Source: "build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs + +[Icons] +Name: "{autoprograms}\YouTube Personal"; Filename: "{app}\youtube_personal.exe" +Name: "{autodesktop}\YouTube Personal"; Filename: "{app}\youtube_personal.exe" + +[Run] +Filename: "{app}\youtube_personal.exe"; Description: "Lancer YouTube Personal"; Flags: nowait postinstall skipifsilent \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..c3930cc --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,193 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:window_manager/window_manager.dart'; + +import 'screens/home_page.dart'; +import 'screens/library_pages.dart'; +import 'screens/search_page.dart'; +import 'screens/settings_page.dart'; +import 'screens/subscriptions_page.dart'; +import 'services/app_settings_service.dart'; +import 'services/background_playback_service.dart'; +import 'services/google_auth_service.dart'; +import 'services/library_service.dart'; +import 'services/media_cache_settings_service.dart'; +import 'theme/app_theme.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); + + if (Platform.isWindows) { + await windowManager.ensureInitialized(); + } + + await BackgroundPlaybackService.instance.initialize(); + await LibraryService.instance.initialize(); + await AppSettingsService.instance.initialize(); + await MediaCacheSettingsService.instance.ensureInitialized(); + await GoogleAuthService.instance.initialize(); + + runApp(const YouTubePersonalApp()); +} + +class YouTubePersonalApp extends StatelessWidget { + const YouTubePersonalApp({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: AppSettingsService.instance.appearanceMode, + builder: (context, appearanceMode, child) { + final hour = DateTime.now().hour; + final themeMode = switch (appearanceMode) { + 'dark' => ThemeMode.dark, + 'light' => ThemeMode.light, + 'auto' => (hour >= 19 || hour < 7) ? ThemeMode.dark : ThemeMode.light, + _ => ThemeMode.light, + }; + + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'YouTube Personal', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: themeMode, + home: const MainScreen(), + ); + }, + ); + } +} + +class MainScreen extends StatefulWidget { + const MainScreen({super.key}); + + @override + State createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + int selectedIndex = 0; + + final pages = const [ + HomePage(), + SearchPage(), + SubscriptionsPage(), + LibraryHubPage(), + SettingsPage(), + ]; + + @override + Widget build(BuildContext context) { + final mediaSize = MediaQuery.sizeOf(context); + final width = mediaSize.width; + final shortestSide = mediaSize.shortestSide; + final desktop = width >= 1000 && shortestSide >= 700; + + if (desktop) { + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: selectedIndex, + onDestinationSelected: (index) { + setState(() { + selectedIndex = index; + }); + }, + labelType: NavigationRailLabelType.selected, + leading: const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Icon( + Icons.play_circle_fill, + size: 42, + color: Color(0xFFFF4E45), + ), + ), + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: Text('Accueil'), + ), + NavigationRailDestination( + icon: Icon(Icons.search), + label: Text('Recherche'), + ), + NavigationRailDestination( + icon: Icon(Icons.subscriptions_outlined), + selectedIcon: Icon(Icons.subscriptions), + label: Text('Abonnements'), + ), + NavigationRailDestination( + icon: Icon(Icons.video_library_outlined), + selectedIcon: Icon(Icons.video_library), + label: Text('Bibliothèque'), + ), + NavigationRailDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: Text('Réglages'), + ), + ], + ), + const VerticalDivider(width: 1), + Expanded( + child: IndexedStack( + index: selectedIndex, + children: pages, + ), + ), + ], + ), + ); + } + + return Scaffold( + body: IndexedStack( + index: selectedIndex, + children: pages, + ), + bottomNavigationBar: NavigationBar( + height: 72, + labelBehavior: + NavigationDestinationLabelBehavior.alwaysShow, + selectedIndex: selectedIndex, + onDestinationSelected: (index) { + setState(() { + selectedIndex = index; + }); + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Accueil', + ), + NavigationDestination( + icon: Icon(Icons.search), + label: 'Recherche', + ), + NavigationDestination( + icon: Icon(Icons.subscriptions_outlined), + selectedIcon: Icon(Icons.subscriptions), + label: 'Abos', + ), + NavigationDestination( + icon: Icon(Icons.video_library_outlined), + selectedIcon: Icon(Icons.video_library), + label: 'Bibliothèque', + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Réglages', + ), + ], + ), + ); + } +} diff --git a/lib/models/channel_details.dart b/lib/models/channel_details.dart new file mode 100644 index 0000000..58d1031 --- /dev/null +++ b/lib/models/channel_details.dart @@ -0,0 +1,39 @@ +class ChannelDetails { + const ChannelDetails({ + required this.channelId, + required this.title, + required this.description, + required this.thumbnailUrl, + this.publishedAt, + this.joinDateLabel, + this.viewCount, + this.subscriberCount, + this.hiddenSubscriberCount = false, + this.videoCount, + this.country, + }); + + final String channelId; + final String title; + final String description; + final String thumbnailUrl; + + /// Channel creation date from the official YouTube Data API. + final DateTime? publishedAt; + + /// Human-readable join date returned by the public YouTube About page. + /// Used when no Google OAuth session is active. + final String? joinDateLabel; + + /// Public total views for the channel. + final int? viewCount; + + /// Public subscriber count. YouTube may hide it for some channels. + final int? subscriberCount; + final bool hiddenSubscriberCount; + + /// Number of public videos reported by YouTube. + final int? videoCount; + + final String? country; +} diff --git a/lib/models/channel_rank_item.dart b/lib/models/channel_rank_item.dart new file mode 100644 index 0000000..5234340 --- /dev/null +++ b/lib/models/channel_rank_item.dart @@ -0,0 +1,25 @@ +import 'subscription_channel.dart'; + +class ChannelRankItem { + const ChannelRankItem({ + required this.channel, + required this.personalViewCount, + required this.distinctVideoCount, + required this.topVideoPersonalViews, + this.topVideoTitle, + }); + + final SubscriptionChannel channel; + + /// Number of times the user actually opened videos from this channel in + /// YouTube Personal. This is the primary ranking metric. + final int personalViewCount; + + /// Distinct history videos currently known for this channel. + final int distinctVideoCount; + + /// Personal repeat count for the user's most watched known video. + final int topVideoPersonalViews; + + final String? topVideoTitle; +} diff --git a/lib/models/playback_option.dart b/lib/models/playback_option.dart new file mode 100644 index 0000000..d15bece --- /dev/null +++ b/lib/models/playback_option.dart @@ -0,0 +1,17 @@ +class YoutubePlaybackOption { + const YoutubePlaybackOption({ + required this.height, + required this.label, + required this.videoUrl, + this.audioUrl, + this.fps, + }); + + final int height; + final String label; + final String videoUrl; + final String? audioUrl; + final num? fps; + + bool get isAdaptive => audioUrl != null; +} diff --git a/lib/models/radar_recommendation.dart b/lib/models/radar_recommendation.dart new file mode 100644 index 0000000..54a1596 --- /dev/null +++ b/lib/models/radar_recommendation.dart @@ -0,0 +1,102 @@ +import 'video_item.dart'; + +enum RadarSessionMode { + normal, + discovery, + learn, + relax, + short, + long, +} + +extension RadarSessionModeLabel on RadarSessionMode { + String get label { + switch (this) { + case RadarSessionMode.normal: + return 'Comme d’habitude'; + case RadarSessionMode.discovery: + return 'Je veux du nouveau'; + case RadarSessionMode.learn: + return 'Apprendre'; + case RadarSessionMode.relax: + return 'Détente'; + case RadarSessionMode.short: + return 'Courtes vidéos'; + case RadarSessionMode.long: + return 'Long format'; + } + } + + String get shortLabel { + switch (this) { + case RadarSessionMode.normal: + return 'Normal'; + case RadarSessionMode.discovery: + return 'Découverte'; + case RadarSessionMode.learn: + return 'Apprendre'; + case RadarSessionMode.relax: + return 'Détente'; + case RadarSessionMode.short: + return 'Court'; + case RadarSessionMode.long: + return 'Long'; + } + } +} + +enum RadarLane { + forYou, + continueTopic, + discovery, +} + +class RadarRecommendation { + const RadarRecommendation({ + required this.video, + required this.score, + required this.reasons, + required this.breakdown, + required this.lane, + required this.matchedTerms, + required this.isNewAuthor, + }); + + final VideoItem video; + final int score; + final List reasons; + final Map breakdown; + final RadarLane lane; + final List matchedTerms; + final bool isNewAuthor; +} + +class RadarFeed { + const RadarFeed({ + required this.mode, + required this.generatedAt, + required this.profileTopics, + required this.primary, + required this.continueTopic, + required this.discovery, + this.categories = const >{}, + }); + + final RadarSessionMode mode; + final DateTime generatedAt; + final List profileTopics; + final List primary; + final List continueTopic; + final List discovery; + + /// Primary local classification of proposed videos. + /// + /// A recommendation belongs to one main category so the category lanes + /// stay clean and predictable. + final Map> categories; + + bool get isEmpty => + primary.isEmpty && + continueTopic.isEmpty && + discovery.isEmpty; +} diff --git a/lib/models/subscription_channel.dart b/lib/models/subscription_channel.dart new file mode 100644 index 0000000..8f296ff --- /dev/null +++ b/lib/models/subscription_channel.dart @@ -0,0 +1,13 @@ +class SubscriptionChannel { + const SubscriptionChannel({ + required this.channelId, + required this.title, + required this.thumbnailUrl, + this.description = '', + }); + + final String channelId; + final String title; + final String thumbnailUrl; + final String description; +} diff --git a/lib/models/video_item.dart b/lib/models/video_item.dart new file mode 100644 index 0000000..e65236e --- /dev/null +++ b/lib/models/video_item.dart @@ -0,0 +1,113 @@ +class VideoItem { + const VideoItem({ + required this.id, + required this.title, + required this.author, + required this.thumbnailUrl, + this.duration, + this.channelId, + this.viewCount, + this.likeCount, + this.commentCount, + this.description = '', + this.uploadDate, + }); + + final String id; + final String title; + final String author; + final String thumbnailUrl; + final Duration? duration; + + /// YouTube channel ID when it is known. + /// + /// Old history/favorites saved before step 18.3 simply have null here. + /// The channel screen can resolve it lazily from the video ID. + final String? channelId; + + /// Public YouTube view count at the time the metadata was fetched. + final int? viewCount; + + /// Public likes for this video when YouTube exposes the statistic. + final int? likeCount; + + /// Public comments for this video when YouTube exposes the statistic. + final int? commentCount; + + /// Public YouTube video description. + final String description; + + /// YouTube upload date when available. + final DateTime? uploadDate; + + String get durationLabel { + final value = duration; + if (value == null) { + return ''; + } + + final hours = value.inHours; + final minutes = value.inMinutes.remainder(60); + final seconds = value.inSeconds.remainder(60); + + if (hours > 0) { + return '$hours:${minutes.toString().padLeft(2, '0')}:' + '${seconds.toString().padLeft(2, '0')}'; + } + + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'author': author, + 'thumbnailUrl': thumbnailUrl, + 'durationSeconds': duration?.inSeconds, + if (channelId != null) 'channelId': channelId, + if (viewCount != null) 'viewCount': viewCount, + if (likeCount != null) 'likeCount': likeCount, + if (commentCount != null) 'commentCount': commentCount, + if (description.isNotEmpty) 'description': description, + if (uploadDate != null) + 'uploadDate': uploadDate!.toIso8601String(), + }; + } + + factory VideoItem.fromJson(Map json) { + final durationSeconds = json['durationSeconds']; + final rawViewCount = json['viewCount']; + final rawLikeCount = json['likeCount']; + final rawCommentCount = json['commentCount']; + final rawUploadDate = json['uploadDate']; + + return VideoItem( + id: json['id'] as String, + title: json['title'] as String, + author: json['author'] as String, + thumbnailUrl: json['thumbnailUrl'] as String, + duration: durationSeconds is num + ? Duration(seconds: durationSeconds.toInt()) + : null, + channelId: json['channelId'] is String + ? json['channelId'] as String + : null, + viewCount: rawViewCount is num + ? rawViewCount.toInt() + : null, + likeCount: rawLikeCount is num + ? rawLikeCount.toInt() + : null, + commentCount: rawCommentCount is num + ? rawCommentCount.toInt() + : null, + description: json['description'] is String + ? json['description'] as String + : '', + uploadDate: rawUploadDate is String + ? DateTime.tryParse(rawUploadDate) + : null, + ); + } +} diff --git a/lib/screens/channel_videos_page.dart b/lib/screens/channel_videos_page.dart new file mode 100644 index 0000000..81de88a --- /dev/null +++ b/lib/screens/channel_videos_page.dart @@ -0,0 +1,2036 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../models/channel_details.dart'; +import '../models/subscription_channel.dart'; +import '../models/video_item.dart'; +import '../services/google_auth_service.dart'; +import '../services/hidden_channels_service.dart'; +import '../services/youtube_account_service.dart'; +import '../services/youtube_service.dart'; +import '../services/windows_channel_video_catalog_service.dart'; +import 'video_player_page.dart'; + +enum _ChannelSort { + newest, + mostViewed, + mostLiked, + oldest, +} + +class ChannelVideosPage extends StatefulWidget { + const ChannelVideosPage({ + super.key, + required this.channel, + }); + + final SubscriptionChannel channel; + + @override + State createState() => + _ChannelVideosPageState(); +} + +class _ChannelVideosPageState extends State { + final YoutubeAccountService _accountService = YoutubeAccountService(); + final YoutubeService _publicService = YoutubeService(); + + final List _videos = []; + + ChannelDetails? _channelDetails; + bool _detailsLoading = true; + + bool _loading = true; + bool _loadingMore = false; + bool _publicHasMore = false; + bool _complete = false; + String? _error; + String _sourceLabel = 'YouTube'; + int _loadGeneration = 0; + bool _usingPublicSource = false; + _ChannelSort _sort = _ChannelSort.newest; + bool _channelInfoExpanded = true; + + @override + void initState() { + super.initState(); + // Sur Android, l'espace vertical est précieux : les informations + // détaillées de la chaîne sont repliées par défaut afin d'afficher + // immédiatement le catalogue vidéo. Sur desktop, on conserve la vue + // détaillée historique, tout en permettant de la replier manuellement. + _channelInfoExpanded = !Platform.isAndroid; + _loadAllVideos(); + } + + @override + void dispose() { + _loadGeneration++; + _publicService.close(); + super.dispose(); + } + + Future _loadPublicChannelDetails( + int generation, + ) async { + try { + final details = + await _publicService.getPublicChannelDetails( + widget.channel.channelId, + ); + + if (!mounted || + generation != _loadGeneration) { + return; + } + + setState(() { + // Official Data API details, when available, may replace this a bit + // later with an exact creation timestamp & public video count. + _channelDetails = details; + _detailsLoading = false; + }); + } catch (error) { + if (!mounted || + generation != _loadGeneration) { + return; + } + + debugPrint( + '[CHANNEL] infos publiques indisponibles: $error', + ); + + setState(() { + _detailsLoading = false; + }); + } + } + + Future _loadAllVideos() async { + final generation = ++_loadGeneration; + + if (!mounted) { + return; + } + + setState(() { + _videos.clear(); + _channelDetails = null; + _detailsLoading = true; + _loading = true; + _loadingMore = false; + _publicHasMore = false; + _complete = false; + _error = null; + _sourceLabel = 'Connexion à YouTube…'; + _usingPublicSource = false; + }); + + try { + unawaited( + _loadPublicChannelDetails(generation), + ); + + var usedOfficialApi = false; + + try { + final accessToken = + await GoogleAuthService.instance.getAccessToken(); + + usedOfficialApi = true; + _usingPublicSource = false; + _sourceLabel = 'YouTube Data API'; + + try { + final details = + await _accountService.fetchChannelDetails( + accessToken, + widget.channel.channelId, + ); + + if (mounted && generation == _loadGeneration) { + setState(() { + _channelDetails = details; + _detailsLoading = false; + }); + } + } catch (detailsError) { + debugPrint( + '[CHANNEL] statistiques chaîne indisponibles: ' + '$detailsError', + ); + + if (mounted && generation == _loadGeneration) { + setState(() { + _detailsLoading = false; + }); + } + } + + await for (final video in _accountService.streamAllVideosForChannel( + accessToken, + widget.channel.channelId, + )) { + if (!mounted || generation != _loadGeneration) { + return; + } + + _videos.add(video); + + // Official API yields one enriched playlist page at a time. + if (_videos.length <= 10 || _videos.length % 10 == 0) { + setState(() {}); + } + } + } catch (officialError) { + if (!mounted || generation != _loadGeneration) { + return; + } + + debugPrint( + '[CHANNEL] Data API indisponible -> fallback public: ' + '$officialError', + ); + + // If the official source failed before producing data, use the + // library's explicit full-upload traversal fallback. + if (_videos.isEmpty) { + usedOfficialApi = false; + _usingPublicSource = true; + _sourceLabel = + 'Accès public • liste rapide'; + + final publicSort = switch (_sort) { + _ChannelSort.newest => + ChannelUploadSort.newest, + _ChannelSort.mostViewed => + ChannelUploadSort.popularity, + _ChannelSort.mostLiked => + ChannelUploadSort.newest, + _ChannelSort.oldest => + ChannelUploadSort.oldest, + }; + + final firstBatch = + await _publicService.getChannelUploadsPage( + widget.channel.channelId, + sort: publicSort, + reset: true, + ); + + if (!mounted || generation != _loadGeneration) { + return; + } + + _videos.addAll(firstBatch.videos); + _publicHasMore = firstBatch.hasMore; + + if (_videos.isNotEmpty) { + setState(() {}); + } + + // Windows: enrich the WHOLE visible catalogue through one yt-dlp + // flat-playlist process. No videos.get() per entry. + if (Platform.isWindows && + mounted && + generation == _loadGeneration && + _videos.isNotEmpty) { + setState(() { + _sourceLabel = + 'Accès public • métadonnées groupées…'; + }); + + final metadata = + await WindowsChannelVideoCatalogService + .instance + .getMetadata( + widget.channel.channelId, + ); + + if (!mounted || + generation != _loadGeneration) { + return; + } + + if (metadata.isNotEmpty) { + for (var index = 0; + index < _videos.length; + index++) { + final base = + _videos[index]; + final extra = + metadata[base.id]; + + if (extra != null) { + _videos[index] = + _mergeVideoMetadata( + base, + extra, + ); + } + } + + setState(() { + _sourceLabel = + 'Accès public • métadonnées groupées'; + }); + } + } + } else { + rethrow; + } + } + + if (!mounted || generation != _loadGeneration) { + return; + } + + setState(() { + _loading = false; + _complete = usedOfficialApi || !_publicHasMore; + _sourceLabel = usedOfficialApi + ? 'YouTube Data API' + : 'Accès public'; + }); + } catch (error) { + if (!mounted || generation != _loadGeneration) { + return; + } + + setState(() { + _loading = false; + _error = error.toString(); + }); + } + } + + DateTime? get _newestLoadedVideoDate { + DateTime? newest; + + for (final video in _videos) { + final date = video.uploadDate; + + if (date == null) { + continue; + } + + if (newest == null || + date.isAfter(newest)) { + newest = date; + } + } + + return newest; + } + + int get _loadedViewCount { + return _videos.fold( + 0, + (sum, video) => + sum + (video.viewCount ?? 0), + ); + } + + int get _viewsMeasuredVideos { + return _videos + .where( + (video) => + video.viewCount != null, + ) + .length; + } + + int get _averageViewCount { + final measured = + _viewsMeasuredVideos; + + if (measured == 0) { + return 0; + } + + return _loadedViewCount ~/ measured; + } + + Duration get _loadedDuration { + return _videos.fold( + Duration.zero, + (sum, video) => + sum + + (video.duration ?? + Duration.zero), + ); + } + + DateTime? get _oldestLoadedVideoDate { + DateTime? oldest; + + for (final video in _videos) { + final date = video.uploadDate; + if (date == null) { + continue; + } + + if (oldest == null || date.isBefore(oldest)) { + oldest = date; + } + } + + return oldest; + } + + int get _loadedLikeCount { + return _videos.fold( + 0, + (sum, video) => sum + (video.likeCount ?? 0), + ); + } + + int get _loadedCommentCount { + return _videos.fold( + 0, + (sum, video) => sum + (video.commentCount ?? 0), + ); + } + + int get _likesMeasuredVideos { + return _videos + .where((video) => video.likeCount != null) + .length; + } + + int get _commentsMeasuredVideos { + return _videos + .where((video) => video.commentCount != null) + .length; + } + + VideoItem _mergeVideoMetadata( + VideoItem base, + VideoItem extra, + ) { + Duration? duration = + extra.duration; + + if (duration == null || + duration <= Duration.zero) { + duration = base.duration; + } + + final extraDescription = + extra.description.trim(); + + return VideoItem( + id: base.id, + title: extra.title.trim().isNotEmpty + ? extra.title + : base.title, + author: extra.author.trim().isNotEmpty + ? extra.author + : base.author, + thumbnailUrl: + extra.thumbnailUrl.trim().isNotEmpty + ? extra.thumbnailUrl + : base.thumbnailUrl, + duration: duration, + channelId: + extra.channelId?.trim().isNotEmpty == true + ? extra.channelId + : base.channelId, + viewCount: + extra.viewCount ?? + base.viewCount, + likeCount: + extra.likeCount ?? + base.likeCount, + commentCount: + extra.commentCount ?? + base.commentCount, + description: + extraDescription.isNotEmpty + ? extra.description + : base.description, + uploadDate: + extra.uploadDate ?? + base.uploadDate, + ); + } + + List get _sortedVideos { + final list = List.of(_videos); + final epoch = + DateTime.fromMillisecondsSinceEpoch(0); + + int newestFirst( + VideoItem a, + VideoItem b, + ) { + final date = (b.uploadDate ?? epoch) + .compareTo(a.uploadDate ?? epoch); + + if (date != 0) { + return date; + } + + return (b.viewCount ?? 0) + .compareTo(a.viewCount ?? 0); + } + + switch (_sort) { + case _ChannelSort.newest: + list.sort(newestFirst); + break; + + case _ChannelSort.mostViewed: + list.sort( + (a, b) { + final views = + (b.viewCount ?? 0) + .compareTo( + a.viewCount ?? 0, + ); + + return views != 0 + ? views + : newestFirst(a, b); + }, + ); + break; + + case _ChannelSort.mostLiked: + list.sort( + (a, b) { + final likes = + (b.likeCount ?? 0) + .compareTo( + a.likeCount ?? 0, + ); + + return likes != 0 + ? likes + : newestFirst(a, b); + }, + ); + break; + + case _ChannelSort.oldest: + list.sort( + (a, b) { + final date = + (a.uploadDate ?? epoch) + .compareTo( + b.uploadDate ?? epoch, + ); + + if (date != 0) { + return date; + } + + return (a.viewCount ?? 0) + .compareTo( + b.viewCount ?? 0, + ); + }, + ); + break; + } + + return list; + } + + ChannelUploadSort get _publicSort { + return switch (_sort) { + _ChannelSort.newest => ChannelUploadSort.newest, + _ChannelSort.mostViewed => ChannelUploadSort.popularity, + _ChannelSort.mostLiked => ChannelUploadSort.newest, + _ChannelSort.oldest => ChannelUploadSort.oldest, + }; + } + + Future _loadMorePublicVideos() async { + if (_loading || + _loadingMore || + !_usingPublicSource || + !_publicHasMore) { + return; + } + + final generation = _loadGeneration; + + setState(() { + _loadingMore = true; + _sourceLabel = 'Accès public • page suivante…'; + }); + + try { + final batch = await _publicService.getChannelUploadsPage( + widget.channel.channelId, + sort: _publicSort, + ); + + if (!mounted || generation != _loadGeneration) { + return; + } + + final existingIds = _videos + .map((video) => video.id) + .toSet(); + final added = batch.videos + .where((video) => existingIds.add(video.id)) + .toList(growable: false); + final firstAddedIndex = _videos.length; + + _videos.addAll(added); + _publicHasMore = batch.hasMore; + + // Windows already has one grouped yt-dlp catalogue in cache. Reuse it + // to enrich only the newly appended page, without per-video requests. + if (Platform.isWindows && added.isNotEmpty) { + final metadata = + await WindowsChannelVideoCatalogService.instance.getMetadata( + widget.channel.channelId, + ); + + if (!mounted || generation != _loadGeneration) { + return; + } + + if (metadata.isNotEmpty) { + for (var index = firstAddedIndex; + index < _videos.length; + index++) { + final base = _videos[index]; + final extra = metadata[base.id]; + if (extra != null) { + _videos[index] = _mergeVideoMetadata(base, extra); + } + } + } + } + + setState(() { + _loadingMore = false; + _complete = !_publicHasMore; + _sourceLabel = 'Accès public'; + }); + } catch (error) { + if (!mounted || generation != _loadGeneration) { + return; + } + + setState(() { + _loadingMore = false; + _sourceLabel = 'Accès public'; + }); + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + duration: Duration(seconds: 4), + content: Text( + 'Impossible de charger la page suivante : $error', + ), + ), + ); + } + } + + Future _openVideo(VideoItem video) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VideoPlayerPage(video: video), + ), + ); + } + + Future _hideChannel() async { + await HiddenChannelsService.instance.hide(widget.channel); + + if (!mounted) { + return; + } + + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final videos = _sortedVideos; + + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + titleSpacing: 0, + title: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundImage: widget.channel.thumbnailUrl.isNotEmpty + ? NetworkImage(widget.channel.thumbnailUrl) + : null, + child: widget.channel.thumbnailUrl.isEmpty + ? Icon(Icons.account_circle) + : null, + ), + SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.channel.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + _complete + ? '${_videos.length} vidéo${_videos.length > 1 ? 's' : ''} • $_sourceLabel' + : '${_videos.length} chargée${_videos.length > 1 ? 's' : ''} • $_sourceLabel', + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + ), + ), + ], + ), + ), + ], + ), + actions: [ + IconButton( + tooltip: 'Actualiser', + onPressed: _loading ? null : _loadAllVideos, + icon: Icon(Icons.refresh), + ), + PopupMenuButton( + tooltip: 'Options de la chaîne', + color: Theme.of(context).colorScheme.surfaceContainerHighest, + onSelected: (value) { + if (value == 'hide') { + _hideChannel(); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'hide', + child: Row( + children: [ + Icon(Icons.visibility_off_outlined), + SizedBox(width: 10), + Text('Ne plus me proposer cette chaîne'), + ], + ), + ), + ], + ), + ], + ), + body: SafeArea( + child: Column( + children: [ + _ChannelInfoToggle( + expanded: _channelInfoExpanded, + onPressed: () { + setState(() { + _channelInfoExpanded = !_channelInfoExpanded; + }); + }, + ), + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: _channelInfoExpanded + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ChannelInfoCard( + channel: widget.channel, + details: _channelDetails, + detailsLoading: _detailsLoading, + loadedLikeCount: _loadedLikeCount, + likesMeasuredVideos: _likesMeasuredVideos, + loadedCommentCount: _loadedCommentCount, + commentsMeasuredVideos: _commentsMeasuredVideos, + catalogueComplete: _complete, + oldestLoadedVideoDate: _oldestLoadedVideoDate, + ), + _ChannelCatalogueSummary( + loadedVideos: _videos.length, + loadedViews: _loadedViewCount, + viewsMeasuredVideos: _viewsMeasuredVideos, + averageViews: _averageViewCount, + loadedLikes: _loadedLikeCount, + loadedDuration: _loadedDuration, + newestDate: _newestLoadedVideoDate, + oldestDate: _oldestLoadedVideoDate, + loading: _loading, + ), + ], + ) + : const SizedBox.shrink(), + ), + _ChannelToolbar( + sort: _sort, + loading: _loading, + onSortChanged: (value) { + if (value == _sort) { + return; + } + + setState(() { + _sort = value; + }); + + // The public YouTube channel endpoint itself knows how to + // order newest/popular/oldest. Reload when using it so a sort + // still changes the list even if some metadata fields are + // absent. + if (_usingPublicSource) { + unawaited( + _loadAllVideos(), + ); + } + }, + ), + Expanded(child: _buildBody(videos)), + ], + ), + ), + ); + } + + Widget _buildBody(List videos) { + if (_loading && videos.isEmpty) { + return Center( + child: Column( + mainAxisSize: + MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 14), + Text( + 'Chargement et enrichissement des vidéos…', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + ), + ), + SizedBox(height: 5), + Text( + 'Durée • vues • likes • date • description', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.52), + fontSize: 11, + ), + ), + ], + ), + ); + } + + if (_error != null && + videos.isEmpty) { + return Center( + child: Padding( + padding: + EdgeInsets.all(24), + child: Column( + mainAxisSize: + MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 52, + color: + Colors.redAccent, + ), + SizedBox(height: 14), + Text( + _error!, + textAlign: + TextAlign.center, + style: + TextStyle( + color: + Colors.redAccent, + ), + ), + SizedBox(height: 18), + FilledButton.icon( + onPressed: + _loadAllVideos, + icon: + Icon( + Icons.refresh, + ), + label: + Text( + 'Réessayer', + ), + ), + ], + ), + ), + ); + } + + if (videos.isEmpty) { + return Center( + child: Text( + 'Aucune vidéo publique trouvée.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + ), + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final width = + constraints.maxWidth; + + final columns = width >= 1500 + ? 4 + : width >= 1080 + ? 3 + : width >= 720 + ? 2 + : 1; + + final cardHeight = + columns == 1 + ? 350.0 + : 360.0; + + return Column( + children: [ + Expanded( + child: RefreshIndicator( + onRefresh: _loadAllVideos, + child: GridView.builder( + key: ValueKey<_ChannelSort>( + _sort, + ), + physics: + AlwaysScrollableScrollPhysics(), + padding: + EdgeInsets.fromLTRB( + 18, + 10, + 18, + 20, + ), + itemCount: videos.length, + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + crossAxisSpacing: 14, + mainAxisSpacing: 14, + mainAxisExtent: cardHeight, + ), + itemBuilder: + (context, index) { + final video = + videos[index]; + + return _ChannelVideoCard( + video: video, + rank: index + 1, + onTap: () => + _openVideo(video), + ); + }, + ), + ), + ), + if (_usingPublicSource && + (_publicHasMore || _loadingMore)) + SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + 18, + 10, + 18, + 14, + ), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _loadingMore + ? null + : _loadMorePublicVideos, + icon: _loadingMore + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Icon( + Icons.expand_more, + ), + label: Text( + _loadingMore + ? 'Chargement…' + : 'Charger 30 vidéos de plus', + ), + ), + ), + ), + ), + ], + ); + }, + ); + } + + String _formatCount(int value) { + if (value >= 1000000000) { + return '${(value / 1000000000).toStringAsFixed(value >= 10000000000 ? 0 : 1)} Md'; + } + if (value >= 1000000) { + return '${(value / 1000000).toStringAsFixed(value >= 10000000 ? 0 : 1)} M'; + } + if (value >= 1000) { + return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)} k'; + } + return '$value'; + } + + String _formatDate(DateTime value) { + return '${value.day.toString().padLeft(2, '0')}/' + '${value.month.toString().padLeft(2, '0')}/' + '${value.year}'; + } +} + +class _ChannelInfoToggle extends StatelessWidget { + const _ChannelInfoToggle({ + required this.expanded, + required this.onPressed, + }); + + final bool expanded; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Material( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + child: Row( + children: [ + Icon( + expanded + ? Icons.expand_less_rounded + : Icons.info_outline_rounded, + size: 21, + color: colors.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + expanded + ? 'Masquer les infos' + : 'Infos de la chaîne', + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + ), + Icon( + expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + color: colors.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ChannelCatalogueSummary + extends StatelessWidget { + const _ChannelCatalogueSummary({ + required this.loadedVideos, + required this.loadedViews, + required this.viewsMeasuredVideos, + required this.averageViews, + required this.loadedLikes, + required this.loadedDuration, + required this.newestDate, + required this.oldestDate, + required this.loading, + }); + + final int loadedVideos; + final int loadedViews; + final int viewsMeasuredVideos; + final int averageViews; + final int loadedLikes; + final Duration loadedDuration; + final DateTime? newestDate; + final DateTime? oldestDate; + final bool loading; + + @override + Widget build(BuildContext context) { + if (loadedVideos == 0 && + loading) { + return SizedBox.shrink(); + } + + return Padding( + padding: + EdgeInsets.fromLTRB( + 14, + 7, + 14, + 1, + ), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _CatalogueMetric( + icon: + Icons.video_library_outlined, + label: 'Chargées', + value: '$loadedVideos', + ), + if (viewsMeasuredVideos > 0) + _CatalogueMetric( + icon: + Icons.visibility_outlined, + label: 'Vues cumulées', + value: + _compact(loadedViews), + ), + if (averageViews > 0) + _CatalogueMetric( + icon: + Icons.analytics_outlined, + label: 'Moyenne / vidéo', + value: + _compact(averageViews), + ), + if (loadedLikes > 0) + _CatalogueMetric( + icon: + Icons.thumb_up_alt_outlined, + label: 'Likes cumulés', + value: + _compact(loadedLikes), + ), + if (loadedDuration > + Duration.zero) + _CatalogueMetric( + icon: + Icons.schedule_outlined, + label: 'Durée chargée', + value: _duration( + loadedDuration, + ), + ), + if (newestDate != null && + oldestDate != null) + _CatalogueMetric( + icon: + Icons.date_range_outlined, + label: 'Période', + value: + '${_year(oldestDate!)} → ${_year(newestDate!)}', + ), + ], + ), + ); + } + + static String _compact(int value) { + if (value >= 1000000000) { + return '${(value / 1000000000).toStringAsFixed(value >= 10000000000 ? 0 : 1)} Md'; + } + if (value >= 1000000) { + return '${(value / 1000000).toStringAsFixed(value >= 10000000 ? 0 : 1)} M'; + } + if (value >= 1000) { + return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)} k'; + } + return '$value'; + } + + static String _duration( + Duration value, + ) { + if (value.inHours >= 24) { + final days = + value.inHours / 24; + + return '${days.toStringAsFixed(days >= 10 ? 0 : 1)} j'; + } + + if (value.inHours > 0) { + return '${value.inHours} h'; + } + + return '${value.inMinutes} min'; + } + + static String _year(DateTime value) { + return '${value.year}'; + } +} + +class _CatalogueMetric + extends StatelessWidget { + const _CatalogueMetric({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: + EdgeInsets.symmetric( + horizontal: 11, + vertical: 8, + ), + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.surfaceContainer, + borderRadius: + BorderRadius.circular(11), + border: Border.all( + color: + Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + child: Row( + mainAxisSize: + MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: + Color(0xFFFF8E87), + ), + SizedBox(width: 7), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + label, + style: + TextStyle( + color: + Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 9.5, + ), + ), + Text( + value, + style: + TextStyle( + color: + Theme.of(context).colorScheme.onSurface, + fontSize: 12, + fontWeight: + FontWeight.w700, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _ChannelVideoCard + extends StatelessWidget { + const _ChannelVideoCard({ + required this.video, + required this.rank, + required this.onTap, + }); + + final VideoItem video; + final int rank; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final description = + video.description + .replaceAll( + RegExp(r'\s+'), + ' ', + ) + .trim(); + + return Material( + color: + Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: + BorderRadius.circular(15), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + video.thumbnailUrl, + fit: BoxFit.cover, + errorBuilder: + (_, __, ___) => + ColoredBox( + color: + Theme.of(context).colorScheme.surfaceContainerHigh, + child: Center( + child: Icon( + Icons + .broken_image_outlined, + color: + Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.52), + ), + ), + ), + ), + Positioned( + left: 8, + top: 8, + child: DecoratedBox( + decoration: + BoxDecoration( + color: + Colors.black87, + borderRadius: + BorderRadius + .circular(7), + ), + child: Padding( + padding: + const EdgeInsets + .symmetric( + horizontal: 7, + vertical: 4, + ), + child: Text( + '#$rank', + style: + TextStyle( + fontSize: 10, + fontWeight: + FontWeight + .w800, + ), + ), + ), + ), + ), + if (video.duration != null && + video.duration! > + Duration.zero) + Positioned( + right: 7, + bottom: 7, + child: DecoratedBox( + decoration: + BoxDecoration( + color: + Colors.black87, + borderRadius: + BorderRadius + .circular(5), + ), + child: Padding( + padding: + const EdgeInsets + .symmetric( + horizontal: 6, + vertical: 3, + ), + child: Text( + video.durationLabel, + style: + TextStyle( + fontSize: 10.5, + fontWeight: + FontWeight + .w700, + ), + ), + ), + ), + ), + ], + ), + ), + Expanded( + child: Padding( + padding: + EdgeInsets.fromLTRB( + 12, + 10, + 12, + 10, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + video.title, + maxLines: 2, + overflow: + TextOverflow.ellipsis, + style: + TextStyle( + fontSize: 14.5, + height: 1.25, + fontWeight: + FontWeight.w700, + ), + ), + SizedBox(height: 7), + Wrap( + spacing: 10, + runSpacing: 5, + children: [ + if (video.viewCount != + null && + video.viewCount! > 0) + _MiniVideoStat( + icon: + Icons.visibility_outlined, + text: + '${_compact(video.viewCount!)} vues', + ), + if (video.likeCount != + null && + video.likeCount! > 0) + _MiniVideoStat( + icon: + Icons.thumb_up_alt_outlined, + text: + '${_compact(video.likeCount!)} likes', + ), + if (video.commentCount != + null) + _MiniVideoStat( + icon: + Icons.chat_bubble_outline, + text: + '${_compact(video.commentCount!)} commentaires', + ), + ], + ), + if (video.uploadDate != + null) ...[ + SizedBox(height: 7), + Text( + 'Publiée le ${_date(video.uploadDate!)}', + style: + TextStyle( + color: + Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 10.5, + ), + ), + ], + if (description.isNotEmpty) ...[ + SizedBox(height: 7), + Expanded( + child: Text( + description, + maxLines: 3, + overflow: + TextOverflow.ellipsis, + style: + TextStyle( + color: + Color(0x73FFFFFF), + fontSize: 11, + height: 1.32, + ), + ), + ), + ] else + Spacer(), + ], + ), + ), + ), + ], + ), + ), + ); + } + + static String _compact(int value) { + if (value >= 1000000000) { + return '${(value / 1000000000).toStringAsFixed(value >= 10000000000 ? 0 : 1)} Md'; + } + if (value >= 1000000) { + return '${(value / 1000000).toStringAsFixed(value >= 10000000 ? 0 : 1)} M'; + } + if (value >= 1000) { + return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)} k'; + } + return '$value'; + } + + static String _date(DateTime value) { + return '${value.day.toString().padLeft(2, '0')}/' + '${value.month.toString().padLeft(2, '0')}/' + '${value.year}'; + } +} + +class _MiniVideoStat + extends StatelessWidget { + const _MiniVideoStat({ + required this.icon, + required this.text, + }); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: + MainAxisSize.min, + children: [ + Icon( + icon, + size: 13, + color: + Color(0xFFFF938D), + ), + SizedBox(width: 4), + Text( + text, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + fontSize: 10.5, + fontWeight: + FontWeight.w500, + ), + ), + ], + ); + } +} + +class _ChannelInfoCard extends StatelessWidget { + const _ChannelInfoCard({ + required this.channel, + required this.details, + required this.detailsLoading, + required this.loadedLikeCount, + required this.likesMeasuredVideos, + required this.loadedCommentCount, + required this.commentsMeasuredVideos, + required this.catalogueComplete, + required this.oldestLoadedVideoDate, + }); + + final SubscriptionChannel channel; + final ChannelDetails? details; + final bool detailsLoading; + final int loadedLikeCount; + final int likesMeasuredVideos; + final int loadedCommentCount; + final int commentsMeasuredVideos; + final bool catalogueComplete; + final DateTime? oldestLoadedVideoDate; + + @override + Widget build(BuildContext context) { + final value = details; + + final description = value?.description.trim().isNotEmpty == true + ? value!.description.trim() + : channel.description.trim(); + + return Container( + width: double.infinity, + margin: EdgeInsets.fromLTRB(14, 12, 14, 6), + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 34, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundImage: (value?.thumbnailUrl.isNotEmpty == true) + ? NetworkImage(value!.thumbnailUrl) + : channel.thumbnailUrl.isNotEmpty + ? NetworkImage(channel.thumbnailUrl) + : null, + child: (value?.thumbnailUrl.isNotEmpty == true || + channel.thumbnailUrl.isNotEmpty) + ? null + : Icon( + Icons.account_circle, + size: 42, + ), + ), + SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value?.title.isNotEmpty == true + ? value!.title + : channel.title, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + if (value?.country != null && + value!.country!.trim().isNotEmpty) ...[ + SizedBox(height: 4), + Text( + 'Pays : ${value.country}', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12, + ), + ), + ], + if (description.isNotEmpty) ...[ + SizedBox(height: 8), + Text( + description, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + fontSize: 12, + height: 1.35, + ), + ), + ], + ], + ), + ), + if (detailsLoading) + Padding( + padding: EdgeInsets.only(left: 8, top: 4), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + ], + ), + SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (value?.publishedAt != null) + _ChannelStatChip( + icon: Icons.calendar_month_outlined, + label: 'Créée le', + value: _formatDate(value!.publishedAt!), + ) + else if (value?.joinDateLabel != null && + value!.joinDateLabel!.isNotEmpty) + _ChannelStatChip( + icon: Icons.calendar_month_outlined, + label: 'Chaîne créée', + value: value.joinDateLabel!, + ) + else if (catalogueComplete && + oldestLoadedVideoDate != null) + _ChannelStatChip( + icon: Icons.history_rounded, + label: 'Plus ancienne vidéo', + value: _formatDate(oldestLoadedVideoDate!), + ), + if (value?.subscriberCount != null) + _ChannelStatChip( + icon: Icons.people_alt_outlined, + label: 'Abonnés', + value: _formatCount(value!.subscriberCount!), + ) + else if (value?.hiddenSubscriberCount == true) + _ChannelStatChip( + icon: Icons.people_alt_outlined, + label: 'Abonnés', + value: 'masqués', + ), + if (value?.viewCount != null) + _ChannelStatChip( + icon: Icons.visibility_outlined, + label: 'Vues chaîne', + value: _formatCount(value!.viewCount!), + ), + if (value?.videoCount != null) + _ChannelStatChip( + icon: Icons.video_library_outlined, + label: 'Vidéos publiques', + value: _formatCount(value!.videoCount!), + ), + if (likesMeasuredVideos > 0) + _ChannelStatChip( + icon: Icons.thumb_up_alt_outlined, + label: catalogueComplete + ? 'Likes cumulés' + : 'Likes chargés', + value: _formatCount(loadedLikeCount), + subtitle: + '$likesMeasuredVideos vidéo${likesMeasuredVideos > 1 ? 's' : ''} mesurée${likesMeasuredVideos > 1 ? 's' : ''}', + showInfo: true, + ), + if (commentsMeasuredVideos > 0) + _ChannelStatChip( + icon: Icons.mode_comment_outlined, + label: catalogueComplete + ? 'Commentaires cumulés' + : 'Commentaires chargés', + value: _formatCount(loadedCommentCount), + subtitle: + '$commentsMeasuredVideos vidéo${commentsMeasuredVideos > 1 ? 's' : ''} mesurée${commentsMeasuredVideos > 1 ? 's' : ''}', + ), + ], + ), + if (likesMeasuredVideos > 0) ...[ + SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 15, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + ), + SizedBox(width: 6), + Expanded( + child: Text( + 'YouTube ne fournit pas un compteur de likes global au niveau de la chaîne. ' + 'YouTube Personal additionne donc les likes publics des vidéos chargées. ' + 'Ce calcul fonctionne aussi sans connexion Google.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 10.5, + height: 1.3, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } + + static String _formatCount(int value) { + if (value >= 1000000000) { + return '${(value / 1000000000).toStringAsFixed(value >= 10000000000 ? 0 : 1)} Md'; + } + + if (value >= 1000000) { + return '${(value / 1000000).toStringAsFixed(value >= 10000000 ? 0 : 1)} M'; + } + + if (value >= 1000) { + return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)} k'; + } + + return '$value'; + } + + static String _formatDate(DateTime value) { + return '${value.day.toString().padLeft(2, '0')}/' + '${value.month.toString().padLeft(2, '0')}/' + '${value.year}'; + } +} + +class _ChannelStatChip extends StatelessWidget { + const _ChannelStatChip({ + required this.icon, + required this.label, + required this.value, + this.subtitle, + this.showInfo = false, + }); + + final IconData icon; + final String label; + final String value; + final String? subtitle; + final bool showInfo; + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints( + minWidth: 132, + ), + padding: EdgeInsets.symmetric( + horizontal: 11, + vertical: 9, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 18, + color: Color(0xFFFF766F), + ), + SizedBox(width: 8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 10.5, + ), + ), + ), + if (showInfo) ...[ + SizedBox(width: 4), + Icon( + Icons.info_outline, + size: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.52), + ), + ], + ], + ), + SizedBox(height: 1), + Text( + value, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + if (subtitle != null) ...[ + SizedBox(height: 1), + Text( + subtitle!, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 9.5, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _ChannelToolbar + extends StatelessWidget { + const _ChannelToolbar({ + required this.sort, + required this.loading, + required this.onSortChanged, + }); + + final _ChannelSort sort; + final bool loading; + final ValueChanged<_ChannelSort> + onSortChanged; + + String get _description { + switch (sort) { + case _ChannelSort.newest: + return 'Date de publication • plus récente en premier'; + case _ChannelSort.mostViewed: + return 'Vues publiques YouTube • plus vue en premier'; + case _ChannelSort.mostLiked: + return 'Likes publics • plus aimée en premier'; + case _ChannelSort.oldest: + return 'Date de publication • plus ancienne en premier'; + } + } + + @override + Widget build(BuildContext context) { + return Container( + margin: + EdgeInsets.fromLTRB( + 14, + 8, + 14, + 4, + ), + padding: + EdgeInsets.symmetric( + horizontal: 14, + vertical: 9, + ), + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.surface, + borderRadius: + BorderRadius.circular(13), + border: Border.all( + color: + Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + child: Row( + children: [ + Icon( + Icons.sort_rounded, + size: 19, + color: + Color(0xFFFF8E87), + ), + SizedBox(width: 8), + Text( + 'Trier', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + fontSize: 12, + ), + ), + SizedBox(width: 10), + DropdownButton<_ChannelSort>( + value: sort, + underline: + SizedBox.shrink(), + onChanged: loading + ? null + : (value) { + if (value != null) { + onSortChanged(value); + } + }, + items: [ + DropdownMenuItem( + value: + _ChannelSort.newest, + child: + Text('Plus récentes'), + ), + DropdownMenuItem( + value: + _ChannelSort.mostViewed, + child: + Text('Plus vues'), + ), + DropdownMenuItem( + value: + _ChannelSort.mostLiked, + child: + Text('Plus aimées'), + ), + DropdownMenuItem( + value: + _ChannelSort.oldest, + child: + Text('Plus anciennes'), + ), + ], + ), + SizedBox(width: 14), + Expanded( + child: Text( + _description, + maxLines: 1, + overflow: + TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 10.5, + ), + ), + ), + if (loading) ...[ + SizedBox(width: 10), + SizedBox( + width: 14, + height: 14, + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ], + ], + ), + ); + } +} + diff --git a/lib/screens/channels_page.dart b/lib/screens/channels_page.dart new file mode 100644 index 0000000..0f66a4d --- /dev/null +++ b/lib/screens/channels_page.dart @@ -0,0 +1,536 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../models/channel_rank_item.dart'; +import '../services/channel_catalog_service.dart'; +import '../services/hidden_channels_service.dart'; +import '../services/library_service.dart'; +import 'channel_videos_page.dart'; + +class ChannelsPage extends StatefulWidget { + const ChannelsPage({super.key}); + + @override + State createState() => _ChannelsPageState(); +} + +class _ChannelsPageState extends State { + List _channels = []; + bool _loading = true; + String? _error; + String _filter = ''; + Timer? _refreshDebounce; + + @override + void initState() { + super.initState(); + + LibraryService.instance.watchRevision.addListener(_onWatchChanged); + HiddenChannelsService.instance.revision.addListener(_onHiddenChanged); + + _load(); + } + + @override + void dispose() { + _refreshDebounce?.cancel(); + LibraryService.instance.watchRevision.removeListener(_onWatchChanged); + HiddenChannelsService.instance.revision.removeListener(_onHiddenChanged); + super.dispose(); + } + + void _onWatchChanged() { + _scheduleRefresh(); + } + + void _onHiddenChanged() { + _scheduleRefresh(); + } + + void _scheduleRefresh() { + _refreshDebounce?.cancel(); + _refreshDebounce = Timer( + Duration(milliseconds: 300), + () { + if (mounted) { + unawaited(_load(force: true, quiet: true)); + } + }, + ); + } + + Future _load({ + bool force = false, + bool quiet = false, + }) async { + if (!quiet) { + setState(() { + _loading = true; + _error = null; + }); + } + + try { + final channels = + await ChannelCatalogService.instance.load(force: force); + + if (!mounted) { + return; + } + + setState(() { + _channels = channels; + _loading = false; + _error = null; + }); + } catch (error) { + if (!mounted) { + return; + } + + setState(() { + _loading = false; + _error = error.toString(); + }); + } + } + + List get _filteredChannels { + final query = _filter.trim().toLowerCase(); + + if (query.isEmpty) { + return _channels; + } + + return _channels + .where( + (item) => + item.channel.title.toLowerCase().contains(query) || + (item.topVideoTitle ?? '').toLowerCase().contains(query), + ) + .toList(growable: false); + } + + Future _open(ChannelRankItem item) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelVideosPage(channel: item.channel), + ), + ); + + if (mounted) { + await _load(force: true, quiet: true); + } + } + + Future _hide(ChannelRankItem item) async { + await HiddenChannelsService.instance.hide(item.channel); + + if (!mounted) { + return; + } + + setState(() { + _channels = _channels + .where( + (candidate) => + candidate.channel.channelId != item.channel.channelId, + ) + .toList(growable: false); + }); + + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + + final controller = messenger.showSnackBar( + SnackBar( + duration: Duration(seconds: 3), + content: Text( + '${item.channel.title} ne sera plus proposée.', + ), + action: SnackBarAction( + label: 'ANNULER', + onPressed: () { + unawaited(_restore(item)); + }, + ), + ), + ); + + // Sur certaines configurations desktop/accessibilité, un SnackBar avec + // action peut rester visible au-delà de sa durée. On impose donc aussi + // sa fermeture côté application. + unawaited( + Future.delayed(Duration(seconds: 3)).then((_) { + if (!mounted) { + return; + } + controller.close(); + }), + ); + } + + Future _restore(ChannelRankItem item) async { + await HiddenChannelsService.instance.restore(item.channel); + if (mounted) { + await _load(force: true, quiet: true); + } + } + + @override + Widget build(BuildContext context) { + final channels = _filteredChannels; + final canGoBack = Navigator.of(context).canPop(); + + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: SafeArea( + child: RefreshIndicator( + onRefresh: () => _load(force: true), + child: CustomScrollView( + physics: AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: _ChannelsHeader( + count: _channels.length, + loading: _loading, + canGoBack: canGoBack, + onRefresh: () => _load(force: true), + onFilterChanged: (value) { + setState(() { + _filter = value; + }); + }, + ), + ), + if (_loading && _channels.isEmpty) + SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_error != null && _channels.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _ChannelsError( + message: _error!, + onRetry: _load, + ), + ) + else if (channels.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _EmptyChannels(), + ) + else + SliverPadding( + padding: EdgeInsets.fromLTRB(18, 4, 18, 28), + sliver: SliverList.separated( + itemCount: channels.length, + separatorBuilder: (_, __) => SizedBox(height: 8), + itemBuilder: (context, index) { + final item = channels[index]; + + return _ChannelRankCard( + rank: index + 1, + item: item, + onTap: () => _open(item), + onHide: () => _hide(item), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ChannelsHeader extends StatelessWidget { + const _ChannelsHeader({ + required this.count, + required this.loading, + required this.canGoBack, + required this.onRefresh, + required this.onFilterChanged, + }); + + final int count; + final bool loading; + final bool canGoBack; + final VoidCallback onRefresh; + final ValueChanged onFilterChanged; + + @override + Widget build(BuildContext context) { + final wide = MediaQuery.sizeOf(context).width >= 800; + + return Padding( + padding: EdgeInsets.fromLTRB( + wide ? 30 : 18, + 24, + wide ? 30 : 18, + 14, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (canGoBack) ...[ + IconButton( + tooltip: 'Retour', + onPressed: () => Navigator.of(context).pop(), + icon: Icon(Icons.arrow_back), + ), + SizedBox(width: 2), + ], + Icon( + Icons.video_library_outlined, + size: 31, + color: Color(0xFFFF4E45), + ), + SizedBox(width: 10), + Expanded( + child: Text( + 'Chaînes', + style: TextStyle( + fontSize: 25, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + tooltip: 'Actualiser', + onPressed: loading ? null : onRefresh, + icon: Icon(Icons.refresh), + ), + ], + ), + SizedBox(height: 7), + Text( + count == 0 + ? 'Les chaînes apparaissent à partir de ce que tu regardes réellement.' + : '$count chaîne${count > 1 ? 's' : ''} • triées selon le nombre de tes propres vues', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 13, + ), + ), + SizedBox(height: 16), + TextField( + onChanged: onFilterChanged, + decoration: InputDecoration( + hintText: 'Rechercher une chaîne', + prefixIcon: Icon(Icons.search), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerLow, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + ), + ), + ], + ), + ); + } +} + +class _ChannelRankCard extends StatelessWidget { + const _ChannelRankCard({ + required this.rank, + required this.item, + required this.onTap, + required this.onHide, + }); + + final int rank; + final ChannelRankItem item; + final VoidCallback onTap; + final VoidCallback onHide; + + @override + Widget build(BuildContext context) { + final channel = item.channel; + + return Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(15), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(15), + child: Padding( + padding: EdgeInsets.all(13), + child: Row( + children: [ + SizedBox( + width: 32, + child: Text( + '#$rank', + style: TextStyle( + color: Color(0xFFFF766F), + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + CircleAvatar( + radius: 27, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundImage: channel.thumbnailUrl.isNotEmpty + ? NetworkImage(channel.thumbnailUrl) + : null, + child: channel.thumbnailUrl.isEmpty + ? Icon(Icons.account_circle, size: 31) + : null, + ), + SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + channel.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 5), + Text( + '${item.personalViewCount} de mes vue${item.personalViewCount > 1 ? 's' : ''}' + ' • ${item.distinctVideoCount} vidéo${item.distinctVideoCount > 1 ? 's' : ''} différente${item.distinctVideoCount > 1 ? 's' : ''}', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + fontSize: 12, + ), + ), + if (item.topVideoTitle != null) ...[ + SizedBox(height: 3), + Text( + 'La plus regardée par moi (${item.topVideoPersonalViews}×) : ${item.topVideoTitle}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 11, + ), + ), + ], + ], + ), + ), + PopupMenuButton( + tooltip: 'Options', + color: Theme.of(context).colorScheme.surfaceContainerHighest, + onSelected: (value) { + if (value == 'hide') { + onHide(); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'hide', + child: Row( + children: [ + Icon(Icons.visibility_off_outlined), + SizedBox(width: 10), + Text('Cette chaîne ne m’intéresse pas'), + ], + ), + ), + ], + icon: Icon( + Icons.more_vert, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ChannelsError extends StatelessWidget { + const _ChannelsError({ + required this.message, + required this.onRetry, + }); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 52, + color: Colors.redAccent, + ), + SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.redAccent), + ), + SizedBox(height: 16), + FilledButton.icon( + onPressed: onRetry, + icon: Icon(Icons.refresh), + label: Text('Réessayer'), + ), + ], + ), + ), + ); + } +} + +class _EmptyChannels extends StatelessWidget { + const _EmptyChannels(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.video_library_outlined, + size: 68, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.44), + ), + SizedBox(height: 16), + Text( + 'Pas encore assez de chaînes', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 7), + Text( + 'Regarde quelques vidéos. Le classement sera construit à partir de tes propres vues.', + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78)), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/home_page.dart b/lib/screens/home_page.dart new file mode 100644 index 0000000..1d9177f --- /dev/null +++ b/lib/screens/home_page.dart @@ -0,0 +1,2589 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../models/radar_recommendation.dart'; +import '../models/video_item.dart'; +import '../services/hidden_channels_service.dart'; +import '../services/channel_preview_metadata_service.dart'; +import '../services/library_service.dart'; +import '../services/radar_service.dart'; +import 'video_player_page.dart'; + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => + _HomePageState(); +} + +class _HomePageState + extends State { + RadarSessionMode _mode = + RadarSessionMode.normal; + + static const List + _interestOptions = [ + 'Tous', + 'Politique', + 'Finance', + 'Technologie & IA', + 'Jeux vidéo', + 'Culture & histoire', + 'Science & espace', + 'OVNI & mystères', + 'Auto & transport', + 'Musique', + 'Société & actualité', + 'Tutoriels & apprentissage', + ]; + + String _selectedInterest = 'Tous'; + String? _selectedRadarCategory; + RadarFeed? _feed; + bool _loadingRadar = false; + String? _radarError; + Timer? _refreshDebounce; + + @override + void initState() { + super.initState(); + + LibraryService.instance.history + .addListener(_onLibraryChanged); + LibraryService.instance.favorites + .addListener(_onLibraryChanged); + HiddenChannelsService.instance.revision + .addListener(_onLibraryChanged); + + WidgetsBinding.instance + .addPostFrameCallback( + (_) => _refreshRadar(), + ); + } + + @override + void dispose() { + _refreshDebounce?.cancel(); + + LibraryService.instance.history + .removeListener(_onLibraryChanged); + LibraryService.instance.favorites + .removeListener(_onLibraryChanged); + HiddenChannelsService.instance.revision + .removeListener(_onLibraryChanged); + + super.dispose(); + } + + void _onLibraryChanged() { + _refreshDebounce?.cancel(); + + _refreshDebounce = Timer( + const Duration(milliseconds: 650), + () { + if (mounted) { + unawaited( + _refreshRadar(force: true), + ); + } + }, + ); + } + + Future _refreshRadar({ + bool force = false, + }) async { + if (_loadingRadar) { + return; + } + + final history = + LibraryService.instance.history.value; + final favorites = + LibraryService.instance.favorites.value; + + if (history.isEmpty && + favorites.isEmpty) { + if (mounted) { + setState(() { + _feed = null; + _radarError = null; + _loadingRadar = false; + }); + } + return; + } + + setState(() { + _loadingRadar = true; + _radarError = null; + }); + + try { + final feed = + await RadarService.instance.generate( + mode: _mode, + force: force, + interest: _selectedInterest, + ); + + if (!mounted) { + return; + } + + setState(() { + _feed = feed; + + if (feed.categories.isEmpty) { + _selectedRadarCategory = null; + } else if (_selectedRadarCategory == null || + !feed.categories.containsKey( + _selectedRadarCategory, + )) { + _selectedRadarCategory = + feed.categories.keys.first; + } + + _loadingRadar = false; + }); + } catch (error) { + if (!mounted) { + return; + } + + setState(() { + _loadingRadar = false; + _radarError = + 'Le Radar n’a pas pu reconstruire ' + 'les propositions.'; + }); + + debugPrint( + '[RADAR] génération échouée: $error', + ); + } + } + + Future _changeInterest( + String interest, + ) async { + if (interest == _selectedInterest) { + return; + } + + setState(() { + _selectedInterest = interest; + }); + + await _refreshRadar( + force: true, + ); + } + + Future _changeMode( + RadarSessionMode mode, + ) async { + if (mode == _mode) { + return; + } + + setState(() { + _mode = mode; + }); + + await _refreshRadar(); + } + + Future _dismiss( + RadarRecommendation item, + ) async { + await RadarService.instance + .dismissVideo(item.video.id); + + if (!mounted) { + return; + } + + _removeFromFeed(item.video.id); + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text( + 'Pas maintenant. Cette vidéo disparaît ' + 'du Radar pendant un moment.', + ), + duration: Duration(seconds: 2), + ), + ); + } + + Future _reduceAuthor( + RadarRecommendation item, + ) async { + await RadarService.instance + .reduceAuthor(item.video.author); + + if (!mounted) { + return; + } + + _removeAuthorFromFeed( + item.video.author, + ); + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + 'Le Radar montrera moins de vidéos de ' + '${item.video.author}.', + ), + duration: const Duration( + seconds: 2, + ), + ), + ); + } + + void _removeFromFeed( + String videoId, + ) { + final current = _feed; + + if (current == null) { + return; + } + + setState(() { + _feed = RadarFeed( + mode: current.mode, + generatedAt: current.generatedAt, + profileTopics: + current.profileTopics, + primary: current.primary + .where( + (item) => + item.video.id != videoId, + ) + .toList(growable: false), + continueTopic: + current.continueTopic + .where( + (item) => + item.video.id != + videoId, + ) + .toList(growable: false), + discovery: current.discovery + .where( + (item) => + item.video.id != videoId, + ) + .toList(growable: false), + categories: { + for (final entry + in current.categories.entries) + entry.key: entry.value + .where( + (item) => + item.video.id != videoId, + ) + .toList( + growable: false, + ), + }, + ); + }); + } + + void _removeAuthorFromFeed( + String author, + ) { + final current = _feed; + + if (current == null) { + return; + } + + final normalized = + author.trim().toLowerCase(); + + bool keep( + RadarRecommendation item, + ) { + return item.video.author + .trim() + .toLowerCase() != + normalized; + } + + setState(() { + _feed = RadarFeed( + mode: current.mode, + generatedAt: current.generatedAt, + profileTopics: + current.profileTopics, + primary: + current.primary + .where(keep) + .toList( + growable: false, + ), + continueTopic: + current.continueTopic + .where(keep) + .toList( + growable: false, + ), + discovery: + current.discovery + .where(keep) + .toList( + growable: false, + ), + categories: { + for (final entry + in current.categories.entries) + entry.key: entry.value + .where(keep) + .toList( + growable: false, + ), + }, + ); + }); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: ValueListenableBuilder< + List>( + valueListenable: + LibraryService.instance.history, + builder: ( + context, + history, + child, + ) { + return ValueListenableBuilder< + List>( + valueListenable: + LibraryService + .instance.favorites, + builder: ( + context, + favorites, + child, + ) { + final continueWatching = + history + .where( + (video) => + LibraryService + .instance + .getPlaybackPosition( + video.id, + ) != + null, + ) + .take(10) + .toList( + growable: false, + ); + + final rediscover = + _buildRediscover( + history, + favorites, + ); + + final feed = _feed; + + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _RadarHeader( + historyCount: + history.length, + favoritesCount: + favorites.length, + mode: _mode, + profileTopics: + feed?.profileTopics ?? + const [], + interests: + _interestOptions, + selectedInterest: + _selectedInterest, + loading: + _loadingRadar, + onInterestChanged: + _changeInterest, + onModeChanged: + _changeMode, + onShuffle: () => + _refreshRadar( + force: true, + ), + ), + ), + + if (feed != null && + feed.primary.isNotEmpty) + SliverToBoxAdapter( + child: _RadarSection( + title: + 'Pour toi maintenant', + subtitle: + _selectedInterest == 'Tous' + ? 'Le meilleur mélange calculé localement pour cette session.' + : 'Sélection actuelle : $_selectedInterest.', + icon: + Icons.radar_rounded, + items: + feed.primary, + accent: + const Color( + 0xFFFF4E45, + ), + onWhy: _showWhy, + onDismiss: + _dismiss, + onReduceAuthor: + _reduceAuthor, + ), + ), + + if (_loadingRadar && + (feed == null || + feed.isEmpty)) + const SliverToBoxAdapter( + child: + _RadarLoadingCard(), + ), + + if (_radarError != null) + SliverToBoxAdapter( + child: + _RadarErrorCard( + text: _radarError!, + onRetry: + _refreshRadar, + ), + ), + + if (continueWatching + .isNotEmpty) + SliverToBoxAdapter( + child: _VideoSection( + title: + 'Continuer à regarder', + subtitle: + 'Reprends exactement là où tu t’étais arrêté.', + videos: + continueWatching, + showProgress: true, + ), + ), + + if (feed != null && + feed.continueTopic + .isNotEmpty) + SliverToBoxAdapter( + child: _RadarSection( + title: + 'Continuer ce sujet', + subtitle: + 'Même fil conducteur, sans répéter exactement ce que tu as déjà vu.', + icon: + Icons.route_rounded, + items: feed + .continueTopic, + accent: + const Color( + 0xFF7CA8FF, + ), + onWhy: _showWhy, + onDismiss: + _dismiss, + onReduceAuthor: + _reduceAuthor, + ), + ), + + if (feed != null && + feed.discovery + .isNotEmpty) + SliverToBoxAdapter( + child: _RadarSection( + title: + 'Quelque chose de différent', + subtitle: + 'Des chaînes nouvelles, mais reliées à tes vrais centres d’intérêt.', + icon: Icons + .explore_outlined, + items: + feed.discovery, + accent: + const Color( + 0xFF7FE0B8, + ), + onWhy: _showWhy, + onDismiss: + _dismiss, + onReduceAuthor: + _reduceAuthor, + ), + ), + + if (rediscover.isNotEmpty) + SliverToBoxAdapter( + child: _VideoSection( + title: + 'À redécouvrir', + subtitle: + 'Des vidéos de ton propre historique qui ont disparu de ton écran depuis un moment.', + videos: + rediscover, + ), + ), + + if (history.isNotEmpty) + SliverToBoxAdapter( + child: _VideoSection( + title: + 'Vues récemment', + subtitle: + 'Tes dernières vidéos regardées.', + videos: history + .take(10) + .toList( + growable: + false, + ), + ), + ), + + if (favorites.isNotEmpty) + SliverToBoxAdapter( + child: _VideoSection( + title: + 'Favoris récents', + subtitle: + 'Tes vidéos enregistrées.', + videos: favorites + .take(10) + .toList( + growable: + false, + ), + ), + ), + + if (history.isEmpty && + favorites.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: _EmptyHome(), + ), + + const SliverToBoxAdapter( + child: + SizedBox(height: 36), + ), + ], + ); + }, + ); + }, + ), + ); + } + + List _buildRediscover( + List history, + List favorites, + ) { + final result = + []; + final used = + {}; + + for (final video + in favorites.skip(6)) { + if (used.add(video.id)) { + result.add(video); + } + + if (result.length >= 8) { + return result; + } + } + + for (final video + in history.skip(15)) { + if (used.add(video.id)) { + result.add(video); + } + + if (result.length >= 8) { + break; + } + } + + return result; + } + + void _showWhy( + RadarRecommendation item, + ) { + showModalBottomSheet( + context: context, + showDragHandle: true, + backgroundColor: + const Color(0xFF181818), + builder: (context) { + return SafeArea( + child: Padding( + padding: + const EdgeInsets.fromLTRB( + 20, + 4, + 20, + 24, + ), + child: Column( + mainAxisSize: + MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + _ScoreBadge( + score: item.score, + compact: false, + ), + const SizedBox( + width: 12, + ), + const Expanded( + child: Text( + 'Pourquoi cette vidéo ?', + style: TextStyle( + fontSize: 20, + fontWeight: + FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 18), + Text( + item.video.title, + maxLines: 2, + overflow: + TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: + FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + item.video.author, + style: const TextStyle( + color: + Colors.white60, + ), + ), + const SizedBox(height: 18), + ...item.reasons.map( + (reason) => Padding( + padding: + const EdgeInsets.only( + bottom: 9, + ), + child: Row( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + const Icon( + Icons + .check_circle_outline, + size: 18, + color: Color( + 0xFF7FE0B8, + ), + ), + const SizedBox( + width: 9, + ), + Expanded( + child: Text( + reason, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: item + .breakdown.entries + .map( + (entry) => + _ScorePartChip( + label: + entry.key, + points: + entry.value, + ), + ) + .toList( + growable: + false, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _RadarHeader + extends StatelessWidget { + const _RadarHeader({ + required this.historyCount, + required this.favoritesCount, + required this.mode, + required this.profileTopics, + required this.interests, + required this.selectedInterest, + required this.loading, + required this.onInterestChanged, + required this.onModeChanged, + required this.onShuffle, + }); + + final int historyCount; + final int favoritesCount; + final RadarSessionMode mode; + final List profileTopics; + final List interests; + final String selectedInterest; + final bool loading; + final ValueChanged + onInterestChanged; + final ValueChanged + onModeChanged; + final VoidCallback onShuffle; + + @override + Widget build(BuildContext context) { + final wide = + MediaQuery.sizeOf(context).width >= + 800; + + return Padding( + padding: EdgeInsets.fromLTRB( + wide ? 30 : 18, + 22, + wide ? 30 : 18, + 8, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.play_circle_fill, + color: + Color(0xFFFF4E45), + size: 36, + ), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'YouTube Personal', + style: TextStyle( + fontSize: 25, + fontWeight: + FontWeight.w700, + ), + ), + ), + FilledButton.tonalIcon( + onPressed: + loading ? null : onShuffle, + icon: loading + ? const SizedBox( + width: 16, + height: 16, + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon( + Icons.shuffle_rounded, + ), + label: Text( + wide + ? 'Mélanger mon Radar' + : 'Mélanger', + ), + ), + ], + ), + const SizedBox(height: 22), + Row( + children: [ + const Icon( + Icons.radar_rounded, + color: + Color(0xFFFF4E45), + size: 28, + ), + const SizedBox(width: 9), + Text( + 'Radar personnel', + style: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith( + fontWeight: + FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 7), + Text( + historyCount == 0 + ? 'Regarde quelques vidéos pour donner du signal au Radar.' + : '$historyCount vidéo${historyCount > 1 ? 's' : ''} ' + 'dans l’historique • ' + '$favoritesCount favori${favoritesCount > 1 ? 's' : ''} ' + '• recommandations classées localement', + style: const TextStyle( + color: Colors.white60, + fontSize: 13, + ), + ), + const SizedBox(height: 15), + const Text( + 'Centres d’intérêt', + style: TextStyle( + color: Colors.white70, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 9), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: interests + .map( + (interest) => Padding( + padding: + const EdgeInsets.only( + right: 8, + ), + child: ChoiceChip( + selected: + interest == + selectedInterest, + label: Text(interest), + onSelected: loading + ? null + : (_) => + onInterestChanged( + interest, + ), + ), + ), + ) + .toList( + growable: false, + ), + ), + ), + const SizedBox(height: 18), + const Text( + 'Humeur de cette session', + style: TextStyle( + fontSize: 13, + fontWeight: + FontWeight.w600, + color: Colors.white70, + ), + ), + const SizedBox(height: 9), + SingleChildScrollView( + scrollDirection: + Axis.horizontal, + child: Row( + children: + RadarSessionMode.values + .map( + (item) => Padding( + padding: + const EdgeInsets + .only( + right: 8, + ), + child: ChoiceChip( + selected: + item == mode, + label: Text( + item.label, + ), + onSelected: + (_) => + onModeChanged( + item, + ), + ), + ), + ) + .toList( + growable: false, + ), + ), + ), + ], + ), + ); + } +} + +class _RadarSection + extends StatelessWidget { + const _RadarSection({ + required this.title, + required this.subtitle, + required this.icon, + required this.items, + required this.accent, + required this.onWhy, + required this.onDismiss, + required this.onReduceAuthor, + }); + + final String title; + final String subtitle; + final IconData icon; + final List items; + final Color accent; + final ValueChanged + onWhy; + final ValueChanged + onDismiss; + final ValueChanged + onReduceAuthor; + + @override + Widget build(BuildContext context) { + final wide = + MediaQuery.sizeOf(context).width >= + 800; + final cardWidth = + wide ? 320.0 : 250.0; + + return Padding( + padding: EdgeInsets.only( + top: 20, + bottom: 5, + left: wide ? 30 : 18, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only( + right: wide ? 30 : 18, + ), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: + BoxDecoration( + color: accent + .withValues( + alpha: 0.14, + ), + borderRadius: + BorderRadius + .circular( + 10, + ), + ), + child: Icon( + icon, + size: 20, + color: accent, + ), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + title, + style: + const TextStyle( + fontSize: 20, + fontWeight: + FontWeight + .w700, + ), + ), + const SizedBox( + height: 3, + ), + Text( + subtitle, + style: + const TextStyle( + color: + Colors.white54, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 13), + _HorizontalVideoRail( + height: wide ? 326 : 292, + itemCount: items.length, + itemWidth: cardWidth, + endPadding: wide ? 30 : 18, + itemBuilder: (context, index) { + final item = items[index]; + + return _RadarVideoCard( + item: item, + accent: accent, + onTap: () => _openVideo( + context, + item.video, + ), + onWhy: () => onWhy(item), + onDismiss: () => + onDismiss(item), + onReduceAuthor: () => + onReduceAuthor(item), + ); + }, + ), + ], + ), + ); + } +} + +enum _RadarCardAction { + why, + dismiss, + reduceAuthor, +} + +class _RadarVideoCard + extends StatelessWidget { + const _RadarVideoCard({ + required this.item, + required this.accent, + required this.onTap, + required this.onWhy, + required this.onDismiss, + required this.onReduceAuthor, + }); + + final RadarRecommendation item; + final Color accent; + final VoidCallback onTap; + final VoidCallback onWhy; + final VoidCallback onDismiss; + final VoidCallback onReduceAuthor; + + @override + Widget build(BuildContext context) { + final video = item.video; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: + BorderRadius.circular(14), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: + BorderRadius.circular( + 12, + ), + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + color: const Color( + 0xFF202020, + ), + child: Image.network( + video.thumbnailUrl, + fit: BoxFit.cover, + errorBuilder: ( + context, + error, + stackTrace, + ) { + return const Center( + child: Icon( + Icons + .broken_image_outlined, + color: + Colors.white38, + size: 38, + ), + ); + }, + ), + ), + const Positioned.fill( + child: + DecoratedBox( + decoration: + BoxDecoration( + gradient: + LinearGradient( + begin: Alignment + .topCenter, + end: Alignment + .bottomCenter, + colors: [ + Color( + 0x22000000, + ), + Color( + 0x77000000, + ), + ], + ), + ), + ), + ), + Positioned( + left: 8, + top: 8, + child: _ScoreBadge( + score: + item.score, + ), + ), + Positioned( + right: 4, + top: 3, + child: PopupMenuButton< + _RadarCardAction>( + tooltip: + 'Options Radar', + color: const Color( + 0xFF252525, + ), + icon: Container( + decoration: + BoxDecoration( + color: Colors + .black54, + shape: BoxShape + .circle, + ), + child: + const Padding( + padding: + EdgeInsets + .all( + 5, + ), + child: Icon( + Icons + .more_vert, + size: 18, + ), + ), + ), + onSelected: + (action) { + switch ( + action) { + case _RadarCardAction + .why: + onWhy(); + break; + case _RadarCardAction + .dismiss: + onDismiss(); + break; + case _RadarCardAction + .reduceAuthor: + onReduceAuthor(); + break; + } + }, + itemBuilder: + (context) => + const [ + PopupMenuItem( + value: + _RadarCardAction + .why, + child: Text( + 'Pourquoi cette vidéo ?', + ), + ), + PopupMenuItem( + value: + _RadarCardAction + .dismiss, + child: Text( + 'Pas maintenant', + ), + ), + PopupMenuItem( + value: + _RadarCardAction + .reduceAuthor, + child: Text( + 'Moins de cette chaîne', + ), + ), + ], + ), + ), + if (video + .durationLabel + .isNotEmpty) + Positioned( + right: 8, + bottom: 8, + child: + _DurationBadge( + text: video + .durationLabel, + ), + ), + Positioned( + left: 8, + bottom: 8, + child: Material( + color: accent + .withValues( + alpha: 0.9, + ), + borderRadius: + BorderRadius + .circular( + 999, + ), + child: InkWell( + onTap: onWhy, + borderRadius: + BorderRadius + .circular( + 999, + ), + child: + const Padding( + padding: + EdgeInsets + .symmetric( + horizontal: + 9, + vertical: 5, + ), + child: Row( + mainAxisSize: + MainAxisSize + .min, + children: [ + Icon( + Icons + .psychology_alt_outlined, + size: 14, + color: Colors + .black87, + ), + SizedBox( + width: 4, + ), + Text( + 'Pourquoi ?', + style: + TextStyle( + color: Colors + .black87, + fontSize: + 11, + fontWeight: + FontWeight + .w700, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Text( + video.title, + maxLines: 2, + overflow: + TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + height: 1.25, + fontWeight: + FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + video.author, + maxLines: 1, + overflow: + TextOverflow + .ellipsis, + style: + const TextStyle( + color: + Colors.white54, + fontSize: 12, + ), + ), + ), + if (item.isNewAuthor) + const Padding( + padding: + EdgeInsets.only( + left: 6, + ), + child: Text( + 'NOUVEAU', + style: TextStyle( + color: Color( + 0xFF7FE0B8, + ), + fontSize: 9, + fontWeight: + FontWeight + .w800, + letterSpacing: + 0.5, + ), + ), + ), + ], + ), + const SizedBox(height: 5), + _RadarPreviewMetadata( + video: video, + ), + if (video.description.trim().isNotEmpty) ...[ + const SizedBox(height: 5), + Text( + video.description + .replaceAll( + RegExp(r'\s+'), + ' ', + ) + .trim(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white38, + fontSize: 10.5, + height: 1.2, + ), + ), + ], + if (item.reasons.isNotEmpty) ...[ + const SizedBox(height: 5), + Text( + item.reasons.first, + maxLines: 1, + overflow: + TextOverflow.ellipsis, + style: TextStyle( + color: accent + .withValues( + alpha: 0.86, + ), + fontSize: 11, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _RadarPreviewMetadata extends StatelessWidget { + const _RadarPreviewMetadata({ + required this.video, + }); + + final VideoItem video; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: ChannelPreviewMetadataService.instance + .getChannel(video.channelId), + builder: (context, snapshot) { + final channel = snapshot.data; + final parts = []; + + if (channel?.subscriberCount != null) { + parts.add( + '${_compact(channel!.subscriberCount!)} abonnés', + ); + } + + if (video.viewCount != null) { + parts.add( + '${_compact(video.viewCount!)} vues', + ); + } + + if (video.likeCount != null) { + parts.add( + '${_compact(video.likeCount!)} likes', + ); + } + + if (parts.isEmpty) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return const SizedBox( + height: 13, + child: Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: 11, + height: 11, + child: CircularProgressIndicator( + strokeWidth: 1.3, + ), + ), + ), + ); + } + + return const SizedBox.shrink(); + } + + return Text( + parts.join(' • '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white54, + fontSize: 10.5, + fontWeight: FontWeight.w500, + ), + ); + }, + ); + } + + static String _compact(int value) { + if (value >= 1000000000) { + return '${(value / 1000000000).toStringAsFixed(value >= 10000000000 ? 0 : 1)} Md'; + } + if (value >= 1000000) { + return '${(value / 1000000).toStringAsFixed(value >= 10000000 ? 0 : 1)} M'; + } + if (value >= 1000) { + return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)} k'; + } + return '$value'; + } +} + +class _ScoreBadge + extends StatelessWidget { + const _ScoreBadge({ + required this.score, + this.compact = true, + }); + + final int score; + final bool compact; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: const Color( + 0xEE111111, + ), + borderRadius: + BorderRadius.circular(999), + border: Border.all( + color: const Color( + 0x55FFFFFF, + ), + ), + ), + child: Padding( + padding: + EdgeInsets.symmetric( + horizontal: + compact ? 8 : 11, + vertical: + compact ? 5 : 7, + ), + child: Text( + '$score%', + style: TextStyle( + color: Colors.white, + fontSize: + compact ? 11 : 15, + fontWeight: + FontWeight.w800, + ), + ), + ), + ); + } +} + +class _ScorePartChip + extends StatelessWidget { + const _ScorePartChip({ + required this.label, + required this.points, + }); + + final String label; + final int points; + + @override + Widget build(BuildContext context) { + final positive = points >= 0; + + return DecoratedBox( + decoration: BoxDecoration( + color: positive + ? const Color( + 0x187FE0B8, + ) + : const Color( + 0x18FF7D7D, + ), + borderRadius: + BorderRadius.circular(999), + border: Border.all( + color: positive + ? const Color( + 0x557FE0B8, + ) + : const Color( + 0x55FF7D7D, + ), + ), + ), + child: Padding( + padding: + const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Text( + '$label ${points >= 0 ? '+' : ''}$points', + style: TextStyle( + fontSize: 12, + color: positive + ? const Color( + 0xFF9CE8C7, + ) + : const Color( + 0xFFFF9A9A, + ), + ), + ), + ), + ); + } +} + +class _RadarLoadingCard + extends StatelessWidget { + const _RadarLoadingCard(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: + EdgeInsets.fromLTRB( + 30, + 24, + 30, + 10, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xFF181818), + borderRadius: + BorderRadius.all( + Radius.circular(16), + ), + ), + child: Padding( + padding: + EdgeInsets.all(18), + child: Row( + children: [ + SizedBox( + width: 22, + height: 22, + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ), + SizedBox(width: 13), + Expanded( + child: Text( + 'Le Radar mélange ton historique, tes favoris et de nouvelles pistes…', + style: TextStyle( + color: + Colors.white70, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _RadarErrorCard + extends StatelessWidget { + const _RadarErrorCard({ + required this.text, + required this.onRetry, + }); + + final String text; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: + const EdgeInsets.fromLTRB( + 30, + 20, + 30, + 4, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color( + 0xFF211818, + ), + borderRadius: + BorderRadius.circular( + 14, + ), + ), + child: Padding( + padding: + const EdgeInsets.all( + 15, + ), + child: Row( + children: [ + const Icon( + Icons + .radar_outlined, + color: + Colors.white54, + ), + const SizedBox( + width: 11, + ), + Expanded( + child: Text(text), + ), + TextButton( + onPressed: onRetry, + child: const Text( + 'Réessayer', + ), + ), + ], + ), + ), + ), + ); + } +} + +class _RadarCategoriesBlock + extends StatelessWidget { + const _RadarCategoriesBlock({ + required this.categories, + required this.selectedCategory, + required this.onCategorySelected, + required this.onWhy, + required this.onDismiss, + required this.onReduceAuthor, + }); + + final Map> + categories; + final String selectedCategory; + final ValueChanged + onCategorySelected; + final ValueChanged + onWhy; + final ValueChanged + onDismiss; + final ValueChanged + onReduceAuthor; + + @override + Widget build(BuildContext context) { + final selected = + categories[selectedCategory] ?? + categories.values.first; + + final wide = + MediaQuery.sizeOf(context).width >= + 800; + + return Padding( + padding: EdgeInsets.only( + top: 24, + bottom: 4, + left: wide ? 30 : 18, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only( + right: wide ? 30 : 18, + ), + child: const Row( + children: [ + Icon( + Icons + .category_outlined, + color: + Color(0xFFFFC76A), + size: 26, + ), + SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + 'Explorer par catégorie', + style: + TextStyle( + fontSize: 20, + fontWeight: + FontWeight + .w700, + ), + ), + SizedBox(height: 3), + Text( + 'Le classement est local : Jeux vidéo, Culture, OVNI & mystères, Technologie, Science…', + style: + TextStyle( + color: + Colors.white54, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: + Axis.horizontal, + child: Row( + children: categories.entries + .map( + (entry) => Padding( + padding: + const EdgeInsets + .only( + right: 8, + ), + child: ChoiceChip( + selected: + entry.key == + selectedCategory, + label: Text( + '${entry.key} (${entry.value.length})', + ), + onSelected: (_) => + onCategorySelected( + entry.key, + ), + ), + ), + ) + .toList( + growable: false, + ), + ), + ), + const SizedBox(height: 10), + _HorizontalVideoRail( + height: wide ? 326 : 292, + itemCount: selected.length, + itemWidth: wide ? 320 : 250, + endPadding: wide ? 30 : 18, + itemBuilder: (context, index) { + final item = selected[index]; + + return _RadarVideoCard( + item: item, + accent: const Color( + 0xFFFFC76A, + ), + onTap: () => _openVideo( + context, + item.video, + ), + onWhy: () => onWhy(item), + onDismiss: () => + onDismiss(item), + onReduceAuthor: () => + onReduceAuthor(item), + ); + }, + ), + ], + ), + ); + } +} + +class _HorizontalVideoRail extends StatefulWidget { + const _HorizontalVideoRail({ + required this.height, + required this.itemCount, + required this.itemWidth, + required this.itemBuilder, + this.endPadding = 18, + }); + + final double height; + final int itemCount; + final double itemWidth; + final double endPadding; + final IndexedWidgetBuilder itemBuilder; + + @override + State<_HorizontalVideoRail> createState() => + _HorizontalVideoRailState(); +} + +class _HorizontalVideoRailState + extends State<_HorizontalVideoRail> { + final ScrollController _controller = + ScrollController(); + + bool _canGoBack = false; + bool _canGoForward = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_syncButtons); + + WidgetsBinding.instance.addPostFrameCallback( + (_) => _syncButtons(), + ); + } + + @override + void didUpdateWidget( + covariant _HorizontalVideoRail oldWidget, + ) { + super.didUpdateWidget(oldWidget); + + WidgetsBinding.instance.addPostFrameCallback( + (_) => _syncButtons(), + ); + } + + @override + void dispose() { + _controller + ..removeListener(_syncButtons) + ..dispose(); + + super.dispose(); + } + + void _syncButtons() { + if (!mounted || !_controller.hasClients) { + return; + } + + final position = _controller.position; + final canGoBack = + position.pixels > position.minScrollExtent + 2; + final canGoForward = + position.pixels < position.maxScrollExtent - 2; + + if (canGoBack == _canGoBack && + canGoForward == _canGoForward) { + return; + } + + setState(() { + _canGoBack = canGoBack; + _canGoForward = canGoForward; + }); + } + + Future _move(int direction) async { + if (!_controller.hasClients) { + return; + } + + final position = _controller.position; + + // Move almost one viewport at a time, but always at least one card. + final distance = ( + position.viewportDimension * 0.82 + ).clamp( + widget.itemWidth + 12, + double.infinity, + ); + + final target = ( + _controller.offset + + distance * direction + ).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + + await _controller.animateTo( + target.toDouble(), + duration: + const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + final desktop = + MediaQuery.sizeOf(context).width >= 800; + + final behavior = + ScrollConfiguration.of(context).copyWith( + dragDevices: const { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.trackpad, + PointerDeviceKind.stylus, + }, + scrollbars: false, + ); + + return SizedBox( + height: widget.height, + child: Stack( + children: [ + Positioned.fill( + child: Scrollbar( + controller: _controller, + thumbVisibility: desktop, + trackVisibility: false, + thickness: desktop ? 4 : 2, + radius: const Radius.circular(99), + child: ScrollConfiguration( + behavior: behavior, + child: ListView.separated( + controller: _controller, + scrollDirection: + Axis.horizontal, + physics: + const ClampingScrollPhysics(), + padding: EdgeInsets.only( + right: widget.endPadding, + bottom: desktop ? 9 : 0, + ), + itemCount: widget.itemCount, + separatorBuilder: + (_, __) => + const SizedBox( + width: 12, + ), + itemBuilder: + (context, index) => + SizedBox( + width: widget.itemWidth, + child: widget.itemBuilder( + context, + index, + ), + ), + ), + ), + ), + ), + + if (desktop && _canGoBack) + Positioned( + left: 8, + top: 0, + bottom: 12, + child: Center( + child: _RailArrowButton( + icon: + Icons.chevron_left_rounded, + tooltip: + 'Vidéos précédentes', + onPressed: () => + _move(-1), + ), + ), + ), + + if (desktop && _canGoForward) + Positioned( + right: 12, + top: 0, + bottom: 12, + child: Center( + child: _RailArrowButton( + icon: + Icons.chevron_right_rounded, + tooltip: + 'Voir la suite', + onPressed: () => + _move(1), + ), + ), + ), + ], + ), + ); + } +} + +class _RailArrowButton extends StatelessWidget { + const _RailArrowButton({ + required this.icon, + required this.tooltip, + required this.onPressed, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: Material( + color: const Color(0xE6222222), + shape: const CircleBorder(), + elevation: 8, + shadowColor: Colors.black87, + child: InkWell( + onTap: onPressed, + customBorder: + const CircleBorder(), + child: Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: + const Color(0x44FFFFFF), + ), + ), + child: Icon( + icon, + size: 30, + color: Colors.white, + ), + ), + ), + ), + ); + } +} + +class _VideoSection + extends StatelessWidget { + const _VideoSection({ + required this.title, + required this.subtitle, + required this.videos, + this.showProgress = false, + }); + + final String title; + final String subtitle; + final List videos; + final bool showProgress; + + @override + Widget build(BuildContext context) { + final wide = + MediaQuery.sizeOf(context).width >= + 800; + final cardWidth = + wide ? 290.0 : 235.0; + + return Padding( + padding: EdgeInsets.only( + top: 18, + bottom: 6, + left: wide ? 30 : 18, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only( + right: wide ? 30 : 18, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + title, + style: + const TextStyle( + fontSize: 20, + fontWeight: + FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: + const TextStyle( + color: + Colors.white54, + fontSize: 13, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + _HorizontalVideoRail( + height: wide ? 272 : 242, + itemCount: videos.length, + itemWidth: cardWidth, + endPadding: wide ? 30 : 18, + itemBuilder: (context, index) { + final video = videos[index]; + + return _HomeVideoCard( + video: video, + showProgress: showProgress, + onTap: () => _openVideo( + context, + video, + ), + ); + }, + ), + ], + ), + ); + } +} + +class _HomeVideoCard + extends StatelessWidget { + const _HomeVideoCard({ + required this.video, + required this.showProgress, + required this.onTap, + }); + + final VideoItem video; + final bool showProgress; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final position = + LibraryService.instance + .getPlaybackPosition( + video.id, + ); + + final progress = + _progressValue( + position, + video.duration, + ); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: + BorderRadius.circular(14), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: + BorderRadius.circular( + 12, + ), + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + color: const Color( + 0xFF202020, + ), + child: Image.network( + video.thumbnailUrl, + fit: BoxFit.cover, + errorBuilder: ( + context, + error, + stackTrace, + ) { + return const Center( + child: Icon( + Icons + .broken_image_outlined, + color: + Colors.white38, + size: 38, + ), + ); + }, + ), + ), + const Positioned.fill( + child: + DecoratedBox( + decoration: + BoxDecoration( + gradient: + LinearGradient( + begin: Alignment + .topCenter, + end: Alignment + .bottomCenter, + colors: [ + Colors + .transparent, + Color( + 0x55000000, + ), + ], + ), + ), + ), + ), + if (video + .durationLabel + .isNotEmpty) + Positioned( + right: 7, + bottom: + showProgress + ? 10 + : 7, + child: + _DurationBadge( + text: video + .durationLabel, + ), + ), + if (showProgress && + position != null && + position > + Duration.zero) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: + LinearProgressIndicator( + value: progress, + minHeight: 4, + backgroundColor: + Colors + .white24, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 9), + Text( + video.title, + maxLines: 2, + overflow: + TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + height: 1.25, + fontWeight: + FontWeight.w600, + ), + ), + const SizedBox(height: 5), + Text( + video.author, + maxLines: 1, + overflow: + TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white54, + fontSize: 12, + ), + ), + if (showProgress && + position != null) ...[ + const SizedBox(height: 4), + Text( + 'Reprendre à ' + '${_formatDuration(position)}', + style: + const TextStyle( + color: + Colors.white70, + fontSize: 12, + fontWeight: + FontWeight.w500, + ), + ), + ], + ], + ), + ), + ); + } + + double? _progressValue( + Duration? position, + Duration? duration, + ) { + if (position == null || + duration == null || + duration <= Duration.zero) { + return null; + } + + return (position.inMilliseconds / + duration.inMilliseconds) + .clamp(0.0, 1.0); + } + + String _formatDuration( + Duration value, + ) { + final hours = + value.inHours; + final minutes = + value.inMinutes.remainder(60); + final seconds = + value.inSeconds.remainder(60); + + if (hours > 0) { + return '$hours:' + '${minutes.toString().padLeft(2, '0')}:' + '${seconds.toString().padLeft(2, '0')}'; + } + + return '$minutes:' + '${seconds.toString().padLeft(2, '0')}'; + } +} + +class _DurationBadge + extends StatelessWidget { + const _DurationBadge({ + required this.text, + }); + + final String text; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: + BorderRadius.circular(5), + ), + child: Padding( + padding: + const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + child: Text( + text, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: + FontWeight.w600, + ), + ), + ), + ); + } +} + +class _EmptyHome + extends StatelessWidget { + const _EmptyHome(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: + const EdgeInsets.all(30), + child: Column( + mainAxisSize: + MainAxisSize.min, + children: [ + const Icon( + Icons.radar_rounded, + size: 72, + color: Colors.white24, + ), + const SizedBox(height: 18), + const Text( + 'Ton Radar est encore vide', + textAlign: + TextAlign.center, + style: TextStyle( + fontSize: 21, + fontWeight: + FontWeight.w600, + ), + ), + const SizedBox(height: 8), + const Text( + 'Recherche et regarde quelques vidéos. ' + 'Le Radar apprendra uniquement à partir de ' + 'ton historique et de tes favoris.', + textAlign: + TextAlign.center, + style: TextStyle( + color: + Colors.white60, + fontSize: 14, + height: 1.4, + ), + ), + ], + ), + ), + ); + } +} + +void _openVideo( + BuildContext context, + VideoItem video, +) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + VideoPlayerPage( + video: video, + ), + ), + ); +} diff --git a/lib/screens/library_pages.dart b/lib/screens/library_pages.dart new file mode 100644 index 0000000..e19efa3 --- /dev/null +++ b/lib/screens/library_pages.dart @@ -0,0 +1,490 @@ +import 'package:flutter/material.dart'; + +import '../models/video_item.dart'; +import '../services/library_service.dart'; +import '../widgets/video_result_card.dart'; +import 'channels_page.dart'; +import 'video_player_page.dart'; + +class LibraryHubPage extends StatelessWidget { + const LibraryHubPage({super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: ValueListenableBuilder>( + valueListenable: LibraryService.instance.history, + builder: (context, history, _) { + return ValueListenableBuilder>( + valueListenable: LibraryService.instance.favorites, + builder: (context, favorites, __) { + final recentHistory = history.take(3).toList(growable: false); + final recentFavorites = + favorites.take(3).toList(growable: false); + + return ListView( + padding: EdgeInsets.fromLTRB(18, 20, 18, 28), + children: [ + Text( + 'Bibliothèque', + style: + Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 6), + Text( + 'Retrouve rapidement ton historique, tes favoris et tes chaînes.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 13, + ), + ), + SizedBox(height: 18), + LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final crossAxisCount = width >= 1100 + ? 3 + : width >= 720 + ? 2 + : 1; + final mainAxisExtent = width >= 1100 + ? 220.0 + : width >= 720 + ? 210.0 + : 196.0; + + return GridView.count( + crossAxisCount: crossAxisCount, + shrinkWrap: true, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + mainAxisExtent: mainAxisExtent, + physics: NeverScrollableScrollPhysics(), + children: [ + _LibraryHubCard( + icon: Icons.history, + title: 'Historique', + value: '${history.length}', + subtitle: history.isEmpty + ? 'Aucune vidéo lue pour le moment.' + : 'Tes vidéos récemment ouvertes.', + onTap: () => _openPage( + context, + HistoryPage(), + ), + ), + _LibraryHubCard( + icon: Icons.favorite, + title: 'Favoris', + value: '${favorites.length}', + subtitle: favorites.isEmpty + ? 'Aucune vidéo en favori.' + : 'Tes vidéos enregistrées pour plus tard.', + onTap: () => _openPage( + context, + FavoritesPage(), + ), + ), + _LibraryHubCard( + icon: Icons.video_library, + title: 'Chaînes', + value: 'Explorer', + subtitle: + 'Accède à tes chaînes personnelles et au catalogue.', + onTap: () => _openPage( + context, + ChannelsPage(), + ), + ), + ], + ); + }, + ), + SizedBox(height: 24), + if (recentHistory.isNotEmpty) ...[ + _SectionHeader( + title: 'Reprendre vite', + actionLabel: 'Voir tout', + onTap: () => _openPage(context, HistoryPage()), + ), + SizedBox(height: 8), + ...recentHistory.map( + (video) => Padding( + padding: EdgeInsets.only(bottom: 6), + child: VideoResultCard( + video: video, + onTap: () => _openVideo(context, video), + ), + ), + ), + SizedBox(height: 16), + ], + if (recentFavorites.isNotEmpty) ...[ + _SectionHeader( + title: 'Favoris récents', + actionLabel: 'Voir tout', + onTap: () => _openPage(context, FavoritesPage()), + ), + SizedBox(height: 8), + ...recentFavorites.map( + (video) => Padding( + padding: EdgeInsets.only(bottom: 6), + child: VideoResultCard( + video: video, + onTap: () => _openVideo(context, video), + ), + ), + ), + ], + ], + ); + }, + ); + }, + ), + ); + } + + void _openPage(BuildContext context, Widget page) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => page), + ); + } + + void _openVideo(BuildContext context, VideoItem video) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VideoPlayerPage(video: video), + ), + ); + } +} + +class HistoryPage extends StatelessWidget { + const HistoryPage({super.key}); + + @override + Widget build(BuildContext context) { + return _LibraryPage( + title: 'Historique', + emptyIcon: Icons.history, + emptyTitle: 'Aucun historique', + emptySubtitle: + 'Les vidéos que tu regardes apparaîtront ici automatiquement.', + listenable: LibraryService.instance.history, + onClear: LibraryService.instance.clearHistory, + ); + } +} + +class FavoritesPage extends StatelessWidget { + const FavoritesPage({super.key}); + + @override + Widget build(BuildContext context) { + return _LibraryPage( + title: 'Favoris', + emptyIcon: Icons.favorite_border, + emptyTitle: 'Aucun favori', + emptySubtitle: 'Ajoute des vidéos aux favoris depuis le lecteur.', + listenable: LibraryService.instance.favorites, + onClear: LibraryService.instance.clearFavorites, + ); + } +} + +class _LibraryHubCard extends StatelessWidget { + const _LibraryHubCard({ + required this.icon, + required this.title, + required this.value, + required this.subtitle, + required this.onTap, + }); + + final IconData icon; + final String title; + final String value; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(22), + child: InkWell( + borderRadius: BorderRadius.circular(22), + onTap: onTap, + child: Ink( + padding: EdgeInsets.all(18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: Color(0x22FF6B63), + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, color: Color(0xFFFF7A73)), + ), + SizedBox(height: 16), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 4), + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 10), + Expanded( + child: Align( + alignment: Alignment.topLeft, + child: Text( + subtitle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12.5, + height: 1.35, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.title, + required this.actionLabel, + required this.onTap, + }); + + final String title; + final String actionLabel; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ), + TextButton(onPressed: onTap, child: Text(actionLabel)), + ], + ); + } +} + +class _LibraryPage extends StatelessWidget { + const _LibraryPage({ + required this.title, + required this.emptyIcon, + required this.emptyTitle, + required this.emptySubtitle, + required this.listenable, + required this.onClear, + }); + + final String title; + final IconData emptyIcon; + final String emptyTitle; + final String emptySubtitle; + final ValueNotifier> listenable; + final Future Function() onClear; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: ValueListenableBuilder>( + valueListenable: listenable, + builder: (context, videos, child) { + final canGoBack = Navigator.of(context).canPop(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(20, 18, 14, 10), + child: Row( + children: [ + if (canGoBack) ...[ + IconButton( + tooltip: 'Retour', + onPressed: () => Navigator.of(context).pop(), + icon: Icon(Icons.arrow_back), + ), + SizedBox(width: 4), + ], + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + if (videos.isNotEmpty) + IconButton( + tooltip: 'Tout effacer', + onPressed: () => _confirmClear(context), + icon: Icon(Icons.delete_outline), + ), + ], + ), + ), + Expanded( + child: videos.isEmpty + ? _EmptyLibrary( + icon: emptyIcon, + title: emptyTitle, + subtitle: emptySubtitle, + ) + : ListView.separated( + padding: EdgeInsets.fromLTRB(12, 4, 12, 24), + itemCount: videos.length, + separatorBuilder: (context, index) => + SizedBox(height: 4), + itemBuilder: (context, index) { + final video = videos[index]; + + return VideoResultCard( + video: video, + onTap: () => _openVideo(context, video), + trailing: listenable == LibraryService.instance.history + ? IconButton( + tooltip: "Supprimer de l'historique", + icon: Icon(Icons.remove_circle_outline), + onPressed: () => LibraryService.instance.removeFromHistory(video.id), + ) + : null, + ); + }, + ), + ), + ], + ); + }, + ), + ); + } + + void _openVideo( + BuildContext context, + VideoItem video, + ) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => VideoPlayerPage(video: video), + ), + ); + } + + Future _confirmClear(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text('Effacer $title ?'), + content: Text( + 'Cette action supprimera cette liste uniquement sur cet appareil.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text('Effacer'), + ), + ], + ); + }, + ); + + if (confirmed == true) { + await onClear(); + } + } +} + +class _EmptyLibrary extends StatelessWidget { + const _EmptyLibrary({ + required this.icon, + required this.title, + required this.subtitle, + }); + + final IconData icon; + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 64, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.44), + ), + SizedBox(height: 16), + Text( + title, + style: TextStyle( + fontSize: 21, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8), + Text( + subtitle, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + fontSize: 15, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/search_page.dart b/lib/screens/search_page.dart new file mode 100644 index 0000000..1637b7a --- /dev/null +++ b/lib/screens/search_page.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; + +import '../models/video_item.dart'; +import '../services/app_settings_service.dart'; +import '../services/preferred_language_service.dart'; +import '../services/youtube_service.dart'; +import '../widgets/video_result_card.dart'; +import 'video_player_page.dart'; + +class SearchPage extends StatefulWidget { + const SearchPage({super.key}); + + @override + State createState() => _SearchPageState(); +} + +class _SearchPageState extends State { + final TextEditingController _controller = TextEditingController(); + final YoutubeService _youtubeService = YoutubeService(); + + List _results = []; + bool _loading = false; + String? _error; + String _lastQuery = ''; + + Future _search() async { + final query = _controller.text.trim(); + + if (query.isEmpty || _loading) { + return; + } + + FocusScope.of(context).unfocus(); + + setState(() { + _loading = true; + _error = null; + _lastQuery = query; + }); + + try { + final results = await _youtubeService.searchVideos(query); + final preferredLanguage = + AppSettingsService.instance.preferredLanguage.value; + final rankedResults = PreferredLanguageService.rankVideos( + results, + preferredLanguage, + ); + + if (!mounted) { + return; + } + + setState(() { + _results = rankedResults; + }); + } catch (error) { + if (!mounted) { + return; + } + + setState(() { + _results = []; + _error = + 'Impossible de récupérer les résultats YouTube.\n' + 'Vérifie ta connexion puis réessaie.'; + }); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + + void _clear() { + _controller.clear(); + + setState(() { + _results = []; + _error = null; + _lastQuery = ''; + }); + } + + void _openVideo(VideoItem video) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => VideoPlayerPage(video: video), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + _youtubeService.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + children: [ + _SearchHeader( + controller: _controller, + loading: _loading, + onSearch: _search, + onClear: _clear, + ), + Expanded( + child: _buildBody(), + ), + ], + ), + ); + } + + Widget _buildBody() { + if (_loading && _results.isEmpty) { + return Center( + child: CircularProgressIndicator(), + ); + } + + if (_error != null) { + return _ErrorState( + message: _error!, + onRetry: _search, + ); + } + + if (_results.isEmpty) { + if (_lastQuery.isNotEmpty) { + return Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Aucune vidéo trouvée pour « $_lastQuery ».', + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + fontSize: 16, + ), + ), + ), + ); + } + + return _EmptySearchState(); + } + + return Column( + children: [ + Padding( + padding: EdgeInsets.fromLTRB(20, 4, 20, 6), + child: Row( + children: [ + Expanded( + child: Text( + 'Résultats pour « $_lastQuery »', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + if (_loading) + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + ), + Expanded( + child: ListView.separated( + padding: EdgeInsets.fromLTRB(12, 4, 12, 24), + itemCount: _results.length, + separatorBuilder: (context, index) => SizedBox(height: 4), + itemBuilder: (context, index) { + final video = _results[index]; + + return VideoResultCard( + video: video, + onTap: () => _openVideo(video), + ); + }, + ), + ), + ], + ); + } +} + +class _SearchHeader extends StatelessWidget { + const _SearchHeader({ + required this.controller, + required this.loading, + required this.onSearch, + required this.onClear, + }); + + final TextEditingController controller; + final bool loading; + final VoidCallback onSearch; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final wide = MediaQuery.sizeOf(context).width >= 700; + + return Padding( + padding: EdgeInsets.fromLTRB( + wide ? 28 : 16, + 18, + wide ? 28 : 16, + 14, + ), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 900), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + textInputAction: TextInputAction.search, + onSubmitted: (_) => onSearch(), + decoration: InputDecoration( + hintText: 'Rechercher sur YouTube...', + prefixIcon: Icon(Icons.search), + suffixIcon: ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, child) { + if (value.text.isEmpty) { + return SizedBox.shrink(); + } + + return IconButton( + tooltip: 'Effacer', + onPressed: onClear, + icon: Icon(Icons.close), + ); + }, + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: BorderSide( + color: Color(0xFFFF4E45), + width: 1.5, + ), + ), + ), + ), + ), + SizedBox(width: 10), + IconButton.filled( + tooltip: 'Rechercher', + onPressed: loading ? null : onSearch, + icon: Icon(Icons.search), + ), + ], + ), + ), + ), + ); + } +} + +class _EmptySearchState extends StatelessWidget { + const _EmptySearchState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.youtube_searched_for, + size: 70, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.44), + ), + SizedBox(height: 18), + Text( + 'Recherche YouTube', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8), + Text( + 'Saisis un titre, une chaîne ou un sujet.', + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + fontSize: 15, + ), + ), + ], + ), + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({ + required this.message, + required this.onRetry, + }); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.cloud_off_outlined, + size: 62, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + ), + SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 15, + ), + ), + SizedBox(height: 18), + FilledButton.icon( + onPressed: onRetry, + icon: Icon(Icons.refresh), + label: Text('Réessayer'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart new file mode 100644 index 0000000..247a241 --- /dev/null +++ b/lib/screens/settings_page.dart @@ -0,0 +1,899 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../services/app_settings_service.dart'; +import '../services/media_cache_settings_service.dart'; +import '../services/preferred_language_service.dart'; +import '../widgets/memory_transfer_settings.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: ListView( + padding: EdgeInsets.fromLTRB(20, 22, 20, 32), + children: [ + Text( + 'Paramètres', + style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800), + ), + SizedBox(height: 6), + Text( + 'Personnalise le comportement de YouTube Personal.', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 13, + ), + ), + SizedBox(height: 24), + _SettingsSection( + title: 'Apparence', + icon: Icons.brightness_6_outlined, + child: ValueListenableBuilder( + valueListenable: AppSettingsService.instance.appearanceMode, + builder: (context, appearanceMode, child) { + return _SettingsRow( + title: 'Thème de l’application', + subtitle: + 'Système suit automatiquement le thème clair ou sombre configuré sur Windows ou Android.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: appearanceMode, + dropdownColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: AppSettingsService.supportedAppearanceModes + .map( + (mode) => DropdownMenuItem( + value: mode, + child: Text( + AppSettingsService.appearanceModeLabel(mode), + ), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + AppSettingsService.instance.setAppearanceMode(value); + }, + ), + ), + ); + }, + ), + ), + SizedBox(height: 14), + _SettingsSection( + title: 'Lecture', + icon: Icons.play_circle_outline, + child: ValueListenableBuilder( + valueListenable: AppSettingsService.instance.defaultResolution, + builder: (context, resolution, child) { + return _SettingsRow( + title: 'Résolution par défaut', + subtitle: + 'Utilisée à l’ouverture d’une nouvelle vidéo. La qualité reste modifiable dans le lecteur.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: resolution, + dropdownColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: AppSettingsService.supportedResolutions + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + AppSettingsService.resolutionLabel(value), + ), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + + AppSettingsService.instance.setDefaultResolution(value); + }, + ), + ), + ); + }, + ), + ), + SizedBox(height: 14), + _SettingsSection( + title: 'Recommandations', + icon: Icons.language, + child: ValueListenableBuilder( + valueListenable: AppSettingsService.instance.preferredLanguage, + builder: (context, language, child) { + return _SettingsRow( + title: 'Langue préférée des vidéos', + subtitle: + 'Favorise cette langue dans le Radar et dans l’ordre des résultats de recherche, sans masquer les autres langues.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: language, + dropdownColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: PreferredLanguageService.options + .map( + (option) => DropdownMenuItem( + value: option.code, + child: Text(option.label), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + + AppSettingsService.instance.setPreferredLanguage(value); + }, + ), + ), + ); + }, + ), + ), + SizedBox(height: 14), + _SettingsSection( + title: 'Filtre contenu très jeune', + icon: Icons.child_care, + child: ValueListenableBuilder( + valueListenable: + AppSettingsService.instance.youngAudienceFilterLevel, + builder: (context, level, child) { + return _SettingsRow( + title: 'Chaînes / vidéos orientées très jeune', + subtitle: + 'Modéré masque surtout les contenus explicitement enfants, bébés, jouets ou comptines. Strict élargit le filtrage aux chaînes très fortement orientées jeune public. Le filtre s’applique aussi à Continuer à regarder, Continuer ce sujet et aux autres recommandations automatiques. Il se base sur les métadonnées textuelles, pas sur l’apparence des personnes.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: level, + dropdownColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: AppSettingsService + .supportedYoungAudienceFilterLevels + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + AppSettingsService.youngAudienceFilterLabel( + value, + ), + ), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + + AppSettingsService.instance.setYoungAudienceFilterLevel( + value, + ); + }, + ), + ), + ); + }, + ), + ), + if (Platform.isWindows) ...[ + SizedBox(height: 14), + _WindowsMediaCacheSettings(), + ], + if (Platform.isAndroid) ...[ + SizedBox(height: 14), + _AndroidMediaCacheSettings(), + SizedBox(height: 14), + _SettingsSection( + title: 'Écran Android', + icon: Icons.bedtime_outlined, + child: ValueListenableBuilder( + valueListenable: + AppSettingsService.instance.pauseScreenSleepMinutes, + builder: (context, minutes, child) { + return _SettingsRow( + title: 'Mise en veille après pause', + subtitle: + 'Pendant la lecture, l’écran reste allumé. À la fin de la vidéo, la mise en veille système redevient immédiatement possible. En pause, le maintien de l’écran est libéré après ce délai.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: minutes, + dropdownColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: AppSettingsService + .supportedPauseScreenSleepMinutes + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + AppSettingsService.pauseScreenSleepLabel( + value, + ), + ), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + + AppSettingsService.instance + .setPauseScreenSleepMinutes(value); + }, + ), + ), + ); + }, + ), + ), + ], + SizedBox(height: 14), + const MemoryTransferSettings(), + SizedBox(height: 14), + _SettingsSection( + title: 'Navigation', + icon: Icons.dashboard_outlined, + child: Text( + 'La barre du bas mobile a été simplifiée à 5 onglets : Accueil, Recherche, Abos, Bibliothèque et Réglages. Historique, Favoris et Chaînes sont maintenant regroupés dans Bibliothèque pour un rendu plus lisible.', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + height: 1.45, + fontSize: 12.5, + ), + ), + ), + ], + ), + ); + } +} + +class _WindowsMediaCacheSettings extends StatefulWidget { + const _WindowsMediaCacheSettings(); + + @override + State<_WindowsMediaCacheSettings> createState() => + _WindowsMediaCacheSettingsState(); +} + +class _WindowsMediaCacheSettingsState + extends State<_WindowsMediaCacheSettings> { + final MediaCacheSettingsService _cache = MediaCacheSettingsService.instance; + + bool _loading = true; + String _effectivePath = ''; + int _currentBytes = 0; + int _defaultBytes = 0; + int _maxGb = MediaCacheSettingsService.defaultMaxCacheGb; + + @override + void initState() { + super.initState(); + _reload(); + } + + Future _reload() async { + setState(() => _loading = true); + + await _cache.ensureInitialized(); + final effectivePath = await _cache.effectiveCachePath(); + final currentBytes = await _cache.currentCacheSizeBytes(); + final defaultBytes = await _cache.defaultCacheSizeBytes(); + + if (!mounted) { + return; + } + + setState(() { + _effectivePath = effectivePath; + _currentBytes = currentBytes; + _defaultBytes = defaultBytes; + _maxGb = _cache.maxCacheGb.value; + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + final usingCustomPath = _cache.customCachePath.value.trim().isNotEmpty; + final defaultPath = _cache.defaultCachePath; + + return _SettingsSection( + title: 'Cache média Windows', + icon: Icons.storage_outlined, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Le cache accélère les relectures et les bascules HD. Il est maintenant limité automatiquement pour éviter de remplir le disque système.', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12.5, + height: 1.4, + ), + ), + SizedBox(height: 16), + Text('Emplacement', style: TextStyle(fontWeight: FontWeight.w700)), + SizedBox(height: 6), + Container( + width: double.infinity, + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: SelectableText( + _loading ? 'Calcul...' : _effectivePath, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12.5, + ), + ), + ), + SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: _loading ? null : _changePath, + icon: Icon(Icons.folder_outlined), + label: Text('Modifier le chemin'), + ), + if (usingCustomPath) + TextButton.icon( + onPressed: _loading ? null : _resetPath, + icon: Icon(Icons.restart_alt), + label: Text('Revenir au chemin par défaut'), + ), + ], + ), + SizedBox(height: 16), + _SettingsRow( + title: 'Taille maximale', + subtitle: + 'Quand la limite est dépassée, les vidéos les moins récemment utilisées sont supprimées. La vidéo active est protégée.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: _maxGb, + dropdownColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: MediaCacheSettingsService.supportedMaxCacheGb + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + MediaCacheSettingsService.maxCacheLabel(value), + ), + ), + ) + .toList(growable: false), + onChanged: _loading + ? null + : (value) async { + if (value == null) { + return; + } + await _cache.setMaxCacheGb(value); + await _reload(); + }, + ), + ), + ), + SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Text( + _loading + ? 'Taille actuelle : calcul...' + : 'Taille actuelle : ${MediaCacheSettingsService.formatBytesForUi(_currentBytes)}', + style: TextStyle(fontWeight: FontWeight.w700), + ), + ), + OutlinedButton.icon( + onPressed: _loading ? null : _clearCurrentCache, + icon: Icon(Icons.delete_sweep_outlined), + label: Text('Vider le cache'), + ), + ], + ), + if (usingCustomPath && + _defaultBytes > 0 && + _effectivePath.toLowerCase() != defaultPath.toLowerCase()) ...[ + SizedBox(height: 14), + Container( + width: double.infinity, + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Color(0x1AFF9800), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Color(0x44FF9800)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ancien cache sur C: ${MediaCacheSettingsService.formatBytesForUi(_defaultBytes)}', + style: TextStyle(fontWeight: FontWeight.w700), + ), + SizedBox(height: 4), + SelectableText( + defaultPath, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12, + ), + ), + SizedBox(height: 8), + TextButton.icon( + onPressed: _clearDefaultCache, + icon: Icon(Icons.delete_outline), + label: Text('Supprimer cet ancien cache'), + ), + ], + ), + ), + ], + ], + ), + ); + } + + Future _changePath() async { + final initialValue = _cache.customCachePath.value.trim().isNotEmpty + ? _cache.customCachePath.value + : _effectivePath; + final controller = TextEditingController(text: initialValue); + + final value = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text('Emplacement du cache'), + content: SizedBox( + width: 560, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Entre un dossier disposant de suffisamment d’espace. Exemple : D:\\YouTubePersonalCache', + ), + SizedBox(height: 14), + TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: 'Chemin du cache', + border: OutlineInputBorder(), + ), + ), + SizedBox(height: 8), + Text( + 'Le changement ne copie pas les anciens fichiers. Ils pourront être supprimés séparément.', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(controller.text), + child: Text('Utiliser ce dossier'), + ), + ], + ); + }, + ); + + controller.dispose(); + + if (value == null) { + return; + } + + try { + await _cache.setCustomCachePath(value); + await _reload(); + } catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Impossible d’utiliser ce dossier : $error')), + ); + } + } + + Future _resetPath() async { + await _cache.resetCustomCachePath(); + await _reload(); + } + + Future _clearCurrentCache() async { + final confirmed = await _confirmDelete( + 'Vider le cache média ?', + 'Ferme d’abord toute vidéo en cours. Les vidéos mises en cache devront être retéléchargées lors de la prochaine lecture.', + ); + + if (!confirmed) { + return; + } + + await _cache.clearCurrentCache(); + await _reload(); + } + + Future _clearDefaultCache() async { + final confirmed = await _confirmDelete( + 'Supprimer l’ancien cache sur C: ?', + 'Cette opération libérera l’espace utilisé par l’ancien cache média. Le nouveau cache restera intact.', + ); + + if (!confirmed) { + return; + } + + await _cache.clearDefaultCache(); + await _reload(); + } + + Future _confirmDelete(String title, String message) async { + final result = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text('Supprimer'), + ), + ], + ); + }, + ); + + return result == true; + } +} + +class _AndroidMediaCacheSettings extends StatefulWidget { + const _AndroidMediaCacheSettings(); + + @override + State<_AndroidMediaCacheSettings> createState() => + _AndroidMediaCacheSettingsState(); +} + +class _AndroidMediaCacheSettingsState + extends State<_AndroidMediaCacheSettings> { + final MediaCacheSettingsService _cache = MediaCacheSettingsService.instance; + + bool _loading = true; + int _bufferMb = MediaCacheSettingsService.defaultAndroidBufferMb; + int _diskMode = MediaCacheSettingsService.androidDiskCacheDisabled; + String _path = ''; + int _sizeBytes = 0; + + @override + void initState() { + super.initState(); + _reload(); + } + + Future _reload() async { + if (mounted) { + setState(() => _loading = true); + } + + await _cache.ensureInitialized(); + final mode = _cache.androidDiskCacheMode.value; + final path = await _cache.androidCachePathForMode(mode); + final bytes = await _cache.androidCacheSizeBytes(mode); + + if (!mounted) { + return; + } + + setState(() { + _bufferMb = _cache.androidBufferMb.value; + _diskMode = mode; + _path = path; + _sizeBytes = bytes; + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + return _SettingsSection( + title: 'Cache média Android', + icon: Icons.memory_outlined, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Par défaut, Android garde le cache vidéo en RAM pour éviter de remplir le stockage. Le cache disque temporaire est optionnel et reste dans l’espace privé de l’application.', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12.5, + height: 1.4, + ), + ), + SizedBox(height: 16), + _SettingsRow( + title: 'Tampon mémoire vidéo', + subtitle: + 'Limite le cache démuxeur de media_kit. Le changement s’applique aux nouvelles pages vidéo.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: _bufferMb, + dropdownColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: MediaCacheSettingsService.supportedAndroidBufferMb + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + MediaCacheSettingsService.androidBufferLabel(value), + ), + ), + ) + .toList(growable: false), + onChanged: _loading + ? null + : (value) async { + if (value == null) { + return; + } + await _cache.setAndroidBufferMb(value); + await _reload(); + }, + ), + ), + ), + SizedBox(height: 14), + _SettingsRow( + title: 'Cache disque temporaire', + subtitle: + 'RAM uniquement est recommandé. Interne/Externe utilise le cache temporaire mpv et le supprime à la fermeture normale de la vidéo.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: _diskMode, + dropdownColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: MediaCacheSettingsService.supportedAndroidDiskCacheModes + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + MediaCacheSettingsService.androidDiskCacheModeLabel( + value, + ), + ), + ), + ) + .toList(growable: false), + onChanged: _loading + ? null + : (value) async { + if (value == null) { + return; + } + await _cache.setAndroidDiskCacheMode(value); + await _reload(); + }, + ), + ), + ), + if (_diskMode != + MediaCacheSettingsService.androidDiskCacheDisabled) ...[ + SizedBox(height: 14), + Text('Emplacement', style: TextStyle(fontWeight: FontWeight.w700)), + SizedBox(height: 6), + Container( + width: double.infinity, + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: SelectableText( + _loading + ? 'Calcul...' + : (_path.isEmpty ? 'Indisponible' : _path), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12.5, + ), + ), + ), + SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Text( + 'Taille temporaire : ${MediaCacheSettingsService.formatBytesForUi(_sizeBytes)}', + style: TextStyle(fontWeight: FontWeight.w700), + ), + ), + OutlinedButton.icon( + onPressed: _loading ? null : _clear, + icon: Icon(Icons.delete_sweep_outlined), + label: Text('Vider'), + ), + ], + ), + ], + SizedBox(height: 10), + Text( + 'Android ne permet pas à l’application d’écrire librement dans n’importe quel dossier sans passer par le sélecteur de stockage. Le choix Externe utilise donc le dossier externe privé de l’application, sans permission supplémentaire.', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 11.5, + height: 1.35, + ), + ), + ], + ), + ); + } + + Future _clear() async { + await _cache.clearAndroidMediaCache(_diskMode); + await _reload(); + } +} + +class _SettingsSection extends StatelessWidget { + const _SettingsSection({ + required this.title, + required this.icon, + required this.child, + }); + + final String title; + final IconData icon; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 19, color: Color(0xFFFF6B63)), + SizedBox(width: 8), + Text( + title, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + ], + ), + SizedBox(height: 14), + child, + ], + ), + ); + } +} + +class _SettingsRow extends StatelessWidget { + const _SettingsRow({ + required this.title, + required this.subtitle, + required this.trailing, + }); + + final String title; + final String subtitle; + final Widget trailing; + + @override + Widget build(BuildContext context) { + final narrow = MediaQuery.sizeOf(context).width < 560; + + final text = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontWeight: FontWeight.w700)), + SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12, + height: 1.35, + ), + ), + ], + ); + + if (narrow) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [text, SizedBox(height: 12), trailing], + ); + } + + return Row( + children: [ + Expanded(child: text), + SizedBox(width: 18), + trailing, + ], + ); + } +} diff --git a/lib/screens/settings_page.dart.before_memory_v1 b/lib/screens/settings_page.dart.before_memory_v1 new file mode 100644 index 0000000..cd12872 --- /dev/null +++ b/lib/screens/settings_page.dart.before_memory_v1 @@ -0,0 +1,888 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../services/app_settings_service.dart'; +import '../services/media_cache_settings_service.dart'; +import '../services/preferred_language_service.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: ListView( + padding: EdgeInsets.fromLTRB(20, 22, 20, 32), + children: [ + Text( + 'Paramètres', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 6), + Text( + 'Personnalise le comportement de YouTube Personal.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 13, + ), + ), + SizedBox(height: 24), + _SettingsSection( + title: 'Apparence', + icon: Icons.brightness_6_outlined, + child: ValueListenableBuilder( + valueListenable: AppSettingsService.instance.appearanceMode, + builder: (context, appearanceMode, child) { + return _SettingsRow( + title: 'Thème de l’application', + subtitle: + 'Système suit automatiquement le thème clair ou sombre configuré sur Windows ou Android.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: appearanceMode, + dropdownColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: AppSettingsService.supportedAppearanceModes + .map( + (mode) => DropdownMenuItem( + value: mode, + child: Text( + AppSettingsService.appearanceModeLabel(mode), + ), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + AppSettingsService.instance.setAppearanceMode(value); + }, + ), + ), + ); + }, + ), + ), + SizedBox(height: 14), + _SettingsSection( + title: 'Lecture', + icon: Icons.play_circle_outline, + child: ValueListenableBuilder( + valueListenable: AppSettingsService.instance.defaultResolution, + builder: (context, resolution, child) { + return _SettingsRow( + title: 'Résolution par défaut', + subtitle: + 'Utilisée à l’ouverture d’une nouvelle vidéo. La qualité reste modifiable dans le lecteur.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: resolution, + dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: AppSettingsService.supportedResolutions + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + AppSettingsService.resolutionLabel(value), + ), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + + AppSettingsService.instance + .setDefaultResolution(value); + }, + ), + ), + ); + }, + ), + ), + SizedBox(height: 14), + _SettingsSection( + title: 'Recommandations', + icon: Icons.language, + child: ValueListenableBuilder( + valueListenable: AppSettingsService.instance.preferredLanguage, + builder: (context, language, child) { + return _SettingsRow( + title: 'Langue préférée des vidéos', + subtitle: + 'Favorise cette langue dans le Radar et dans l’ordre des résultats de recherche, sans masquer les autres langues.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: language, + dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: PreferredLanguageService.options + .map( + (option) => DropdownMenuItem( + value: option.code, + child: Text(option.label), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + + AppSettingsService.instance + .setPreferredLanguage(value); + }, + ), + ), + ); + }, + ), + ), + SizedBox(height: 14), + _SettingsSection( + title: 'Filtre contenu très jeune', + icon: Icons.child_care, + child: ValueListenableBuilder( + valueListenable: + AppSettingsService.instance.youngAudienceFilterLevel, + builder: (context, level, child) { + return _SettingsRow( + title: 'Chaînes / vidéos orientées très jeune', + subtitle: + 'Modéré masque surtout les contenus explicitement enfants, bébés, jouets ou comptines. Strict élargit le filtrage aux chaînes très fortement orientées jeune public. Le filtre s’applique aussi à Continuer à regarder, Continuer ce sujet et aux autres recommandations automatiques. Il se base sur les métadonnées textuelles, pas sur l’apparence des personnes.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: level, + dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: AppSettingsService + .supportedYoungAudienceFilterLevels + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + AppSettingsService + .youngAudienceFilterLabel(value), + ), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + + AppSettingsService.instance + .setYoungAudienceFilterLevel(value); + }, + ), + ), + ); + }, + ), + ), + if (Platform.isWindows) ...[ + SizedBox(height: 14), + _WindowsMediaCacheSettings(), + ], + if (Platform.isAndroid) ...[ + SizedBox(height: 14), + _AndroidMediaCacheSettings(), + SizedBox(height: 14), + _SettingsSection( + title: 'Écran Android', + icon: Icons.bedtime_outlined, + child: ValueListenableBuilder( + valueListenable: + AppSettingsService.instance.pauseScreenSleepMinutes, + builder: (context, minutes, child) { + return _SettingsRow( + title: 'Mise en veille après pause', + subtitle: + 'Pendant la lecture, l’écran reste allumé. À la fin de la vidéo, la mise en veille système redevient immédiatement possible. En pause, le maintien de l’écran est libéré après ce délai.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: minutes, + dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: AppSettingsService + .supportedPauseScreenSleepMinutes + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + AppSettingsService + .pauseScreenSleepLabel(value), + ), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + + AppSettingsService.instance + .setPauseScreenSleepMinutes(value); + }, + ), + ), + ); + }, + ), + ), + ], + SizedBox(height: 14), + _SettingsSection( + title: 'Navigation', + icon: Icons.dashboard_outlined, + child: Text( + 'La barre du bas mobile a été simplifiée à 5 onglets : Accueil, Recherche, Abos, Bibliothèque et Réglages. Historique, Favoris et Chaînes sont maintenant regroupés dans Bibliothèque pour un rendu plus lisible.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + height: 1.45, + fontSize: 12.5, + ), + ), + ), + ], + ), + ); + } +} + +class _WindowsMediaCacheSettings extends StatefulWidget { + const _WindowsMediaCacheSettings(); + + @override + State<_WindowsMediaCacheSettings> createState() => + _WindowsMediaCacheSettingsState(); +} + +class _WindowsMediaCacheSettingsState + extends State<_WindowsMediaCacheSettings> { + final MediaCacheSettingsService _cache = + MediaCacheSettingsService.instance; + + bool _loading = true; + String _effectivePath = ''; + int _currentBytes = 0; + int _defaultBytes = 0; + int _maxGb = MediaCacheSettingsService.defaultMaxCacheGb; + + @override + void initState() { + super.initState(); + _reload(); + } + + Future _reload() async { + setState(() => _loading = true); + + await _cache.ensureInitialized(); + final effectivePath = await _cache.effectiveCachePath(); + final currentBytes = await _cache.currentCacheSizeBytes(); + final defaultBytes = await _cache.defaultCacheSizeBytes(); + + if (!mounted) { + return; + } + + setState(() { + _effectivePath = effectivePath; + _currentBytes = currentBytes; + _defaultBytes = defaultBytes; + _maxGb = _cache.maxCacheGb.value; + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + final usingCustomPath = _cache.customCachePath.value.trim().isNotEmpty; + final defaultPath = _cache.defaultCachePath; + + return _SettingsSection( + title: 'Cache média Windows', + icon: Icons.storage_outlined, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Le cache accélère les relectures et les bascules HD. Il est maintenant limité automatiquement pour éviter de remplir le disque système.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12.5, + height: 1.4, + ), + ), + SizedBox(height: 16), + Text( + 'Emplacement', + style: TextStyle(fontWeight: FontWeight.w700), + ), + SizedBox(height: 6), + Container( + width: double.infinity, + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: SelectableText( + _loading ? 'Calcul...' : _effectivePath, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12.5, + ), + ), + ), + SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: _loading ? null : _changePath, + icon: Icon(Icons.folder_outlined), + label: Text('Modifier le chemin'), + ), + if (usingCustomPath) + TextButton.icon( + onPressed: _loading ? null : _resetPath, + icon: Icon(Icons.restart_alt), + label: Text('Revenir au chemin par défaut'), + ), + ], + ), + SizedBox(height: 16), + _SettingsRow( + title: 'Taille maximale', + subtitle: + 'Quand la limite est dépassée, les vidéos les moins récemment utilisées sont supprimées. La vidéo active est protégée.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: _maxGb, + dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: MediaCacheSettingsService.supportedMaxCacheGb + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + MediaCacheSettingsService.maxCacheLabel(value), + ), + ), + ) + .toList(growable: false), + onChanged: _loading + ? null + : (value) async { + if (value == null) { + return; + } + await _cache.setMaxCacheGb(value); + await _reload(); + }, + ), + ), + ), + SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Text( + _loading + ? 'Taille actuelle : calcul...' + : 'Taille actuelle : ${MediaCacheSettingsService.formatBytesForUi(_currentBytes)}', + style: TextStyle( + fontWeight: FontWeight.w700, + ), + ), + ), + OutlinedButton.icon( + onPressed: _loading ? null : _clearCurrentCache, + icon: Icon(Icons.delete_sweep_outlined), + label: Text('Vider le cache'), + ), + ], + ), + if (usingCustomPath && + _defaultBytes > 0 && + _effectivePath.toLowerCase() != defaultPath.toLowerCase()) ...[ + SizedBox(height: 14), + Container( + width: double.infinity, + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Color(0x1AFF9800), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Color(0x44FF9800)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ancien cache sur C: ${MediaCacheSettingsService.formatBytesForUi(_defaultBytes)}', + style: TextStyle(fontWeight: FontWeight.w700), + ), + SizedBox(height: 4), + SelectableText( + defaultPath, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12, + ), + ), + SizedBox(height: 8), + TextButton.icon( + onPressed: _clearDefaultCache, + icon: Icon(Icons.delete_outline), + label: Text('Supprimer cet ancien cache'), + ), + ], + ), + ), + ], + ], + ), + ); + } + + Future _changePath() async { + final initialValue = _cache.customCachePath.value.trim().isNotEmpty + ? _cache.customCachePath.value + : _effectivePath; + final controller = TextEditingController(text: initialValue); + + final value = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text('Emplacement du cache'), + content: SizedBox( + width: 560, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Entre un dossier disposant de suffisamment d’espace. Exemple : D:\\YouTubePersonalCache', + ), + SizedBox(height: 14), + TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: 'Chemin du cache', + border: OutlineInputBorder(), + ), + ), + SizedBox(height: 8), + Text( + 'Le changement ne copie pas les anciens fichiers. Ils pourront être supprimés séparément.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop( + controller.text, + ), + child: Text('Utiliser ce dossier'), + ), + ], + ); + }, + ); + + controller.dispose(); + + if (value == null) { + return; + } + + try { + await _cache.setCustomCachePath(value); + await _reload(); + } catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Impossible d’utiliser ce dossier : $error'), + ), + ); + } + } + + Future _resetPath() async { + await _cache.resetCustomCachePath(); + await _reload(); + } + + Future _clearCurrentCache() async { + final confirmed = await _confirmDelete( + 'Vider le cache média ?', + 'Ferme d’abord toute vidéo en cours. Les vidéos mises en cache devront être retéléchargées lors de la prochaine lecture.', + ); + + if (!confirmed) { + return; + } + + await _cache.clearCurrentCache(); + await _reload(); + } + + Future _clearDefaultCache() async { + final confirmed = await _confirmDelete( + 'Supprimer l’ancien cache sur C: ?', + 'Cette opération libérera l’espace utilisé par l’ancien cache média. Le nouveau cache restera intact.', + ); + + if (!confirmed) { + return; + } + + await _cache.clearDefaultCache(); + await _reload(); + } + + Future _confirmDelete(String title, String message) async { + final result = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text('Supprimer'), + ), + ], + ); + }, + ); + + return result == true; + } +} + +class _AndroidMediaCacheSettings extends StatefulWidget { + const _AndroidMediaCacheSettings(); + + @override + State<_AndroidMediaCacheSettings> createState() => + _AndroidMediaCacheSettingsState(); +} + +class _AndroidMediaCacheSettingsState + extends State<_AndroidMediaCacheSettings> { + final MediaCacheSettingsService _cache = + MediaCacheSettingsService.instance; + + bool _loading = true; + int _bufferMb = MediaCacheSettingsService.defaultAndroidBufferMb; + int _diskMode = MediaCacheSettingsService.androidDiskCacheDisabled; + String _path = ''; + int _sizeBytes = 0; + + @override + void initState() { + super.initState(); + _reload(); + } + + Future _reload() async { + if (mounted) { + setState(() => _loading = true); + } + + await _cache.ensureInitialized(); + final mode = _cache.androidDiskCacheMode.value; + final path = await _cache.androidCachePathForMode(mode); + final bytes = await _cache.androidCacheSizeBytes(mode); + + if (!mounted) { + return; + } + + setState(() { + _bufferMb = _cache.androidBufferMb.value; + _diskMode = mode; + _path = path; + _sizeBytes = bytes; + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + return _SettingsSection( + title: 'Cache média Android', + icon: Icons.memory_outlined, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Par défaut, Android garde le cache vidéo en RAM pour éviter de remplir le stockage. Le cache disque temporaire est optionnel et reste dans l’espace privé de l’application.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12.5, + height: 1.4, + ), + ), + SizedBox(height: 16), + _SettingsRow( + title: 'Tampon mémoire vidéo', + subtitle: + 'Limite le cache démuxeur de media_kit. Le changement s’applique aux nouvelles pages vidéo.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: _bufferMb, + dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: MediaCacheSettingsService.supportedAndroidBufferMb + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + MediaCacheSettingsService.androidBufferLabel(value), + ), + ), + ) + .toList(growable: false), + onChanged: _loading + ? null + : (value) async { + if (value == null) { + return; + } + await _cache.setAndroidBufferMb(value); + await _reload(); + }, + ), + ), + ), + SizedBox(height: 14), + _SettingsRow( + title: 'Cache disque temporaire', + subtitle: + 'RAM uniquement est recommandé. Interne/Externe utilise le cache temporaire mpv et le supprime à la fermeture normale de la vidéo.', + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: _diskMode, + dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + items: MediaCacheSettingsService.supportedAndroidDiskCacheModes + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + MediaCacheSettingsService + .androidDiskCacheModeLabel(value), + ), + ), + ) + .toList(growable: false), + onChanged: _loading + ? null + : (value) async { + if (value == null) { + return; + } + await _cache.setAndroidDiskCacheMode(value); + await _reload(); + }, + ), + ), + ), + if (_diskMode != + MediaCacheSettingsService.androidDiskCacheDisabled) ...[ + SizedBox(height: 14), + Text( + 'Emplacement', + style: TextStyle(fontWeight: FontWeight.w700), + ), + SizedBox(height: 6), + Container( + width: double.infinity, + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: SelectableText( + _loading + ? 'Calcul...' + : (_path.isEmpty ? 'Indisponible' : _path), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12.5, + ), + ), + ), + SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Text( + 'Taille temporaire : ${MediaCacheSettingsService.formatBytesForUi(_sizeBytes)}', + style: TextStyle(fontWeight: FontWeight.w700), + ), + ), + OutlinedButton.icon( + onPressed: _loading ? null : _clear, + icon: Icon(Icons.delete_sweep_outlined), + label: Text('Vider'), + ), + ], + ), + ], + SizedBox(height: 10), + Text( + 'Android ne permet pas à l’application d’écrire librement dans n’importe quel dossier sans passer par le sélecteur de stockage. Le choix Externe utilise donc le dossier externe privé de l’application, sans permission supplémentaire.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 11.5, + height: 1.35, + ), + ), + ], + ), + ); + } + + Future _clear() async { + await _cache.clearAndroidMediaCache(_diskMode); + await _reload(); + } +} + +class _SettingsSection extends StatelessWidget { + const _SettingsSection({ + required this.title, + required this.icon, + required this.child, + }); + + final String title; + final IconData icon; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 19, color: Color(0xFFFF6B63)), + SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + SizedBox(height: 14), + child, + ], + ), + ); + } +} + +class _SettingsRow extends StatelessWidget { + const _SettingsRow({ + required this.title, + required this.subtitle, + required this.trailing, + }); + + final String title; + final String subtitle; + final Widget trailing; + + @override + Widget build(BuildContext context) { + final narrow = MediaQuery.sizeOf(context).width < 560; + + final text = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12, + height: 1.35, + ), + ), + ], + ); + + if (narrow) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text, + SizedBox(height: 12), + trailing, + ], + ); + } + + return Row( + children: [ + Expanded(child: text), + SizedBox(width: 18), + trailing, + ], + ); + } +} diff --git a/lib/screens/subscriptions_page.dart b/lib/screens/subscriptions_page.dart new file mode 100644 index 0000000..2cd305f --- /dev/null +++ b/lib/screens/subscriptions_page.dart @@ -0,0 +1,1128 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../models/subscription_channel.dart'; +import '../services/google_auth_service.dart'; +import '../services/google_oauth_config_service.dart'; +import '../services/youtube_account_service.dart'; +import 'channel_videos_page.dart'; + +class SubscriptionsPage extends StatefulWidget { + const SubscriptionsPage({super.key}); + + @override + State createState() => + _SubscriptionsPageState(); +} + +class _SubscriptionsPageState + extends State { + final YoutubeAccountService _youtubeAccountService = + YoutubeAccountService(); + + List _subscriptions = + []; + + bool _loading = false; + bool _subscriptionsRequestInFlight = false; + String? _error; + String _filterQuery = ''; + + @override + void initState() { + super.initState(); + + GoogleAuthService.instance.session.addListener( + _onSessionChanged, + ); + + if (GoogleAuthService.instance.session.value != + null) { + _loadSubscriptions(); + } + } + + @override + void dispose() { + GoogleAuthService.instance.session.removeListener( + _onSessionChanged, + ); + + super.dispose(); + } + + void _onSessionChanged() { + if (!mounted) { + return; + } + + final session = + GoogleAuthService.instance.session.value; + + if (session == null) { + setState(() { + _subscriptions = []; + _error = null; + _loading = false; + }); + + return; + } + + // Un rafraîchissement du jeton OAuth ne doit jamais déclencher + // une nouvelle requête subscriptions.list pendant qu'une requête + // est déjà en cours. C'était la cause de la boucle qui épuisait + // le quota YouTube Data API. + if (_subscriptionsRequestInFlight || _loading) { + return; + } + + // On charge automatiquement uniquement quand la session vient + // d'apparaître et que la liste n'a encore rien. + if (_subscriptions.isEmpty) { + _loadSubscriptions(); + } + } + + Future _connect() async { + setState(() { + _loading = true; + _error = null; + }); + + try { + await GoogleAuthService.instance.signIn(); + + if (mounted) { + await _loadSubscriptions(); + } + } catch (error) { + if (!mounted) { + return; + } + + setState(() { + _loading = false; + _error = error.toString(); + }); + } + } + + Future _loadSubscriptions() async { + final session = + GoogleAuthService.instance.session.value; + + if (session == null || + _subscriptionsRequestInFlight) { + return; + } + + _subscriptionsRequestInFlight = true; + + if (mounted) { + setState(() { + _loading = true; + _error = null; + }); + } + + try { + final accessToken = + await GoogleAuthService.instance + .getAccessToken(); + + final subscriptions = + await _youtubeAccountService + .fetchSubscriptions( + accessToken, + ); + + if (!mounted) { + return; + } + + setState(() { + _subscriptions = subscriptions; + _loading = false; + }); + } catch (error) { + if (!mounted) { + return; + } + + setState(() { + _loading = false; + _error = error.toString(); + }); + } finally { + _subscriptionsRequestInFlight = false; + } + } + + Future _openChannel( + SubscriptionChannel channel, + ) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelVideosPage( + channel: channel, + ), + ), + ); + } + + Future _disconnect() async { + await GoogleAuthService.instance.signOut(); + } + + Future _showConfigurationDialog() async { + final initial = + GoogleOauthConfigService.instance.config.value; + + final result = await showDialog<_OauthDialogResult>( + context: context, + builder: (dialogContext) { + return _GoogleOauthConfigDialog( + initial: initial, + ); + }, + ); + + if (result == null || !mounted) { + return; + } + + setState(() { + _loading = true; + _error = null; + }); + + try { + if (Platform.isAndroid) { + final canUseWithoutRestart = + await GoogleAuthService.instance + .saveAndroidConfiguration( + result.webClientId, + ); + + if (!mounted) { + return; + } + + setState(() { + _loading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + canUseWithoutRestart + ? 'Configuration OAuth enregistrée.' + : 'Configuration OAuth enregistrée. ' + 'Redémarre l’application avant ' + 'la connexion Google.', + ), + ), + ); + + return; + } + + if (Platform.isWindows) { + await GoogleAuthService.instance + .saveWindowsConfiguration( + desktopClientId: + result.desktopClientId, + desktopClientSecret: + result.desktopClientSecret, + ); + } + + if (!mounted) { + return; + } + + setState(() { + _loading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Configuration OAuth enregistrée.', + ), + ), + ); + } catch (error) { + if (!mounted) { + return; + } + + setState(() { + _loading = false; + _error = error.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: ValueListenableBuilder( + valueListenable: + GoogleAuthService.instance.session, + builder: ( + context, + session, + child, + ) { + if (session == null) { + return _buildSignedOut(); + } + + return _buildSignedIn(session); + }, + ), + ); + } + + Widget _buildSignedOut() { + final configured = + GoogleAuthService.instance.isConfigured; + + return Center( + child: ConstrainedBox( + constraints: + BoxConstraints(maxWidth: 620), + child: Padding( + padding: EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.subscriptions_outlined, + size: 72, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.52), + ), + SizedBox(height: 18), + Text( + 'Tes abonnements YouTube', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 10), + Text( + 'Connecte ton compte Google pour ' + 'charger tes chaînes abonnées via ' + 'YouTube Data API.', + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + height: 1.4, + ), + ), + SizedBox(height: 20), + Container( + width: double.infinity, + padding: EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: + BorderRadius.circular(12), + border: Border.all( + color: configured + ? Colors.green.withValues( + alpha: 0.35, + ) + : Colors.orange.withValues( + alpha: 0.35, + ), + ), + ), + child: Row( + children: [ + Icon( + configured + ? Icons.check_circle_outline + : Icons + .settings_outlined, + color: configured + ? Colors.greenAccent + : Colors.orangeAccent, + ), + SizedBox(width: 12), + Expanded( + child: Text( + configured + ? 'OAuth Google configuré ' + 'pour cette plateforme.' + : GoogleAuthService + .instance + .configurationHint, + style: TextStyle( + color: configured + ? Colors.greenAccent + : Colors.orangeAccent, + ), + ), + ), + TextButton( + onPressed: + _showConfigurationDialog, + child: Text( + configured + ? 'Modifier' + : 'Configurer', + ), + ), + ], + ), + ), + if (_error != null) ...[ + SizedBox(height: 14), + Text( + _error!, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.redAccent, + ), + ), + ], + SizedBox(height: 20), + FilledButton.icon( + onPressed: + configured && !_loading + ? _connect + : null, + icon: _loading + ? SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Icon(Icons.login), + label: Text( + 'Se connecter avec Google', + ), + ), + SizedBox(height: 10), + Text( + 'Permission demandée : lecture ' + 'seule des données YouTube.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 12, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSignedIn( + GoogleSession session, + ) { + final mobile = + MediaQuery.sizeOf(context).width < 600; + + final normalizedQuery = + _filterQuery.trim().toLowerCase(); + + final visibleSubscriptions = + normalizedQuery.isEmpty + ? _subscriptions + : _subscriptions + .where( + (channel) => + channel.title + .toLowerCase() + .contains( + normalizedQuery, + ) || + channel.description + .toLowerCase() + .contains( + normalizedQuery, + ), + ) + .toList(growable: false); + + return Column( + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + mobile ? 14 : 20, + 18, + mobile ? 6 : 12, + 10, + ), + child: Row( + children: [ + CircleAvatar( + radius: mobile ? 18 : 20, + backgroundImage: + session.photoUrl != null + ? NetworkImage( + session.photoUrl!, + ) + : null, + child: session.photoUrl == null + ? Icon(Icons.person) + : null, + ), + SizedBox(width: mobile ? 10 : 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + _subscriptions.isEmpty + ? 'Abonnements' + : 'Abonnements ' + '(${_subscriptions.length})', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: mobile ? 20 : 24, + fontWeight: + FontWeight.w700, + ), + ), + Text( + session.email ?? + session.displayName, + maxLines: 1, + overflow: + TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + ), + ), + ], + ), + ), + IconButton( + tooltip: 'Actualiser', + onPressed: + _loading + ? null + : _loadSubscriptions, + icon: + Icon(Icons.refresh), + ), + if (mobile) + PopupMenuButton( + tooltip: 'Plus', + icon: Icon(Icons.more_vert), + onSelected: (value) { + if (value == 'oauth') { + _showConfigurationDialog(); + } else if (value == 'logout') { + _disconnect(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'oauth', + child: Row( + children: [ + Icon(Icons.settings), + SizedBox(width: 10), + Text('Configuration OAuth'), + ], + ), + ), + PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout), + SizedBox(width: 10), + Text('Déconnexion'), + ], + ), + ), + ], + ) + else ...[ + IconButton( + tooltip: + 'Configuration OAuth', + onPressed: + _showConfigurationDialog, + icon: + Icon(Icons.settings), + ), + IconButton( + tooltip: 'Déconnexion', + onPressed: _disconnect, + icon: Icon(Icons.logout), + ), + ], + ], + ), + ), + if (_subscriptions.isNotEmpty) + Padding( + padding: + EdgeInsets.fromLTRB( + 18, + 4, + 18, + 10, + ), + child: TextField( + onChanged: (value) { + setState(() { + _filterQuery = value; + }); + }, + decoration: InputDecoration( + prefixIcon: + Icon(Icons.search), + hintText: + 'Rechercher parmi ' + '${_subscriptions.length} chaînes', + filled: true, + fillColor: + Theme.of(context).colorScheme.surfaceContainerLow, + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + ), + ), + ), + if (_loading) + LinearProgressIndicator( + minHeight: 2, + ), + if (_error != null) + Padding( + padding: + EdgeInsets.all(18), + child: Text( + _error!, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.redAccent, + ), + ), + ), + Expanded( + child: + !_loading && + visibleSubscriptions.isEmpty + ? Center( + child: Text( + _subscriptions.isEmpty + ? 'Aucun abonnement trouvé.' + : 'Aucune chaîne ne correspond ' + 'à "$_filterQuery".', + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + ), + ), + ) + : LayoutBuilder( + builder: ( + context, + constraints, + ) { + final mobile = + constraints.maxWidth < 600; + + if (mobile) { + return ListView.separated( + padding: + const EdgeInsets + .fromLTRB( + 12, + 8, + 12, + 24, + ), + itemCount: + visibleSubscriptions + .length, + separatorBuilder: + (_, __) => + SizedBox( + height: 8, + ), + itemBuilder: ( + context, + index, + ) { + final channel = + visibleSubscriptions[ + index]; + + return _SubscriptionListTile( + channel: channel, + onTap: () => + _openChannel( + channel, + ), + ); + }, + ); + } + + final columns = + constraints.maxWidth >= + 1100 + ? 5 + : constraints.maxWidth >= + 800 + ? 4 + : 3; + + return GridView.builder( + padding: + const EdgeInsets + .all(18), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: + columns, + crossAxisSpacing: + 12, + mainAxisSpacing: + 12, + childAspectRatio: + 1.35, + ), + itemCount: + visibleSubscriptions + .length, + itemBuilder: ( + context, + index, + ) { + final channel = + visibleSubscriptions[ + index]; + + return _SubscriptionCard( + channel: channel, + compact: false, + onTap: () => + _openChannel( + channel, + ), + ); + }, + ); + }, + ), + ), + ], + ); + } +} + + +class _OauthDialogResult { + const _OauthDialogResult({ + this.webClientId = '', + this.desktopClientId = '', + this.desktopClientSecret = '', + }); + + final String webClientId; + final String desktopClientId; + final String desktopClientSecret; +} + +class _GoogleOauthConfigDialog extends StatefulWidget { + const _GoogleOauthConfigDialog({ + required this.initial, + }); + + final GoogleOauthConfig initial; + + @override + State<_GoogleOauthConfigDialog> createState() => + _GoogleOauthConfigDialogState(); +} + +class _GoogleOauthConfigDialogState + extends State<_GoogleOauthConfigDialog> { + late final TextEditingController _webController; + late final TextEditingController _desktopIdController; + late final TextEditingController _desktopSecretController; + + bool _obscureSecret = true; + + @override + void initState() { + super.initState(); + + _webController = TextEditingController( + text: widget.initial.webClientId, + ); + + _desktopIdController = TextEditingController( + text: widget.initial.desktopClientId, + ); + + _desktopSecretController = TextEditingController( + text: widget.initial.desktopClientSecret, + ); + } + + @override + void dispose() { + _webController.dispose(); + _desktopIdController.dispose(); + _desktopSecretController.dispose(); + super.dispose(); + } + + void _submit() { + Navigator.of(context).pop( + _OauthDialogResult( + webClientId: _webController.text.trim(), + desktopClientId: + _desktopIdController.text.trim(), + desktopClientSecret: + _desktopSecretController.text.trim(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + 'Configuration Google OAuth', + ), + content: SizedBox( + width: 560, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + if (Platform.isAndroid) ...[ + Text( + 'Android', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 8), + Text( + 'Dans Google Cloud, il faut ' + 'un client OAuth Android ' + 'pour le package ' + 'com.studioweb.youtube_personal ' + 'avec le SHA-1 de l’application. ' + 'Colle ici le Client ID du client ' + 'OAuth de type "Web application".', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + height: 1.4, + ), + ), + SizedBox(height: 14), + TextField( + controller: _webController, + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: 'Client ID Web', + hintText: + '...apps.googleusercontent.com', + ), + ), + ] else if (Platform.isWindows) ...[ + Text( + 'Windows', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 8), + Text( + 'Crée un client OAuth ' + '"Desktop app" dans Google Cloud ' + 'et colle ses identifiants ici.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + height: 1.4, + ), + ), + SizedBox(height: 14), + TextField( + controller: _desktopIdController, + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: 'Client ID Desktop', + hintText: + '...apps.googleusercontent.com', + ), + ), + SizedBox(height: 12), + TextField( + controller: + _desktopSecretController, + obscureText: _obscureSecret, + decoration: InputDecoration( + border: + OutlineInputBorder(), + labelText: + 'Client Secret (si fourni)', + suffixIcon: IconButton( + onPressed: () { + setState(() { + _obscureSecret = + !_obscureSecret; + }); + }, + icon: Icon( + _obscureSecret + ? Icons.visibility_outlined + : Icons + .visibility_off_outlined, + ), + ), + ), + ), + ], + SizedBox(height: 16), + Text( + 'YouTube Data API v3 doit être ' + 'activée dans le même projet ' + 'Google Cloud.', + style: TextStyle( + color: Colors.orangeAccent, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text('Annuler'), + ), + FilledButton( + onPressed: _submit, + child: Text('Enregistrer'), + ), + ], + ); + } +} + +class _SubscriptionListTile extends StatelessWidget { + const _SubscriptionListTile({ + required this.channel, + required this.onTap, + }); + + final SubscriptionChannel channel; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + child: Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundImage: + channel.thumbnailUrl.isNotEmpty + ? NetworkImage( + channel.thumbnailUrl, + ) + : null, + child: + channel.thumbnailUrl.isEmpty + ? Icon( + Icons.account_circle, + size: 38, + ) + : null, + ), + SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + channel.title, + maxLines: 2, + overflow: + TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + SizedBox(height: 5), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.play_circle_outline, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + ), + SizedBox(width: 5), + Text( + 'Voir les vidéos', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + SizedBox(width: 8), + Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + ), + ], + ), + ), + ), + ); + } +} + +class _SubscriptionCard + extends StatelessWidget { + const _SubscriptionCard({ + required this.channel, + required this.onTap, + required this.compact, + }); + + final SubscriptionChannel channel; + final VoidCallback onTap; + final bool compact; + + @override + Widget build(BuildContext context) { + final avatarRadius = compact ? 29.0 : 34.0; + final cardPadding = compact ? 10.0 : 14.0; + + return Card( + clipBehavior: Clip.antiAlias, + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: InkWell( + onTap: onTap, + child: Padding( + padding: EdgeInsets.all(cardPadding), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + CircleAvatar( + radius: avatarRadius, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundImage: + channel.thumbnailUrl.isNotEmpty + ? NetworkImage( + channel.thumbnailUrl, + ) + : null, + child: + channel.thumbnailUrl.isEmpty + ? Icon( + Icons.account_circle, + size: + compact ? 40 : 46, + ) + : null, + ), + SizedBox( + height: compact ? 9 : 12, + ), + Flexible( + child: Text( + channel.title, + maxLines: 2, + overflow: + TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: + compact ? 13 : null, + height: 1.15, + ), + ), + ), + SizedBox( + height: compact ? 5 : 7, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.play_circle_outline, + size: compact ? 14 : 16, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + ), + SizedBox(width: 5), + Flexible( + child: Text( + 'Voir les vidéos', + maxLines: 1, + overflow: + TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: + compact ? 11 : 12, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/video_player_page.dart b/lib/screens/video_player_page.dart new file mode 100644 index 0000000..73904ad --- /dev/null +++ b/lib/screens/video_player_page.dart @@ -0,0 +1,6341 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../models/channel_details.dart'; +import '../models/subscription_channel.dart'; +import '../models/video_item.dart'; +import '../services/app_settings_service.dart'; +import '../services/background_playback_service.dart'; +import '../services/interest_profile_service.dart'; +import '../services/library_service.dart'; +import '../services/media_cache_settings_service.dart'; +import '../services/sponsor_block_service.dart'; +import '../services/youtube_service.dart'; +import '../services/windows_hd_service.dart'; +import '../services/windows_media_relay_service.dart'; +import '../services/windows_embedded_progressive_service.dart'; +import 'channel_videos_page.dart'; + +class VideoPlayerPage extends StatefulWidget { + const VideoPlayerPage({ + super.key, + required this.video, + }); + + final VideoItem video; + + @override + State createState() => _VideoPlayerPageState(); +} + +class _VideoPlayerPageState extends State + with WidgetsBindingObserver { + final YoutubeService _youtubeService = YoutubeService(); + final WindowsMediaRelayService _windowsRelay = + WindowsMediaRelayService(); + final WindowsEmbeddedProgressiveService _windowsEmbeddedProgressive = + WindowsEmbeddedProgressiveService(); + + late final Player _player; + late final VideoController _videoController; + late final FocusNode _videoFocusNode; + + final GlobalKey _videoKey = + GlobalKey(); + + // Gestes Android sur la surface vidéo : + // - double tap gauche/droite en plein écran = -10/+10 s + // - glissement vertical à droite = volume système média + // - glissement vertical à gauche = luminosité de la fenêtre + // - pincement à deux doigts = zoom vidéo + bool? _androidDoubleTapSeekLeft; + String _androidGestureMode = 'none'; + double _androidGestureStartY = 0.0; + double _androidGestureStartValue = 0.5; + double _androidSystemVolume = 0.5; + double _androidWindowBrightness = 0.5; + double _androidZoomScale = 1.0; + double _androidZoomStartScale = 1.0; + final ValueNotifier _androidGestureFeedbackNotifier = + ValueNotifier(null); + Timer? _androidGestureFeedbackTimer; + Timer? _androidPauseSleepTimer; + Timer? _androidScreenPolicyWatchdog; + DateTime? _androidPausedSince; + bool? _androidKeepScreenOnApplied; + int? _androidScheduledPauseSleepMinutes; + + Timer? _androidOrientationDebounce; + bool _androidOrientationTransitionInProgress = false; + bool? _androidPendingLandscape; + + final Map _androidPointerPositions = {}; + int? _androidPrimaryPointer; + Offset _androidGestureStartPosition = Offset.zero; + double _androidPinchStartDistance = 0.0; + bool _androidGestureActivated = false; + + bool _androidPlaybackCompleted = false; + bool _androidMediaCacheConfigured = false; + + StreamSubscription? _positionSubscription; + StreamSubscription? _playingSubscription; + StreamSubscription? _completedSubscription; + + bool _loading = true; + bool _restoringPosition = false; + bool _openingChannel = false; + bool _historyRecordedForThisOpen = false; + + ChannelDetails? _inlineChannelDetails; + VideoItem? _inlineVideoDetails; + bool _inlineChannelDetailsLoading = true; + bool _descriptionExpanded = false; + + String? _error; + + List _sponsorSegments = []; + bool _sponsorSeekInProgress = false; + String? _activeSponsorSegmentId; + + Duration _latestPosition = Duration.zero; + Duration _lastSavedPosition = Duration.zero; + double _playbackRate = 1.0; + + static const MethodChannel _pipChannel = MethodChannel( + 'com.studioweb.youtube_personal/pip', + ); + + static const MethodChannel _hdChannel = MethodChannel( + 'com.studioweb.youtube_personal/hd', + ); + + static const MethodChannel _deviceChannel = MethodChannel( + 'com.studioweb.youtube_personal/device', + ); + + bool _androidPip = false; + bool _desktopPip = false; + Rect? _desktopPreviousBounds; + bool _desktopWasMaximized = false; + + bool _backgroundAudioActive = false; + bool _screenInteractive = true; + + // URL progressive 360p stable. Elle reste volontairement utilisée + // par le Player audio dédié lorsque l'écran Android est verrouillé. + String? _playableUrl; + + // Le Player visible peut utiliser un flux vidéo adaptatif HD séparé + // du flux audio. media_kit/libmpv rattache l'audio externe au même Player. + String? _foregroundPlayableUrl; + bool _hdActive = false; + String _qualityLabel = '360p stable'; + String _qualityDetails = 'Flux audio + vidéo'; + + // Chaque nouvelle demande de qualité Windows invalide le téléchargement + // HD précédent. Le 360p stable reste lisible pendant la préparation. + int _windowsHdUpgradeGeneration = 0; + + // Qualités réellement absentes de la liste web_embedded pour la vidéo + // courante. Elles sont désactivées après la première vérification. + final Set _windowsUnavailableQualityHeights = {}; + + // Résultat d'une vérification 1440p/2160p effectuée AVANT de toucher + // au Player. _prepareAndSwitchWindowsHd le réutilise pour éviter une + // deuxième extraction yt-dlp. + Map? _windowsHdPreflight; + int? _windowsHdPreflightTarget; + + // 0 = Auto. Android et Windows. + int _selectedQualityHeight = + AppSettingsService.instance.defaultResolution.value; + + static const Map _qualityOptions = { + 0: 'Auto', + 360: '360p', + 720: '720p', + 1080: '1080p', + 1440: '1440p', + 2160: '2160p (4K)', + }; + + String get _preferredAudioLanguage => + AppSettingsService.instance.preferredLanguage.value; + + // 18.7.26 : choix audio uniquement pour la vidéo courante. + // null = suivre le réglage global ; original/ar/en/fr/... = forçage local. + String? _audioTrackOverride; + bool _audioTrackLoading = false; + + String get _effectiveAudioLanguagePreference => + _audioTrackOverride ?? _preferredAudioLanguage; + + String get _audioTrackDisplayLabel { + final value = _audioTrackOverride; + if (value == null) { + return 'Auto'; + } + if (value == 'original') { + return 'Originale'; + } + return _audioLanguageLabel(value); + } + + String _audioLanguageLabel(String raw) { + final code = raw.trim().toLowerCase().split(RegExp(r'[-_]')).first; + switch (code) { + case 'ar': + return 'Arabe'; + case 'en': + return 'Anglais'; + case 'fr': + return 'Français'; + case 'de': + return 'Allemand'; + case 'es': + return 'Espagnol'; + case 'it': + return 'Italien'; + case 'pt': + return 'Portugais'; + case 'tr': + return 'Turc'; + case 'ru': + return 'Russe'; + case 'ja': + return 'Japonais'; + case 'ko': + return 'Coréen'; + case 'hi': + return 'Hindi'; + default: + return raw.trim().isEmpty ? 'Piste audio' : raw.trim(); + } + } + + String _inferOriginalAudioLanguageHint() { + final text = '${widget.video.title} ${widget.video.author}'; + + // Dernier filet de sécurité pour les clients/extracteurs qui ne marquent + // pas explicitement la piste originale. On reste volontairement prudent : + // l'arabe est fiable grâce à son alphabet distinct. + if (RegExp(r'[\u0600-\u06FF]').hasMatch(text)) { + return 'ar'; + } + + return 'auto'; + } + + + // YouTube peut encoder une vidéo non 16:9 dans un palier qualité sans + // conserver la hauteur nominale du libellé. Exemples réels : + // - 1920x1012 = palier 1080p + // - 2560x1350 = palier 1440p + // - 3840x2026 = palier 2160p / 4K + // La validation doit donc reconnaître le palier par les dimensions, + // et non exiger exactement 1440 ou 2160 lignes. + static bool _matchesQualityTier({ + required int targetHeight, + int? width, + int? height, + }) { + final w = width ?? 0; + final h = height ?? 0; + final longest = math.max(w, h); + + if (targetHeight >= 2160) { + return longest >= 3800 || h >= 2000; + } + if (targetHeight >= 1440) { + return longest >= 2500 || h >= 1300; + } + if (targetHeight >= 1080) { + return longest >= 1900 || h >= 1000; + } + if (targetHeight >= 720) { + return longest >= 1200 || h >= 650; + } + return h >= targetHeight; + } + + // Android arrête le Player vidéo avant ACTION_SCREEN_OFF sur certains + // appareils. On mémorise donc ici l'intention de lecture dès le premier + // callback lifecycle (hidden/paused), tant que Player.state.playing vaut + // encore true. + bool _continuePlaybackOnScreenOff = false; + + Timer? _tapFeedbackTimer; + IconData? _tapFeedbackIcon; + + @override + void initState() { + super.initState(); + + if (Platform.isAndroid) { + final bufferMb = + MediaCacheSettingsService.instance.androidBufferMb.value; + _player = Player( + configuration: PlayerConfiguration( + bufferSize: bufferMb * 1024 * 1024, + ), + ); + } else { + _player = Player(); + } + _videoController = VideoController(_player); + _videoFocusNode = FocusNode( + debugLabel: 'youtube-personal-video', + ); + + _positionSubscription = _player.stream.position.listen( + _onPositionChanged, + ); + + WidgetsBinding.instance.addObserver(this); + + if (Platform.isAndroid) { + _pipChannel.setMethodCallHandler(_handlePipMethodCall); + _playingSubscription = + _player.stream.playing.listen(_onAndroidPlayingChanged); + _completedSubscription = + _player.stream.completed.listen(_onAndroidCompletedChanged); + + unawaited(_primeAndroidDeviceControls()); + unawaited(_primeAndroidOrientation()); + _startAndroidScreenPolicyWatchdog(); + + unawaited( + _pipChannel.invokeMethod( + 'requestNotificationPermission', + ), + ); + } + + unawaited( + BackgroundPlaybackService.instance.attachPlayer( + _player, + widget.video, + ), + ); + + // The card already contains the video metadata we need. + // Do not issue another watch-page request just for the description: + // that extra request contributed to YouTube rate limiting. + _inlineVideoDetails = widget.video; + + // Les notes et catégories personnelles sont chargées indépendamment du + // pipeline vidéo. Une correction manuelle doit être disponible dès que + // la page est affichée, sans déclencher de requête YouTube supplémentaire. + unawaited( + InterestProfileService.instance.ensureLoaded().then((_) { + if (mounted) { + setState(() {}); + } + }), + ); + + unawaited(_loadSponsorSegments()); + + if (Platform.isWindows) { + unawaited( + _loadVideo().whenComplete(() { + if (mounted) { + unawaited( + _loadInlineChannelDetails(), + ); + } + }), + ); + } else { + unawaited( + _loadInlineChannelDetails(), + ); + unawaited(_loadVideo()); + } + } + + Future _configureAndroidMediaCache() async { + if (!Platform.isAndroid || _androidMediaCacheConfigured) { + return; + } + + _androidMediaCacheConfigured = true; + + try { + final cache = MediaCacheSettingsService.instance; + await cache.ensureInitialized(); + + final native = _player.platform; + if (native is! NativePlayer) { + return; + } + + final mode = cache.androidDiskCacheMode.value; + + if (mode == MediaCacheSettingsService.androidDiskCacheDisabled) { + await native.setProperty('cache-on-disk', 'no'); + debugPrint( + '[MEDIA-CACHE][18.7.15] Android: RAM uniquement ' + '${cache.androidBufferMb.value}Mo', + ); + return; + } + + // Les fichiers mpv sont temporaires. On nettoie les restes d'une + // éventuelle fermeture brutale avant de préparer la nouvelle session. + await cache.clearAndroidMediaCache(mode); + + final path = await cache.androidCachePathForMode(mode); + if (path.isEmpty) { + await native.setProperty('cache-on-disk', 'no'); + return; + } + + await native.setProperty('demuxer-cache-dir', path); + await native.setProperty('demuxer-cache-unlink-files', 'whendone'); + await native.setProperty('cache-on-disk', 'yes'); + + debugPrint( + '[MEDIA-CACHE][18.7.15] Android: cache disque temporaire ' + 'mode=$mode path=$path buffer=${cache.androidBufferMb.value}Mo', + ); + } catch (error) { + // Le cache est une optimisation. Une option mpv non disponible ne doit + // jamais empêcher la lecture : on garde le comportement réseau normal. + debugPrint( + '[MEDIA-CACHE][18.7.15] configuration Android ignorée: $error', + ); + } + } + + Future _primeAndroidDeviceControls() async { + if (!Platform.isAndroid) { + return; + } + + try { + final volume = await _deviceChannel.invokeMethod( + 'getSystemVolume', + ); + final brightness = await _deviceChannel.invokeMethod( + 'getWindowBrightness', + ); + + if (volume != null) { + _androidSystemVolume = + volume.toDouble().clamp(0.0, 1.0).toDouble(); + } + + if (brightness != null) { + _androidWindowBrightness = + brightness.toDouble().clamp(0.02, 1.0).toDouble(); + } + } catch (error) { + debugPrint( + '[ANDROID-DEVICE][18.7.14] init contrôles: $error', + ); + } + } + + Future _primeAndroidOrientation() async { + if (!Platform.isAndroid) { + return; + } + + try { + final orientation = await _pipChannel.invokeMethod( + 'getOrientation', + ); + + if (!mounted || orientation == null) { + return; + } + + _scheduleAndroidFullscreenForOrientation( + orientation == 'landscape', + ); + } catch (error) { + debugPrint( + '[ANDROID-ORIENTATION][18.7.19] init: $error', + ); + } + } + + void _scheduleAndroidFullscreenForOrientation(bool landscape) { + if (!Platform.isAndroid || !mounted || _androidPip) { + return; + } + + _androidPendingLandscape = landscape; + _androidOrientationDebounce?.cancel(); + _androidOrientationDebounce = Timer( + Duration(milliseconds: 250), + () { + final target = _androidPendingLandscape; + if (target == null) { + return; + } + unawaited(_applyAndroidFullscreenForOrientation(target)); + }, + ); + } + + Future _applyAndroidFullscreenForOrientation( + bool landscape, + ) async { + if (!Platform.isAndroid || + !mounted || + _androidPip || + _androidOrientationTransitionInProgress) { + return; + } + + final state = _videoKey.currentState; + if (state == null) { + _androidOrientationDebounce?.cancel(); + _androidOrientationDebounce = Timer( + Duration(milliseconds: 300), + () => _scheduleAndroidFullscreenForOrientation(landscape), + ); + return; + } + + final fullscreen = state.isFullscreen(); + if (landscape == fullscreen) { + // media_kit peut verrouiller temporairement l'orientation lors de + // l'entrée fullscreen. On rend immédiatement la main au capteur Android + // pour que le retour portrait puisse déclencher la sortie fullscreen. + await SystemChrome.setPreferredOrientations( + [], + ); + return; + } + + _androidOrientationTransitionInProgress = true; + + try { + if (landscape) { + await state.toggleFullscreen(); + debugPrint( + '[ANDROID-ORIENTATION][18.7.19] paysage -> plein écran', + ); + } else { + await state.exitFullscreen(); + debugPrint( + '[ANDROID-ORIENTATION][18.7.19] portrait -> sortie plein écran', + ); + } + + await SystemChrome.setPreferredOrientations( + [], + ); + } catch (error) { + debugPrint( + '[ANDROID-ORIENTATION][18.7.19] sync: $error', + ); + } finally { + await Future.delayed( + Duration(milliseconds: 250), + ); + _androidOrientationTransitionInProgress = false; + } + } + + void _onAndroidPlayingChanged(bool playing) { + if (!Platform.isAndroid) { + return; + } + + if (playing) { + _androidPlaybackCompleted = false; + _androidPausedSince = null; + _androidScheduledPauseSleepMinutes = null; + _androidPauseSleepTimer?.cancel(); + unawaited(_setAndroidKeepScreenOn(true)); + return; + } + + if (_loading) { + return; + } + + if (_androidPlaybackCompleted || _player.state.completed) { + _androidScheduledPauseSleepMinutes = null; + _androidPauseSleepTimer?.cancel(); + unawaited(_setAndroidKeepScreenOn(false)); + return; + } + + _androidPausedSince ??= DateTime.now(); + _scheduleAndroidPauseScreenSleep(); + } + + void _onAndroidCompletedChanged(bool completed) { + if (!Platform.isAndroid) { + return; + } + + _androidPlaybackCompleted = completed; + + if (completed) { + _androidPausedSince = null; + _androidScheduledPauseSleepMinutes = null; + _androidPauseSleepTimer?.cancel(); + unawaited(_setAndroidKeepScreenOn(false)); + debugPrint( + '[ANDROID-SCREEN][18.7.19] fin vidéo -> veille autorisée', + ); + } + } + + void _startAndroidScreenPolicyWatchdog() { + if (!Platform.isAndroid) { + return; + } + + _androidScreenPolicyWatchdog?.cancel(); + _androidScreenPolicyWatchdog = Timer.periodic( + Duration(seconds: 1), + (_) => _syncAndroidScreenPolicy(), + ); + _syncAndroidScreenPolicy(); + } + + void _syncAndroidScreenPolicy() { + if (!Platform.isAndroid || !mounted) { + return; + } + + if (_androidPlaybackCompleted || _player.state.completed) { + _androidPausedSince = null; + _androidScheduledPauseSleepMinutes = null; + unawaited(_setAndroidKeepScreenOn(false)); + return; + } + + if (_loading || _player.state.playing) { + _androidPausedSince = null; + _androidScheduledPauseSleepMinutes = null; + unawaited(_setAndroidKeepScreenOn(true)); + return; + } + + final minutes = + AppSettingsService.instance.pauseScreenSleepMinutes.value; + + if (minutes == 0) { + _androidPausedSince = null; + _androidScheduledPauseSleepMinutes = 0; + unawaited(_setAndroidKeepScreenOn(true)); + return; + } + + _androidPausedSince ??= DateTime.now(); + + // Le watchdog ne maintient plus lui-même un Timer Flutter. Il garantit + // seulement que le timer NATIF Android a bien été armé. Ce timer reste + // valable dans la route fullscreen de media_kit. + if (_androidScheduledPauseSleepMinutes != minutes) { + _scheduleAndroidPauseScreenSleep(); + } + } + + void _scheduleAndroidPauseScreenSleep() { + if (!Platform.isAndroid) { + return; + } + + _androidPauseSleepTimer?.cancel(); + + final minutes = + AppSettingsService.instance.pauseScreenSleepMinutes.value; + + if (minutes == 0) { + _androidScheduledPauseSleepMinutes = 0; + unawaited(_setAndroidKeepScreenOn(true)); + return; + } + + _androidScheduledPauseSleepMinutes = minutes; + _androidKeepScreenOnApplied = true; + + unawaited( + _deviceChannel.invokeMethod( + 'schedulePauseScreenSleep', + { + 'milliseconds': Duration(minutes: minutes).inMilliseconds, + }, + ).then((_) { + debugPrint( + '[ANDROID-SCREEN][18.7.19] timer natif pause=${minutes}min', + ); + }).catchError((Object error) { + debugPrint( + '[ANDROID-SCREEN][18.7.19] timer natif pause: $error', + ); + _androidScheduledPauseSleepMinutes = null; + }), + ); + } + + Future _setAndroidKeepScreenOn(bool value) async { + if (!Platform.isAndroid) { + return; + } + + if (_androidKeepScreenOnApplied == value) { + return; + } + + try { + await _deviceChannel.invokeMethod( + 'setKeepScreenOn', + {'value': value}, + ); + _androidKeepScreenOnApplied = value; + debugPrint( + '[ANDROID-SCREEN][18.7.19] keepOn=$value', + ); + } catch (error) { + debugPrint( + '[ANDROID-SCREEN][18.7.19] keepOn=$value: $error', + ); + } + } + + void _showAndroidGestureFeedback(String value) { + _androidGestureFeedbackTimer?.cancel(); + + if (!mounted) { + return; + } + + _androidGestureFeedbackNotifier.value = value; + + _androidGestureFeedbackTimer = Timer( + Duration(milliseconds: 750), + () { + if (!mounted) { + return; + } + _androidGestureFeedbackNotifier.value = null; + }, + ); + } + + Future _applyAndroidVideoZoom(double scale) async { + if (!Platform.isAndroid) { + return; + } + + try { + final native = _player.platform; + if (native is! NativePlayer) { + return; + } + + // mpv video-zoom est logarithmique base 2 : + // 0.0 = 1x, 1.0 = 2x, log2(3) ~= 1.585 = 3x. + final mpvZoom = math.log(scale) / math.ln2; + await native.setProperty( + 'video-zoom', + mpvZoom.toStringAsFixed(4), + ); + } catch (error) { + debugPrint( + '[ANDROID-GESTURE][18.7.18] zoom natif: $error', + ); + } + } + + Widget _buildAndroidVideoControls(VideoState state) { + return LayoutBuilder( + builder: (context, constraints) { + return Stack( + fit: StackFit.expand, + children: [ + // Ce Listener est un PARENT des contrôles media_kit. + // Il observe donc les pointeurs sans intercepter les taps. + // media_kit recopie ce builder dans sa route fullscreen, ce qui + // rend automatiquement les mêmes gestes disponibles en plein écran. + Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) => _handleAndroidPointerDown( + event, + constraints.maxWidth, + ), + onPointerMove: (event) => _handleAndroidPointerMove( + event, + constraints.maxWidth, + constraints.maxHeight, + ), + onPointerUp: _handleAndroidPointerEnd, + onPointerCancel: _handleAndroidPointerEnd, + child: MaterialVideoControls(state), + ), + IgnorePointer( + child: Center( + child: ValueListenableBuilder( + valueListenable: _androidGestureFeedbackNotifier, + builder: (context, feedback, child) { + if (feedback == null) { + return SizedBox.shrink(); + } + + return Container( + padding: EdgeInsets.symmetric( + horizontal: 18, + vertical: 10, + ), + decoration: BoxDecoration( + color: Color(0xB8000000), + borderRadius: BorderRadius.circular(18), + ), + child: Text( + feedback, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + ); + }, + ), + ), + ), + ], + ); + }, + ); + } + + void _handleAndroidPointerDown( + PointerDownEvent event, + double width, + ) { + if (!Platform.isAndroid || width <= 0) { + return; + } + + _androidPointerPositions[event.pointer] = event.localPosition; + + if (_androidPointerPositions.length >= 2) { + final points = _androidPointerPositions.values.take(2).toList(); + _androidGestureMode = 'zoom'; + _androidGestureActivated = true; + _androidZoomStartScale = _androidZoomScale; + _androidPinchStartDistance = (points[0] - points[1]).distance; + return; + } + + _androidPrimaryPointer = event.pointer; + _androidGestureStartPosition = event.localPosition; + _androidGestureStartY = event.localPosition.dy; + _androidGestureActivated = false; + + if (event.localPosition.dx < width / 2) { + _androidGestureMode = 'brightness-pending'; + _androidGestureStartValue = _androidWindowBrightness; + } else { + _androidGestureMode = 'volume-pending'; + _androidGestureStartValue = _androidSystemVolume; + } + } + + void _handleAndroidPointerMove( + PointerMoveEvent event, + double width, + double height, + ) { + if (!Platform.isAndroid || width <= 0 || height <= 0) { + return; + } + + if (!_androidPointerPositions.containsKey(event.pointer)) { + return; + } + + _androidPointerPositions[event.pointer] = event.localPosition; + + if (_androidPointerPositions.length >= 2 || + _androidGestureMode == 'zoom') { + if (_androidPointerPositions.length < 2) { + return; + } + + final points = _androidPointerPositions.values.take(2).toList(); + final distance = (points[0] - points[1]).distance; + + if (_androidGestureMode != 'zoom' || + _androidPinchStartDistance <= 0.0) { + _androidGestureMode = 'zoom'; + _androidGestureActivated = true; + _androidZoomStartScale = _androidZoomScale; + _androidPinchStartDistance = distance; + return; + } + + if (_androidPinchStartDistance <= 0.0) { + return; + } + + final ratio = distance / _androidPinchStartDistance; + final next = (_androidZoomStartScale * ratio) + .clamp(1.0, 3.0) + .toDouble(); + + if ((next - _androidZoomScale).abs() >= 0.01) { + _androidZoomScale = next; + unawaited(_applyAndroidVideoZoom(next)); + _showAndroidGestureFeedback( + 'Zoom ${next.toStringAsFixed(1)}×', + ); + } + return; + } + + if (event.pointer != _androidPrimaryPointer) { + return; + } + + final dy = _androidGestureStartPosition.dy - event.localPosition.dy; + final dx = event.localPosition.dx - _androidGestureStartPosition.dx; + + // Laisse les petits mouvements aux taps/double-taps et aux contrôles. + if (!_androidGestureActivated) { + if (dy.abs() < 14.0) { + return; + } + if (dy.abs() <= dx.abs() * 1.15) { + return; + } + _androidGestureActivated = true; + if (_androidGestureMode == 'volume-pending') { + _androidGestureMode = 'volume'; + } else if (_androidGestureMode == 'brightness-pending') { + _androidGestureMode = 'brightness'; + } + } + + if (_androidGestureMode != 'volume' && + _androidGestureMode != 'brightness') { + return; + } + + final valueDelta = dy / (height * 0.65); + final next = (_androidGestureStartValue + valueDelta) + .clamp(0.0, 1.0) + .toDouble(); + + if (_androidGestureMode == 'volume') { + if ((next - _androidSystemVolume).abs() < 0.01) { + return; + } + _androidSystemVolume = next; + unawaited(_applyAndroidSystemVolume(next)); + _showAndroidGestureFeedback( + 'Volume ${(next * 100).round()}%', + ); + return; + } + + final brightness = next.clamp(0.02, 1.0).toDouble(); + if ((brightness - _androidWindowBrightness).abs() < 0.01) { + return; + } + _androidWindowBrightness = brightness; + unawaited(_applyAndroidWindowBrightness(brightness)); + _showAndroidGestureFeedback( + 'Luminosité ${(brightness * 100).round()}%', + ); + } + + void _handleAndroidPointerEnd(PointerEvent event) { + if (!Platform.isAndroid) { + return; + } + + _androidPointerPositions.remove(event.pointer); + + if (_androidPointerPositions.isEmpty) { + debugPrint( + '[ANDROID-GESTURE][18.7.18] mode=$_androidGestureMode ' + 'zoom=${_androidZoomScale.toStringAsFixed(2)} ' + 'volume=${(_androidSystemVolume * 100).round()}% ' + 'brightness=${(_androidWindowBrightness * 100).round()}%', + ); + _androidPrimaryPointer = null; + _androidGestureMode = 'none'; + _androidGestureActivated = false; + _androidPinchStartDistance = 0.0; + return; + } + + // Après un pincement, on attend que tous les doigts soient relevés pour + // éviter qu'un doigt restant soit interprété comme volume/luminosité. + if (_androidGestureMode == 'zoom') { + _androidGestureMode = 'ignore-until-up'; + } + } + + Future _applyAndroidSystemVolume(double value) async { + try { + await _deviceChannel.invokeMethod( + 'setSystemVolume', + {'value': value}, + ); + } catch (error) { + debugPrint( + '[ANDROID-GESTURE][18.7.18] volume natif: $error', + ); + } + } + + Future _applyAndroidWindowBrightness(double value) async { + try { + await _deviceChannel.invokeMethod( + 'setWindowBrightness', + {'value': value}, + ); + } catch (error) { + debugPrint( + '[ANDROID-GESTURE][18.7.18] luminosité native: $error', + ); + } + } + + void _handleAndroidScaleStart( + ScaleStartDetails details, + double width, + ) { + if (!Platform.isAndroid || width <= 0) { + _androidGestureMode = 'none'; + return; + } + + if (details.pointerCount >= 2) { + _androidGestureMode = 'zoom'; + _androidZoomStartScale = _androidZoomScale; + return; + } + + _androidGestureStartY = details.localFocalPoint.dy; + + if (details.localFocalPoint.dx < width / 2) { + _androidGestureMode = 'brightness'; + _androidGestureStartValue = _androidWindowBrightness; + } else { + _androidGestureMode = 'volume'; + _androidGestureStartValue = _androidSystemVolume; + } + } + + void _handleAndroidScaleUpdate( + ScaleUpdateDetails details, + double height, + ) { + if (!Platform.isAndroid || height <= 0) { + return; + } + + if (details.pointerCount >= 2) { + if (_androidGestureMode != 'zoom') { + _androidGestureMode = 'zoom'; + _androidZoomStartScale = _androidZoomScale; + } + + final next = (_androidZoomStartScale * details.scale) + .clamp(1.0, 3.0) + .toDouble(); + + if ((next - _androidZoomScale).abs() < 0.01) { + return; + } + + _androidZoomScale = next; + unawaited(_applyAndroidVideoZoom(next)); + _showAndroidGestureFeedback( + 'Zoom ${next.toStringAsFixed(1)}×', + ); + return; + } + + if (_androidGestureMode != 'volume' && + _androidGestureMode != 'brightness') { + return; + } + + final delta = + _androidGestureStartY - details.localFocalPoint.dy; + final valueDelta = delta / (height * 0.65); + final next = (_androidGestureStartValue + valueDelta) + .clamp(0.0, 1.0) + .toDouble(); + + if (_androidGestureMode == 'volume') { + if ((next - _androidSystemVolume).abs() < 0.01) { + return; + } + _androidSystemVolume = next; + unawaited( + _deviceChannel.invokeMethod( + 'setSystemVolume', + {'value': next}, + ), + ); + _showAndroidGestureFeedback( + 'Volume ${(next * 100).round()}%', + ); + return; + } + + final brightness = next.clamp(0.02, 1.0).toDouble(); + if ((brightness - _androidWindowBrightness).abs() < 0.01) { + return; + } + _androidWindowBrightness = brightness; + unawaited( + _deviceChannel.invokeMethod( + 'setWindowBrightness', + {'value': brightness}, + ), + ); + _showAndroidGestureFeedback( + 'Luminosité ${(brightness * 100).round()}%', + ); + } + + void _handleAndroidScaleEnd(ScaleEndDetails details) { + if (!Platform.isAndroid) { + return; + } + + debugPrint( + '[ANDROID-GESTURE][18.7.14] mode=$_androidGestureMode ' + 'zoom=${_androidZoomScale.toStringAsFixed(2)} ' + 'volume=${(_androidSystemVolume * 100).round()}% ' + 'brightness=${(_androidWindowBrightness * 100).round()}%', + ); + + _androidGestureMode = 'none'; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (!Platform.isAndroid) { + return; + } + + final playingNow = _player.state.playing; + + // Important sur Xiaomi/HyperOS et certains Android récents : + // hidden/paused arrive AVANT que le système stoppe AudioTrack. + // C'est donc ici qu'on sauvegarde le vrai état "était en lecture". + if (state == AppLifecycleState.inactive || + state == AppLifecycleState.hidden) { + // Ces callbacks arrivent assez tôt pour refléter l'état réel choisi + // par l'utilisateur. Si la vidéo est déjà en pause, on mémorise false. + _continuePlaybackOnScreenOff = playingNow; + } else if (state == AppLifecycleState.paused && + playingNow) { + // Si Android passe directement par paused alors que le Player joue + // encore, on conserve true. En revanche un paused=false tardif ne doit + // jamais écraser un true capturé juste avant l'arrêt système d'AudioTrack. + _continuePlaybackOnScreenOff = true; + } + + if (state == AppLifecycleState.resumed && + !_backgroundAudioActive) { + _continuePlaybackOnScreenOff = false; + } + + debugPrint( + '[BACKGROUND] lifecycle=$state ' + 'pip=$_androidPip ' + 'playing=$playingNow ' + 'continueOnLock=$_continuePlaybackOnScreenOff', + ); + } + + Future _switchToDedicatedBackgroundAudio() async { + if (_backgroundAudioActive || + _loading || + _error != null || + _playableUrl == null) { + return; + } + + final shouldPlay = + _continuePlaybackOnScreenOff || + _player.state.playing; + + final position = + _latestPosition > _player.state.position + ? _latestPosition + : _player.state.position; + + debugPrint( + '[BACKGROUND] start dedicated audio ' + 'at ${position.inMilliseconds} ms ' + '(playerPlaying=${_player.state.playing} ' + 'continueOnLock=$_continuePlaybackOnScreenOff)', + ); + + try { + await BackgroundPlaybackService.instance.enterBackgroundAudio( + url: _playableUrl!, + video: widget.video, + position: position, + shouldPlay: shouldPlay, + rate: _playbackRate, + ); + + _backgroundAudioActive = true; + _continuePlaybackOnScreenOff = false; + + // Le Player vidéo a déjà perdu sa Surface ou est sur le point de + // la perdre. On le laisse en pause : le Player de fond est désormais + // la source audio officielle pour la MediaSession. + if (_player.state.playing) { + await _player.pause(); + } + + debugPrint( + '[BACKGROUND] dedicated audio active', + ); + } catch (error) { + debugPrint( + '[BACKGROUND] dedicated audio failed: $error', + ); + } + } + + Future _restoreVideoFromBackgroundAudio() async { + if (!_backgroundAudioActive) { + return; + } + + debugPrint( + '[BACKGROUND] restore video from dedicated audio', + ); + + try { + final snapshot = + await BackgroundPlaybackService.instance.exitBackgroundAudio( + foregroundPlayer: _player, + video: widget.video, + ); + + _backgroundAudioActive = false; + _continuePlaybackOnScreenOff = false; + + if (snapshot == null) { + return; + } + + // Laisse Android recréer complètement la Surface du FlutterSurfaceView. + await Future.delayed( + Duration(milliseconds: 650), + ); + + if (!mounted) { + return; + } + + await _seekAndConfirm(snapshot.position); + await _player.setRate(snapshot.rate); + + _latestPosition = snapshot.position; + _lastSavedPosition = snapshot.position; + + debugPrint( + '[BACKGROUND] foreground seek confirmed ' + 'at ${snapshot.position.inMilliseconds} ms', + ); + + if (snapshot.playing) { + await _player.play(); + } else { + await _player.pause(); + } + + debugPrint( + '[BACKGROUND] video restored ' + 'at ${snapshot.position.inMilliseconds} ms ' + 'playing=${snapshot.playing}', + ); + } catch (error) { + debugPrint( + '[BACKGROUND] restore video failed: $error', + ); + } + } + + Future _handlePipMethodCall( + MethodCall call, + ) async { + if (call.method == 'pipChanged' && mounted) { + setState(() { + _androidPip = call.arguments == true; + }); + + return null; + } + + if (call.method == 'screenInteractiveChanged') { + final interactive = call.arguments == true; + _screenInteractive = interactive; + + debugPrint( + '[BACKGROUND] native screen interactive=$interactive ' + 'pip=$_androidPip ' + 'playing=${_player.state.playing}', + ); + + if (!interactive) { + await _switchToDedicatedBackgroundAudio(); + } else { + await _restoreVideoFromBackgroundAudio(); + } + + return null; + } + + if (call.method == 'pauseScreenSleepReleased') { + // Le timer natif vient réellement de retirer KEEP_SCREEN_ON. + _androidKeepScreenOnApplied = false; + debugPrint( + '[ANDROID-SCREEN][18.7.19] timer natif -> keepOn=false', + ); + return null; + } + + if (call.method == 'orientationChanged') { + final orientation = call.arguments?.toString(); + if (orientation == 'landscape' || orientation == 'portrait') { + _scheduleAndroidFullscreenForOrientation( + orientation == 'landscape', + ); + } + return null; + } + + return null; + } + + Future _togglePlayPause() async { + if (_loading || _error != null) { + return; + } + + if (_player.state.playing) { + _continuePlaybackOnScreenOff = false; + await _player.pause(); + _showTapFeedback(Icons.pause_rounded); + } else { + _continuePlaybackOnScreenOff = true; + await _player.play(); + _showTapFeedback(Icons.play_arrow_rounded); + } + } + + void _showTapFeedback(IconData icon) { + _tapFeedbackTimer?.cancel(); + + if (!mounted) { + return; + } + + setState(() { + _tapFeedbackIcon = icon; + }); + + _tapFeedbackTimer = Timer( + Duration(milliseconds: 500), + () { + if (!mounted) { + return; + } + + setState(() { + _tapFeedbackIcon = null; + }); + }, + ); + } + + Future _togglePictureInPicture() async { + if (Platform.isAndroid) { + try { + setState(() { + _androidPip = true; + }); + + final entered = await _pipChannel.invokeMethod( + 'enterPip', + ); + + if (entered != true && mounted) { + setState(() { + _androidPip = false; + }); + } + } catch (error) { + if (mounted) { + setState(() { + _androidPip = false; + }); + } + + debugPrint('[PIP][ANDROID] $error'); + } + + return; + } + + if (Platform.isWindows) { + if (_desktopPip) { + await _exitDesktopPip(); + } else { + await _enterDesktopPip(); + } + } + } + + Future _enterDesktopPip() async { + _desktopPreviousBounds = await windowManager.getBounds(); + _desktopWasMaximized = await windowManager.isMaximized(); + + if (_desktopWasMaximized) { + await windowManager.unmaximize(); + } + + if (mounted) { + setState(() { + _desktopPip = true; + }); + } + + await windowManager.setAlwaysOnTop(true); + await windowManager.setResizable(true); + await windowManager.setSize( + Size(480, 300), + animate: true, + ); + await windowManager.setAlignment( + Alignment.bottomRight, + animate: true, + ); + } + + Future _exitDesktopPip() async { + await windowManager.setAlwaysOnTop(false); + + final previousBounds = _desktopPreviousBounds; + + if (previousBounds != null) { + await windowManager.setBounds( + previousBounds, + animate: true, + ); + } + + if (_desktopWasMaximized) { + await windowManager.maximize(); + } + + if (mounted) { + setState(() { + _desktopPip = false; + }); + } + } + + Future _loadSponsorSegments() async { + final segments = await SponsorBlockService.instance + .getSponsorSegments(widget.video.id); + + if (!mounted) { + return; + } + + _sponsorSegments = segments; + + if (segments.isNotEmpty) { + debugPrint( + '[SPONSORBLOCK] ${segments.length} segment(s) sponsor trouvé(s) ' + 'pour ${widget.video.id}', + ); + + unawaited(_maybeSkipSponsor(_player.state.position)); + } + } + + Future _maybeSkipSponsor(Duration position) async { + if (_sponsorSeekInProgress || + _restoringPosition || + _backgroundAudioActive || + _sponsorSegments.isEmpty) { + return; + } + + SponsorSegment? match; + + for (final segment in _sponsorSegments) { + if (segment.contains(position)) { + match = segment; + break; + } + } + + if (match == null) { + _activeSponsorSegmentId = null; + return; + } + + if (_activeSponsorSegmentId == match.id) { + return; + } + + _activeSponsorSegmentId = match.id; + _sponsorSeekInProgress = true; + + final wasPlaying = _player.state.playing; + final target = match.end + Duration(milliseconds: 80); + + try { + debugPrint( + '[SPONSORBLOCK] skip ' + '${match.start.inMilliseconds} -> ${match.end.inMilliseconds} ms', + ); + + await _player.seek(target); + + if (wasPlaying) { + await _player.play(); + } + + _latestPosition = target; + _lastSavedPosition = target; + + unawaited( + LibraryService.instance.savePlaybackPosition( + widget.video.id, + target, + duration: widget.video.duration, + ), + ); + + if (mounted) { + final seconds = match.duration.inSeconds; + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + 'Sponsor ignoré automatiquement' + '${seconds > 0 ? ' • ${seconds}s' : ''}', + ), + duration: Duration(milliseconds: 1400), + ), + ); + } + } catch (error) { + debugPrint('[SPONSORBLOCK] skip impossible: $error'); + } finally { + _sponsorSeekInProgress = false; + } + } + + void _onPositionChanged(Duration position) { + _latestPosition = position; + + if (_restoringPosition) { + return; + } + + unawaited(_maybeSkipSponsor(position)); + + if ((position - _lastSavedPosition).abs() >= + Duration(seconds: 5)) { + _lastSavedPosition = position; + + unawaited( + LibraryService.instance.savePlaybackPosition( + widget.video.id, + position, + duration: widget.video.duration, + ), + ); + } + } + + List> _parseAvailableAudioTracks( + Map? info, + ) { + final byKey = >{}; + + final rawTracks = info?['availableAudioTracks']; + if (rawTracks is List) { + for (final raw in rawTracks) { + if (raw is! Map) { + continue; + } + final map = raw.map( + (key, value) => MapEntry(key.toString(), value), + ); + final key = (map['key'] ?? map['language'] ?? '') + .toString() + .trim() + .toLowerCase(); + if (key.isEmpty || key == 'und' || key == 'auto') { + continue; + } + final original = map['original'] == true || key == 'original'; + final normalizedKey = original ? 'original' : key; + final rawLabel = (map['label'] ?? '').toString().trim(); + byKey.putIfAbsent( + normalizedKey, + () => { + 'key': normalizedKey, + 'label': original + ? (rawLabel.isEmpty ? 'Originale' : 'Originale • $rawLabel') + : (rawLabel.isEmpty ? _audioLanguageLabel(key) : rawLabel), + 'original': original, + }, + ); + } + } + + // Compatibilité avec l'extracteur Android 18.7.25. + final rawLanguages = info?['availableAudioLanguages']; + if (rawLanguages is List) { + for (final raw in rawLanguages) { + final value = raw?.toString().trim() ?? ''; + if (value.isEmpty) { + continue; + } + final lower = value.toLowerCase(); + String key = lower.split(RegExp(r'[-_]')).first; + if (lower.contains('original') || + lower.contains('الأصلية') || + lower.contains('أصلي') || + lower.contains('اصلي')) { + key = 'original'; + } + if (key == 'und' || key == 'auto') { + continue; + } + byKey.putIfAbsent( + key, + () => { + 'key': key, + 'label': key == 'original' + ? 'Originale' + : _audioLanguageLabel(key), + 'original': key == 'original', + }, + ); + } + } + + final values = byKey.values.toList(growable: false); + values.sort((a, b) { + if (a['original'] == true && b['original'] != true) return -1; + if (b['original'] == true && a['original'] != true) return 1; + return (a['label'] as String).compareTo(b['label'] as String); + }); + return values; + } + + Future?> _discoverAudioTrackInfo() async { + final currentHeight = _selectedQualityHeight; + final targetHeight = currentHeight <= 360 + ? 720 + : currentHeight <= 0 + ? 1080 + : currentHeight; + + if (Platform.isAndroid) { + return _hdChannel.invokeMapMethod( + 'getHdStream', + { + 'videoId': widget.video.id, + 'targetHeight': targetHeight, + // La découverte ne doit pas dépendre de la piste déjà choisie. + 'preferredLanguage': 'auto', + 'originalLanguageHint': _inferOriginalAudioLanguageHint(), + }, + ); + } + + if (Platform.isWindows) { + return WindowsHdService.instance.getEmbeddedHdStream( + videoId: widget.video.id, + targetHeight: targetHeight, + preferredLanguage: 'auto', + originalLanguageHint: _inferOriginalAudioLanguageHint(), + ); + } + + return null; + } + + Future _showAudioTrackPicker() async { + if (!(Platform.isAndroid || Platform.isWindows) || + _audioTrackLoading || + _loading) { + return; + } + + setState(() { + _audioTrackLoading = true; + }); + + Map? info; + try { + info = await _discoverAudioTrackInfo().timeout( + const Duration(seconds: 15), + ); + } catch (error) { + debugPrint( + '[AUDIO-TRACK][18.7.26] découverte impossible: $error', + ); + } finally { + if (mounted) { + setState(() { + _audioTrackLoading = false; + }); + } + } + + if (!mounted) { + return; + } + + final tracks = _parseAvailableAudioTracks(info); + final choices = >[ + { + 'key': 'settings', + 'label': 'Auto (selon les réglages)', + 'original': false, + }, + ...tracks, + ]; + + if (!choices.any((item) => item['key'] == 'original')) { + choices.insert( + 1, + { + 'key': 'original', + 'label': 'Originale', + 'original': true, + }, + ); + } + + final selectedKey = _audioTrackOverride ?? 'settings'; + + final picked = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + return SafeArea( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 12), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 10), + child: Text( + 'Piste audio', + style: Theme.of(sheetContext).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + for (final choice in choices) + RadioListTile( + value: choice['key'] as String, + groupValue: selectedKey, + title: Text(choice['label'] as String), + subtitle: choice['key'] == 'settings' + ? Text( + 'Réglage global : ${_preferredAudioLanguage == 'auto' ? 'originale prioritaire' : _audioLanguageLabel(_preferredAudioLanguage)}', + ) + : null, + onChanged: (value) { + if (value != null) { + Navigator.of(sheetContext).pop(value); + } + }, + ), + ], + ), + ); + }, + ); + + if (picked == null || !mounted) { + return; + } + + await _applyAudioTrackChoice(picked); + } + + Future _applyAudioTrackChoice(String choice) async { + final override = choice == 'settings' ? null : choice; + + if (_audioTrackOverride == override) { + return; + } + + setState(() { + _audioTrackOverride = override; + }); + + final currentHeight = _selectedQualityHeight; + final targetHeight = currentHeight == 360 + ? 720 + : currentHeight; + + if (currentHeight == 360 && mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text( + 'Le changement de piste audio nécessite un flux HD séparé.', + ), + duration: Duration(seconds: 2), + ), + ); + } + + debugPrint( + '[AUDIO-TRACK][18.7.26] choix=${override ?? 'settings'} ' + 'préférence=$_effectiveAudioLanguagePreference ' + 'cible=${targetHeight}p', + ); + + await _changeQuality(targetHeight, force: true); + } + + Future _loadVideo() async { + await _loadVideoInternal(); + } + + Future _changeQuality(int height, {bool force = false}) async { + if (!(Platform.isAndroid || Platform.isWindows) || + _loading || + (!force && height == _selectedQualityHeight)) { + return; + } + + // 18.7.9 Android uniquement : la bascule de qualité ne repasse plus + // par _loadVideoInternal. Cette ancienne voie rechargeait aussi le flux + // stable, effectuait un second seek et gardait l'overlay _loading actif + // bien après que MediaCodec avait déjà confirmé le 720p. + // + // Windows ne passe JAMAIS ici et conserve intégralement sa baseline + // protégée 18.7.5. + if (Platform.isAndroid) { + await _changeAndroidQuality(height, force: force); + return; + } + + // Windows : ne jamais faire semblant qu'une 4K/1440p existe. + // + // Le log précédent montrait cible=2160 puis format 303 1920x1080. + // Cela signifie que web_embedded n'exposait pas de 2160p pour cette + // vidéo. On vérifie donc AVANT d'interrompre la lecture actuelle. + if (Platform.isWindows && height > 1080) { + if (_windowsUnavailableQualityHeights.contains(height)) { + _showUnavailableWindowsQuality(height); + return; + } + + debugPrint( + '[HD-WINDOWS][17.21] vérification stricte ' + '${_qualityOptions[height] ?? '${height}p'} ' + 'avant changement', + ); + + try { + final preflight = + await WindowsHdService.instance.getEmbeddedHdStream( + videoId: widget.video.id, + targetHeight: height, + preferredLanguage: _effectiveAudioLanguagePreference, + originalLanguageHint: _inferOriginalAudioLanguageHint(), + ); + + if (!mounted) { + return; + } + + final actualHeight = + preflight?['height']; + + final rawAvailable = + preflight?['availableHeights']; + + final available = rawAvailable is List + ? rawAvailable + .whereType() + .map((value) => value.toInt()) + .toSet() + : {}; + + final actualWidth = preflight?['width']; + final tierMatched = + preflight != null && + actualHeight is int && + _matchesQualityTier( + targetHeight: height, + width: actualWidth is int ? actualWidth : null, + height: actualHeight, + ); + + if (mounted) { + setState(() { + if (tierMatched) { + _windowsUnavailableQualityHeights.remove(height); + } else { + _windowsUnavailableQualityHeights.add(height); + } + }); + } + + if (!tierMatched) { + debugPrint( + '[HD-WINDOWS][17.21] ' + '${height}p indisponible; ' + 'résolution réelle max obtenue=' + '${actualHeight ?? 'aucune'} ' + 'disponibles=$available', + ); + + _showUnavailableWindowsQuality( + height, + actualHeight: + actualHeight is int ? actualHeight : null, + ); + + // Très important : on garde la vidéo actuelle telle quelle. + // Pas de retour 360p, pas de rechargement, pas de faux "4K". + return; + } + + _windowsHdPreflight = preflight; + _windowsHdPreflightTarget = height; + } catch (error) { + debugPrint( + '[HD-WINDOWS][17.21] vérification qualité ' + '${height}p impossible: $error', + ); + + if (mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + 'Impossible de vérifier cette qualité pour le moment.', + ), + duration: Duration(seconds: 2), + ), + ); + } + + return; + } + } else if (Platform.isWindows) { + _windowsHdPreflight = null; + _windowsHdPreflightTarget = null; + } + + final currentPosition = + _player.state.position > _latestPosition + ? _player.state.position + : _latestPosition; + + final wasPlaying = _player.state.playing; + + if (Platform.isWindows) { + _windowsHdUpgradeGeneration++; + unawaited( + _windowsEmbeddedProgressive.stop(), + ); + } + + setState(() { + _selectedQualityHeight = height; + }); + + debugPrint( + '[HD] changement qualité -> ' + '${_qualityOptions[height] ?? height}', + ); + + await _loadVideoInternal( + forcedResumePosition: currentPosition, + forcePlay: wasPlaying, + ); + } + + Future _changeAndroidQuality(int height, {bool force = false}) async { + if (!Platform.isAndroid || + _loading || + (!force && height == _selectedQualityHeight)) { + return; + } + + final previousHeight = _selectedQualityHeight; + final previousLabel = _qualityLabel; + final previousDetails = _qualityDetails; + + final switchPosition = + _player.state.position > _latestPosition + ? _player.state.position + : _latestPosition; + final wasPlaying = _player.state.playing; + + debugPrint( + '[HD-ANDROID][18.7.10] changement qualité ' + '${_qualityOptions[previousHeight] ?? previousHeight} -> ' + '${_qualityOptions[height] ?? height} ' + 'à ${switchPosition.inMilliseconds}ms', + ); + + // 360p est déjà un flux muxé audio+vidéo. On peut revenir dessus sans + // interroger de nouveau l'extracteur natif HD. + if (height == 360) { + try { + final stableUrl = + (_playableUrl != null && _playableUrl!.isNotEmpty) + ? _playableUrl! + : await _youtubeService.getPlayableMuxedUrl( + widget.video.id, + ); + + if (!mounted) { + return; + } + + setState(() { + _selectedQualityHeight = 360; + _loading = true; + _error = null; + _qualityLabel = '360p'; + _qualityDetails = 'Bascule Android…'; + }); + + await _player.open( + Media( + stableUrl, + start: switchPosition, + ), + play: false, + ); + + await _player.setRate(_playbackRate).timeout( + Duration(seconds: 3), + onTimeout: () {}, + ); + + if (wasPlaying) { + await _player.play().timeout( + Duration(seconds: 3), + onTimeout: () {}, + ); + } else { + await _player.pause().timeout( + Duration(seconds: 3), + onTimeout: () {}, + ); + } + + _hdActive = false; + _foregroundPlayableUrl = stableUrl; + + if (mounted) { + setState(() { + _loading = false; + _qualityLabel = '360p'; + _qualityDetails = 'Qualité choisie'; + }); + } + + debugPrint( + '[HD-ANDROID][18.7.10] retour 360p confirmé ' + 'à ${switchPosition.inMilliseconds}ms', + ); + return; + } catch (error) { + debugPrint( + '[HD-ANDROID][18.7.10] retour 360p échoué: $error', + ); + + if (mounted) { + setState(() { + _selectedQualityHeight = previousHeight; + _loading = false; + _qualityLabel = previousLabel; + _qualityDetails = previousDetails; + }); + } + return; + } + } + + // Préparer le flux HD pendant que la qualité actuelle continue à jouer. + // Aucun overlay plein écran tant que l'extraction native n'est pas prête. + if (mounted) { + setState(() { + _selectedQualityHeight = height; + _qualityDetails = + 'Préparation ${_qualityOptions[height] ?? '${height}p'}…'; + }); + } + + Map? hd; + try { + hd = await _requestPlatformHdStream(height).timeout( + Duration(seconds: 15), + ); + } catch (error) { + debugPrint( + '[HD-ANDROID][18.7.10] extraction ${height}p échouée: $error', + ); + + if (mounted) { + setState(() { + _selectedQualityHeight = previousHeight; + _qualityLabel = previousLabel; + _qualityDetails = previousDetails; + _loading = false; + }); + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + '${_qualityOptions[height] ?? '${height}p'} indisponible pour le moment.', + ), + duration: Duration(seconds: 2), + ), + ); + } + return; + } + + final videoUrl = hd?['videoUrl']; + final audioUrl = hd?['audioUrl']; + final width = hd?['width']; + final resolvedHeight = hd?['height']; + final fps = hd?['fps']; + final videoCodec = hd?['videoCodec']; + final audioCodec = hd?['audioCodec']; + final audioLanguage = hd?['audioLanguage']; + final audioTrackName = hd?['audioTrackName']; + final preferredAudioMatched = + hd?['preferredAudioMatched'] == true; + final exactTargetMatched = + hd?['exactTargetMatched'] == true; + final availableHeightsRaw = hd?['availableHeights']; + final availableHeights = []; + if (availableHeightsRaw is List) { + for (final value in availableHeightsRaw) { + if (value is int) { + availableHeights.add(value); + } + } + availableHeights.sort(); + } + + debugPrint( + '[HD-ANDROID][18.7.10] extracteur cible=$height ' + 'résolue=${resolvedHeight ?? '?'} ' + 'exact=$exactTargetMatched ' + 'disponibles=$availableHeights', + ); + + if (videoUrl is! String || + videoUrl.isEmpty || + audioUrl is! String || + audioUrl.isEmpty || + resolvedHeight is! int || + resolvedHeight < 720) { + debugPrint( + '[HD-ANDROID][18.7.10] paire ${height}p invalide', + ); + + if (mounted) { + setState(() { + _selectedQualityHeight = previousHeight; + _qualityLabel = previousLabel; + _qualityDetails = previousDetails; + _loading = false; + }); + } + return; + } + + // Sur Android, un choix 1440p/2160p ne doit jamais être validé par + // un flux 1080p. Si la vidéo ne propose pas la hauteur demandée, + // on conserve la qualité précédente et on l'indique clairement. + if (height > 1080 && + !_matchesQualityTier( + targetHeight: height, + width: width is int ? width : null, + height: resolvedHeight, + )) { + final maxAvailable = availableHeights.isEmpty + ? resolvedHeight + : availableHeights.last; + + debugPrint( + '[HD-ANDROID][18.7.10] ${height}p refusée: ' + 'extracteur=${resolvedHeight}p max=$maxAvailable ' + 'disponibles=$availableHeights', + ); + + if (mounted) { + setState(() { + _selectedQualityHeight = previousHeight; + _qualityLabel = previousLabel; + _qualityDetails = previousDetails; + _loading = false; + }); + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + '${_qualityOptions[height] ?? '${height}p'} ' + 'n’est pas disponible pour cette vidéo' + '${maxAvailable > 0 ? ' (max ${maxAvailable}p).' : '.'}', + ), + duration: Duration(seconds: 3), + ), + ); + } + return; + } + + final resolvedWidth = width is int ? width : 0; + final resolvedFps = fps is int ? fps : 0; + final resolvedAudioLanguage = + audioLanguage is String && audioLanguage.trim().isNotEmpty + ? audioLanguage.trim() + : 'und'; + final resolvedAudioTitle = + audioTrackName is String && audioTrackName.trim().isNotEmpty + ? audioTrackName.trim() + : preferredAudioMatched + ? 'YouTube audio • langue préférée' + : 'YouTube audio'; + + try { + // Le flux est déjà résolu. L'overlay ne couvre l'écran que pendant la + // vraie bascule du Player, pas pendant l'extraction NewPipe. + if (mounted) { + setState(() { + _loading = true; + _error = null; + _restoringPosition = switchPosition > Duration.zero; + _qualityLabel = '${resolvedHeight}p'; + _qualityDetails = 'Bascule Android…'; + }); + } + + // Point clé du correctif : media_kit ouvre directement le nouveau média + // au timestamp courant. On évite le cycle 0s -> lecture -> seek -> flush + // observé dans les logs 18.7.8. + await _player.open( + Media( + videoUrl, + start: switchPosition, + ), + play: false, + ); + + await _player.setAudioTrack( + AudioTrack.uri( + audioUrl, + title: resolvedAudioTitle, + language: resolvedAudioLanguage, + ), + ); + + await _player.setRate(_playbackRate).timeout( + Duration(seconds: 3), + onTimeout: () {}, + ); + + // Un play bref force MediaCodec à publier la résolution réellement + // décodée. Le timeout protège l'UI : il ne peut plus rester bloqué en + // chargement infini à cause d'un Future natif qui tarde à se terminer. + await _player.play().timeout( + Duration(seconds: 3), + onTimeout: () {}, + ); + + final actualHeight = + await _waitForAndroidSelectedResolution( + resolvedHeight, + ).timeout( + Duration(seconds: 12), + ); + final actualWidth = _player.state.width ?? resolvedWidth; + + _hdActive = true; + _foregroundPlayableUrl = videoUrl; + _latestPosition = switchPosition; + _lastSavedPosition = switchPosition; + _restoringPosition = false; + + // IMPORTANT : on libère l'overlay dès que le média HD est réellement + // prêt. Historique, compteur de vues et métadonnées ne participent pas + // à une simple bascule de qualité. + if (mounted) { + setState(() { + _loading = false; + + final fpsSuffix = resolvedFps > 30 ? '$resolvedFps' : ''; + _qualityLabel = '${actualHeight}p$fpsSuffix'; + + final languageText = resolvedAudioLanguage != 'und' + ? ' • audio ${resolvedAudioLanguage.toUpperCase()}' + : ' • audio séparé'; + final preferredText = preferredAudioMatched + ? ' • langue préférée' + : ''; + + _qualityDetails = + '${actualWidth}x$actualHeight' + '$languageText' + '$preferredText'; + }); + } + + if (!wasPlaying) { + await _player.pause().timeout( + Duration(seconds: 2), + onTimeout: () {}, + ); + } + + debugPrint( + '[HD-ANDROID][18.7.10] bascule confirmée ' + '${actualWidth}x$actualHeight ' + 'video=${videoCodec ?? '?'} ' + 'audio=${audioCodec ?? '?'} ' + 'position=${switchPosition.inMilliseconds}ms', + ); + } catch (error) { + debugPrint( + '[HD-ANDROID][18.7.10] bascule ${height}p échouée: $error', + ); + + // Si l'ouverture HD a déjà remplacé le média courant, on restaure le + // 360p connu plutôt que de laisser un écran noir ou un spinner infini. + try { + final stableUrl = _playableUrl; + if (stableUrl != null && stableUrl.isNotEmpty) { + await _player.open( + Media( + stableUrl, + start: switchPosition, + ), + play: wasPlaying, + ).timeout(Duration(seconds: 8)); + _foregroundPlayableUrl = stableUrl; + _hdActive = false; + } + } catch (restoreError) { + debugPrint( + '[HD-ANDROID][18.7.10] restauration 360p échouée: $restoreError', + ); + } + + _restoringPosition = false; + + if (mounted) { + setState(() { + _selectedQualityHeight = previousHeight; + _loading = false; + _qualityLabel = previousLabel; + _qualityDetails = previousDetails; + }); + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + 'La bascule HD a échoué. La lecture précédente a été conservée.', + ), + duration: Duration(seconds: 2), + ), + ); + } + } + } + + void _showUnavailableWindowsQuality( + int requestedHeight, { + int? actualHeight, + }) { + if (!mounted) { + return; + } + + final requested = + _qualityOptions[requestedHeight] ?? + '${requestedHeight}p'; + + final suffix = actualHeight != null && + actualHeight > 0 + ? ' Cette vidéo est disponible ici jusqu’à ' + '${actualHeight}p.' + : ''; + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + '$requested n’est pas disponible pour cette vidéo.$suffix', + ), + duration: Duration(seconds: 3), + ), + ); + } + + Future _loadVideoInternal({ + Duration? forcedResumePosition, + bool? forcePlay, + }) async { + if (Platform.isAndroid) { + await _configureAndroidMediaCache(); + } + + final shouldPlayAfterLoad = + forcePlay ?? true; + + final windowsStartupWatch = + Platform.isWindows + ? (Stopwatch()..start()) + : null; + + if (mounted) { + setState(() { + _loading = true; + _error = null; + _hdActive = false; + + if (Platform.isWindows) { + _qualityLabel = + _selectedQualityHeight <= 0 + ? 'Auto' + : '${_selectedQualityHeight}p'; + _qualityDetails = + 'Démarrage rapide 360p • secours yt-dlp automatique'; + } else { + _qualityLabel = + '360p stable'; + _qualityDetails = + 'Flux audio + vidéo'; + } + }); + } + + try { + late final String stableUrl; + + if (Platform.isWindows) { + // 18.7.5 : restauration du chemin 17.18/17.19 qui était le seul + // démarrage réellement immédiat et déjà validé par l'utilisateur. + // + // On tente d'abord le flux muxé 360p de youtube_explode. Cette voie + // donne directement image + son au Player et évite d'attendre le + // prébuffer yt-dlp web_embedded. + // + // IMPORTANT : 18.7.2 avait supprimé cette voie parce que /watch avait + // été temporairement rate-limité. On ne revient pas à cette régression : + // en cas d'erreur ou de timeout court, le pipeline yt-dlp-only robuste + // reste automatiquement disponible comme fallback. + try { + stableUrl = + await _youtubeService + .getPlayableMuxedUrl( + widget.video.id, + ).timeout( + Duration(seconds: 3), + ); + + _playableUrl = stableUrl; + + debugPrint( + '[HD-WINDOWS][18.7.5] ' + 'muxed 360p rapide résolu en ' + '${windowsStartupWatch?.elapsedMilliseconds ?? 0}ms', + ); + } on Object catch (fastError) { + debugPrint( + '[HD-WINDOWS][18.7.5] ' + 'muxed 360p rapide indisponible -> ' + 'fallback yt-dlp-only: $fastError', + ); + + await _loadWindowsWithoutPublicWatchPage( + forcedResumePosition: + forcedResumePosition, + shouldPlayAfterLoad: + shouldPlayAfterLoad, + ); + + windowsStartupWatch?.stop(); + + debugPrint( + '[HD-WINDOWS][18.7.5] ' + 'fallback yt-dlp terminé en ' + '${windowsStartupWatch?.elapsedMilliseconds ?? 0}ms', + ); + + return; + } + } else { + stableUrl = + await _youtubeService + .getPlayableMuxedUrl( + widget.video.id, + ); + + _playableUrl = stableUrl; + } + + var hdLoaded = false; + + if (Platform.isWindows && + _selectedQualityHeight != 360) { + final targetHeight = + _selectedQualityHeight <= 0 + ? 1080 + : _selectedQualityHeight; + + // Le 360p muxé est ouvert immédiatement par cette méthode, puis le + // pipeline web_embedded prépare la résolution demandée en arrière-plan. + // C'est le comportement validé des étapes 17.18/17.19. + hdLoaded = + await _tryOpenWindowsEmbeddedDirect( + targetHeight: targetHeight, + shouldPlayAfterLoad: + shouldPlayAfterLoad, + ); + } else if (Platform.isAndroid && + _selectedQualityHeight != 360) { + hdLoaded = + await _tryOpenDirectHd( + targetHeight: + _selectedQualityHeight, + shouldPlayAfterLoad: + shouldPlayAfterLoad, + ); + + if (!hdLoaded && + (_selectedQualityHeight == 0 || + _selectedQualityHeight > + 1080)) { + debugPrint( + '[HD] essai de secours 1080p AVC', + ); + + hdLoaded = + await _tryOpenDirectHd( + targetHeight: 1080, + shouldPlayAfterLoad: + shouldPlayAfterLoad, + fallbackFromHeight: + _selectedQualityHeight, + ); + } + } + + if (!hdLoaded) { + _hdActive = false; + _foregroundPlayableUrl = + stableUrl; + + if (_selectedQualityHeight == + 360) { + _qualityLabel = '360p'; + _qualityDetails = + 'Qualité choisie'; + } else { + _qualityLabel = + '360p stable'; + _qualityDetails = + 'Fallback automatique'; + } + + await _player.open( + Media(stableUrl), + play: + shouldPlayAfterLoad, + ); + } + + if (!mounted) { + return; + } + + final storedResumePosition = + LibraryService.instance + .getPlaybackPosition( + widget.video.id, + ); + + final resumePosition = + forcedResumePosition ?? + storedResumePosition; + + final shouldResume = + resumePosition != null && + (forcedResumePosition != null + ? resumePosition > + Duration.zero + : resumePosition >= + Duration( + seconds: 5, + )); + + _restoringPosition = + shouldResume; + + if (shouldResume) { + await _waitForMediaReady( + resumePosition, + ); + await _seekAndConfirm( + resumePosition, + ); + + _latestPosition = + resumePosition; + _lastSavedPosition = + resumePosition; + } + + _restoringPosition = false; + + await _player.setRate( + _playbackRate, + ); + + if (shouldPlayAfterLoad) { + await _player.play(); + } else { + await _player.pause(); + } + + await LibraryService.instance + .addToHistory( + widget.video, + incrementWatchCount: + !_historyRecordedForThisOpen, + ); + + _historyRecordedForThisOpen = + true; + + if (!mounted) { + return; + } + + setState(() { + _loading = false; + }); + + if (Platform.isWindows) { + windowsStartupWatch?.stop(); + + debugPrint( + '[HD-WINDOWS][18.7.5] ' + '360p Player prêt en ' + '${windowsStartupWatch?.elapsedMilliseconds ?? 0}ms ' + 'cible=${_selectedQualityHeight <= 0 ? 1080 : _selectedQualityHeight}', + ); + } + + if (shouldResume && + forcedResumePosition == + null) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + 'Reprise confirmée à ' + '${_formatDuration(resumePosition)}', + ), + duration: + Duration( + seconds: 2, + ), + ), + ); + } + } catch (error) { + _restoringPosition = false; + windowsStartupWatch?.stop(); + + debugPrint( + '[YT-PLAYER] $error', + ); + + if (!mounted) { + return; + } + + setState(() { + _loading = false; + _error = + 'Impossible de charger cette vidéo.\n' + 'Essaie une autre vidéo ou réessaie dans quelques instants.'; + }); + } + } + + Future _loadWindowsWithoutPublicWatchPage({ + required Duration? forcedResumePosition, + required bool shouldPlayAfterLoad, + }) async { + final storedResumePosition = + LibraryService.instance + .getPlaybackPosition( + widget.video.id, + ); + + final resumePosition = + forcedResumePosition ?? + storedResumePosition; + + final shouldResume = + resumePosition != null && + (forcedResumePosition != null + ? resumePosition > + Duration.zero + : resumePosition >= + Duration( + seconds: 5, + )); + + final startPosition = + shouldResume + ? resumePosition + : Duration.zero; + + final generation = + ++_windowsHdUpgradeGeneration; + + await _windowsRelay.stop(); + await _windowsEmbeddedProgressive + .stop(); + + final requestedHeight = + _selectedQualityHeight <= 0 + ? 1080 + : _selectedQualityHeight; + + debugPrint( + '[HD-WINDOWS][18.7.2] ' + 'yt-dlp-only start ' + 'video=${widget.video.id} ' + 'cible=$requestedHeight ' + 'resume=${startPosition.inMilliseconds}ms', + ); + + Map? hd; + + if (_windowsHdPreflightTarget == + requestedHeight && + _windowsHdPreflight != null) { + hd = _windowsHdPreflight; + _windowsHdPreflight = null; + _windowsHdPreflightTarget = + null; + + debugPrint( + '[HD-WINDOWS][18.7.2] ' + 'préflight réutilisé', + ); + } else { + hd = await WindowsHdService.instance + .getEmbeddedHdStream( + videoId: widget.video.id, + targetHeight: requestedHeight, + preferredLanguage: _effectiveAudioLanguagePreference, + originalLanguageHint: _inferOriginalAudioLanguageHint(), + ); + } + + if (!mounted || + generation != + _windowsHdUpgradeGeneration) { + return; + } + + if (hd == null) { + throw StateError( + 'web_embedded ne fournit aucun flux exploitable.', + ); + } + + final width = hd['width']; + final height = hd['height']; + final fps = hd['fps']; + final videoCodec = + hd['videoCodec']; + final audioCodec = + hd['audioCodec']; + final videoFormatId = + hd['videoFormatId']; + final audioFormatId = + hd['audioFormatId']; + final videoLength = + hd['videoLength']; + final audioLength = + hd['audioLength']; + final videoExt = + hd['videoExt']; + final audioExt = + hd['audioExt']; + final videoMimeType = + hd['videoMimeType']; + final ytDlpPath = + hd['ytDlpPath']; + final denoPath = + hd['denoPath']; + final watchUrl = + hd['watchUrl']; + + if (height is! int || + height < 240 || + videoFormatId is! String || + videoFormatId.isEmpty || + audioFormatId is! String || + audioFormatId.isEmpty || + videoLength is! int || + videoLength <= 0 || + audioLength is! int || + audioLength <= 0 || + ytDlpPath is! String || + ytDlpPath.isEmpty || + denoPath is! String || + denoPath.isEmpty || + watchUrl is! String || + watchUrl.isEmpty) { + throw StateError( + 'Métadonnées web_embedded incomplètes.', + ); + } + + if (requestedHeight > 1080 && + !_matchesQualityTier( + targetHeight: requestedHeight, + width: width is int ? width : null, + height: height, + )) { + _windowsUnavailableQualityHeights.add(requestedHeight); + + throw StateError( + 'Qualité ${requestedHeight}p demandée mais le flux ' + '${width is int ? '${width}x' : ''}${height} ' + 'n’appartient pas à ce palier.', + ); + } + + final expectedWidth = + width is int ? width : 0; + final expectedFps = + fps is int ? fps : 0; + + final session = + await _windowsEmbeddedProgressive + .start( + videoId: widget.video.id, + ytDlpPath: ytDlpPath, + denoPath: denoPath, + watchUrl: watchUrl, + videoFormatId: + videoFormatId, + audioFormatId: + audioFormatId, + videoExt: + videoExt is String + ? videoExt + : 'mp4', + audioExt: + audioExt is String + ? audioExt + : 'webm', + videoMimeType: + videoMimeType is String + ? videoMimeType + : 'video/mp4', + videoLength: videoLength, + audioLength: audioLength, + ); + + if (!mounted || + generation != + _windowsHdUpgradeGeneration) { + return; + } + + final mediaDuration = + widget.video.duration ?? + Duration.zero; + + final startedAt = + DateTime.now(); + var lastPercent = -1; + + while (mounted && + generation == + _windowsHdUpgradeGeneration) { + final ready = + await _windowsEmbeddedProgressive + .hasEnoughBuffer( + position: startPosition, + mediaDuration: + mediaDuration, + ahead: + Duration( + seconds: 12, + ), + ); + + final status = + await _windowsEmbeddedProgressive + .status( + mediaDuration: + mediaDuration, + ); + + final percent = + ((status.videoBytes / + videoLength) * + 100) + .floor() + .clamp(0, 100); + + if (percent != lastPercent && + mounted) { + lastPercent = percent; + + setState(() { + _qualityLabel = + '${height}p'; + _qualityDetails = + 'Prébuffer Windows ' + '$percent% • audio ' + '${status.audioComplete ? 'prêt' : '...'}'; + }); + } + + if (ready) { + debugPrint( + '[HD-WINDOWS][18.7.2] ' + 'prébuffer prêt ' + 'video=${status.videoBytes}/${status.videoLength} ' + 'audio=${status.audioBytes}/${status.audioLength}', + ); + break; + } + + if (DateTime.now() + .difference(startedAt) > + Duration( + seconds: 45, + )) { + throw TimeoutException( + 'Prébuffer Windows > 45 s', + ); + } + + await Future.delayed( + Duration( + milliseconds: 120, + ), + ); + } + + if (!mounted || + generation != + _windowsHdUpgradeGeneration) { + return; + } + + _restoringPosition = + startPosition > + Duration.zero; + + await _player.open( + Media( + session.videoUrl, + start: startPosition, + ), + play: false, + ); + + await _player.setAudioTrack( + AudioTrack.uri( + session.audioUri, + title: + 'YouTube audio $audioFormatId', + language: 'und', + ), + ); + + await _player.setRate( + _playbackRate, + ); + + await _player.play(); + + final actualHeight = + await _waitForWindowsExpectedHeight( + height, + ); + + if (!mounted || + generation != + _windowsHdUpgradeGeneration) { + _restoringPosition = false; + return; + } + + final actualPosition = + _player.state.position; + + if (startPosition > + Duration.zero && + (actualPosition - + startPosition) + .abs() > + Duration( + seconds: 2, + )) { + await _seekAndConfirm( + startPosition, + ); + } + + _latestPosition = + startPosition; + _lastSavedPosition = + startPosition; + _restoringPosition = false; + + if (shouldPlayAfterLoad) { + await _player.play(); + } else { + await _player.pause(); + } + + _playableUrl = null; + _foregroundPlayableUrl = + session.videoUrl; + _hdActive = true; + + final actualWidth = + _player.state.width ?? + expectedWidth; + + final fpsSuffix = + expectedFps > 30 + ? '$expectedFps' + : ''; + + if (mounted) { + setState(() { + _qualityLabel = + '${actualHeight}p$fpsSuffix'; + _qualityDetails = + '${actualWidth}x$actualHeight' + ' • yt-dlp progressif' + ' • ${audioCodec ?? 'audio'}'; + }); + } + + debugPrint( + '[HD-WINDOWS][18.7.2] ' + 'lecture confirmée ' + '${actualWidth}x$actualHeight ' + 'video=$videoFormatId/${videoCodec ?? '?'} ' + 'audio=$audioFormatId/${audioCodec ?? '?'}', + ); + + await LibraryService.instance + .addToHistory( + widget.video, + incrementWatchCount: + !_historyRecordedForThisOpen, + ); + + _historyRecordedForThisOpen = + true; + + if (!mounted) { + return; + } + + setState(() { + _loading = false; + }); + + if (shouldResume && + forcedResumePosition == null) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + 'Reprise confirmée à ' + '${_formatDuration(startPosition)}', + ), + duration: + Duration( + seconds: 2, + ), + ), + ); + } + } + + + Future _tryOpenDirectHd({ + required int targetHeight, + required bool shouldPlayAfterLoad, + int? fallbackFromHeight, + }) async { + if (Platform.isWindows) { + return _tryOpenWindowsMpvHd( + targetHeight: targetHeight, + shouldPlayAfterLoad: shouldPlayAfterLoad, + fallbackFromHeight: fallbackFromHeight, + ); + } + + try { + final hd = + await _requestPlatformHdStream( + targetHeight, + ); + + final videoUrl = hd?['videoUrl']; + final audioUrl = hd?['audioUrl']; + final width = hd?['width']; + final height = hd?['height']; + final fps = hd?['fps']; + final videoCodec = hd?['videoCodec']; + final audioCodec = hd?['audioCodec']; + final audioLanguage = hd?['audioLanguage']; + final audioTrackName = hd?['audioTrackName']; + final preferredAudioMatched = + hd?['preferredAudioMatched'] == true; + + if (videoUrl is! String || + videoUrl.isEmpty || + audioUrl is! String || + audioUrl.isEmpty || + height is! int || + height < 720) { + debugPrint( + '[HD] paire directe invalide pour cible=$targetHeight', + ); + return false; + } + + final expectedHeight = height; + final expectedWidth = + width is int ? width : 0; + final expectedFps = + fps is int ? fps : 0; + + _hdActive = true; + _foregroundPlayableUrl = videoUrl; + + final fpsSuffix = + expectedFps > 30 ? '${expectedFps}' : ''; + + _qualityLabel = + '${expectedHeight}p$fpsSuffix'; + + _qualityDetails = + 'Vidéo ${videoCodec is String ? videoCodec : 'HD'}' + ' + audio ${audioCodec is String ? audioCodec : 'séparé'}'; + + debugPrint( + '[HD] paire directe sélectionnée: ' + '${_qualityLabel} ' + '${expectedWidth}x$expectedHeight ' + 'video=${videoCodec ?? 'inconnu'} ' + 'audio=${audioCodec ?? 'inconnu'} ' + 'cible=$targetHeight', + ); + + await _player.open( + Media(videoUrl), + play: false, + ); + + final resolvedAudioLanguage = + audioLanguage is String && audioLanguage.trim().isNotEmpty + ? audioLanguage.trim() + : 'und'; + final resolvedAudioTitle = + audioTrackName is String && audioTrackName.trim().isNotEmpty + ? audioTrackName.trim() + : preferredAudioMatched + ? 'YouTube audio • langue préférée' + : 'YouTube audio'; + + await _player.setAudioTrack( + AudioTrack.uri( + audioUrl, + title: resolvedAudioTitle, + language: resolvedAudioLanguage, + ), + ); + + // On démarre brièvement pour forcer MediaCodec à annoncer + // la vraie résolution décodée. + await _player.play(); + + // Laisse au hook un court instant pour publier le chemin yt-dlp, + // puis journalise-le. Cela nous permet de distinguer immédiatement + // "hook non exécuté" de "yt-dlp exécuté mais flux refusé". + await Future.delayed( + Duration(milliseconds: 1200), + ); + + try { + final platform = _player.platform; + if (platform is NativePlayer) { + final ytdlPath = await platform.getProperty( + 'user-data/mpv/ytdl/path', + ); + + debugPrint( + '[HD-WINDOWS] ytdl_hook path=$ytdlPath', + ); + } + } catch (error) { + debugPrint( + '[HD-WINDOWS] lecture propriété ytdl/path impossible: $error', + ); + } + + final actualHeight = + await _waitForDirectHdReady( + expectedHeight, + ); + + final actualWidth = + _player.state.width ?? 0; + + debugPrint( + '[HD] lecture confirmée: ' + '${actualWidth}x$actualHeight ' + '(sélection=${_qualityLabel}, cible=$targetHeight)', + ); + + if (!shouldPlayAfterLoad) { + await _player.pause(); + } + + if (mounted) { + setState(() { + final actualFpsSuffix = + expectedFps > 30 + ? '${expectedFps}' + : ''; + + _qualityLabel = + '${actualHeight}p$actualFpsSuffix'; + + final fallbackText = + fallbackFromHeight != null + ? ' • secours 1080p' + : ''; + + final languageText = + resolvedAudioLanguage != 'und' + ? ' • audio ${resolvedAudioLanguage.toUpperCase()}' + : ' • audio séparé'; + final preferredText = + preferredAudioMatched + ? ' • langue préférée' + : ''; + + _qualityDetails = + '${actualWidth}x$actualHeight' + '$languageText' + '$preferredText' + '$fallbackText'; + }); + } + + return true; + } catch (error) { + debugPrint( + '[HD] cible=$targetHeight non lisible: $error', + ); + + return false; + } + } + + Future _tryOpenWindowsEmbeddedDirect({ + required int targetHeight, + required bool shouldPlayAfterLoad, + int? fallbackFromHeight, + }) async { + final stableUrl = _playableUrl; + + if (stableUrl == null || stableUrl.isEmpty) { + return false; + } + + final generation = + ++_windowsHdUpgradeGeneration; + + try { + await _windowsRelay.stop(); + await _windowsEmbeddedProgressive.stop(); + + // IMPORTANT 17.18 : + // on ne bloque PLUS l'écran pendant le téléchargement HD. + // + // Le flux 360p muxé est déjà disponible à ce stade. On le lance + // immédiatement avec image + son, puis la HD se prépare en arrière-plan. + _hdActive = false; + _foregroundPlayableUrl = stableUrl; + + if (mounted) { + setState(() { + _qualityLabel = '360p'; + _qualityDetails = + 'Lecture immédiate • HD en préparation 0%'; + }); + } + + debugPrint( + '[HD-WINDOWS][17.19] lecture immédiate 360p ' + 'pendant préparation cible=$targetHeight', + ); + + await _player.open( + Media(stableUrl), + play: shouldPlayAfterLoad, + ); + + unawaited( + _prepareAndSwitchWindowsHd( + generation: generation, + targetHeight: targetHeight, + fallbackFromHeight: fallbackFromHeight, + ), + ); + + // On retourne true car le Player est déjà ouvert sur le fallback + // stable. _loadVideoInternal peut donc restaurer immédiatement la + // position, la vitesse et l'état play/pause sans attendre la HD. + return true; + } on Object catch (error) { + debugPrint( + '[HD-WINDOWS][17.19] démarrage immédiat impossible: $error', + ); + + return false; + } + } + + Future _prepareAndSwitchWindowsHd({ + required int generation, + required int targetHeight, + int? fallbackFromHeight, + }) async { + try { + debugPrint( + '[HD-WINDOWS][17.19] préparation progressive ' + 'cible=$targetHeight génération=$generation', + ); + + Map? hd; + + if (_windowsHdPreflightTarget == targetHeight && + _windowsHdPreflight != null) { + hd = _windowsHdPreflight; + + _windowsHdPreflight = null; + _windowsHdPreflightTarget = null; + + debugPrint( + '[HD-WINDOWS][17.21] résultat préflight réutilisé ' + 'pour cible=$targetHeight', + ); + } else { + hd = + await WindowsHdService.instance.getEmbeddedHdStream( + videoId: widget.video.id, + targetHeight: targetHeight, + preferredLanguage: _effectiveAudioLanguagePreference, + originalLanguageHint: _inferOriginalAudioLanguageHint(), + ); + } + + if (!_isWindowsUpgradeCurrent(generation) || + hd == null) { + return; + } + + final width = hd['width']; + final height = hd['height']; + final fps = hd['fps']; + final videoCodec = hd['videoCodec']; + final audioCodec = hd['audioCodec']; + final videoFormatId = hd['videoFormatId']; + final audioFormatId = hd['audioFormatId']; + + final videoLength = hd['videoLength']; + final audioLength = hd['audioLength']; + final videoExt = hd['videoExt']; + final audioExt = hd['audioExt']; + final videoMimeType = hd['videoMimeType']; + final ytDlpPath = hd['ytDlpPath']; + final denoPath = hd['denoPath']; + final watchUrl = hd['watchUrl']; + + if (height is! int || + height < 720 || + videoFormatId is! String || + videoFormatId.isEmpty || + audioFormatId is! String || + audioFormatId.isEmpty || + videoLength is! int || + videoLength <= 0 || + audioLength is! int || + audioLength <= 0 || + ytDlpPath is! String || + ytDlpPath.isEmpty || + denoPath is! String || + denoPath.isEmpty || + watchUrl is! String || + watchUrl.isEmpty) { + throw StateError( + 'Métadonnées web_embedded incomplètes.', + ); + } + + // Deuxième garde-fou : une demande 1440p/2160p ne doit jamais + // être validée silencieusement par un flux 1080p. + if (targetHeight > 1080 && + !_matchesQualityTier( + targetHeight: targetHeight, + width: width is int ? width : null, + height: height, + )) { + if (mounted) { + setState(() { + _windowsUnavailableQualityHeights.add(targetHeight); + }); + } + + throw StateError( + 'Qualité ${targetHeight}p demandée mais le flux ' + '${width is int ? '${width}x' : ''}${height} ' + 'n’appartient pas à ce palier.', + ); + } + + final expectedWidth = + width is int ? width : 0; + final expectedFps = + fps is int ? fps : 0; + + debugPrint( + '[HD-WINDOWS][17.19] flux progressif: ' + '${expectedWidth}x$height ' + '${expectedFps > 0 ? '${expectedFps}fps ' : ''}' + 'video=$videoFormatId/${videoCodec ?? '?'} ' + 'audio=$audioFormatId/${audioCodec ?? '?'}', + ); + + final session = + await _windowsEmbeddedProgressive.start( + videoId: widget.video.id, + ytDlpPath: ytDlpPath, + denoPath: denoPath, + watchUrl: watchUrl, + videoFormatId: videoFormatId, + audioFormatId: audioFormatId, + videoExt: + videoExt is String ? videoExt : 'mp4', + audioExt: + audioExt is String ? audioExt : 'webm', + videoMimeType: + videoMimeType is String + ? videoMimeType + : 'video/mp4', + videoLength: videoLength, + audioLength: audioLength, + ); + + if (!_isWindowsUpgradeCurrent(generation)) { + return; + } + + var lastUiAhead = -1; + + // 360p continue à jouer. On n'attend qu'un tampon HD autour du + // timestamp courant, pas les 378 Mo de la vidéo complète. + while (_isWindowsUpgradeCurrent(generation)) { + final duration = + _player.state.duration; + + final currentPosition = + _player.state.position > _latestPosition + ? _player.state.position + : _latestPosition; + + final ready = + await _windowsEmbeddedProgressive.hasEnoughBuffer( + position: currentPosition, + mediaDuration: duration, + ahead: Duration(seconds: 20), + ); + + final status = + await _windowsEmbeddedProgressive.status( + mediaDuration: duration, + ); + + final aheadSeconds = math.max( + 0, + status.estimatedBufferedDuration.inSeconds - + currentPosition.inSeconds, + ); + + if (aheadSeconds != lastUiAhead && + mounted) { + lastUiAhead = aheadSeconds; + + setState(() { + _qualityDetails = + 'Lecture 360p • buffer HD ' + '${aheadSeconds}s ' + '• audio ' + '${status.audioComplete ? 'prêt' : '...'}'; + }); + } + + if (ready) { + debugPrint( + '[HD-WINDOWS][17.19] buffer suffisant: ' + 'video=${status.videoBytes}/${status.videoLength} ' + '≈${status.estimatedBufferedDuration.inSeconds}s ' + 'audio=${status.audioComplete}', + ); + break; + } + + final videoFailed = + !status.videoComplete && + status.videoBytes == 0 && + status.audioComplete && + false; + + if (videoFailed) { + throw StateError( + 'Téléchargement vidéo progressif interrompu.', + ); + } + + await Future.delayed( + Duration(milliseconds: 120), + ); + } + + if (!_isWindowsUpgradeCurrent(generation)) { + return; + } + + // _loadVideoInternal doit avoir fini de restaurer le resume 360p. + for (var i = 0; + i < 100 && + _loading && + _isWindowsUpgradeCurrent(generation); + i++) { + await Future.delayed( + Duration(milliseconds: 50), + ); + } + + if (!_isWindowsUpgradeCurrent(generation)) { + return; + } + + final switchPosition = + _player.state.position > _latestPosition + ? _player.state.position + : _latestPosition; + + final wasPlaying = + _player.state.playing; + + debugPrint( + '[HD-WINDOWS][17.19] bascule vers HD bufferisée ' + 'à ${switchPosition.inMilliseconds} ms', + ); + + _restoringPosition = true; + + // media_kit 1.2.6 accepte un start directement dans Media. + // Le nouveau média se prépare donc AU timestamp courant au lieu + // d'afficher la première image de la vidéo avant un seek. + await _player.open( + Media( + session.videoUrl, + start: switchPosition, + ), + play: false, + ); + + await _player.setAudioTrack( + AudioTrack.uri( + session.audioUri, + title: + 'YouTube audio $audioFormatId', + language: 'und', + ), + ); + + await _player.setRate( + _playbackRate, + ); + + // Lancer permet à mpv de décoder la première frame HD demandée. + await _player.play(); + + final actualHeight = + await _waitForWindowsExpectedHeight( + height, + ); + + final actualWidth = + _player.state.width ?? 0; + + if (!_isWindowsUpgradeCurrent(generation)) { + _restoringPosition = false; + return; + } + + // Vérification légère seulement. Normalement Media(start:) a déjà + // positionné mpv avant la première frame rendue. + final actualPosition = + _player.state.position; + + if ((actualPosition - switchPosition).abs() > + Duration(seconds: 2)) { + await _seekAndConfirm( + switchPosition, + ); + } + + _latestPosition = + switchPosition; + _lastSavedPosition = + switchPosition; + _restoringPosition = false; + + if (wasPlaying) { + await _player.play(); + } else { + await _player.pause(); + } + + _hdActive = true; + _foregroundPlayableUrl = + session.videoUrl; + + final fpsSuffix = + expectedFps > 30 + ? '$expectedFps' + : ''; + + if (mounted && + _isWindowsUpgradeCurrent(generation)) { + setState(() { + _qualityLabel = + '${actualHeight}p$fpsSuffix'; + + final fallbackText = + fallbackFromHeight != null + ? ' • secours ${actualHeight}p' + : ''; + + _qualityDetails = + '${actualWidth}x$actualHeight' + ' • streaming progressif' + ' • ${audioCodec ?? 'audio'}' + '$fallbackText'; + }); + } + + debugPrint( + '[HD-WINDOWS][17.19] HD progressive confirmée: ' + '${actualWidth}x$actualHeight ' + 'audio=$audioFormatId/${audioCodec ?? '?'}', + ); + } on Object catch (error) { + _restoringPosition = false; + + if (!_isWindowsUpgradeCurrent(generation)) { + return; + } + + debugPrint( + '[HD-WINDOWS][17.19] HD progressive échouée ' + '-> maintien 360p: $error', + ); + + if (mounted) { + setState(() { + _hdActive = false; + _qualityLabel = '360p stable'; + _qualityDetails = + 'Lecture continue • HD progressive indisponible'; + }); + } + } + } + + bool _isWindowsUpgradeCurrent( + int generation, + ) { + return mounted && + Platform.isWindows && + generation == _windowsHdUpgradeGeneration && + _selectedQualityHeight != 360; + } + + Future _waitForWindowsExpectedHeight( + int expectedHeight, + ) async { + var actualHeight = _player.state.height ?? 0; + + if (actualHeight >= expectedHeight) { + return actualHeight; + } + + final streamedHeight = + await _player.stream.height + .firstWhere( + (height) => + (height ?? 0) >= expectedHeight, + ) + .timeout( + Duration(seconds: 30), + ); + + actualHeight = streamedHeight ?? 0; + + if (_player.state.duration <= Duration.zero) { + await _player.stream.duration + .firstWhere( + (duration) => duration > Duration.zero, + ) + .timeout( + Duration(seconds: 30), + ); + } + + return actualHeight; + } + + Future _tryOpenWindowsMuxedHls({ + required int targetHeight, + required bool shouldPlayAfterLoad, + int? fallbackFromHeight, + }) async { + try { + final hls = + await WindowsHdService.instance.getMuxedHlsStream( + videoId: widget.video.id, + targetHeight: targetHeight, + preferredLanguage: _effectiveAudioLanguagePreference, + originalLanguageHint: _inferOriginalAudioLanguageHint(), + ); + + if (hls == null) { + return false; + } + + final url = hls['url']; + final expectedHeight = hls['height']; + final expectedWidth = hls['width']; + final fps = hls['fps']; + final videoCodec = hls['videoCodec']; + final audioCodec = hls['audioCodec']; + + if (url is! String || + url.isEmpty || + expectedHeight is! int || + expectedHeight < 720) { + return false; + } + + final headers = {}; + final rawHeaders = hls['httpHeaders']; + + if (rawHeaders is Map) { + for (final entry in rawHeaders.entries) { + final key = entry.key.toString().trim(); + final value = + entry.value?.toString().trim() ?? ''; + + if (key.isNotEmpty && value.isNotEmpty) { + headers[key] = value; + } + } + } + + await _windowsRelay.stop(); + + final platform = _player.platform; + + if (platform is NativePlayer) { + await WindowsHdService.instance.applyHttpHeaders( + platform, + headers, + ); + } + + debugPrint( + '[HD-WINDOWS][17.13] ouverture HLS muxé: ' + '${expectedWidth ?? 0}x$expectedHeight ' + '${fps is int && fps > 0 ? '${fps}fps ' : ''}' + 'video=${videoCodec ?? 'inconnu'} ' + 'audio=${audioCodec ?? 'inconnu'}', + ); + + _hdActive = true; + _foregroundPlayableUrl = url; + + await _player.open( + Media( + url, + httpHeaders: headers, + ), + play: false, + ); + + await _player.play(); + + final actualHeight = + await _waitForDirectHdReady( + expectedHeight, + ); + + final actualWidth = + _player.state.width ?? 0; + + if (actualHeight < 720) { + throw StateError( + 'HLS muxé décodé en ' + '${actualWidth}x$actualHeight.', + ); + } + + final fpsSuffix = + fps is int && fps > 30 ? '$fps' : ''; + + debugPrint( + '[HD-WINDOWS][17.13] HLS AV confirmé: ' + '${actualWidth}x$actualHeight ' + '(vidéo + audio muxés)', + ); + + if (!shouldPlayAfterLoad) { + await _player.pause(); + } else { + await _player.play(); + } + + if (mounted) { + setState(() { + _qualityLabel = + '${actualHeight}p$fpsSuffix'; + + final fallbackText = + fallbackFromHeight != null + ? ' • secours HLS 1080p' + : ''; + + _qualityDetails = + '${actualWidth}x$actualHeight' + ' • HLS vidéo+audio' + '$fallbackText'; + }); + } + + return true; + } on Object catch (error) { + debugPrint( + '[HD-WINDOWS][17.13] HLS muxé non lisible: $error', + ); + + return false; + } + } + + Future _tryOpenWindowsMpvHd({ + required int targetHeight, + required bool shouldPlayAfterLoad, + int? fallbackFromHeight, + }) async { + StreamSubscription? errorSubscription; + StreamSubscription? logSubscription; + + try { + await _windowsEmbeddedProgressive.stop(); + + final embeddedOk = + await _tryOpenWindowsEmbeddedDirect( + targetHeight: targetHeight, + shouldPlayAfterLoad: shouldPlayAfterLoad, + fallbackFromHeight: fallbackFromHeight, + ); + + if (embeddedOk) { + return true; + } + + debugPrint( + '[HD-WINDOWS][17.14] web_embedded indisponible/illisible ' + '-> anciens secours Windows', + ); + + // Priorité Windows <=1080p : HLS pré-muxé web_safari. + // Une seule URL contient vidéo + audio, donc aucun AudioTrack.uri() + // et aucun relais DASH/PO Token n'est nécessaire. + if (targetHeight <= 1080) { + final hlsOk = await _tryOpenWindowsMuxedHls( + targetHeight: targetHeight, + shouldPlayAfterLoad: shouldPlayAfterLoad, + fallbackFromHeight: fallbackFromHeight, + ); + + if (hlsOk) { + return true; + } + + debugPrint( + '[HD-WINDOWS][17.13] HLS muxé indisponible/illisible ' + '-> secours mweb adaptatif', + ); + } + + await _windowsRelay.stop(); + + debugPrint( + '[HD-WINDOWS][17.12] yt-dlp -> relais yt-dlp natif -> libmpv ' + 'pour ${widget.video.id} cible=$targetHeight', + ); + + final hd = + await WindowsHdService.instance.getHdStream( + videoId: widget.video.id, + targetHeight: targetHeight, + preferredLanguage: _effectiveAudioLanguagePreference, + originalLanguageHint: _inferOriginalAudioLanguageHint(), + ); + + final videoUrl = hd?['videoUrl']; + final audioUrl = hd?['audioUrl']; + final width = hd?['width']; + final height = hd?['height']; + final fps = hd?['fps']; + final videoCodec = hd?['videoCodec']; + final audioCodec = hd?['audioCodec']; + + final videoLength = hd?['videoLength']; + final audioLength = hd?['audioLength']; + final videoChunkSize = hd?['videoChunkSize']; + final audioChunkSize = hd?['audioChunkSize']; + final videoMimeType = hd?['videoMimeType']; + final audioMimeType = hd?['audioMimeType']; + final ytDlpPath = hd?['ytDlpPath']; + + Map stringMap(dynamic raw) { + final output = {}; + + if (raw is Map) { + for (final entry in raw.entries) { + final key = entry.key.toString().trim(); + final value = + entry.value?.toString().trim() ?? ''; + + if (key.isNotEmpty && value.isNotEmpty) { + output[key] = value; + } + } + } + + return output; + } + + final videoHeaders = + stringMap(hd?['videoHeaders']); + final audioHeaders = + stringMap(hd?['audioHeaders']); + + if (videoUrl is! String || + videoUrl.isEmpty || + audioUrl is! String || + audioUrl.isEmpty || + height is! int || + height < 720 || + videoLength is! int || + videoLength <= 0 || + audioLength is! int || + audioLength <= 0 || + ytDlpPath is! String || + ytDlpPath.isEmpty) { + throw StateError( + 'Paire vidéo/audio Windows invalide ' + 'ou taille de flux inconnue.', + ); + } + + final expectedHeight = height; + final expectedWidth = + width is int ? width : 0; + final expectedFps = + fps is int ? fps : 0; + + final relay = await _windowsRelay.start( + ytDlpPath: ytDlpPath, + videoUrl: videoUrl, + audioUrl: audioUrl, + videoHeaders: videoHeaders, + audioHeaders: audioHeaders, + videoLength: videoLength, + audioLength: audioLength, + videoChunkSize: + videoChunkSize is int + ? videoChunkSize + : 10 * 1024 * 1024, + audioChunkSize: + audioChunkSize is int + ? audioChunkSize + : 10 * 1024 * 1024, + videoMimeType: + videoMimeType is String + ? videoMimeType + : 'video/mp4', + audioMimeType: + audioMimeType is String + ? audioMimeType + : 'audio/mp4', + ); + + debugPrint( + '[HD-WINDOWS] paire relayée: ' + '${expectedWidth}x$expectedHeight ' + '${expectedFps > 0 ? '${expectedFps}fps ' : ''}' + 'video=${videoCodec ?? 'inconnu'} ' + 'audio=${audioCodec ?? 'inconnu'}', + ); + + errorSubscription = _player.stream.error.listen( + (error) { + debugPrint( + '[HD-WINDOWS][MPV-ERROR] $error', + ); + }, + ); + + logSubscription = _player.stream.log.listen( + (entry) { + final prefix = entry.prefix.toLowerCase(); + final text = entry.text.toLowerCase(); + + if (prefix.contains('ffmpeg') || + prefix.contains('lavf') || + prefix.contains('file') || + text.contains('http error') || + text.contains('403') || + text.contains('forbidden') || + text.contains('failed to open')) { + debugPrint( + '[HD-WINDOWS][MPV] ' + '${entry.prefix}: ${entry.text.trim()}', + ); + } + }, + ); + + _hdActive = true; + _foregroundPlayableUrl = relay.videoUrl; + + final fpsSuffix = + expectedFps > 30 ? '$expectedFps' : ''; + + _qualityLabel = + '${expectedHeight}p$fpsSuffix'; + + _qualityDetails = + 'Relais yt-dlp • ' + '${videoCodec is String ? videoCodec : 'HD'}' + ' + ${audioCodec is String ? audioCodec : 'audio'}'; + + // libmpv n'accède plus directement à googlevideo.com. + // Il lit une URL 127.0.0.1, tandis que notre relais reproduit le + // transport yt-dlp avec des requêtes Range <= 10 MiB. + await _player.open( + Media(relay.videoUrl), + play: false, + ); + + await _player.play(); + + final videoOnlyHeight = + await _waitForDirectHdReady( + expectedHeight, + ); + + final videoOnlyWidth = + _player.state.width ?? 0; + + debugPrint( + '[HD-WINDOWS] vidéo relayée confirmée: ' + '${videoOnlyWidth}x$videoOnlyHeight', + ); + + await _player.setAudioTrack( + AudioTrack.uri( + relay.audioUrl, + title: 'YouTube audio', + language: 'und', + ), + ); + + await Future.delayed( + Duration(milliseconds: 1200), + ); + + final actualHeight = + _player.state.height ?? videoOnlyHeight; + + final actualWidth = + _player.state.width ?? videoOnlyWidth; + + if (actualHeight < 720) { + throw StateError( + 'La vidéo relayée est retombée à ' + '${actualWidth}x$actualHeight après ajout audio.', + ); + } + + debugPrint( + '[HD-WINDOWS] lecture confirmée: ' + '${actualWidth}x$actualHeight ' + '(relais local)', + ); + + if (!shouldPlayAfterLoad) { + await _player.pause(); + } else { + await _player.play(); + } + + if (mounted) { + setState(() { + _qualityLabel = + '${actualHeight}p$fpsSuffix'; + + final fallbackText = + fallbackFromHeight != null + ? ' • secours 1080p' + : ''; + + _qualityDetails = + '${actualWidth}x$actualHeight' + ' • relais yt-dlp' + '$fallbackText'; + }); + } + + return true; + } catch (error) { + debugPrint( + '[HD-WINDOWS][17.12] cible=$targetHeight ' + 'non lisible: $error', + ); + + await _windowsRelay.stop(); + return false; + } finally { + await errorSubscription?.cancel(); + await logSubscription?.cancel(); + } + } + + Future?> _requestPlatformHdStream( + int targetHeight, + ) async { + if (Platform.isAndroid) { + debugPrint( + '[HD-ANDROID][18.7.8] demande native pour ${widget.video.id} ' + 'cible=$targetHeight', + ); + + return _hdChannel.invokeMapMethod( + 'getHdStream', + { + 'videoId': widget.video.id, + 'targetHeight': targetHeight, + 'preferredLanguage': _effectiveAudioLanguagePreference, + 'originalLanguageHint': _inferOriginalAudioLanguageHint(), + }, + ); + } + + return null; + } + + Future _waitForAndroidSelectedResolution( + int expectedHeight, + ) async { + if (!Platform.isAndroid) { + return _player.state.height ?? 0; + } + + final current = _player.state.height ?? 0; + if (current == expectedHeight) { + return current; + } + + final streamedHeight = await _player.stream.height + .firstWhere( + (height) => (height ?? 0) == expectedHeight, + ) + .timeout(Duration(seconds: 12)); + + return streamedHeight ?? 0; + } + + Future _waitForDirectHdReady( + int expectedHeight, + ) async { + if (Platform.isAndroid && expectedHeight >= 720) { + return _waitForAndroidSelectedResolution(expectedHeight); + } + + final minimumHeight = + expectedHeight >= 720 ? 720 : expectedHeight; + + var actualHeight = _player.state.height ?? 0; + + final readyTimeout = Platform.isWindows + ? Duration(seconds: 30) + : Duration(seconds: 12); + + if (actualHeight < minimumHeight) { + final streamedHeight = await _player.stream.height + .firstWhere( + (height) => (height ?? 0) >= minimumHeight, + ) + .timeout( + readyTimeout, + ); + + actualHeight = streamedHeight ?? 0; + } + + if (_player.state.duration <= Duration.zero) { + await _player.stream.duration + .firstWhere( + (duration) => duration > Duration.zero, + ) + .timeout( + readyTimeout, + ); + } + + return actualHeight; + } + + Future _waitForMediaReady(Duration resumePosition) async { + final currentDuration = _player.state.duration; + + if (currentDuration > resumePosition) { + return; + } + + await _player.stream.duration + .firstWhere( + (duration) => + duration > Duration.zero && duration > resumePosition, + ) + .timeout(Duration(seconds: 10)); + } + + Future _seekAndConfirm(Duration target) async { + const tolerance = Duration(seconds: 2); + + for (var attempt = 1; attempt <= 3; attempt++) { + debugPrint( + '[YT-PLAYER] Resume seek attempt $attempt -> ' + '${target.inMilliseconds} ms', + ); + + await _player.seek(target); + + final current = _player.state.position; + if (_isCloseToTarget(current, target, tolerance)) { + debugPrint( + '[YT-PLAYER] Resume seek confirmed immediately at ' + '${current.inMilliseconds} ms', + ); + return; + } + + try { + final confirmed = await _player.stream.position + .firstWhere( + (position) => + _isCloseToTarget(position, target, tolerance), + ) + .timeout(Duration(seconds: 3)); + + debugPrint( + '[YT-PLAYER] Resume seek confirmed at ' + '${confirmed.inMilliseconds} ms', + ); + return; + } on TimeoutException { + debugPrint( + '[YT-PLAYER] Resume seek attempt $attempt not confirmed', + ); + + await Future.delayed( + Duration(milliseconds: 350), + ); + } + } + + throw TimeoutException( + 'La position de reprise n’a pas pu être appliquée.', + ); + } + + bool _isCloseToTarget( + Duration position, + Duration target, + Duration tolerance, + ) { + final difference = + (position.inMilliseconds - target.inMilliseconds).abs(); + + return difference <= tolerance.inMilliseconds; + } + + Future _seekRelative(int seconds) async { + final current = _player.state.position; + final duration = _player.state.duration; + + var target = current + Duration(seconds: seconds); + + if (target < Duration.zero) { + target = Duration.zero; + } + + if (duration > Duration.zero && target > duration) { + target = duration; + } + + await _player.seek(target); + + _latestPosition = target; + _lastSavedPosition = target; + } + + Future _adjustVolume( + double delta, + ) async { + final current = + _player.state.volume; + + final next = + (current + delta) + .clamp(0.0, 100.0) + .toDouble(); + + await _player.setVolume(next); + + if (next <= 0) { + _showTapFeedback( + Icons.volume_off_rounded, + ); + } else if (delta > 0) { + _showTapFeedback( + Icons.volume_up_rounded, + ); + } else { + _showTapFeedback( + Icons.volume_down_rounded, + ); + } + + debugPrint( + '[FULLSCREEN-KEYS] volume=' + '${next.toStringAsFixed(0)}%', + ); + } + + Future _fullscreenSeek( + int seconds, + ) async { + await _seekRelative(seconds); + + _showTapFeedback( + seconds < 0 + ? Icons.replay_10_rounded + : Icons.forward_10_rounded, + ); + + debugPrint( + '[FULLSCREEN-KEYS] seek ' + '${seconds > 0 ? '+' : ''}${seconds}s', + ); + } + + Future _toggleFullscreenFromKeyboard() async { + final state = + _videoKey.currentState; + + if (state == null) { + return; + } + + await state.toggleFullscreen(); + } + + Future _exitFullscreenFromKeyboard() async { + final state = + _videoKey.currentState; + + if (state == null || + !state.isFullscreen()) { + return; + } + + await state.exitFullscreen(); + } + + Map + _fullscreenKeyboardShortcuts() { + return { + SingleActivator( + LogicalKeyboardKey.space, + ): () { + unawaited( + _togglePlayPause(), + ); + }, + SingleActivator( + LogicalKeyboardKey.arrowLeft, + ): () { + unawaited( + _fullscreenSeek(-10), + ); + }, + SingleActivator( + LogicalKeyboardKey.arrowRight, + ): () { + unawaited( + _fullscreenSeek(10), + ); + }, + SingleActivator( + LogicalKeyboardKey.arrowUp, + ): () { + unawaited( + _adjustVolume(5), + ); + }, + SingleActivator( + LogicalKeyboardKey.arrowDown, + ): () { + unawaited( + _adjustVolume(-5), + ); + }, + + // On conserve aussi les raccourcis essentiels du fullscreen + // natif de media_kit. + SingleActivator( + LogicalKeyboardKey.keyF, + ): () { + unawaited( + _toggleFullscreenFromKeyboard(), + ); + }, + SingleActivator( + LogicalKeyboardKey.escape, + ): () { + unawaited( + _exitFullscreenFromKeyboard(), + ); + }, + }; + } + + bool _isAndroidVideoFullscreen() { + if (!Platform.isAndroid) { + return false; + } + + final state = _videoKey.currentState; + + return state != null && + state.isFullscreen(); + } + + void _handleAndroidDoubleTapDown( + TapDownDetails details, + double width, + ) { + if (!_isAndroidVideoFullscreen()) { + _androidDoubleTapSeekLeft = null; + return; + } + + _androidDoubleTapSeekLeft = + details.localPosition.dx < + width / 2; + } + + void _handleAndroidDoubleTap() { + if (!_isAndroidVideoFullscreen()) { + return; + } + + final seekLeft = + _androidDoubleTapSeekLeft; + + if (seekLeft == null) { + return; + } + + unawaited( + _seekRelative( + seekLeft ? -10 : 10, + ), + ); + + _showTapFeedback( + seekLeft + ? Icons.replay_10_rounded + : Icons.forward_10_rounded, + ); + + debugPrint( + '[ANDROID-FULLSCREEN] ' + 'double tap ${seekLeft ? '-10' : '+10'}s', + ); + } + + Future _restartVideo() async { + await _player.seek(Duration.zero); + + _latestPosition = Duration.zero; + _lastSavedPosition = Duration.zero; + + await LibraryService.instance.clearPlaybackPosition( + widget.video.id, + ); + } + + Future _setPlaybackRate(double rate) async { + await _player.setRate(rate); + + if (!mounted) { + return; + } + + setState(() { + _playbackRate = rate; + }); + } + + Future _toggleFavorite() async { + final isFavorite = await LibraryService.instance.toggleFavorite( + widget.video, + ); + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + isFavorite + ? 'Ajouté aux favoris' + : 'Retiré des favoris', + ), + duration: Duration(seconds: 1), + ), + ); + } + + String _formatDuration(Duration value) { + final hours = value.inHours; + final minutes = value.inMinutes.remainder(60); + final seconds = value.inSeconds.remainder(60); + + if (hours > 0) { + return '$hours:${minutes.toString().padLeft(2, '0')}:' + '${seconds.toString().padLeft(2, '0')}'; + } + + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + + @override + void dispose() { + _positionSubscription?.cancel(); + _playingSubscription?.cancel(); + _completedSubscription?.cancel(); + _tapFeedbackTimer?.cancel(); + _androidGestureFeedbackTimer?.cancel(); + _androidGestureFeedbackNotifier.dispose(); + _androidPauseSleepTimer?.cancel(); + _androidScreenPolicyWatchdog?.cancel(); + _androidOrientationDebounce?.cancel(); + _androidPointerPositions.clear(); + WidgetsBinding.instance.removeObserver(this); + + if (Platform.isAndroid) { + _pipChannel.setMethodCallHandler(null); + unawaited(_setAndroidKeepScreenOn(false)); + unawaited( + _deviceChannel.invokeMethod( + 'resetWindowBrightness', + ), + ); + } + + if (Platform.isWindows && _desktopPip) { + unawaited(windowManager.setAlwaysOnTop(false)); + } + + if (!_restoringPosition && + _latestPosition >= Duration(seconds: 5)) { + unawaited( + LibraryService.instance.savePlaybackPosition( + widget.video.id, + _latestPosition, + duration: widget.video.duration, + ), + ); + } + + unawaited( + BackgroundPlaybackService.instance.detachPlayer(), + ); + + if (Platform.isWindows) { + _windowsHdUpgradeGeneration++; + unawaited(_windowsRelay.stop()); + unawaited(_windowsEmbeddedProgressive.stop()); + } + + _videoFocusNode.dispose(); + _player.dispose(); + _youtubeService.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final compactPip = _androidPip || _desktopPip; + + if (compactPip) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Stack( + fit: StackFit.expand, + children: [ + _buildPlayerSurface( + compact: true, + ), + if (_desktopPip) + Positioned( + top: 8, + right: 8, + child: Material( + color: Colors.black54, + shape: CircleBorder(), + child: IconButton( + tooltip: 'Quitter le mode PiP', + onPressed: _exitDesktopPip, + icon: Icon( + Icons.close_fullscreen, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } + + final width = MediaQuery.sizeOf(context).width; + final isDesktop = width >= 800; + + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + titleSpacing: 0, + title: Text( + widget.video.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 16), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 1200), + child: Padding( + padding: EdgeInsets.fromLTRB( + isDesktop ? 28 : 0, + isDesktop ? 18 : 0, + isDesktop ? 28 : 0, + 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPlayer(), + Padding( + padding: EdgeInsets.fromLTRB( + isDesktop ? 0 : 16, + 18, + isDesktop ? 0 : 16, + 0, + ), + child: _buildMetadata(), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildPlayer() { + return AspectRatio( + aspectRatio: 16 / 9, + child: _buildPlayerSurface(), + ); + } + + Widget _buildPlayerSurface({ + bool compact = false, + }) { + return ColoredBox( + color: Colors.black, + child: Stack( + fit: StackFit.expand, + children: [ + if (_error == null) + Platform.isWindows + ? MaterialDesktopVideoControlsTheme( + normal: + MaterialDesktopVideoControlsThemeData(), + fullscreen: + MaterialDesktopVideoControlsThemeData( + keyboardShortcuts: + _fullscreenKeyboardShortcuts(), + ), + child: Video( + key: _videoKey, + controller: _videoController, + fit: BoxFit.contain, + focusNode: _videoFocusNode, + ), + ) + : ClipRect( + child: Video( + key: _videoKey, + controller: _videoController, + fit: BoxFit.contain, + focusNode: _videoFocusNode, + // 18.7.18: le builder de contrôles est recopié par + // media_kit dans sa route fullscreen. Les gestes Android + // sont donc disponibles en vue normale ET plein écran. + controls: Platform.isAndroid + ? _buildAndroidVideoControls + : AdaptiveVideoControls, + // Android: le maintien d'écran est piloté par + // l'application afin d'autoriser la veille en pause/fin. + wakelock: !Platform.isAndroid, + ), + ), + if (_loading) + DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: NetworkImage(widget.video.thumbnailUrl), + fit: BoxFit.cover, + colorFilter: ColorFilter.mode( + Colors.black54, + BlendMode.darken, + ), + ), + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + if (_restoringPosition) ...[ + SizedBox(height: 14), + Text( + 'Reprise de la lecture…', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ), + // Sur Android les PointerEvents sont observés directement + // autour de Video afin de ne jamais masquer les contrôles natifs. + // Cette couche de taps personnalisés reste uniquement hors Android. + if (!_loading && _error == null && !Platform.isAndroid) + Positioned( + left: compact ? 0 : 30, + right: compact ? 0 : 30, + top: compact ? 0 : 18, + bottom: compact ? 0 : 64, + child: LayoutBuilder( + builder: (context, constraints) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + _videoFocusNode.requestFocus(); + _togglePlayPause(); + }, + onDoubleTapDown: (details) { + _handleAndroidDoubleTapDown( + details, + constraints.maxWidth, + ); + }, + onDoubleTap: _handleAndroidDoubleTap, + ); + }, + ), + ), + if (_tapFeedbackIcon != null) + IgnorePointer( + child: Center( + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: Icon( + _tapFeedbackIcon, + size: 42, + color: Colors.white, + ), + ), + ), + ), + if (_error != null) + _PlayerError( + message: _error!, + onRetry: _loadVideo, + ), + ], + ), + ); + } + + Future _loadInlineChannelDetails() async { + try { + String? channelId = + widget.video.channelId?.trim(); + + if (channelId == null || + channelId.isEmpty) { + // Old persisted items may not have channelId yet. Resolve only this + // missing relation; modern Search/Radar/Channel cards already carry + // the ID and therefore make no extra lookup here. + final channel = + await _youtubeService + .getChannelForVideo( + widget.video.id, + ); + + channelId = + channel.channelId; + } + + final details = + await _youtubeService + .getPublicChannelDetails( + channelId, + ); + + if (!mounted) { + return; + } + + setState(() { + _inlineChannelDetails = + details; + _inlineVideoDetails = + widget.video; + _inlineChannelDetailsLoading = + false; + }); + } catch (error) { + debugPrint( + '[CHANNEL-INLINE][18.7.1] ' + 'infos chaîne partielles: $error', + ); + + if (mounted) { + setState(() { + // Keep the metadata already present in widget.video even if the + // channel lookup fails. + _inlineVideoDetails = + widget.video; + _inlineChannelDetailsLoading = + false; + }); + } + } + } + + Future _openCurrentChannel() async { + if (_openingChannel) { + return; + } + + setState(() { + _openingChannel = true; + }); + + try { + SubscriptionChannel channel; + + final channelId = + widget.video.channelId?.trim(); + + if (channelId != null && + channelId.isNotEmpty) { + channel = + await _youtubeService.getChannel( + channelId, + ); + } else { + channel = + await _youtubeService + .getChannelForVideo( + widget.video.id, + ); + } + + if (!mounted) { + return; + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ChannelVideosPage( + channel: channel, + ), + ), + ); + } catch (error) { + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + 'Impossible d’ouvrir la chaîne : $error', + ), + ), + ); + } finally { + if (mounted) { + setState(() { + _openingChannel = false; + }); + } + } + } + + Widget _buildInlineChannelAvatar() { + final thumbnailUrl = + _inlineChannelDetails?.thumbnailUrl.trim() ?? ''; + + if (thumbnailUrl.isEmpty) { + return Icon( + Icons.account_circle, + size: 32, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + } + + return ClipOval( + child: Image.network( + thumbnailUrl, + width: 32, + height: 32, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (context, error, stackTrace) { + return Icon( + Icons.account_circle, + size: 32, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + }, + ), + ); + } + + Future _rateCurrentVideo(int rating) async { + final service = InterestProfileService.instance; + await service.ensureLoaded(); + final category = service.classifyVideo(widget.video); + await service.rateVideo( + widget.video, + rating, + category: category, + ); + + if (!mounted) { + return; + } + + setState(() {}); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text('$rating★ enregistré dans $category.'), + duration: const Duration(seconds: 2), + ), + ); + } + + Future _showCategoryEditor() async { + final service = InterestProfileService.instance; + await service.ensureLoaded(); + if (!mounted) { + return; + } + + const autoValue = '__auto__'; + var videoValue = service.videoCategoryOverride(widget.video) ?? autoValue; + var channelValue = + service.channelCategoryOverride(widget.video) ?? autoValue; + + final shouldSave = await showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (dialogContext, setDialogState) { + final items = >[ + const DropdownMenuItem( + value: autoValue, + child: Text('Automatique'), + ), + for (final category in service.assignableCategories) + DropdownMenuItem( + value: category, + child: Text(category), + ), + ]; + + Widget selector({ + required String label, + required String helper, + required String value, + required ValueChanged onChanged, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 5), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Theme.of(dialogContext) + .colorScheme + .surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Theme.of(dialogContext) + .colorScheme + .outlineVariant, + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + items: items, + onChanged: onChanged, + ), + ), + ), + const SizedBox(height: 4), + Text( + helper, + style: TextStyle( + color: Theme.of(dialogContext) + .colorScheme + .onSurfaceVariant, + fontSize: 11, + ), + ), + ], + ); + } + + return AlertDialog( + title: const Text('Catégoriser'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Détection automatique : ' + '${service.classifyVideoAutomatically(widget.video)}', + style: TextStyle( + color: Theme.of(dialogContext) + .colorScheme + .onSurfaceVariant, + fontSize: 12, + ), + ), + const SizedBox(height: 16), + selector( + label: 'Cette vidéo', + helper: + 'Prioritaire sur la catégorie attribuée à la chaîne.', + value: videoValue, + onChanged: (value) { + if (value == null) { + return; + } + setDialogState(() { + videoValue = value; + }); + }, + ), + const SizedBox(height: 18), + selector( + label: 'Toute la chaîne · ${widget.video.author}', + helper: + 'Les vidéos de cette chaîne seront rattachées à ce centre d’intérêt, sauf correction vidéo individuelle.', + value: channelValue, + onChanged: (value) { + if (value == null) { + return; + } + setDialogState(() { + channelValue = value; + }); + }, + ), + const SizedBox(height: 14), + Text( + 'Priorité : vidéo > chaîne > détection automatique.', + style: TextStyle( + color: Theme.of(dialogContext) + .colorScheme + .onSurfaceVariant, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Enregistrer'), + ), + ], + ); + }, + ); + }, + ); + + if (shouldSave != true || !mounted) { + return; + } + + await service.setChannelCategoryOverride( + widget.video, + channelValue == autoValue ? null : channelValue, + ); + await service.setVideoCategoryOverride( + widget.video, + videoValue == autoValue ? null : videoValue, + ); + + if (!mounted) { + return; + } + + final effective = service.classifyVideo(widget.video); + setState(() {}); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text('Catégorie active pour cette vidéo : $effective.'), + duration: const Duration(seconds: 2), + ), + ); + } + + Widget _buildPersonalFeedbackCard() { + final service = InterestProfileService.instance; + final effective = service.classifyVideo(widget.video); + final automatic = service.classifyVideoAutomatically(widget.video); + final videoOverride = service.videoCategoryOverride(widget.video); + final channelOverride = service.channelCategoryOverride(widget.video); + final rating = service.ratingFor( + widget.video, + category: effective, + ); + + final source = videoOverride != null + ? 'Catégorie choisie pour cette vidéo' + : channelOverride != null + ? 'Catégorie héritée de la chaîne' + : automatic == 'Autres' + ? 'Non classée automatiquement' + : 'Détection automatique'; + + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(13, 11, 13, 11), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(13), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Wrap( + spacing: 18, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Ta note', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 11.5, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 1; index <= 5; index++) + InkResponse( + onTap: () => _rateCurrentVideo(index), + radius: 19, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 2, + vertical: 2, + ), + child: Icon( + index <= rating + ? Icons.star_rounded + : Icons.star_border_rounded, + size: 24, + color: const Color(0xFFFFC145), + ), + ), + ), + if (rating > 0) ...[ + const SizedBox(width: 5), + Text( + '$rating/5', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + ], + ], + ), + ], + ), + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 220, maxWidth: 520), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.category_outlined, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + effective, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + Text( + source, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + fontSize: 10.5, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _showCategoryEditor, + icon: const Icon(Icons.edit_outlined, size: 16), + label: const Text('Modifier'), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildMetadata() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.video.title, + style: TextStyle( + fontSize: 20, + height: 1.25, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 10), + _buildPersonalFeedbackCard(), + SizedBox(height: 10), + Material( + color: Colors.transparent, + child: InkWell( + onTap: + _openingChannel + ? null + : _openCurrentChannel, + borderRadius: + BorderRadius.circular(12), + child: Padding( + padding: + EdgeInsets.symmetric( + vertical: 7, + horizontal: 5, + ), + child: Row( + children: [ + _buildInlineChannelAvatar(), + SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + widget.video.author, + style: + TextStyle( + color: + Theme.of(context).colorScheme.onSurface, + fontSize: 15, + fontWeight: + FontWeight + .w600, + ), + ), + Text( + 'Ouvrir la chaîne et toutes ses vidéos', + style: + TextStyle( + color: + Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 11, + ), + ), + ], + ), + ), + if (_openingChannel) + SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ) + else + Icon( + Icons + .chevron_right_rounded, + color: + Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + ), + ], + ), + ), + ), + ), + SizedBox(height: 8), + _buildInlineChannelStats(), + SizedBox(height: 12), + _buildVideoDescriptionCard(), + SizedBox(height: 16), + _buildQuickControls(), + SizedBox(height: 16), + _buildQualityControls(), + SizedBox(height: 16), + _buildAudioTrackControls(), + SizedBox(height: 16), + _buildSpeedControls(), + SizedBox(height: 16), + ValueListenableBuilder>( + valueListenable: LibraryService.instance.favorites, + builder: (context, favorites, child) { + final isFavorite = favorites.any( + (item) => item.id == widget.video.id, + ); + + return FilledButton.tonalIcon( + onPressed: _toggleFavorite, + icon: Icon( + isFavorite ? Icons.favorite : Icons.favorite_border, + ), + label: Text( + isFavorite + ? 'Retirer des favoris' + : 'Ajouter aux favoris', + ), + ); + }, + ), + SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InfoChip( + icon: Icons.hd_outlined, + label: _qualityLabel, + ), + _InfoChip( + icon: _hdActive + ? Icons.auto_awesome + : Icons.merge_type, + label: _qualityDetails, + ), + _InfoChip( + icon: Icons.restore, + label: 'Reprise automatique', + ), + ], + ), + ], + ); + } + + Widget _buildVideoDescriptionCard() { + final details = _inlineVideoDetails ?? widget.video; + final description = details.description.trim(); + + final stats = []; + + if (details.viewCount != null) { + stats.add( + '${_formatCompactNumber(details.viewCount!)} vues', + ); + } + + if (details.likeCount != null) { + stats.add( + '${_formatCompactNumber(details.likeCount!)} likes', + ); + } + + if (details.commentCount != null) { + stats.add( + '${_formatCompactNumber(details.commentCount!)} commentaires', + ); + } + + if (details.uploadDate != null) { + stats.add( + _formatCompactDate(details.uploadDate!), + ); + } + + if (_inlineChannelDetailsLoading && + description.isEmpty && + stats.isEmpty) { + return _VideoDescriptionShell(); + } + + if (description.isEmpty && stats.isEmpty) { + return SizedBox.shrink(); + } + + return Container( + width: double.infinity, + padding: EdgeInsets.fromLTRB( + 13, + 11, + 13, + 11, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(13), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (stats.isNotEmpty) + Text( + stats.join(' • '), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + if (stats.isNotEmpty && + description.isNotEmpty) + SizedBox(height: 8), + if (description.isNotEmpty) + Text( + description, + maxLines: _descriptionExpanded ? null : 3, + overflow: _descriptionExpanded + ? TextOverflow.visible + : TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + fontSize: 12.5, + height: 1.42, + ), + ), + if (description.isNotEmpty && + description.length > 180) ...[ + SizedBox(height: 6), + InkWell( + onTap: () { + setState(() { + _descriptionExpanded = + !_descriptionExpanded; + }); + }, + borderRadius: BorderRadius.circular(7), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 2, + vertical: 3, + ), + child: Text( + _descriptionExpanded + ? 'Afficher moins' + : 'Afficher plus', + style: TextStyle( + color: Color(0xFFFF9D97), + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildInlineChannelStats() { + if (_inlineChannelDetailsLoading) { + return Padding( + padding: EdgeInsets.only(left: 5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 13, + height: 13, + child: CircularProgressIndicator( + strokeWidth: 1.5, + ), + ), + SizedBox(width: 7), + Text( + 'Infos de la chaîne…', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 11, + ), + ), + ], + ), + ); + } + + final details = _inlineChannelDetails; + final videoDetails = + _inlineVideoDetails; + + if (details == null && + videoDetails?.likeCount == null) { + return SizedBox.shrink(); + } + + final chips = []; + + final hasChannelStats = + details?.subscriberCount != null || + details?.viewCount != null || + details?.publishedAt != null || + (details?.joinDateLabel?.isNotEmpty ?? false) || + details?.videoCount != null; + + if (details?.subscriberCount != null) { + chips.add( + _InlineChannelChip( + icon: Icons.people_alt_outlined, + text: + '${_formatCompactNumber(details!.subscriberCount!)} abonnés', + ), + ); + } + + if (details?.viewCount != null) { + chips.add( + _InlineChannelChip( + icon: Icons.visibility_outlined, + text: + '${_formatCompactNumber(details!.viewCount!)} vues chaîne', + ), + ); + } + + if (details?.videoCount != null) { + chips.add( + _InlineChannelChip( + icon: Icons.video_library_outlined, + text: + '${_formatCompactNumber(details!.videoCount!)} vidéos', + ), + ); + } + + if (details?.publishedAt != null) { + chips.add( + _InlineChannelChip( + icon: Icons.calendar_month_outlined, + text: + 'Créée ${_formatCompactDate(details!.publishedAt!)}', + ), + ); + } else if (details?.joinDateLabel != null && + details!.joinDateLabel!.isNotEmpty) { + chips.add( + _InlineChannelChip( + icon: Icons.calendar_month_outlined, + text: + 'Créée ${details.joinDateLabel!}', + ), + ); + } + + if (videoDetails?.likeCount != null) { + chips.add( + _InlineChannelChip( + icon: Icons.thumb_up_alt_outlined, + text: + '${_formatCompactNumber(videoDetails!.likeCount!)} likes vidéo', + ), + ); + } + + if (!hasChannelStats) { + chips.add( + _InlineChannelChip( + icon: Icons.info_outline, + text: 'Stats chaîne indisponibles', + ), + ); + } + + if (chips.isEmpty) { + return SizedBox.shrink(); + } + + return Padding( + padding: EdgeInsets.only( + left: 5, + right: 5, + ), + child: Wrap( + spacing: 7, + runSpacing: 7, + children: chips, + ), + ); + } + + String _formatCompactNumber( + int value, + ) { + if (value >= 1000000000) { + return '${(value / 1000000000).toStringAsFixed( + value >= 10000000000 ? 0 : 1, + )} Md'; + } + + if (value >= 1000000) { + return '${(value / 1000000).toStringAsFixed( + value >= 10000000 ? 0 : 1, + )} M'; + } + + if (value >= 1000) { + return '${(value / 1000).toStringAsFixed( + value >= 10000 ? 0 : 1, + )} k'; + } + + return '$value'; + } + + String _formatCompactDate( + DateTime value, + ) { + return '${value.day.toString().padLeft(2, '0')}/' + '${value.month.toString().padLeft(2, '0')}/' + '${value.year}'; + } + + Widget _buildQuickControls() { + return Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + OutlinedButton.icon( + onPressed: _loading ? null : () => _seekRelative(-10), + icon: Icon(Icons.replay_10), + label: Text('-10 s'), + ), + OutlinedButton.icon( + onPressed: _loading ? null : () => _seekRelative(10), + icon: Icon(Icons.forward_10), + label: Text('+10 s'), + ), + OutlinedButton.icon( + onPressed: _loading ? null : _restartVideo, + icon: Icon(Icons.restart_alt), + label: Text('Recommencer'), + ), + OutlinedButton.icon( + onPressed: + _loading ? null : _togglePictureInPicture, + icon: Icon(Icons.picture_in_picture_alt), + label: Text( + Platform.isWindows && _desktopPip + ? 'Quitter PiP' + : 'Picture-in-Picture', + ), + ), + ], + ); + } + + Widget _buildAudioTrackControls() { + if (!(Platform.isAndroid || Platform.isWindows)) { + return const SizedBox.shrink(); + } + + return Row( + children: [ + Expanded( + child: Text( + 'Piste audio', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + OutlinedButton.icon( + onPressed: (_loading || _audioTrackLoading) + ? null + : _showAudioTrackPicker, + icon: _audioTrackLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.audiotrack_rounded), + label: Text( + _audioTrackLoading + ? 'Détection…' + : _audioTrackDisplayLabel, + ), + ), + ], + ); + } + + Widget _buildQualityControls() { + if (!(Platform.isAndroid || Platform.isWindows)) { + return Row( + children: [ + Expanded( + child: Text( + 'Qualité vidéo', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 7, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Text( + '360p', + style: TextStyle( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + } + + return Row( + children: [ + Expanded( + child: Text( + 'Qualité vidéo', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: 10, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedQualityHeight, + dropdownColor: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + items: _qualityOptions.entries + .map( + (entry) { + final unavailable = + Platform.isWindows && + _windowsUnavailableQualityHeights + .contains(entry.key); + + return DropdownMenuItem( + value: entry.key, + enabled: !unavailable, + child: Text( + unavailable + ? '${entry.value} • indisponible' + : entry.value, + ), + ); + }, + ) + .toList(growable: false), + onChanged: _loading + ? null + : (value) { + if (value == null) { + return; + } + + unawaited( + _changeQuality(value), + ); + }, + ), + ), + ), + ], + ); + } + + Widget _buildSpeedControls() { + const minRate = 0.5; + const maxRate = 2.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Vitesse', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Text( + '${_playbackRate.toStringAsFixed(2)}x', + style: TextStyle( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + SizedBox(height: 8), + Row( + children: [ + IconButton( + tooltip: 'Ralentir de 0,05x', + onPressed: _loading + ? null + : () => _adjustPlaybackRate(-0.05), + icon: Icon(Icons.remove_circle_outline), + ), + Expanded( + child: Slider( + min: minRate, + max: maxRate, + divisions: 30, + value: _playbackRate.clamp(minRate, maxRate), + label: '${_playbackRate.toStringAsFixed(2)}x', + onChanged: _loading + ? null + : (value) { + final rounded = _roundRate(value); + + setState(() { + _playbackRate = rounded; + }); + + unawaited(_player.setRate(rounded)); + }, + ), + ), + IconButton( + tooltip: 'Accélérer de 0,05x', + onPressed: _loading + ? null + : () => _adjustPlaybackRate(0.05), + icon: Icon(Icons.add_circle_outline), + ), + ], + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '0.50x', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 12, + ), + ), + Text( + '1.00x', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 12, + ), + ), + Text( + '1.50x', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 12, + ), + ), + Text( + '2.00x', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 12, + ), + ), + ], + ), + ), + SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton( + onPressed: _loading + ? null + : () => _setPlaybackRate(0.5), + child: Text('0.50x'), + ), + OutlinedButton( + onPressed: _loading + ? null + : () => _setPlaybackRate(1.0), + child: Text('1.00x'), + ), + OutlinedButton( + onPressed: _loading + ? null + : () => _setPlaybackRate(1.5), + child: Text('1.50x'), + ), + OutlinedButton( + onPressed: _loading + ? null + : () => _setPlaybackRate(2.0), + child: Text('2.00x'), + ), + ], + ), + ], + ); + } + + double _roundRate(double value) { + final rounded = (value * 20).round() / 20; + return rounded.clamp(0.5, 2.0); + } + + Future _adjustPlaybackRate(double delta) async { + final next = _roundRate(_playbackRate + delta); + await _setPlaybackRate(next); + } +} + +class _VideoDescriptionShell extends StatelessWidget { + const _VideoDescriptionShell(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(13), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(13), + ), + child: Row( + children: [ + SizedBox( + width: 13, + height: 13, + child: CircularProgressIndicator( + strokeWidth: 1.5, + ), + ), + SizedBox(width: 8), + Text( + 'Chargement de la description…', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 11, + ), + ), + ], + ), + ); + } +} + +class _InlineChannelChip extends StatelessWidget { + const _InlineChannelChip({ + required this.icon, + required this.text, + }); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: 9, + vertical: 5, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 14, + color: Color(0xFFFF9D97), + ), + SizedBox(width: 5), + Text( + text, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + fontSize: 10.5, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +class _InfoChip extends StatelessWidget { + const _InfoChip({ + required this.icon, + required this.label, + }); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: 11, + vertical: 7, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +class _PlayerError extends StatelessWidget { + const _PlayerError({ + required this.message, + required this.onRetry, + }); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.all(22), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + ), + SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + SizedBox(height: 16), + FilledButton.icon( + onPressed: onRetry, + icon: Icon(Icons.refresh), + label: Text('Réessayer'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart new file mode 100644 index 0000000..f53ee45 --- /dev/null +++ b/lib/services/app_settings_service.dart @@ -0,0 +1,204 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'preferred_language_service.dart'; + +class AppSettingsService { + AppSettingsService._(); + + static final AppSettingsService instance = AppSettingsService._(); + + static const String _defaultResolutionKey = + 'youtube_personal.settings.default_resolution.v1'; + static const String _preferredLanguageKey = + 'youtube_personal.settings.preferred_language.v1'; + static const String _youngAudienceFilterKey = + 'youtube_personal.settings.young_audience_filter.v1'; + static const String _pauseScreenSleepMinutesKey = + 'youtube_personal.settings.pause_screen_sleep_minutes.v1'; + static const String _appearanceModeKey = + 'youtube_personal.settings.appearance_mode.v1'; + + static const int historicalDefaultResolution = 1080; + + final SharedPreferencesAsync _preferences = SharedPreferencesAsync(); + + final ValueNotifier defaultResolution = + ValueNotifier(historicalDefaultResolution); + + final ValueNotifier preferredLanguage = + ValueNotifier('auto'); + + final ValueNotifier youngAudienceFilterLevel = + ValueNotifier(0); + + final ValueNotifier pauseScreenSleepMinutes = + ValueNotifier(5); + + final ValueNotifier appearanceMode = + ValueNotifier('light'); + + Future initialize() async { + final storedResolution = await _preferences.getInt(_defaultResolutionKey); + final storedLanguage = await _preferences.getString(_preferredLanguageKey); + final storedYoungAudienceFilter = + await _preferences.getInt(_youngAudienceFilterKey); + final storedPauseScreenSleepMinutes = + await _preferences.getInt(_pauseScreenSleepMinutesKey); + final storedAppearanceMode = + await _preferences.getString(_appearanceModeKey); + + if (storedResolution != null && + supportedResolutions.contains(storedResolution)) { + defaultResolution.value = storedResolution; + } + + if (storedLanguage != null && + PreferredLanguageService.isSupported(storedLanguage)) { + preferredLanguage.value = storedLanguage; + } + + if (storedYoungAudienceFilter != null && + supportedYoungAudienceFilterLevels + .contains(storedYoungAudienceFilter)) { + youngAudienceFilterLevel.value = storedYoungAudienceFilter; + } + + if (storedPauseScreenSleepMinutes != null && + supportedPauseScreenSleepMinutes + .contains(storedPauseScreenSleepMinutes)) { + pauseScreenSleepMinutes.value = storedPauseScreenSleepMinutes; + } + + if (storedAppearanceMode != null && + supportedAppearanceModes.contains(storedAppearanceMode)) { + appearanceMode.value = storedAppearanceMode; + } + } + + Future setAppearanceMode(String mode) async { + if (!supportedAppearanceModes.contains(mode)) { + return; + } + if (appearanceMode.value == mode) { + return; + } + appearanceMode.value = mode; + await _preferences.setString(_appearanceModeKey, mode); + } + + Future setDefaultResolution(int resolution) async { + if (!supportedResolutions.contains(resolution)) { + return; + } + + if (defaultResolution.value == resolution) { + return; + } + + defaultResolution.value = resolution; + await _preferences.setInt(_defaultResolutionKey, resolution); + } + + Future setPreferredLanguage(String languageCode) async { + if (!PreferredLanguageService.isSupported(languageCode)) { + return; + } + + if (preferredLanguage.value == languageCode) { + return; + } + + preferredLanguage.value = languageCode; + await _preferences.setString(_preferredLanguageKey, languageCode); + } + + Future setPauseScreenSleepMinutes(int minutes) async { + if (!supportedPauseScreenSleepMinutes.contains(minutes)) { + return; + } + + if (pauseScreenSleepMinutes.value == minutes) { + return; + } + + pauseScreenSleepMinutes.value = minutes; + await _preferences.setInt(_pauseScreenSleepMinutesKey, minutes); + } + + Future setYoungAudienceFilterLevel(int level) async { + if (!supportedYoungAudienceFilterLevels.contains(level)) { + return; + } + + if (youngAudienceFilterLevel.value == level) { + return; + } + + youngAudienceFilterLevel.value = level; + await _preferences.setInt(_youngAudienceFilterKey, level); + } + + static const List supportedAppearanceModes = [ + 'auto', + 'dark', + 'light', + 'system', + ]; + + static String appearanceModeLabel(String mode) { + return switch (mode) { + 'dark' => 'Sombre', + 'light' => 'Clair', + 'auto' => 'Auto (jour/nuit)', + _ => 'Système', + }; + } + + static const List supportedPauseScreenSleepMinutes = [ + 2, + 5, + 10, + 0, + ]; + + static String pauseScreenSleepLabel(int minutes) { + return switch (minutes) { + 0 => 'Jamais', + 2 => '2 minutes', + 10 => '10 minutes', + _ => '5 minutes', + }; + } + + static const List supportedYoungAudienceFilterLevels = [ + 0, + 1, + 2, + ]; + + static String youngAudienceFilterLabel(int level) { + return switch (level) { + 1 => 'Modéré', + 2 => 'Strict', + _ => 'Désactivé', + }; + } + + static const List supportedResolutions = [ + 0, + 360, + 720, + 1080, + 1440, + 2160, + ]; + + static String resolutionLabel(int resolution) { + return switch (resolution) { + 0 => 'Auto', + 2160 => '2160p (4K)', + _ => '${resolution}p', + }; + } +} diff --git a/lib/services/background_playback_service.dart b/lib/services/background_playback_service.dart new file mode 100644 index 0000000..db8a18c --- /dev/null +++ b/lib/services/background_playback_service.dart @@ -0,0 +1,755 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:media_kit/media_kit.dart'; + +import '../models/video_item.dart'; +import 'library_service.dart'; +import 'sponsor_block_service.dart'; + +class BackgroundPlaybackSnapshot { + const BackgroundPlaybackSnapshot({ + required this.position, + required this.playing, + required this.rate, + }); + + final Duration position; + final bool playing; + final double rate; +} + +class BackgroundPlaybackService { + BackgroundPlaybackService._(); + + static final BackgroundPlaybackService instance = + BackgroundPlaybackService._(); + + MediaKitAudioHandler? _handler; + + bool get isAvailable => Platform.isAndroid && _handler != null; + + Future initialize() async { + if (!Platform.isAndroid || _handler != null) { + return; + } + + MediaKitAudioHandler? created; + + await AudioService.init( + builder: () { + created = MediaKitAudioHandler(); + return created!; + }, + config: const AudioServiceConfig( + androidNotificationChannelId: + 'com.studioweb.youtube_personal.playback', + androidNotificationChannelName: + 'Lecture YouTube Personal', + androidNotificationChannelDescription: + 'Contrôles de lecture en arrière-plan', + androidNotificationOngoing: false, + androidStopForegroundOnPause: false, + rewindInterval: Duration(seconds: 10), + fastForwardInterval: Duration(seconds: 10), + ), + ); + + _handler = created; + } + + Future attachPlayer( + Player player, + VideoItem video, + ) async { + final handler = _handler; + + if (handler == null) { + return; + } + + await handler.attachForegroundPlayer( + player, + video, + ); + } + + Future enterBackgroundAudio({ + required String url, + required VideoItem video, + required Duration position, + required bool shouldPlay, + required double rate, + }) async { + await _handler?.enterBackgroundAudio( + url: url, + video: video, + position: position, + shouldPlay: shouldPlay, + rate: rate, + ); + } + + Future exitBackgroundAudio({ + required Player foregroundPlayer, + required VideoItem video, + }) async { + return _handler?.exitBackgroundAudio( + foregroundPlayer: foregroundPlayer, + video: video, + ); + } + + Future detachPlayer() async { + await _handler?.detachPlayer(); + } +} + +class MediaKitAudioHandler extends BaseAudioHandler + with SeekHandler { + Player? _foregroundPlayer; + Player? _backgroundPlayer; + Player? _activePlayer; + + VideoItem? _video; + + final List> _subscriptions = + >[]; + + bool _backgroundMode = false; + + bool _playing = false; + bool _buffering = false; + bool _completed = false; + Duration _position = Duration.zero; + Duration _duration = Duration.zero; + double _speed = 1.0; + Duration _lastPersistedPosition = Duration.zero; + + List _backgroundSponsorSegments = + const []; + bool _backgroundSponsorSeekInProgress = false; + String? _backgroundSponsorActiveId; + + Future attachForegroundPlayer( + Player player, + VideoItem video, + ) async { + _foregroundPlayer = player; + _video = video; + + if (_backgroundMode) { + return; + } + + await _bindPlayer( + player, + video, + ); + } + + Future enterBackgroundAudio({ + required String url, + required VideoItem video, + required Duration position, + required bool shouldPlay, + required double rate, + }) async { + if (_backgroundMode) { + return; + } + + debugPrint( + '[BACKGROUND-AUDIO] switching to dedicated player ' + 'at ${position.inMilliseconds} ms ' + '(shouldPlay=$shouldPlay rate=$rate)', + ); + + _backgroundMode = true; + _video = video; + + unawaited(_prepareBackgroundSponsors(video.id)); + + await _cancelSubscriptions(); + + // Garde la MediaSession/foreground service en état actif pendant + // l'ouverture du nouveau Player afin d'éviter qu'Android arrête + // le service entre les deux moteurs. + _playing = shouldPlay; + _buffering = true; + _completed = false; + _position = position; + _duration = video.duration ?? Duration.zero; + _speed = rate; + _setMediaItem(video); + _broadcastState(); + + final backgroundPlayer = Player( + configuration: const PlayerConfiguration( + vo: 'null', + title: 'YouTube Personal Background', + ), + ); + + _backgroundPlayer = backgroundPlayer; + _activePlayer = backgroundPlayer; + + try { + // Ne pas lancer immédiatement : on désactive d'abord explicitement + // la piste vidéo pour que ce Player ne dépende d'aucune Surface Android. + await backgroundPlayer.open( + Media(url), + play: false, + ); + + await backgroundPlayer.setVideoTrack( + VideoTrack.no(), + ); + + // Sur certains appareils, open() retourne avant que la timeline du + // média soit réellement prête. Un seek envoyé immédiatement peut alors + // être ignoré et laisser position=0. On attend une durée exploitable. + await _waitUntilMediaReady( + backgroundPlayer, + wantedPosition: position, + fallbackDuration: video.duration, + ); + + if (position > Duration.zero) { + await _seekAndConfirm( + backgroundPlayer, + position, + ); + } + + await backgroundPlayer.setRate(rate); + + if (shouldPlay) { + await backgroundPlayer.play(); + + // Confirme aussi que le nouveau Player est réellement passé en + // lecture avant de rendre la main au code écran-verrouillé. + await _waitUntilPlaying(backgroundPlayer); + } + + await _bindPlayer( + backgroundPlayer, + video, + ); + + debugPrint( + '[BACKGROUND-AUDIO] dedicated player ready ' + 'at ${backgroundPlayer.state.position.inMilliseconds} ms ' + 'playing=${backgroundPlayer.state.playing}', + ); + } catch (error) { + debugPrint( + '[BACKGROUND-AUDIO] dedicated player failed: $error', + ); + + _backgroundMode = false; + _backgroundPlayer = null; + + await backgroundPlayer.dispose(); + + final foreground = _foregroundPlayer; + + if (foreground != null) { + await _bindPlayer( + foreground, + video, + ); + } else { + _activePlayer = null; + } + + rethrow; + } + } + + Future exitBackgroundAudio({ + required Player foregroundPlayer, + required VideoItem video, + }) async { + if (!_backgroundMode) { + _foregroundPlayer = foregroundPlayer; + + return BackgroundPlaybackSnapshot( + position: foregroundPlayer.state.position, + playing: foregroundPlayer.state.playing, + rate: foregroundPlayer.state.rate, + ); + } + + final background = _backgroundPlayer; + + if (background == null) { + _backgroundMode = false; + _foregroundPlayer = foregroundPlayer; + + await _bindPlayer( + foregroundPlayer, + video, + ); + + return null; + } + + final snapshot = BackgroundPlaybackSnapshot( + position: background.state.position, + playing: background.state.playing, + rate: background.state.rate, + ); + + await LibraryService.instance.savePlaybackPosition( + video.id, + snapshot.position, + duration: video.duration, + ); + + debugPrint( + '[BACKGROUND-AUDIO] returning to video player ' + 'at ${snapshot.position.inMilliseconds} ms ' + 'playing=${snapshot.playing}', + ); + + // Annule les listeners avant de couper le Player de fond, sinon + // le MediaSession verrait artificiellement une pause. + await _cancelSubscriptions(); + + _backgroundMode = false; + _backgroundPlayer = null; + _foregroundPlayer = foregroundPlayer; + _activePlayer = foregroundPlayer; + + await background.pause(); + await background.dispose(); + + await _bindPlayer( + foregroundPlayer, + video, + ); + + return snapshot; + } + + Future detachPlayer() async { + await _cancelSubscriptions(); + + final background = _backgroundPlayer; + _backgroundPlayer = null; + + if (background != null) { + await background.dispose(); + } + + _foregroundPlayer = null; + _activePlayer = null; + _video = null; + _backgroundMode = false; + + _playing = false; + _buffering = false; + _completed = false; + _position = Duration.zero; + _duration = Duration.zero; + _speed = 1.0; + _lastPersistedPosition = Duration.zero; + _backgroundSponsorSegments = const []; + _backgroundSponsorSeekInProgress = false; + _backgroundSponsorActiveId = null; + + mediaItem.add(null); + + playbackState.add( + PlaybackState( + processingState: AudioProcessingState.idle, + playing: false, + ), + ); + } + + Future _waitUntilMediaReady( + Player player, { + required Duration wantedPosition, + required Duration? fallbackDuration, + }) async { + final currentDuration = player.state.duration; + + if (currentDuration > Duration.zero && + (wantedPosition <= Duration.zero || + currentDuration >= wantedPosition)) { + return; + } + + try { + await player.stream.duration + .firstWhere( + (duration) => + duration > Duration.zero && + (wantedPosition <= Duration.zero || + duration >= wantedPosition), + ) + .timeout(const Duration(seconds: 10)); + } on TimeoutException { + // Si youtube_explode nous a déjà fourni une durée cohérente, on peut + // malgré tout tenter le seek. Le contrôle suivant confirmera ou non. + if (fallbackDuration == null || + fallbackDuration <= Duration.zero || + (wantedPosition > Duration.zero && + fallbackDuration < wantedPosition)) { + rethrow; + } + } + } + + Future _seekAndConfirm( + Player player, + Duration target, + ) async { + const tolerance = Duration(seconds: 2); + + for (var attempt = 1; attempt <= 3; attempt++) { + debugPrint( + '[BACKGROUND-AUDIO] seek attempt $attempt -> ' + '${target.inMilliseconds} ms', + ); + + await player.seek(target); + + final immediate = player.state.position; + + if (_closeEnough( + immediate, + target, + tolerance, + )) { + debugPrint( + '[BACKGROUND-AUDIO] seek confirmed immediately ' + 'at ${immediate.inMilliseconds} ms', + ); + return; + } + + try { + final confirmed = await player.stream.position + .firstWhere( + (position) => _closeEnough( + position, + target, + tolerance, + ), + ) + .timeout(const Duration(seconds: 3)); + + debugPrint( + '[BACKGROUND-AUDIO] seek confirmed ' + 'at ${confirmed.inMilliseconds} ms', + ); + return; + } on TimeoutException { + debugPrint( + '[BACKGROUND-AUDIO] seek attempt $attempt not confirmed ' + '(current=${player.state.position.inMilliseconds} ms)', + ); + + await Future.delayed( + const Duration(milliseconds: 300), + ); + } + } + + throw TimeoutException( + 'Le Player audio de fond n’a pas confirmé le seek.', + ); + } + + Future _waitUntilPlaying(Player player) async { + if (player.state.playing) { + return; + } + + await player.stream.playing + .firstWhere((playing) => playing) + .timeout(const Duration(seconds: 5)); + } + + bool _closeEnough( + Duration value, + Duration target, + Duration tolerance, + ) { + final delta = + (value.inMilliseconds - target.inMilliseconds).abs(); + + return delta <= tolerance.inMilliseconds; + } + + Future _prepareBackgroundSponsors(String videoId) async { + final segments = await SponsorBlockService.instance + .getSponsorSegments(videoId); + + if (_video?.id != videoId) { + return; + } + + _backgroundSponsorSegments = segments; + _backgroundSponsorActiveId = null; + } + + Future _maybeSkipBackgroundSponsor( + Player player, + Duration position, + ) async { + if (!_backgroundMode || + _backgroundSponsorSeekInProgress || + _backgroundSponsorSegments.isEmpty) { + return; + } + + SponsorSegment? match; + + for (final segment in _backgroundSponsorSegments) { + if (segment.contains(position)) { + match = segment; + break; + } + } + + if (match == null) { + _backgroundSponsorActiveId = null; + return; + } + + if (_backgroundSponsorActiveId == match.id) { + return; + } + + _backgroundSponsorActiveId = match.id; + _backgroundSponsorSeekInProgress = true; + + try { + final target = match.end + const Duration(milliseconds: 80); + final wasPlaying = player.state.playing; + + debugPrint( + '[BACKGROUND-SPONSORBLOCK] skip ' + '${match.start.inMilliseconds} -> ${match.end.inMilliseconds} ms', + ); + + await player.seek(target); + + if (wasPlaying) { + await player.play(); + } + + _position = target; + _lastPersistedPosition = target; + } catch (error) { + debugPrint('[BACKGROUND-SPONSORBLOCK] skip impossible: $error'); + } finally { + _backgroundSponsorSeekInProgress = false; + } + } + + Future _bindPlayer( + Player player, + VideoItem video, + ) async { + await _cancelSubscriptions(); + + _activePlayer = player; + _video = video; + + _playing = player.state.playing; + _buffering = player.state.buffering; + _position = player.state.position; + _duration = video.duration ?? player.state.duration; + _speed = player.state.rate; + _completed = false; + _lastPersistedPosition = _position; + + _setMediaItem(video); + + _subscriptions.addAll([ + player.stream.playing.listen((value) { + _playing = value; + _broadcastState(); + }), + player.stream.buffering.listen((value) { + _buffering = value; + _broadcastState(); + }), + player.stream.position.listen((value) { + _position = value; + + if (_backgroundMode) { + unawaited(_maybeSkipBackgroundSponsor(player, value)); + } + + if (_backgroundMode && + (_position - _lastPersistedPosition).abs() >= + const Duration(seconds: 5)) { + _lastPersistedPosition = _position; + + unawaited( + LibraryService.instance.savePlaybackPosition( + video.id, + _position, + duration: video.duration, + ), + ); + } + + _broadcastState(); + }), + player.stream.duration.listen((value) { + if (value > Duration.zero) { + _duration = value; + } + + _broadcastState(); + }), + player.stream.rate.listen((value) { + _speed = value; + _broadcastState(); + }), + player.stream.completed.listen((value) { + _completed = value; + _broadcastState(); + }), + ]); + + _broadcastState(); + } + + void _setMediaItem(VideoItem video) { + mediaItem.add( + MediaItem( + id: video.id, + title: video.title, + artist: video.author, + duration: video.duration, + artUri: Uri.tryParse(video.thumbnailUrl), + ), + ); + } + + Future _cancelSubscriptions() async { + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + + _subscriptions.clear(); + } + + void _broadcastState() { + final processingState = _completed + ? AudioProcessingState.completed + : _buffering + ? AudioProcessingState.buffering + : AudioProcessingState.ready; + + playbackState.add( + PlaybackState( + controls: [ + MediaControl.rewind, + _playing ? MediaControl.pause : MediaControl.play, + MediaControl.fastForward, + ], + androidCompactActionIndices: const [0, 1, 2], + systemActions: const { + MediaAction.seek, + MediaAction.setSpeed, + }, + processingState: processingState, + playing: _playing, + updatePosition: _position, + bufferedPosition: Duration.zero, + speed: _speed, + ), + ); + } + + @override + Future play() async { + await _activePlayer?.play(); + } + + @override + Future pause() async { + await _activePlayer?.pause(); + } + + @override + Future seek(Duration position) async { + final target = _clampPosition(position); + await _activePlayer?.seek(target); + + _position = target; + + final video = _video; + if (_backgroundMode && video != null) { + _lastPersistedPosition = target; + + await LibraryService.instance.savePlaybackPosition( + video.id, + target, + duration: video.duration, + ); + } + + _broadcastState(); + } + + @override + Future rewind() async { + await seek( + _position - const Duration(seconds: 10), + ); + } + + @override + Future fastForward() async { + await seek( + _position + const Duration(seconds: 10), + ); + } + + @override + Future setSpeed(double speed) async { + await _activePlayer?.setRate(speed); + } + + @override + Future stop() async { + await _activePlayer?.stop(); + + _playing = false; + _completed = false; + _position = Duration.zero; + + playbackState.add( + PlaybackState( + processingState: AudioProcessingState.idle, + playing: false, + ), + ); + + await super.stop(); + } + + Duration _clampPosition(Duration value) { + if (value < Duration.zero) { + return Duration.zero; + } + + if (_duration > Duration.zero && value > _duration) { + return _duration; + } + + return value; + } +} diff --git a/lib/services/channel_catalog_service.dart b/lib/services/channel_catalog_service.dart new file mode 100644 index 0000000..7d2550e --- /dev/null +++ b/lib/services/channel_catalog_service.dart @@ -0,0 +1,240 @@ +import '../models/channel_rank_item.dart'; +import '../models/subscription_channel.dart'; +import '../models/video_item.dart'; +import 'hidden_channels_service.dart'; +import 'library_service.dart'; +import 'youtube_service.dart'; + +class ChannelCatalogService { + ChannelCatalogService._(); + + static final ChannelCatalogService instance = + ChannelCatalogService._(); + + final YoutubeService _youtube = YoutubeService(); + + List? _cache; + String? _cacheFingerprint; + DateTime? _cacheAt; + + Future> load({ + bool force = false, + }) async { + await HiddenChannelsService.instance.ensureLoaded(); + + final history = LibraryService.instance.history.value; + + if (history.isEmpty) { + return const []; + } + + final fingerprint = [ + '${HiddenChannelsService.instance.revision.value}', + for (final video in history.take(100)) + '${video.id}:${LibraryService.instance.watchCountForVideo(video.id)}', + ].join('|'); + + final cacheFresh = _cacheAt != null && + DateTime.now().difference(_cacheAt!) < + const Duration(minutes: 10); + + if (!force && + cacheFresh && + _cache != null && + _cacheFingerprint == fingerprint) { + return _cache!; + } + + final enriched = []; + + // History is unique by video ID, but personal repeat counts are stored + // separately. Resolve old entries created before channelId existed. + for (var start = 0; start < history.length; start += 6) { + final end = start + 6 < history.length + ? start + 6 + : history.length; + + final chunk = history.sublist(start, end); + + final resolved = await Future.wait( + chunk.map( + (video) async { + if (video.channelId != null && + video.channelId!.isNotEmpty) { + return video; + } + + try { + return await _youtube.getVideoDetails(video.id); + } catch (_) { + return video; + } + }, + ), + ); + + enriched.addAll(resolved); + } + + final groups = {}; + + for (var index = 0; index < enriched.length; index++) { + final video = enriched[index]; + var channelId = video.channelId?.trim() ?? ''; + SubscriptionChannel? resolvedChannel; + + if (channelId.isEmpty) { + try { + resolvedChannel = + await _youtube.getChannelForVideo(video.id); + channelId = resolvedChannel.channelId; + } catch (_) { + continue; + } + } + + if (HiddenChannelsService.instance.isHiddenChannel( + channelId: channelId, + title: resolvedChannel?.title ?? video.author, + )) { + continue; + } + + final personalViews = + LibraryService.instance.watchCountForVideo(video.id); + + final safePersonalViews = personalViews > 0 ? personalViews : 1; + + groups + .putIfAbsent( + channelId, + () => _ChannelAccumulator( + channelId: channelId, + fallbackTitle: resolvedChannel?.title ?? video.author, + ), + ) + .add( + video, + personalViews: safePersonalViews, + historyIndex: index, + channelOverride: resolvedChannel, + ); + } + + final accumulators = groups.values.toList(); + final result = []; + + for (var start = 0; start < accumulators.length; start += 6) { + final end = start + 6 < accumulators.length + ? start + 6 + : accumulators.length; + + final chunk = accumulators.sublist(start, end); + + final resolved = await Future.wait( + chunk.map( + (group) async { + SubscriptionChannel channel; + + if (group.channelOverride != null) { + channel = group.channelOverride!; + } else { + try { + channel = await _youtube.getChannel(group.channelId); + } catch (_) { + channel = SubscriptionChannel( + channelId: group.channelId, + title: group.fallbackTitle, + thumbnailUrl: '', + ); + } + } + + if (HiddenChannelsService.instance.isHiddenChannel( + channelId: channel.channelId, + title: channel.title, + )) { + return null; + } + + return ChannelRankItem( + channel: channel, + personalViewCount: group.personalViewCount, + distinctVideoCount: group.distinctVideoCount, + topVideoPersonalViews: group.topVideoPersonalViews, + topVideoTitle: group.topVideoTitle, + ); + }, + ), + ); + + result.addAll(resolved.whereType()); + } + + result.sort( + (a, b) { + final views = b.personalViewCount.compareTo(a.personalViewCount); + if (views != 0) { + return views; + } + + final distinct = + b.distinctVideoCount.compareTo(a.distinctVideoCount); + if (distinct != 0) { + return distinct; + } + + return a.channel.title + .toLowerCase() + .compareTo(b.channel.title.toLowerCase()); + }, + ); + + _cache = List.unmodifiable(result); + _cacheFingerprint = fingerprint; + _cacheAt = DateTime.now(); + + return _cache!; + } +} + +class _ChannelAccumulator { + _ChannelAccumulator({ + required this.channelId, + required this.fallbackTitle, + }); + + final String channelId; + final String fallbackTitle; + + SubscriptionChannel? channelOverride; + + int personalViewCount = 0; + int distinctVideoCount = 0; + int topVideoPersonalViews = 0; + int newestHistoryIndex = 1 << 30; + String? topVideoTitle; + + void add( + VideoItem video, { + required int personalViews, + required int historyIndex, + SubscriptionChannel? channelOverride, + }) { + if (channelOverride != null) { + this.channelOverride = channelOverride; + } + + personalViewCount += personalViews; + distinctVideoCount++; + + if (historyIndex < newestHistoryIndex) { + newestHistoryIndex = historyIndex; + } + + if (personalViews >= topVideoPersonalViews) { + topVideoPersonalViews = personalViews; + topVideoTitle = video.title; + } + } +} diff --git a/lib/services/channel_preview_metadata_service.dart b/lib/services/channel_preview_metadata_service.dart new file mode 100644 index 0000000..796f78f --- /dev/null +++ b/lib/services/channel_preview_metadata_service.dart @@ -0,0 +1,33 @@ +import '../models/channel_details.dart'; +import 'youtube_service.dart'; + +class ChannelPreviewMetadataService { + ChannelPreviewMetadataService._(); + + static final ChannelPreviewMetadataService instance = + ChannelPreviewMetadataService._(); + + final YoutubeService _youtube = YoutubeService(); + + final Map> _channelCache = + >{}; + + Future getChannel( + String? channelId, + ) { + final id = channelId?.trim() ?? ''; + + if (id.isEmpty) { + return Future.value(null); + } + + return _channelCache.putIfAbsent( + id, + () => _youtube.getChannelPreviewDetails(id), + ); + } + + void invalidate(String channelId) { + _channelCache.remove(channelId); + } +} diff --git a/lib/services/google_auth_service.dart b/lib/services/google_auth_service.dart new file mode 100644 index 0000000..832cd56 --- /dev/null +++ b/lib/services/google_auth_service.dart @@ -0,0 +1,735 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; + +import 'google_oauth_config_service.dart'; + +class GoogleSession { + const GoogleSession({ + required this.accessToken, + required this.displayName, + this.email, + this.photoUrl, + }); + + final String accessToken; + final String displayName; + final String? email; + final String? photoUrl; + + GoogleSession copyWith({ + String? accessToken, + String? displayName, + String? email, + String? photoUrl, + }) { + return GoogleSession( + accessToken: accessToken ?? this.accessToken, + displayName: displayName ?? this.displayName, + email: email ?? this.email, + photoUrl: photoUrl ?? this.photoUrl, + ); + } +} + +class GoogleAuthConfigurationException implements Exception { + const GoogleAuthConfigurationException(this.message); + + final String message; + + @override + String toString() => message; +} + +class GoogleAuthService { + GoogleAuthService._(); + + static final GoogleAuthService instance = + GoogleAuthService._(); + + static const String youtubeReadonlyScope = + 'https://www.googleapis.com/auth/youtube.readonly'; + + static const String _envWebClientId = + String.fromEnvironment( + 'GOOGLE_WEB_CLIENT_ID', + ); + + static const String _envDesktopClientId = + String.fromEnvironment( + 'GOOGLE_DESKTOP_CLIENT_ID', + ); + + static const String _envDesktopClientSecret = + String.fromEnvironment( + 'GOOGLE_DESKTOP_CLIENT_SECRET', + ); + + final ValueNotifier session = + ValueNotifier(null); + + bool _googleSignInInitialized = false; + String? _initializedAndroidWebClientId; + GoogleSignInAccount? _androidAccount; + + String? _windowsRefreshToken; + DateTime? _windowsAccessTokenExpiry; + + GoogleOauthConfig get _savedConfig => + GoogleOauthConfigService.instance.config.value; + + String get _webClientId { + final environment = _envWebClientId.trim(); + + if (environment.isNotEmpty) { + return environment; + } + + return _savedConfig.webClientId.trim(); + } + + String get _desktopClientId { + final environment = _envDesktopClientId.trim(); + + if (environment.isNotEmpty) { + return environment; + } + + return _savedConfig.desktopClientId.trim(); + } + + String get _desktopClientSecret { + final environment = _envDesktopClientSecret.trim(); + + if (environment.isNotEmpty) { + return environment; + } + + return _savedConfig.desktopClientSecret.trim(); + } + + bool get isConfigured { + if (Platform.isAndroid) { + return _webClientId.isNotEmpty; + } + + if (Platform.isWindows) { + return _desktopClientId.isNotEmpty; + } + + return false; + } + + String get configurationHint { + if (Platform.isAndroid) { + return 'Client ID Web OAuth Google manquant'; + } + + if (Platform.isWindows) { + return 'Client ID OAuth "Desktop app" manquant'; + } + + return 'Plateforme non prise en charge'; + } + + Future initialize() async { + await GoogleOauthConfigService.instance.initialize(); + + if (!Platform.isAndroid || !isConfigured) { + return; + } + + await _initializeAndroidGoogleSignIn(); + + try { + final future = + GoogleSignIn.instance + .attemptLightweightAuthentication(); + + if (future == null) { + return; + } + + final account = await future; + + if (account == null) { + return; + } + + _androidAccount = account; + + final authorization = + await account.authorizationClient + .authorizationForScopes( + const [youtubeReadonlyScope], + ); + + if (authorization == null) { + return; + } + + session.value = GoogleSession( + accessToken: authorization.accessToken, + displayName: + account.displayName ?? account.email, + email: account.email, + photoUrl: account.photoUrl, + ); + } catch (error) { + debugPrint( + '[GOOGLE-AUTH] Restauration ignorée: $error', + ); + } + } + + Future saveAndroidConfiguration( + String webClientId, + ) async { + final previous = _webClientId; + + await GoogleOauthConfigService.instance.saveAndroid( + webClientId: webClientId, + ); + + if (!Platform.isAndroid) { + return true; + } + + final next = _webClientId; + + if (next.isEmpty) { + return true; + } + + if (!_googleSignInInitialized) { + await _initializeAndroidGoogleSignIn(); + return true; + } + + return previous == next; + } + + Future saveWindowsConfiguration({ + required String desktopClientId, + required String desktopClientSecret, + }) async { + await GoogleOauthConfigService.instance.saveWindows( + desktopClientId: desktopClientId, + desktopClientSecret: desktopClientSecret, + ); + } + + Future signIn() async { + if (!isConfigured) { + throw GoogleAuthConfigurationException( + configurationHint, + ); + } + + if (Platform.isAndroid) { + return _signInAndroid(); + } + + if (Platform.isWindows) { + return _signInWindows(); + } + + throw const GoogleAuthConfigurationException( + 'Connexion Google non prise en charge ' + 'sur cette plateforme.', + ); + } + + Future getAccessToken() async { + final current = session.value; + + if (current == null) { + throw StateError( + 'Aucune session Google active.', + ); + } + + if (Platform.isAndroid) { + final account = _androidAccount; + + if (account != null) { + final authorization = + await account.authorizationClient + .authorizationForScopes( + const [youtubeReadonlyScope], + ); + + if (authorization != null) { + final nextAccessToken = + authorization.accessToken; + + // Ne notifie les listeners que si le jeton a réellement + // changé. Avant, chaque simple lecture du jeton réémettait + // session.value et pouvait relancer le chargement des + // abonnements en boucle. + if (nextAccessToken != current.accessToken) { + session.value = current.copyWith( + accessToken: nextAccessToken, + ); + } + + return nextAccessToken; + } + } + + return current.accessToken; + } + + if (Platform.isWindows) { + final expiry = _windowsAccessTokenExpiry; + + if (expiry != null && + DateTime.now().isAfter( + expiry.subtract( + const Duration(minutes: 2), + ), + ) && + _windowsRefreshToken != null) { + await _refreshWindowsAccessToken(); + } + + return session.value?.accessToken ?? + current.accessToken; + } + + return current.accessToken; + } + + Future signOut() async { + if (Platform.isAndroid && + _googleSignInInitialized) { + try { + await GoogleSignIn.instance.signOut(); + } catch (error) { + debugPrint( + '[GOOGLE-AUTH] signOut Android: $error', + ); + } + } + + _androidAccount = null; + _windowsRefreshToken = null; + _windowsAccessTokenExpiry = null; + session.value = null; + } + + Future _initializeAndroidGoogleSignIn() async { + if (_googleSignInInitialized) { + return; + } + + final clientId = _webClientId; + + if (clientId.isEmpty) { + throw const GoogleAuthConfigurationException( + 'Client ID Web OAuth Google manquant.', + ); + } + + await GoogleSignIn.instance.initialize( + serverClientId: clientId, + ); + + _googleSignInInitialized = true; + _initializedAndroidWebClientId = clientId; + } + + Future _signInAndroid() async { + await _initializeAndroidGoogleSignIn(); + + if (_initializedAndroidWebClientId != + _webClientId) { + throw const GoogleAuthConfigurationException( + 'Le Client ID Android a été modifié. ' + 'Redémarre l’application avant de te connecter.', + ); + } + + if (!GoogleSignIn.instance.supportsAuthenticate()) { + throw StateError( + 'Google Sign-In interactif ' + 'n’est pas disponible.', + ); + } + + final account = + await GoogleSignIn.instance.authenticate( + scopeHint: const [youtubeReadonlyScope], + ); + + _androidAccount = account; + + var authorization = + await account.authorizationClient + .authorizationForScopes( + const [youtubeReadonlyScope], + ); + + authorization ??= + await account.authorizationClient + .authorizeScopes( + const [youtubeReadonlyScope], + ); + + final result = GoogleSession( + accessToken: authorization.accessToken, + displayName: + account.displayName ?? account.email, + email: account.email, + photoUrl: account.photoUrl, + ); + + session.value = result; + return result; + } + + Future _signInWindows() async { + final server = await HttpServer.bind( + InternetAddress.loopbackIPv4, + 0, + ); + + final redirectUri = Uri.parse( + 'http://127.0.0.1:${server.port}/', + ); + + final verifier = _randomUrlSafeString(96); + final state = _randomUrlSafeString(32); + + final challenge = base64UrlEncode( + sha256.convert(ascii.encode(verifier)).bytes, + ).replaceAll('=', ''); + + const windowsScopes = [ + 'openid', + 'email', + 'profile', + youtubeReadonlyScope, + ]; + + final authorizationUri = Uri.https( + 'accounts.google.com', + '/o/oauth2/v2/auth', + { + 'client_id': _desktopClientId, + 'redirect_uri': redirectUri.toString(), + 'response_type': 'code', + 'scope': windowsScopes.join(' '), + 'code_challenge': challenge, + 'code_challenge_method': 'S256', + 'state': state, + 'access_type': 'offline', + 'prompt': 'consent', + }, + ); + + final launched = await launchUrl( + authorizationUri, + mode: LaunchMode.externalApplication, + ); + + if (!launched) { + await server.close(force: true); + + throw StateError( + 'Impossible d’ouvrir le navigateur ' + 'pour Google.', + ); + } + + HttpRequest request; + + try { + request = await server.first.timeout( + const Duration(minutes: 3), + ); + } on TimeoutException { + await server.close(force: true); + + throw StateError( + 'La connexion Google a expiré.', + ); + } + + final params = request.uri.queryParameters; + + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.html + ..write( + '' + '' + 'YouTube Personal' + '' + '

Connexion terminée

' + '

Tu peux fermer cet onglet et revenir ' + 'dans YouTube Personal.

', + ); + + await request.response.close(); + await server.close(force: true); + + if (params['state'] != state) { + throw StateError( + 'Réponse OAuth Google invalide ' + '(state incorrect).', + ); + } + + final error = params['error']; + + if (error != null) { + throw StateError( + 'Connexion Google refusée: $error', + ); + } + + final code = params['code']; + + if (code == null || code.isEmpty) { + throw StateError( + 'Google n’a pas renvoyé de ' + 'code d’autorisation.', + ); + } + + final tokenBody = { + 'client_id': _desktopClientId, + 'code': code, + 'code_verifier': verifier, + 'grant_type': 'authorization_code', + 'redirect_uri': redirectUri.toString(), + }; + + if (_desktopClientSecret.isNotEmpty) { + tokenBody['client_secret'] = + _desktopClientSecret; + } + + final tokenResponse = await http.post( + Uri.parse( + 'https://oauth2.googleapis.com/token', + ), + headers: const { + 'Content-Type': + 'application/x-www-form-urlencoded', + }, + body: tokenBody, + ); + + if (tokenResponse.statusCode < 200 || + tokenResponse.statusCode >= 300) { + throw StateError( + 'Échange OAuth Google impossible ' + '(${tokenResponse.statusCode}): ' + '${tokenResponse.body}', + ); + } + + final decoded = jsonDecode( + tokenResponse.body, + ); + + if (decoded is! Map) { + throw StateError( + 'Réponse OAuth Google invalide.', + ); + } + + final accessToken = decoded['access_token']; + + if (accessToken is! String || + accessToken.isEmpty) { + throw StateError( + 'Aucun jeton d’accès Google reçu.', + ); + } + + final refreshToken = decoded['refresh_token']; + + if (refreshToken is String && + refreshToken.isNotEmpty) { + _windowsRefreshToken = refreshToken; + } + + final expiresIn = decoded['expires_in']; + + if (expiresIn is num) { + _windowsAccessTokenExpiry = + DateTime.now().add( + Duration( + seconds: expiresIn.toInt(), + ), + ); + } + + final profile = await _fetchGoogleProfile( + accessToken, + ); + + final result = GoogleSession( + accessToken: accessToken, + displayName: + profile.name ?? + profile.email ?? + 'Compte Google', + email: profile.email, + photoUrl: profile.photoUrl, + ); + + session.value = result; + return result; + } + + Future _refreshWindowsAccessToken() async { + final refreshToken = _windowsRefreshToken; + + if (refreshToken == null || + refreshToken.isEmpty) { + return; + } + + final body = { + 'client_id': _desktopClientId, + 'refresh_token': refreshToken, + 'grant_type': 'refresh_token', + }; + + if (_desktopClientSecret.isNotEmpty) { + body['client_secret'] = + _desktopClientSecret; + } + + final response = await http.post( + Uri.parse( + 'https://oauth2.googleapis.com/token', + ), + headers: const { + 'Content-Type': + 'application/x-www-form-urlencoded', + }, + body: body, + ); + + if (response.statusCode < 200 || + response.statusCode >= 300) { + throw StateError( + 'Impossible de renouveler ' + 'la session Google.', + ); + } + + final decoded = jsonDecode(response.body); + + if (decoded is! Map || + decoded['access_token'] is! String) { + throw StateError( + 'Réponse de renouvellement ' + 'Google invalide.', + ); + } + + final token = + decoded['access_token'] as String; + + final expiresIn = decoded['expires_in']; + + if (expiresIn is num) { + _windowsAccessTokenExpiry = + DateTime.now().add( + Duration( + seconds: expiresIn.toInt(), + ), + ); + } + + final current = session.value; + + if (current != null) { + session.value = current.copyWith( + accessToken: token, + ); + } + } + + Future<_GoogleProfile> _fetchGoogleProfile( + String accessToken, + ) async { + try { + final response = await http.get( + Uri.parse( + 'https://www.googleapis.com/oauth2/v3/userinfo', + ), + headers: { + 'Authorization': 'Bearer $accessToken', + 'Accept': 'application/json', + }, + ); + + if (response.statusCode < 200 || + response.statusCode >= 300) { + return const _GoogleProfile(); + } + + final decoded = jsonDecode(response.body); + + if (decoded is! Map) { + return const _GoogleProfile(); + } + + return _GoogleProfile( + name: decoded['name'] is String + ? decoded['name'] as String + : null, + email: decoded['email'] is String + ? decoded['email'] as String + : null, + photoUrl: decoded['picture'] is String + ? decoded['picture'] as String + : null, + ); + } catch (_) { + return const _GoogleProfile(); + } + } + + String _randomUrlSafeString(int length) { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + 'abcdefghijklmnopqrstuvwxyz' + '0123456789-._~'; + + final random = Random.secure(); + + return List.generate( + length, + (_) => chars[ + random.nextInt(chars.length)], + ).join(); + } +} + +class _GoogleProfile { + const _GoogleProfile({ + this.name, + this.email, + this.photoUrl, + }); + + final String? name; + final String? email; + final String? photoUrl; +} diff --git a/lib/services/google_oauth_config_service.dart b/lib/services/google_oauth_config_service.dart new file mode 100644 index 0000000..9745a8d --- /dev/null +++ b/lib/services/google_oauth_config_service.dart @@ -0,0 +1,131 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class GoogleOauthConfig { + const GoogleOauthConfig({ + this.webClientId = '', + this.desktopClientId = '', + this.desktopClientSecret = '', + }); + + final String webClientId; + final String desktopClientId; + final String desktopClientSecret; + + bool get hasAndroidConfig => webClientId.trim().isNotEmpty; + bool get hasWindowsConfig => desktopClientId.trim().isNotEmpty; + + GoogleOauthConfig copyWith({ + String? webClientId, + String? desktopClientId, + String? desktopClientSecret, + }) { + return GoogleOauthConfig( + webClientId: webClientId ?? this.webClientId, + desktopClientId: + desktopClientId ?? this.desktopClientId, + desktopClientSecret: + desktopClientSecret ?? this.desktopClientSecret, + ); + } +} + +class GoogleOauthConfigService { + GoogleOauthConfigService._(); + + static final GoogleOauthConfigService instance = + GoogleOauthConfigService._(); + + static const String _webClientIdKey = + 'youtube_personal.google.web_client_id.v1'; + static const String _desktopClientIdKey = + 'youtube_personal.google.desktop_client_id.v1'; + static const String _desktopClientSecretKey = + 'youtube_personal.google.desktop_client_secret.v1'; + + final SharedPreferencesAsync _preferences = + SharedPreferencesAsync(); + + final ValueNotifier config = + ValueNotifier( + const GoogleOauthConfig(), + ); + + bool _initialized = false; + + Future initialize() async { + if (_initialized) { + return; + } + + final webClientId = + await _preferences.getString(_webClientIdKey) ?? ''; + final desktopClientId = + await _preferences.getString(_desktopClientIdKey) ?? ''; + final desktopClientSecret = + await _preferences.getString( + _desktopClientSecretKey, + ) ?? + ''; + + config.value = GoogleOauthConfig( + webClientId: webClientId, + desktopClientId: desktopClientId, + desktopClientSecret: desktopClientSecret, + ); + + _initialized = true; + } + + Future saveAndroid({ + required String webClientId, + }) async { + final value = webClientId.trim(); + + if (value.isEmpty) { + await _preferences.remove(_webClientIdKey); + } else { + await _preferences.setString( + _webClientIdKey, + value, + ); + } + + config.value = config.value.copyWith( + webClientId: value, + ); + } + + Future saveWindows({ + required String desktopClientId, + required String desktopClientSecret, + }) async { + final clientId = desktopClientId.trim(); + final clientSecret = desktopClientSecret.trim(); + + if (clientId.isEmpty) { + await _preferences.remove(_desktopClientIdKey); + } else { + await _preferences.setString( + _desktopClientIdKey, + clientId, + ); + } + + if (clientSecret.isEmpty) { + await _preferences.remove( + _desktopClientSecretKey, + ); + } else { + await _preferences.setString( + _desktopClientSecretKey, + clientSecret, + ); + } + + config.value = config.value.copyWith( + desktopClientId: clientId, + desktopClientSecret: clientSecret, + ); + } +} diff --git a/lib/services/hidden_channels_service.dart b/lib/services/hidden_channels_service.dart new file mode 100644 index 0000000..23b8e97 --- /dev/null +++ b/lib/services/hidden_channels_service.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/subscription_channel.dart'; +import '../models/video_item.dart'; + +class HiddenChannelsService { + HiddenChannelsService._(); + + static final HiddenChannelsService instance = + HiddenChannelsService._(); + + static const String _key = + 'youtube_personal.hidden_channels.v1'; + + final SharedPreferencesAsync _preferences = + SharedPreferencesAsync(); + + final Map _hiddenById = {}; + final Set _hiddenTitles = {}; + + bool _loaded = false; + + /// Lets Home/Channels invalidate their caches when the user hides or + /// restores a channel. + final ValueNotifier revision = ValueNotifier(0); + + Future ensureLoaded() async { + if (_loaded) { + return; + } + + _loaded = true; + + final raw = await _preferences.getString(_key); + if (raw == null || raw.isEmpty) { + return; + } + + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + return; + } + + for (final item in decoded) { + if (item is! Map) { + continue; + } + + final id = item['id']; + final title = item['title']; + + if (id is String && id.trim().isNotEmpty) { + _hiddenById[id.trim()] = title is String ? title : ''; + } + + if (title is String && title.trim().isNotEmpty) { + _hiddenTitles.add(_normalize(title)); + } + } + } catch (_) { + _hiddenById.clear(); + _hiddenTitles.clear(); + } + } + + bool isHiddenChannel({ + String? channelId, + String? title, + }) { + final id = channelId?.trim() ?? ''; + if (id.isNotEmpty && _hiddenById.containsKey(id)) { + return true; + } + + final normalized = _normalize(title ?? ''); + return normalized.isNotEmpty && _hiddenTitles.contains(normalized); + } + + bool isHiddenVideo(VideoItem video) { + return isHiddenChannel( + channelId: video.channelId, + title: video.author, + ); + } + + Future hide(SubscriptionChannel channel) async { + await ensureLoaded(); + + _hiddenById[channel.channelId] = channel.title; + + final normalized = _normalize(channel.title); + if (normalized.isNotEmpty) { + _hiddenTitles.add(normalized); + } + + await _save(); + revision.value++; + } + + Future restore(SubscriptionChannel channel) async { + await ensureLoaded(); + + _hiddenById.remove(channel.channelId); + + final normalized = _normalize(channel.title); + if (normalized.isNotEmpty) { + // Keep a title hidden only if another hidden channel still uses it. + final stillUsed = _hiddenById.values.any( + (value) => _normalize(value) == normalized, + ); + + if (!stillUsed) { + _hiddenTitles.remove(normalized); + } + } + + await _save(); + revision.value++; + } + + Future _save() async { + final payload = >[ + for (final entry in _hiddenById.entries) + { + 'id': entry.key, + 'title': entry.value, + }, + ]; + + await _preferences.setString( + _key, + jsonEncode(payload), + ); + } + + String _normalize(String value) { + return value.trim().toLowerCase(); + } +} diff --git a/lib/services/interest_profile_service.dart b/lib/services/interest_profile_service.dart new file mode 100644 index 0000000..2796ad6 --- /dev/null +++ b/lib/services/interest_profile_service.dart @@ -0,0 +1,1042 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/video_item.dart'; + +class InterestLearningScore { + const InterestLearningScore({ + required this.points, + required this.positivePoints, + required this.negativePoints, + required this.channelPoints, + required this.reasons, + }); + + final int points; + final int positivePoints; + final int negativePoints; + final int channelPoints; + final List reasons; +} + +class InterestFeedbackEntry { + const InterestFeedbackEntry({ + required this.video, + required this.category, + required this.rating, + required this.badQuality, + required this.notInterested, + required this.updatedAt, + }); + + final VideoItem video; + final String category; + final int rating; + final bool badQuality; + final bool notInterested; + final int updatedAt; + + Map toJson() { + return { + 'video': video.toJson(), + 'category': category, + 'rating': rating, + 'badQuality': badQuality, + 'notInterested': notInterested, + 'updatedAt': updatedAt, + }; + } + + factory InterestFeedbackEntry.fromJson(Map json) { + final rawVideo = json['video']; + if (rawVideo is! Map) { + throw const FormatException('video absent'); + } + + return InterestFeedbackEntry( + video: VideoItem.fromJson( + rawVideo.map( + (key, value) => MapEntry('$key', value), + ), + ), + category: json['category'] is String + ? json['category'] as String + : InterestProfileService.allCategory, + rating: json['rating'] is num + ? (json['rating'] as num).toInt().clamp(0, 5).toInt() + : 0, + badQuality: json['badQuality'] == true, + notInterested: json['notInterested'] == true, + updatedAt: json['updatedAt'] is num + ? (json['updatedAt'] as num).toInt() + : 0, + ); + } +} + +class InterestProfileService { + InterestProfileService._(); + + static final InterestProfileService instance = + InterestProfileService._(); + + static const String allCategory = 'Tous'; + + static const List categories = [ + allCategory, + 'Politique', + 'Jeux vidéo', + 'Documentaires', + 'Paranormal', + 'Islam', + 'Football', + 'High-tech', + ]; + + static const String _entriesKey = + 'youtube_personal.interest_feedback.v1'; + static const String _activeCategoryKey = + 'youtube_personal.interest_active_category.v1'; + static const String _videoCategoryOverridesKey = + 'youtube_personal.interest_video_category_overrides.v1'; + static const String _channelCategoryOverridesKey = + 'youtube_personal.interest_channel_category_overrides.v1'; + + final SharedPreferencesAsync _preferences = SharedPreferencesAsync(); + + final ValueNotifier activeCategory = + ValueNotifier(allCategory); + final ValueNotifier revision = ValueNotifier(0); + + final Map> _entries = + >{}; + + // 18.7.25 : l'utilisateur peut corriger le classificateur. + // Priorité : vidéo > chaîne > détection automatique. + final Map _videoCategoryOverrides = {}; + final Map _channelCategoryOverrides = {}; + + bool _loaded = false; + Future? _loading; + + Future ensureLoaded() { + if (_loaded) { + return Future.value(); + } + + final current = _loading; + if (current != null) { + return current; + } + + final future = _load(); + _loading = future; + return future; + } + + Future _load() async { + try { + final savedCategory = + await _preferences.getString(_activeCategoryKey); + if (savedCategory == 'Horreur') { + activeCategory.value = 'Paranormal'; + await _preferences.setString(_activeCategoryKey, 'Paranormal'); + } else if (savedCategory != null && categories.contains(savedCategory)) { + activeCategory.value = savedCategory; + } + + await _loadCategoryOverrides(); + + final raw = await _preferences.getString(_entriesKey); + if (raw != null && raw.trim().isNotEmpty) { + final decoded = jsonDecode(raw); + if (decoded is Map) { + for (final categoryEntry in decoded.entries) { + final category = '${categoryEntry.key}'; + final rawItems = categoryEntry.value; + if (rawItems is! Map) { + continue; + } + + final bucket = {}; + for (final item in rawItems.entries) { + final value = item.value; + if (value is! Map) { + continue; + } + + try { + final parsed = InterestFeedbackEntry.fromJson( + value.map( + (key, value) => MapEntry('$key', value), + ), + ); + bucket[parsed.video.id] = parsed; + } catch (_) { + // Ignore a corrupted historical feedback entry. + } + } + + if (bucket.isNotEmpty) { + _entries[category] = bucket; + } + } + } + } + + // 18.7.23d : renommage de la catégorie Horreur -> Paranormal. + // Toutes les notes et feedbacks existants sont conservés. + final paranormalMigrated = _migrateLegacyHorrorFeedback(); + + // 18.7.23a : les anciens feedbacks saisis depuis le profil `Tous` + // pouvaient être rangés dans le bucket technique `Autres` lorsque le + // classificateur ne connaissait pas encore une marque ou un terme. + // On ne migre QUE `Autres` : les buckets sélectionnables représentent + // potentiellement un choix explicite de l'utilisateur et ne doivent pas + // être reclassés automatiquement. + final migrated = _migrateLegacyOtherFeedback(); + if (paranormalMigrated > 0 || migrated > 0) { + await _persistLoadedEntries(); + } + if (paranormalMigrated > 0) { + debugPrint( + '[INTEREST][18.7.23d] $paranormalMigrated feedback(s) Horreur migré(s) vers Paranormal', + ); + } + if (migrated > 0) { + debugPrint( + '[INTEREST][18.7.23a] $migrated feedback(s) Autres reclassé(s)', + ); + } + } catch (error) { + debugPrint('[INTEREST][18.7.23] chargement ignoré: $error'); + } finally { + _loaded = true; + _loading = null; + } + } + + Future setActiveCategory(String category) async { + await ensureLoaded(); + final safe = categories.contains(category) ? category : allCategory; + if (activeCategory.value == safe) { + return; + } + + activeCategory.value = safe; + await _preferences.setString(_activeCategoryKey, safe); + } + + List get assignableCategories => categories + .where((category) => category != allCategory) + .toList(growable: false); + + String? videoCategoryOverride(VideoItem video) { + final value = _videoCategoryOverrides[video.id]; + return value != null && categories.contains(value) && value != allCategory + ? value + : null; + } + + String? channelCategoryOverride(VideoItem video) { + for (final key in _channelLookupKeys(video)) { + final value = _channelCategoryOverrides[key]; + if (value != null && categories.contains(value) && value != allCategory) { + return value; + } + } + return null; + } + + String? manualCategoryFor(VideoItem video) { + return videoCategoryOverride(video) ?? channelCategoryOverride(video); + } + + Future setVideoCategoryOverride( + VideoItem video, + String? category, + ) async { + await ensureLoaded(); + final safe = _safeAssignableCategory(category); + + if (safe == null) { + _videoCategoryOverrides.remove(video.id); + } else { + _videoCategoryOverrides[video.id] = safe; + } + + final target = manualCategoryFor(video) ?? classifyVideoAutomatically(video); + _moveFeedbackForVideoToCategory(video, target); + await _persistCategoryOverrides(); + await _persistLoadedEntries(); + revision.value++; + + debugPrint( + '[INTEREST][18.7.25] vidéo ${video.id} -> ${safe ?? 'Auto ($target)'}', + ); + } + + Future setChannelCategoryOverride( + VideoItem video, + String? category, + ) async { + await ensureLoaded(); + final safe = _safeAssignableCategory(category); + final keys = _channelLookupKeys(video); + + for (final key in keys) { + if (safe == null) { + _channelCategoryOverrides.remove(key); + } else { + _channelCategoryOverrides[key] = safe; + } + } + + _rehomeFeedbackForChannel(video); + await _persistCategoryOverrides(); + await _persistLoadedEntries(); + revision.value++; + + debugPrint( + '[INTEREST][18.7.25] chaîne ${video.author} -> ${safe ?? 'Auto'}', + ); + } + + String effectiveCategoryFor(VideoItem video, {String? requested}) { + final selected = requested ?? activeCategory.value; + if (selected != allCategory && categories.contains(selected)) { + return selected; + } + return classifyVideo(video); + } + + /// Catégorie réellement utilisée par le moteur. Une correction manuelle + /// est toujours prioritaire sur l'analyse automatique. + String classifyVideo(VideoItem video) { + return manualCategoryFor(video) ?? classifyVideoAutomatically(video); + } + + /// Classification automatique pure, utile dans l'interface pour montrer + /// ce que le moteur aurait choisi sans correction de l'utilisateur. + String classifyVideoAutomatically(VideoItem video) { + final haystack = _normalize( + '${video.title} ${video.author} ${video.description}', + ); + + String best = 'Autres'; + int bestScore = 0; + + for (final entry in _categoryKeywords.entries) { + int score = 0; + for (final keyword in entry.value) { + final normalizedKeyword = _normalize(keyword).trim(); + if (normalizedKeyword.isEmpty) { + continue; + } + if (_containsWholeKeyword(haystack, normalizedKeyword)) { + score += normalizedKeyword.contains(' ') ? 4 : 2; + } + } + + if (score > bestScore) { + bestScore = score; + best = entry.key; + } + } + + return best; + } + + bool matchesCategory(VideoItem video, String category) { + if (category == allCategory) { + return true; + } + + // Une note donnée explicitement dans un profil devient une association + // manuelle plus fiable que la classification automatique du titre. + if (_entries[category]?.containsKey(video.id) ?? false) { + return true; + } + + return classifyVideo(video) == category; + } + + String searchHint(String category) { + switch (category) { + case 'Politique': + return 'politique actualité géopolitique'; + case 'Jeux vidéo': + return 'jeux vidéo gaming gameplay'; + case 'Documentaires': + return 'documentaire reportage enquête'; + case 'Paranormal': + return 'paranormal surnaturel horreur haunted ghost scary'; + case 'Islam': + return 'islam musulman coran'; + case 'Football': + return 'football soccer'; + case 'High-tech': + return 'high tech technologie intelligence artificielle'; + case allCategory: + default: + return ''; + } + } + + VideoItem categorySeed(String category) { + return VideoItem( + id: '_interest_seed_${category.hashCode}', + title: searchHint(category), + author: '', + thumbnailUrl: '', + description: '', + ); + } + + int ratingFor(VideoItem video, {String? category}) { + final effective = effectiveCategoryFor(video, requested: category); + return _entries[effective]?[video.id]?.rating ?? 0; + } + + bool isBadQuality(VideoItem video, {String? category}) { + final effective = effectiveCategoryFor(video, requested: category); + return _entries[effective]?[video.id]?.badQuality ?? false; + } + + bool isNotInterested(VideoItem video, {String? category}) { + final effective = effectiveCategoryFor(video, requested: category); + return _entries[effective]?[video.id]?.notInterested ?? false; + } + + bool shouldSuppressExact(VideoItem video, {String? category}) { + final effective = effectiveCategoryFor(video, requested: category); + final entry = _entries[effective]?[video.id]; + if (entry == null) { + return false; + } + return entry.notInterested || entry.badQuality || + (entry.rating > 0 && entry.rating <= 2); + } + + Future rateVideo( + VideoItem video, + int rating, { + String? category, + }) async { + await ensureLoaded(); + final safeRating = rating.clamp(1, 5).toInt(); + final effective = effectiveCategoryFor(video, requested: category); + final previous = _entries[effective]?[video.id]; + + _put( + InterestFeedbackEntry( + video: video, + category: effective, + rating: safeRating, + badQuality: safeRating <= 2, + notInterested: + safeRating >= 4 ? false : (previous?.notInterested ?? false), + updatedAt: DateTime.now().millisecondsSinceEpoch, + ), + ); + + await _persist(); + } + + Future markBadQuality( + VideoItem video, { + String? category, + }) async { + await ensureLoaded(); + final effective = effectiveCategoryFor(video, requested: category); + final previous = _entries[effective]?[video.id]; + + _put( + InterestFeedbackEntry( + video: video, + category: effective, + rating: previous == null || previous.rating == 0 + ? 1 + : min(previous.rating, 2), + badQuality: true, + notInterested: previous?.notInterested ?? false, + updatedAt: DateTime.now().millisecondsSinceEpoch, + ), + ); + + await _persist(); + } + + Future markNotInterested( + VideoItem video, { + String? category, + }) async { + await ensureLoaded(); + final effective = effectiveCategoryFor(video, requested: category); + final previous = _entries[effective]?[video.id]; + + _put( + InterestFeedbackEntry( + video: video, + category: effective, + rating: previous?.rating ?? 0, + badQuality: previous?.badQuality ?? false, + notInterested: true, + updatedAt: DateTime.now().millisecondsSinceEpoch, + ), + ); + + await _persist(); + } + + Future clearVideoFeedback( + VideoItem video, { + String? category, + }) async { + await ensureLoaded(); + final effective = effectiveCategoryFor(video, requested: category); + _entries[effective]?.remove(video.id); + if (_entries[effective]?.isEmpty ?? false) { + _entries.remove(effective); + } + await _persist(); + } + + Future resetAll() async { + await ensureLoaded(); + _entries.clear(); + await _preferences.remove(_entriesKey); + revision.value++; + } + + List positiveExamples(String category) { + final entries = _entriesForScoringCategory(category); + final sorted = entries + .where((entry) => entry.rating >= 4 && !entry.notInterested) + .toList() + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + + return sorted + .take(20) + .map((entry) => entry.video) + .toList(growable: false); + } + + int feedbackCount(String category) { + return _entriesForScoringCategory(category).length; + } + + InterestLearningScore scoreVideo(VideoItem video, String category) { + final effective = category == allCategory ? classifyVideo(video) : category; + final examples = _entries[effective]?.values.toList() ?? + const []; + + if (examples.isEmpty) { + return const InterestLearningScore( + points: 0, + positivePoints: 0, + negativePoints: 0, + channelPoints: 0, + reasons: [], + ); + } + + final candidateSpecific = _specificTerms(video, effective); + final candidateFull = _fullTerms(video); + final author = video.author.trim().toLowerCase(); + + double positiveRaw = 0; + double negativeRaw = 0; + double topicNegativeRaw = 0; + int sameAuthorPositive = 0; + int sameAuthorNegative = 0; + + for (final entry in examples) { + final exampleSpecific = _specificTerms(entry.video, effective); + final exampleFull = _fullTerms(entry.video); + final qualitySimilarity = _similarity(candidateSpecific, exampleSpecific); + final topicSimilarity = _similarity(candidateFull, exampleFull); + final sameAuthor = author.isNotEmpty && + author == entry.video.author.trim().toLowerCase(); + + if (entry.rating >= 4 && !entry.notInterested) { + final weight = entry.rating == 5 ? 1.35 : 1.0; + positiveRaw += qualitySimilarity * weight; + if (sameAuthor) { + sameAuthorPositive += entry.rating == 5 ? 2 : 1; + } + } + + if (entry.rating > 0 && entry.rating <= 2 || entry.badQuality) { + final weight = entry.badQuality || entry.rating == 1 ? 1.35 : 1.0; + negativeRaw += qualitySimilarity * weight; + if (sameAuthor) { + sameAuthorNegative += entry.rating == 1 || entry.badQuality ? 2 : 1; + } + } + + if (entry.notInterested) { + topicNegativeRaw += topicSimilarity * 1.15; + } + } + + final positivePoints = min(28, (positiveRaw * 24).round()); + final negativePoints = min( + 36, + (negativeRaw * 28 + topicNegativeRaw * 24).round(), + ); + final channelPoints = (sameAuthorPositive * 5 - sameAuthorNegative * 7) + .clamp(-18, 14) + .toInt(); + + final points = (positivePoints - negativePoints + channelPoints) + .clamp(-45, 36) + .toInt(); + + final reasons = []; + if (positivePoints >= 5) { + reasons.add('Proche de tes vidéos 4–5★ dans $effective'); + } + if (negativePoints >= 5) { + reasons.add('Éloignée des vidéos mal notées dans $effective'); + } + if (channelPoints >= 5) { + reasons.add('Chaîne bien notée dans $effective'); + } else if (channelPoints <= -5) { + reasons.add('Chaîne déjà mal notée dans $effective'); + } + + return InterestLearningScore( + points: points, + positivePoints: positivePoints, + negativePoints: negativePoints, + channelPoints: channelPoints, + reasons: List.unmodifiable(reasons), + ); + } + + int _migrateLegacyHorrorFeedback() { + final legacy = _entries['Horreur']; + if (legacy == null || legacy.isEmpty) { + _entries.remove('Horreur'); + return 0; + } + + final target = _entries.putIfAbsent( + 'Paranormal', + () => {}, + ); + + int migrated = 0; + for (final item in legacy.entries) { + final entry = item.value; + final existing = target[item.key]; + if (existing == null || entry.updatedAt > existing.updatedAt) { + target[item.key] = InterestFeedbackEntry( + video: entry.video, + category: 'Paranormal', + rating: entry.rating, + badQuality: entry.badQuality, + notInterested: entry.notInterested, + updatedAt: entry.updatedAt, + ); + } + migrated++; + } + + _entries.remove('Horreur'); + return migrated; + } + + int _migrateLegacyOtherFeedback() { + final legacy = _entries['Autres']; + if (legacy == null || legacy.isEmpty) { + return 0; + } + + int migrated = 0; + final ids = legacy.keys.toList(growable: false); + for (final id in ids) { + final entry = legacy[id]; + if (entry == null) { + continue; + } + + final detected = classifyVideo(entry.video); + if (detected == 'Autres' || !categories.contains(detected)) { + continue; + } + + final target = _entries.putIfAbsent( + detected, + () => {}, + ); + final existing = target[id]; + if (existing == null || entry.updatedAt > existing.updatedAt) { + target[id] = InterestFeedbackEntry( + video: entry.video, + category: detected, + rating: entry.rating, + badQuality: entry.badQuality, + notInterested: entry.notInterested, + updatedAt: entry.updatedAt, + ); + } + legacy.remove(id); + migrated++; + } + + if (legacy.isEmpty) { + _entries.remove('Autres'); + } + return migrated; + } + + Future _loadCategoryOverrides() async { + final videoRaw = await _preferences.getString(_videoCategoryOverridesKey); + final channelRaw = await _preferences.getString(_channelCategoryOverridesKey); + + _videoCategoryOverrides + ..clear() + ..addAll(_decodeCategoryOverrideMap(videoRaw)); + _channelCategoryOverrides + ..clear() + ..addAll(_decodeCategoryOverrideMap(channelRaw)); + + bool migrated = false; + for (final map in >[ + _videoCategoryOverrides, + _channelCategoryOverrides, + ]) { + for (final key in map.keys.toList(growable: false)) { + if (map[key] == 'Horreur') { + map[key] = 'Paranormal'; + migrated = true; + } + } + } + + if (migrated) { + await _persistCategoryOverrides(); + } + } + + Map _decodeCategoryOverrideMap(String? raw) { + if (raw == null || raw.trim().isEmpty) { + return {}; + } + + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return {}; + } + + final result = {}; + for (final entry in decoded.entries) { + final value = '${entry.value}'; + final migrated = value == 'Horreur' ? 'Paranormal' : value; + if (categories.contains(migrated) && migrated != allCategory) { + result['${entry.key}'] = migrated; + } + } + return result; + } catch (_) { + return {}; + } + } + + Future _persistCategoryOverrides() async { + await _preferences.setString( + _videoCategoryOverridesKey, + jsonEncode(_videoCategoryOverrides), + ); + await _preferences.setString( + _channelCategoryOverridesKey, + jsonEncode(_channelCategoryOverrides), + ); + } + + String? _safeAssignableCategory(String? category) { + if (category == null || category == allCategory) { + return null; + } + return categories.contains(category) ? category : null; + } + + List _channelLookupKeys(VideoItem video) { + final keys = []; + final channelId = video.channelId?.trim(); + if (channelId != null && channelId.isNotEmpty) { + keys.add('id:$channelId'); + } + + final author = _normalize(video.author).trim(); + if (author.isNotEmpty) { + keys.add('name:$author'); + } + return keys; + } + + bool _sameChannel(VideoItem left, VideoItem right) { + final leftId = left.channelId?.trim(); + final rightId = right.channelId?.trim(); + if (leftId != null && + leftId.isNotEmpty && + rightId != null && + rightId.isNotEmpty && + leftId == rightId) { + return true; + } + + final leftAuthor = _normalize(left.author).trim(); + final rightAuthor = _normalize(right.author).trim(); + return leftAuthor.isNotEmpty && leftAuthor == rightAuthor; + } + + void _moveFeedbackForVideoToCategory(VideoItem video, String target) { + if (target == allCategory || + (!categories.contains(target) && target != 'Autres')) { + return; + } + + InterestFeedbackEntry? latest; + for (final bucket in _entries.values) { + final candidate = bucket.remove(video.id); + if (candidate != null && + (latest == null || candidate.updatedAt > latest.updatedAt)) { + latest = candidate; + } + } + _entries.removeWhere((key, value) => value.isEmpty); + + if (latest == null) { + return; + } + + _put( + InterestFeedbackEntry( + video: latest.video, + category: target, + rating: latest.rating, + badQuality: latest.badQuality, + notInterested: latest.notInterested, + updatedAt: latest.updatedAt, + ), + ); + } + + void _rehomeFeedbackForChannel(VideoItem channelVideo) { + final entries = _entries.values + .expand((bucket) => bucket.values) + .where((entry) => _sameChannel(entry.video, channelVideo)) + .toList(growable: false); + + for (final entry in entries) { + final target = manualCategoryFor(entry.video) ?? + classifyVideoAutomatically(entry.video); + _moveFeedbackForVideoToCategory(entry.video, target); + } + } + + Future _persistLoadedEntries() async { + final payload = { + for (final category in _entries.entries) + category.key: { + for (final entry in category.value.entries) + entry.key: entry.value.toJson(), + }, + }; + await _preferences.setString(_entriesKey, jsonEncode(payload)); + } + + List _entriesForScoringCategory(String category) { + if (category != allCategory) { + return _entries[category]?.values.toList() ?? + const []; + } + + return _entries.values + .expand((bucket) => bucket.values) + .toList(growable: false); + } + + void _put(InterestFeedbackEntry entry) { + _entries.putIfAbsent( + entry.category, + () => {}, + )[entry.video.id] = entry; + } + + Future _persist() async { + final payload = { + for (final category in _entries.entries) + category.key: { + for (final entry in category.value.entries) + entry.key: entry.value.toJson(), + }, + }; + + await _preferences.setString(_entriesKey, jsonEncode(payload)); + revision.value++; + } + + Set _fullTerms(VideoItem video) { + return _terms('${video.title} ${video.description} ${video.author}'); + } + + Set _specificTerms(VideoItem video, String category) { + final result = _terms('${video.title} ${video.description}'); + final generic = { + ...?_categoryKeywords[category]?.expand(_terms), + ..._genericQualityTerms, + }; + result.removeAll(generic); + return result; + } + + double _similarity(Set left, Set right) { + if (left.isEmpty || right.isEmpty) { + return 0; + } + + final intersection = left.intersection(right).length; + if (intersection == 0) { + return 0; + } + + final denominator = max(3, min(left.length, right.length)); + return (intersection / denominator).clamp(0.0, 1.0); + } + + Set _terms(String value) { + final normalized = _normalize(value); + return normalized + .split(RegExp(r'\s+')) + .map((item) => item.trim()) + .where( + (item) => item.length >= 3 && !_stopWords.contains(item), + ) + .toSet(); + } + + bool _containsWholeKeyword(String haystack, String keyword) { + final normalizedHaystack = _normalize(haystack).trim(); + final normalizedKeyword = _normalize(keyword).trim(); + if (normalizedHaystack.isEmpty || normalizedKeyword.isEmpty) { + return false; + } + + // Les espaces jouent ici le rôle de frontières de mots après _normalize. + // Cela évite notamment que le mot-clé court `ai` corresponde à `trait`, + // `maison`, etc. Les expressions multi-mots restent supportées. + return ' $normalizedHaystack '.contains(' $normalizedKeyword '); + } + + String _normalize(String value) { + return value + .toLowerCase() + .replaceAll( + RegExp( + r"[^a-z0-9à-ÿ\u0600-\u06ff]+", + caseSensitive: false, + ), + ' ', + ); + } + + static const Set _genericQualityTerms = { + 'video', + 'youtube', + 'officiel', + 'official', + 'francais', + 'français', + 'english', + 'arabe', + 'arabic', + 'nouveau', + 'new', + '2025', + '2026', + }; + + static const Set _stopWords = { + 'the', 'and', 'for', 'with', 'from', 'this', 'that', 'you', 'your', + 'les', 'des', 'une', 'dans', 'pour', 'avec', 'sur', 'est', 'sont', + 'qui', 'que', 'plus', 'tout', 'tous', 'aux', 'par', 'pas', 'mais', + 'من', 'في', 'على', 'الى', 'إلى', 'عن', 'مع', 'هذا', 'هذه', 'التي', + 'und', 'der', 'die', 'das', 'mit', 'von', 'für', + }; + + static const Map> _categoryKeywords = + >{ + 'Politique': [ + 'politique', 'politics', 'président', 'president', 'gouvernement', + 'government', 'parlement', 'élection', 'election', 'géopolitique', + 'geopolitics', 'ministre', 'minister', 'diplomatie', 'diplomacy', + 'macron', 'trump', 'biden', 'poutine', 'putin', 'ukraine', 'gaza', + 'israel', 'iran', 'onu', 'nato', 'otan', + ], + 'Jeux vidéo': [ + 'jeu vidéo', 'jeux vidéo', 'gaming', 'gameplay', 'gamer', 'game', + 'playstation', 'ps5', 'xbox', 'nintendo', 'switch', 'steam', + 'minecraft', 'roblox', 'fortnite', 'gta', 'call of duty', 'pokemon', + 'elden ring', 'cyberpunk', 'console', + ], + 'Documentaires': [ + 'documentaire', 'documentary', 'reportage', 'enquête', 'enquete', + 'investigation', 'histoire', 'history', 'civilisation', 'civilization', + 'archéologie', 'archeology', 'nature', 'wildlife', 'arte', + 'national geographic', 'discovery channel', 'science documentaire', + ], + 'Paranormal': [ + 'horreur', 'horror', 'épouvante', 'epouvante', 'terrifiant', + 'terrifiante', 'effrayant', 'effrayante', 'scary', 'creepy', + 'slasher', 'jumpscare', 'jump scare', 'creepypasta', 'analog horror', + 'found footage', 'film d horreur', 'horror movie', 'horror film', + 'paranormal', 'haunted', 'haunting', 'ghost', 'ghosts', 'spectre', + 'fantôme', 'fantome', 'maison hantée', 'maison hantee', + 'possession', 'démon', 'demon', 'demonic', 'exorcisme', 'exorcism', + 'resident evil', 'silent hill', 'outlast', 'amnesia horror', + 'jeu d horreur', 'horror game', + 'رعب', 'مرعب', 'مرعبة', 'مخيف', 'مخيفة', 'قصة رعب', 'قصص رعب', + 'اشباح', 'أشباح', 'شبح', 'مسكون', 'مسكونة', + 'ملعون', 'ملعونة', 'لعنة', 'مسحور', 'مسحورة', 'عالم آخر', + 'عالم الجن', 'الجن', 'دوار مهجور', 'قرية مهجورة', 'مكان مهجور', + 'اختفى', 'اختفاء', 'مكان غريب', 'قصة مخيفة', 'قصة مرعبة', + ], + 'Islam': [ + 'islam', 'islamique', 'islamic', 'musulman', 'muslim', 'coran', + 'quran', 'hadith', 'sunna', 'sunnah', 'ramadan', 'allah', 'mosquée', + 'mosque', 'imam', 'prophète', 'prophet', 'muhammad', 'mohammed', + 'الاسلام', 'الإسلام', 'القرآن', 'قرآن', 'حديث', 'مسلم', 'الله', + ], + 'Football': [ + 'football', 'soccer', 'fifa', 'champions league', 'premier league', + 'la liga', 'bundesliga', 'serie a', 'ligue 1', 'real madrid', + 'barcelona', 'barça', 'psg', 'manchester', 'liverpool', 'arsenal', + 'bayern', 'juventus', 'inter milan', 'milan', 'mbappé', 'mbappe', + 'messi', 'ronaldo', 'ballon d or', + ], + 'High-tech': [ + 'high tech', 'high-tech', 'technologie', 'technology', 'tech', + 'android', 'windows', 'linux', 'iphone', 'ios', 'apple', 'macbook', + 'samsung', 'galaxy', 'xiaomi', 'redmi', 'poco', 'huawei', 'honor', + 'oneplus', 'oppo', 'vivo', 'realme', 'google pixel', 'pixel', + 'blackberry', 'black berry', 'بلاك بيري', 'بلاكبيري', + 'سمارتفون', 'هاتف ذكي', 'هواتف ذكية', 'هاتف', 'هواتف', 'تقنية', + 'تكنولوجيا', 'اندرويد', 'أندرويد', 'سامسونج', 'شاومي', 'ايفون', + 'آيفون', 'هواوي', 'هونر', 'smartphone', 'phone', 'mobile', 'laptop', + 'ordinateur', 'ordinateur gaming', 'pc', 'tablet', 'tablette', + 'gpu', 'nvidia', 'geforce', 'radeon', 'amd', 'intel', 'snapdragon', + 'qualcomm', 'mediatek', 'mediatek dimensity', 'usb', 'wifi', 'bluetooth', + 'oled', 'amoled', 'review', 'test smartphone', 'comparatif smartphone', + 'intelligence artificielle', 'artificial intelligence', 'ai', 'chatgpt', + 'openai', 'robot', 'machine learning', 'deep learning', + ], + }; +} diff --git a/lib/services/library_service.dart b/lib/services/library_service.dart new file mode 100644 index 0000000..286473a --- /dev/null +++ b/lib/services/library_service.dart @@ -0,0 +1,258 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/video_item.dart'; + +class LibraryService { + LibraryService._(); + + static final LibraryService instance = LibraryService._(); + + static const String _historyKey = 'youtube_personal.history.v1'; + static const String _favoritesKey = 'youtube_personal.favorites.v1'; + static const String _positionsKey = 'youtube_personal.positions.v1'; + static const String _watchCountsKey = + 'youtube_personal.watch_counts.v1'; + static const int _maxHistoryItems = 100; + + final SharedPreferencesAsync _preferences = SharedPreferencesAsync(); + + final ValueNotifier> history = + ValueNotifier>(const []); + + final ValueNotifier> favorites = + ValueNotifier>(const []); + + final Map _positionsMs = {}; + final Map _watchCounts = {}; + + /// Changes whenever a real video opening increments the user's own view + /// counter. The Channels page uses this to refresh its ranking. + final ValueNotifier watchRevision = ValueNotifier(0); + + Future initialize() async { + history.value = await _readList(_historyKey); + favorites.value = await _readList(_favoritesKey); + + _positionsMs + ..clear() + ..addAll(await _readPositions()); + + _watchCounts + ..clear() + ..addAll(await _readIntMap(_watchCountsKey)); + + // Migration for installs created before personal watch counters existed. + // Every video already present in history counts as at least one personal + // view, without inventing repeat views that we never recorded. + var migrated = false; + for (final video in history.value) { + if ((_watchCounts[video.id] ?? 0) <= 0) { + _watchCounts[video.id] = 1; + migrated = true; + } + } + + if (migrated) { + await _writeWatchCounts(); + } + } + + int watchCountForVideo(String videoId) { + return _watchCounts[videoId] ?? 0; + } + + Future addToHistory( + VideoItem video, { + bool incrementWatchCount = true, + }) async { + final updated = [ + video, + ...history.value.where((item) => item.id != video.id), + ]; + + if (updated.length > _maxHistoryItems) { + updated.removeRange(_maxHistoryItems, updated.length); + } + + history.value = List.unmodifiable(updated); + await _writeList(_historyKey, history.value); + + if (incrementWatchCount) { + _watchCounts.update( + video.id, + (value) => value + 1, + ifAbsent: () => 1, + ); + + await _writeWatchCounts(); + watchRevision.value++; + } + } + + bool isFavorite(String videoId) { + return favorites.value.any((item) => item.id == videoId); + } + + Future toggleFavorite(VideoItem video) async { + final updated = [...favorites.value]; + final index = updated.indexWhere((item) => item.id == video.id); + + if (index >= 0) { + updated.removeAt(index); + } else { + updated.insert(0, video); + } + + favorites.value = List.unmodifiable(updated); + await _writeList(_favoritesKey, favorites.value); + + return isFavorite(video.id); + } + + Duration? getPlaybackPosition(String videoId) { + final milliseconds = _positionsMs[videoId]; + + if (milliseconds == null || milliseconds <= 0) { + return null; + } + + return Duration(milliseconds: milliseconds); + } + + Future savePlaybackPosition( + String videoId, + Duration position, { + Duration? duration, + }) async { + final positionMs = position.inMilliseconds; + + // On ne mémorise pas les toutes premières secondes. + if (positionMs < const Duration(seconds: 5).inMilliseconds) { + return; + } + + // Si la vidéo est pratiquement terminée, on efface la reprise. + if (duration != null && duration > Duration.zero) { + final remaining = duration - position; + + if (remaining <= const Duration(seconds: 15)) { + await clearPlaybackPosition(videoId); + return; + } + } + + _positionsMs[videoId] = positionMs; + await _writePositions(); + } + + Future clearPlaybackPosition(String videoId) async { + if (_positionsMs.remove(videoId) != null) { + await _writePositions(); + } + } + + Future removeFromHistory(String videoId) async { + final updated = history.value.where((item) => item.id != videoId).toList(); + history.value = List.unmodifiable(updated); + await _writeList(_historyKey, history.value); + } + + Future clearHistory() async { + history.value = const []; + await _preferences.remove(_historyKey); + } + + Future clearFavorites() async { + favorites.value = const []; + await _preferences.remove(_favoritesKey); + } + + Future> _readList(String key) async { + final raw = await _preferences.getString(key); + + if (raw == null || raw.isEmpty) { + return const []; + } + + try { + final decoded = jsonDecode(raw); + + if (decoded is! List) { + return const []; + } + + return decoded + .whereType() + .map( + (item) => VideoItem.fromJson( + Map.from(item), + ), + ) + .toList(growable: false); + } catch (_) { + return const []; + } + } + + Future> _readPositions() { + return _readIntMap(_positionsKey); + } + + Future> _readIntMap(String key) async { + final raw = await _preferences.getString(key); + + if (raw == null || raw.isEmpty) { + return const {}; + } + + try { + final decoded = jsonDecode(raw); + + if (decoded is! Map) { + return const {}; + } + + final result = {}; + + for (final entry in decoded.entries) { + final value = entry.value; + + if (entry.key is String && value is num) { + result[entry.key as String] = value.toInt(); + } + } + + return result; + } catch (_) { + return const {}; + } + } + + Future _writeList( + String key, + List videos, + ) async { + final payload = jsonEncode( + videos.map((video) => video.toJson()).toList(growable: false), + ); + + await _preferences.setString(key, payload); + } + + Future _writePositions() async { + await _preferences.setString( + _positionsKey, + jsonEncode(_positionsMs), + ); + } + + Future _writeWatchCounts() async { + await _preferences.setString( + _watchCountsKey, + jsonEncode(_watchCounts), + ); + } +} diff --git a/lib/services/media_cache_settings_service.dart b/lib/services/media_cache_settings_service.dart new file mode 100644 index 0000000..05e8de0 --- /dev/null +++ b/lib/services/media_cache_settings_service.dart @@ -0,0 +1,577 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MediaCacheSettingsService { + MediaCacheSettingsService._(); + + static final MediaCacheSettingsService instance = + MediaCacheSettingsService._(); + + static const String _customPathKey = + 'youtube_personal.cache.custom_path.v1'; + static const String _maxCacheGbKey = + 'youtube_personal.cache.max_gb.v1'; + static const String _androidBufferMbKey = + 'youtube_personal.cache.android_buffer_mb.v1'; + static const String _androidDiskCacheModeKey = + 'youtube_personal.cache.android_disk_mode.v1'; + + static const int defaultMaxCacheGb = 5; + static const List supportedMaxCacheGb = [ + 2, + 5, + 10, + 20, + 50, + 0, + ]; + + // Android : le flux vidéo direct reste par défaut en cache mémoire. + // 64 Mo offre une marge confortable pour 1080p/1440p/4K sans créer + // de cache disque persistant. + static const int defaultAndroidBufferMb = 64; + static const List supportedAndroidBufferMb = [ + 32, + 64, + 128, + ]; + + // 0 = RAM uniquement, 1 = cache temporaire stockage interne app, + // 2 = cache temporaire stockage externe app. + static const int androidDiskCacheDisabled = 0; + static const int androidDiskCacheInternal = 1; + static const int androidDiskCacheExternal = 2; + static const List supportedAndroidDiskCacheModes = [ + androidDiskCacheDisabled, + androidDiskCacheInternal, + androidDiskCacheExternal, + ]; + + static const MethodChannel _androidCacheChannel = MethodChannel( + 'com.studioweb.youtube_personal/cache', + ); + + final SharedPreferencesAsync _preferences = SharedPreferencesAsync(); + + final ValueNotifier customCachePath = + ValueNotifier(''); + + final ValueNotifier maxCacheGb = + ValueNotifier(defaultMaxCacheGb); + + final ValueNotifier androidBufferMb = + ValueNotifier(defaultAndroidBufferMb); + + final ValueNotifier androidDiskCacheMode = + ValueNotifier(androidDiskCacheDisabled); + + Future? _initialization; + bool _initialized = false; + + Future ensureInitialized() async { + if (_initialized) { + return; + } + + _initialization ??= _load(); + await _initialization; + } + + Future _load() async { + final storedPath = await _preferences.getString(_customPathKey); + final storedMaxGb = await _preferences.getInt(_maxCacheGbKey); + final storedAndroidBufferMb = + await _preferences.getInt(_androidBufferMbKey); + final storedAndroidDiskCacheMode = + await _preferences.getInt(_androidDiskCacheModeKey); + + if (storedPath != null && storedPath.trim().isNotEmpty) { + customCachePath.value = storedPath.trim(); + } + + if (storedMaxGb != null && supportedMaxCacheGb.contains(storedMaxGb)) { + maxCacheGb.value = storedMaxGb; + } + + if (storedAndroidBufferMb != null && + supportedAndroidBufferMb.contains(storedAndroidBufferMb)) { + androidBufferMb.value = storedAndroidBufferMb; + } + + if (storedAndroidDiskCacheMode != null && + supportedAndroidDiskCacheModes.contains(storedAndroidDiskCacheMode)) { + androidDiskCacheMode.value = storedAndroidDiskCacheMode; + } + + _initialized = true; + + // Nettoyage immédiat au premier lancement après mise à jour : l'ancien + // cache Windows de plusieurs dizaines de Go est ramené à la limite + // configurée avant même d'ouvrir une nouvelle vidéo. + if (Platform.isWindows) { + await enforceLimit(); + } + } + + String get defaultCachePath { + final localAppData = Platform.environment['LOCALAPPDATA']; + final rootPath = localAppData != null && localAppData.trim().isNotEmpty + ? localAppData.trim() + : Directory.systemTemp.path; + + return '$rootPath' + '${Platform.pathSeparator}' + 'YouTubePersonal' + '${Platform.pathSeparator}' + 'media_cache'; + } + + Future effectiveCachePath() async { + await ensureInitialized(); + + final custom = customCachePath.value.trim(); + if (custom.isNotEmpty) { + return custom; + } + + return defaultCachePath; + } + + Future resolveCacheRoot() async { + final path = await effectiveCachePath(); + final directory = Directory(path); + + try { + await directory.create(recursive: true); + return directory; + } catch (error) { + // Si un disque externe / secondaire a disparu, ne pas casser le + // lecteur Windows : retour temporaire au chemin par défaut. + if (customCachePath.value.trim().isNotEmpty) { + debugPrint( + '[MEDIA-CACHE][18.7.15] chemin personnalisé indisponible ' + '$path -> fallback: $error', + ); + final fallback = Directory(defaultCachePath); + await fallback.create(recursive: true); + return fallback; + } + rethrow; + } + } + + Future setCustomCachePath(String value) async { + await ensureInitialized(); + + final trimmed = value.trim(); + if (trimmed.isEmpty || _samePath(trimmed, defaultCachePath)) { + customCachePath.value = ''; + await _preferences.remove(_customPathKey); + return; + } + + final directory = Directory(trimmed); + await directory.create(recursive: true); + + final testFile = File( + '${directory.path}${Platform.pathSeparator}.youtube_personal_write_test', + ); + + try { + await testFile.writeAsString('ok', flush: true); + } finally { + try { + if (await testFile.exists()) { + await testFile.delete(); + } + } catch (_) { + // Non bloquant. + } + } + + customCachePath.value = directory.path; + await _preferences.setString(_customPathKey, directory.path); + } + + Future resetCustomCachePath() async { + await setCustomCachePath(''); + } + + Future setMaxCacheGb(int value) async { + await ensureInitialized(); + + if (!supportedMaxCacheGb.contains(value)) { + return; + } + + maxCacheGb.value = value; + await _preferences.setInt(_maxCacheGbKey, value); + + if (Platform.isWindows) { + await enforceLimit(); + } + } + + static String maxCacheLabel(int value) { + if (value == 0) { + return 'Illimité'; + } + return '$value Go'; + } + + Future setAndroidBufferMb(int value) async { + await ensureInitialized(); + + if (!supportedAndroidBufferMb.contains(value)) { + return; + } + + androidBufferMb.value = value; + await _preferences.setInt(_androidBufferMbKey, value); + } + + static String androidBufferLabel(int value) => '$value Mo'; + + Future setAndroidDiskCacheMode(int value) async { + await ensureInitialized(); + + if (!supportedAndroidDiskCacheModes.contains(value)) { + return; + } + + androidDiskCacheMode.value = value; + await _preferences.setInt(_androidDiskCacheModeKey, value); + + if (!Platform.isAndroid) { + return; + } + + // Quand le cache disque est désactivé, supprimer les éventuels restes + // d'anciennes sessions sur les deux emplacements. + if (value == androidDiskCacheDisabled) { + await clearAndroidMediaCache(androidDiskCacheInternal); + await clearAndroidMediaCache(androidDiskCacheExternal); + } else { + // Le dossier est temporaire. On retire les restes d'un éventuel crash + // précédent avant la prochaine lecture. + await clearAndroidMediaCache(value); + } + } + + static String androidDiskCacheModeLabel(int value) { + return switch (value) { + androidDiskCacheInternal => 'Interne (app)', + androidDiskCacheExternal => 'Externe (app)', + _ => 'Désactivé (RAM uniquement)', + }; + } + + Future> androidCachePaths() async { + if (!Platform.isAndroid) { + return const {}; + } + + try { + final raw = await _androidCacheChannel.invokeMapMethod( + 'getCachePaths', + ); + + if (raw == null) { + return const {}; + } + + final result = {}; + for (final entry in raw.entries) { + final value = entry.value; + if (value is String && value.trim().isNotEmpty) { + result[entry.key] = value; + } + } + return result; + } catch (error) { + debugPrint('[MEDIA-CACHE][18.7.15] Android paths: $error'); + return const {}; + } + } + + Future androidCachePathForMode(int mode) async { + if (!Platform.isAndroid || mode == androidDiskCacheDisabled) { + return ''; + } + + final paths = await androidCachePaths(); + + if (mode == androidDiskCacheExternal) { + return paths['external'] ?? paths['internal'] ?? ''; + } + + return paths['internal'] ?? ''; + } + + Future androidCacheSizeBytes(int mode) async { + if (!Platform.isAndroid || mode == androidDiskCacheDisabled) { + return 0; + } + + try { + final value = await _androidCacheChannel.invokeMethod( + 'getCacheSize', + {'mode': mode}, + ); + return value?.toInt() ?? 0; + } catch (error) { + debugPrint('[MEDIA-CACHE][18.7.15] Android size: $error'); + return 0; + } + } + + Future clearAndroidMediaCache(int mode) async { + if (!Platform.isAndroid || mode == androidDiskCacheDisabled) { + return; + } + + try { + await _androidCacheChannel.invokeMethod( + 'clearCache', + {'mode': mode}, + ); + } catch (error) { + debugPrint('[MEDIA-CACHE][18.7.15] Android clear: $error'); + } + } + + Future currentCacheSizeBytes() async { + return directorySizeBytes(await resolveCacheRoot()); + } + + Future defaultCacheSizeBytes() async { + return directorySizeBytes(Directory(defaultCachePath)); + } + + Future directorySizeBytes(Directory directory) async { + if (!await directory.exists()) { + return 0; + } + + var total = 0; + + try { + await for (final entity in directory.list( + recursive: true, + followLinks: false, + )) { + if (entity is File) { + try { + total += await entity.length(); + } catch (_) { + // Un fichier verrouillé ne doit pas bloquer l'affichage. + } + } + } + } catch (_) { + // Un dossier partiellement inaccessible ne doit pas bloquer l'app. + } + + return total; + } + + Future clearCurrentCache() async { + final root = await resolveCacheRoot(); + await _deleteChildren(root); + } + + Future clearDefaultCache() async { + final root = Directory(defaultCachePath); + if (!await root.exists()) { + return; + } + await _deleteChildren(root); + } + + Future _deleteChildren(Directory root) async { + try { + final entities = await root.list(followLinks: false).toList(); + for (final entity in entities) { + try { + await entity.delete(recursive: true); + } catch (_) { + // Un fichier encore utilisé sera conservé et pourra être supprimé + // lors du prochain nettoyage. + } + } + } catch (_) { + // Le nettoyage ne doit jamais empêcher l'application de fonctionner. + } + } + + Future touchVideoDirectory(Directory videoDirectory) async { + final marker = File( + '${videoDirectory.path}${Platform.pathSeparator}.last_access', + ); + + try { + await marker.writeAsString( + DateTime.now().toIso8601String(), + flush: true, + ); + } catch (_) { + // Non bloquant. + } + } + + Future enforceLimit({String? keepVideoId}) async { + await ensureInitialized(); + + if (!Platform.isWindows) { + return; + } + + final limitGb = maxCacheGb.value; + if (limitGb <= 0) { + return; + } + + final root = await resolveCacheRoot(); + final maxBytes = limitGb * 1024 * 1024 * 1024; + + final entries = <_CacheEntry>[]; + var totalBytes = 0; + + try { + final children = await root.list(followLinks: false).toList(); + + for (final entity in children) { + final name = entity.uri.pathSegments + .where((part) => part.isNotEmpty) + .last; + + int bytes = 0; + DateTime lastAccess = DateTime.fromMillisecondsSinceEpoch(0); + + if (entity is Directory) { + bytes = await directorySizeBytes(entity); + lastAccess = await _lastAccess(entity); + } else if (entity is File) { + try { + bytes = await entity.length(); + lastAccess = (await entity.stat()).modified; + } catch (_) { + continue; + } + } + + entries.add( + _CacheEntry( + entity: entity, + name: name, + bytes: bytes, + lastAccess: lastAccess, + ), + ); + totalBytes += bytes; + } + } catch (_) { + return; + } + + if (totalBytes <= maxBytes) { + return; + } + + // On descend à 85 % de la limite pour éviter un nettoyage à chaque + // petite écriture suivante. + final targetBytes = (maxBytes * 0.85).round(); + + entries.sort( + (a, b) => a.lastAccess.compareTo(b.lastAccess), + ); + + for (final entry in entries) { + if (totalBytes <= targetBytes) { + break; + } + + if (keepVideoId != null && entry.name == keepVideoId) { + continue; + } + + try { + await entry.entity.delete(recursive: true); + totalBytes -= entry.bytes; + + debugPrint( + '[MEDIA-CACHE][18.7.15] LRU supprimé: ' + '${entry.name} (${_formatBytes(entry.bytes)})', + ); + } catch (_) { + // Probablement encore utilisé par le lecteur. + } + } + + debugPrint( + '[MEDIA-CACHE][18.7.15] taille après nettoyage: ' + '${_formatBytes(totalBytes)} / ${limitGb}Go', + ); + } + + Future _lastAccess(Directory directory) async { + final marker = File( + '${directory.path}${Platform.pathSeparator}.last_access', + ); + + try { + if (await marker.exists()) { + return (await marker.stat()).modified; + } + return (await directory.stat()).modified; + } catch (_) { + return DateTime.fromMillisecondsSinceEpoch(0); + } + } + + bool _samePath(String a, String b) { + String normalize(String value) { + var normalized = value + .replaceAll('/', Platform.pathSeparator) + .replaceAll('\\', Platform.pathSeparator) + .trim(); + while (normalized.endsWith(Platform.pathSeparator)) { + normalized = normalized.substring(0, normalized.length - 1); + } + return Platform.isWindows + ? normalized.toLowerCase() + : normalized; + } + + return normalize(a) == normalize(b); + } + + static String formatBytesForUi(int bytes) => _formatBytes(bytes); + + static String _formatBytes(int bytes) { + if (bytes >= 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} Go'; + } + if (bytes >= 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} Mo'; + } + return '${(bytes / 1024).toStringAsFixed(0)} Ko'; + } +} + +class _CacheEntry { + const _CacheEntry({ + required this.entity, + required this.name, + required this.bytes, + required this.lastAccess, + }); + + final FileSystemEntity entity; + final String name; + final int bytes; + final DateTime lastAccess; +} diff --git a/lib/services/memory_backup_service.dart b/lib/services/memory_backup_service.dart new file mode 100644 index 0000000..0ab68de --- /dev/null +++ b/lib/services/memory_backup_service.dart @@ -0,0 +1,376 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:file_saver/file_saver.dart'; +import 'package:file_selector/file_selector.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum MemoryImportMode { merge, replace } + +class MemoryExportResult { + const MemoryExportResult({ + required this.entryCount, + required this.destination, + }); + + final int entryCount; + final String destination; +} + +class MemoryImportCandidate { + const MemoryImportCandidate({ + required this.sourcePlatform, + required this.sourceDeviceId, + required this.exportedAtUtc, + required this.values, + }); + + final String sourcePlatform; + final String sourceDeviceId; + final DateTime? exportedAtUtc; + final Map values; + + int get entryCount => values.length; +} + +class MemoryImportResult { + const MemoryImportResult({ + required this.importedCount, + required this.removedCount, + required this.mode, + }); + + final int importedCount; + final int removedCount; + final MemoryImportMode mode; +} + +/// Sauvegarde portable de la mémoire comportementale de YouTube Personal. +/// +/// Important : ce service utilise volontairement une liste blanche. Les +/// identifiants Google, tokens OAuth, secrets, chemins de cache et autres +/// données propres à une plateforme ne sont jamais exportés. +class MemoryBackupService { + MemoryBackupService._(); + + static final MemoryBackupService instance = MemoryBackupService._(); + + static const String formatId = 'youtube-personal-memory'; + static const int schemaVersion = 1; + + static const String _deviceIdKey = 'youtube_personal.sync.device_id.v1'; + static const String _lastImportKey = + 'youtube_personal.sync.last_import_utc.v1'; + + static const Set _allowedExactKeys = { + 'youtube_personal.history.v1', + 'youtube_personal.favorites.v1', + 'youtube_personal.positions.v1', + 'youtube_personal.watch_counts.v1', + 'youtube_personal.hidden_channels.v1', + // Ces deux réglages influencent directement les recommandations. + 'youtube_personal.settings.preferred_language.v1', + 'youtube_personal.settings.young_audience_filter.v1', + }; + + static const List _allowedPrefixes = [ + 'youtube_personal.radar.', + 'youtube_personal.interest_', + ]; + + final SharedPreferencesAsync _preferences = SharedPreferencesAsync(); + + Future memoryEntryCount() async { + final all = await _preferences.getAll(); + return all.keys.where(_isMemoryKey).length; + } + + Future exportMemory() async { + final all = await _preferences.getAll(); + final memory = {}; + + for (final entry in all.entries) { + if (!_isMemoryKey(entry.key)) { + continue; + } + memory[entry.key] = entry.value; + } + + final deviceId = await _getOrCreateDeviceId(); + final now = DateTime.now().toUtc(); + + final payload = { + 'format': formatId, + 'schemaVersion': schemaVersion, + 'exportedAtUtc': now.toIso8601String(), + 'sourcePlatform': Platform.operatingSystem, + 'sourceDeviceId': deviceId, + 'entryCount': memory.length, + 'preferences': { + for (final entry in memory.entries) + entry.key: _encodePreference(entry.value), + }, + }; + + final jsonText = const JsonEncoder.withIndent(' ').convert(payload); + final bytes = Uint8List.fromList(utf8.encode(jsonText)); + final fileName = 'YouTube_Personal_Memory_${_fileTimestamp(now)}.json'; + + final baseName = fileName.substring( + 0, + fileName.length - '.ypmemory'.length, + ); + final destination = await FileSaver.instance.saveAs( + name: baseName, + bytes: bytes, + fileExtension: 'json', + mimeType: MimeType.custom, + customMimeType: 'application/json', + ); + + if (destination == null || destination.trim().isEmpty) { + return null; + } + + return MemoryExportResult( + entryCount: memory.length, + destination: destination, + ); + } + + Future pickMemoryBackup() async { + final XFile? selected; + if (Platform.isAndroid) { + // Android ne mappe pas toujours l'extension personnalisée .ypmemory + // vers un type MIME. Le contenu est validé strictement juste après. + selected = await openFile(); + } else { + const memoryFiles = XTypeGroup( + label: 'Mémoire YouTube Personal', + extensions: ['json', 'ypmemory'], + ); + selected = await openFile(acceptedTypeGroups: [memoryFiles]); + } + + if (selected == null) { + return null; + } + + final bytes = await selected.readAsBytes(); + + if (bytes.isEmpty) { + throw const FormatException('Impossible de lire le fichier sélectionné.'); + } + + final rawText = utf8.decode(bytes, allowMalformed: false); + final decoded = jsonDecode(rawText); + + if (decoded is! Map) { + throw const FormatException('Sauvegarde mémoire invalide.'); + } + + final map = decoded.map((key, value) => MapEntry('$key', value)); + if (map['format'] != formatId) { + throw const FormatException( + 'Ce fichier n’est pas une sauvegarde YouTube Personal.', + ); + } + + final rawSchema = map['schemaVersion']; + final schema = rawSchema is num ? rawSchema.toInt() : -1; + if (schema <= 0 || schema > schemaVersion) { + throw FormatException( + 'Version de sauvegarde non prise en charge : $schema.', + ); + } + + final rawPreferences = map['preferences']; + if (rawPreferences is! Map) { + throw const FormatException('La mémoire du fichier est absente.'); + } + + final values = {}; + for (final entry in rawPreferences.entries) { + final key = '${entry.key}'; + if (!_isMemoryKey(key)) { + // Défense supplémentaire : même un fichier modifié manuellement ne + // peut pas injecter une préférence sensible hors de la liste blanche. + continue; + } + + final rawValue = entry.value; + if (rawValue is! Map) { + continue; + } + + values[key] = _decodePreference( + rawValue.map((key, value) => MapEntry('$key', value)), + ); + } + + DateTime? exportedAt; + final rawExportedAt = map['exportedAtUtc']; + if (rawExportedAt is String) { + exportedAt = DateTime.tryParse(rawExportedAt)?.toUtc(); + } + + return MemoryImportCandidate( + sourcePlatform: map['sourcePlatform'] is String + ? map['sourcePlatform'] as String + : 'inconnu', + sourceDeviceId: map['sourceDeviceId'] is String + ? map['sourceDeviceId'] as String + : 'inconnu', + exportedAtUtc: exportedAt, + values: values, + ); + } + + Future importMemory( + MemoryImportCandidate candidate, { + required MemoryImportMode mode, + }) async { + int removed = 0; + + if (mode == MemoryImportMode.replace) { + final current = await _preferences.getAll(); + for (final key in current.keys.where(_isMemoryKey)) { + await _preferences.remove(key); + removed++; + } + } + + int imported = 0; + for (final entry in candidate.values.entries) { + if (!_isMemoryKey(entry.key)) { + continue; + } + await _writePreference(entry.key, entry.value); + imported++; + } + + await _preferences.setString( + _lastImportKey, + DateTime.now().toUtc().toIso8601String(), + ); + + return MemoryImportResult( + importedCount: imported, + removedCount: removed, + mode: mode, + ); + } + + bool _isMemoryKey(String key) { + if (_allowedExactKeys.contains(key)) { + return true; + } + return _allowedPrefixes.any(key.startsWith); + } + + Map _encodePreference(Object? value) { + if (value is bool) { + return {'type': 'bool', 'value': value}; + } + if (value is int) { + return {'type': 'int', 'value': value}; + } + if (value is double) { + return {'type': 'double', 'value': value}; + } + if (value is String) { + return {'type': 'string', 'value': value}; + } + if (value is List) { + return {'type': 'stringList', 'value': value}; + } + if (value is List && value.every((item) => item is String)) { + return { + 'type': 'stringList', + 'value': value.cast(), + }; + } + + throw FormatException( + 'Type de préférence non pris en charge : ${value.runtimeType}', + ); + } + + Object? _decodePreference(Map encoded) { + final type = encoded['type']; + final value = encoded['value']; + + switch (type) { + case 'bool': + if (value is bool) return value; + break; + case 'int': + if (value is num) return value.toInt(); + break; + case 'double': + if (value is num) return value.toDouble(); + break; + case 'string': + if (value is String) return value; + break; + case 'stringList': + if (value is List && value.every((item) => item is String)) { + return value.cast(); + } + break; + } + + throw FormatException('Valeur mémoire invalide pour le type $type.'); + } + + Future _writePreference(String key, Object? value) async { + if (value is bool) { + await _preferences.setBool(key, value); + return; + } + if (value is int) { + await _preferences.setInt(key, value); + return; + } + if (value is double) { + await _preferences.setDouble(key, value); + return; + } + if (value is String) { + await _preferences.setString(key, value); + return; + } + if (value is List) { + await _preferences.setStringList(key, value); + return; + } + + throw FormatException( + 'Type mémoire impossible à importer : ${value.runtimeType}', + ); + } + + Future _getOrCreateDeviceId() async { + final existing = await _preferences.getString(_deviceIdKey); + if (existing != null && existing.trim().isNotEmpty) { + return existing; + } + + final random = Random.secure(); + final bytes = List.generate(12, (_) => random.nextInt(256)); + final randomPart = bytes + .map((value) => value.toRadixString(16).padLeft(2, '0')) + .join(); + final id = '${Platform.operatingSystem}-$randomPart'; + await _preferences.setString(_deviceIdKey, id); + return id; + } + + String _fileTimestamp(DateTime value) { + String two(int number) => number.toString().padLeft(2, '0'); + return '${value.year}-${two(value.month)}-${two(value.day)}_' + '${two(value.hour)}-${two(value.minute)}-${two(value.second)}Z'; + } +} diff --git a/lib/services/preferred_language_service.dart b/lib/services/preferred_language_service.dart new file mode 100644 index 0000000..37cbfc8 --- /dev/null +++ b/lib/services/preferred_language_service.dart @@ -0,0 +1,244 @@ +import '../models/video_item.dart'; + +class PreferredLanguageOption { + const PreferredLanguageOption({ + required this.code, + required this.label, + }); + + final String code; + final String label; +} + +class PreferredLanguageService { + PreferredLanguageService._(); + + static const List options = + [ + PreferredLanguageOption(code: 'auto', label: 'Auto'), + PreferredLanguageOption(code: 'fr', label: 'Français (fr)'), + PreferredLanguageOption(code: 'en', label: 'English (en)'), + PreferredLanguageOption(code: 'ar', label: 'العربية (ar)'), + PreferredLanguageOption(code: 'de', label: 'Deutsch (de)'), + PreferredLanguageOption(code: 'es', label: 'Español (es)'), + ]; + + static bool isSupported(String code) { + return options.any((option) => option.code == code); + } + + static String labelFor(String code) { + for (final option in options) { + if (option.code == code) { + return option.label; + } + } + return 'Auto'; + } + + static String nativeSearchHint(String code) { + return switch (code) { + 'fr' => 'français', + 'en' => 'English', + 'ar' => 'عربي', + 'de' => 'Deutsch', + 'es' => 'español', + _ => '', + }; + } + + static String dubbingSearchHint(String code) { + return switch (code) { + 'fr' => 'VF doublage français version française', + 'en' => 'English dub dubbed', + 'ar' => 'مدبلج عربي دبلجة عربية', + 'de' => 'deutsche Synchronisation Deutsch', + 'es' => 'doblaje español versión española', + _ => '', + }; + } + + /// Returns a positive local boost. This is deliberately a preference, + /// never a hard filter: videos in other languages remain eligible. + static int scoreVideo(VideoItem video, String preferredLanguage) { + if (preferredLanguage == 'auto') { + return 0; + } + + final raw = '${video.title} ${video.description} ${video.author}'; + final text = _normalize(raw); + + var score = 0; + + if (_hasDubbingMarker(text, preferredLanguage)) { + score += 18; + score += _popularityBonus(video.viewCount); + } + + if (_looksLikeLanguage(text, preferredLanguage)) { + score += 12; + } + + return score.clamp(0, 32).toInt(); + } + + static bool isLikelyDubbed(VideoItem video, String preferredLanguage) { + if (preferredLanguage == 'auto') { + return false; + } + + final text = _normalize( + '${video.title} ${video.description} ${video.author}', + ); + + return _hasDubbingMarker(text, preferredLanguage); + } + + static List rankVideos( + List videos, + String preferredLanguage, + ) { + if (preferredLanguage == 'auto' || videos.length < 2) { + return videos; + } + + final indexed = videos.asMap().entries.toList(growable: false); + + final sorted = [...indexed] + ..sort((a, b) { + final aScore = scoreVideo(a.value, preferredLanguage); + final bScore = scoreVideo(b.value, preferredLanguage); + + final scoreCompare = bScore.compareTo(aScore); + if (scoreCompare != 0) { + return scoreCompare; + } + + return a.key.compareTo(b.key); + }); + + return sorted.map((entry) => entry.value).toList(growable: false); + } + + + static int _popularityBonus(int? viewCount) { + if (viewCount == null || viewCount <= 0) { + return 0; + } + + if (viewCount >= 10000000) { + return 8; + } + if (viewCount >= 1000000) { + return 6; + } + if (viewCount >= 100000) { + return 4; + } + if (viewCount >= 10000) { + return 2; + } + return 1; + } + + static bool _hasDubbingMarker(String text, String code) { + final markers = switch (code) { + 'fr' => const [ + ' vf ', + '[vf]', + '(vf)', + 'version francaise', + 'version française', + 'double en francais', + 'doublé en français', + 'doublage francais', + 'doublage français', + 'french dub', + 'french dubbed', + 'audio francais', + 'audio français', + ], + 'en' => const [ + 'english dub', + 'english dubbed', + 'dubbed in english', + 'english audio', + ], + 'ar' => const [ + 'مدبلج عربي', + 'مدبلجة عربية', + 'دبلجة عربية', + 'باللغة العربية', + ], + 'de' => const [ + 'deutsche synchronisation', + 'deutsch synchronisiert', + 'deutsche synchro', + 'german dub', + ], + 'es' => const [ + 'doblaje espanol', + 'doblaje español', + 'version espanola', + 'versión española', + 'spanish dub', + 'audio espanol', + 'audio español', + ], + _ => const [], + }; + + return markers.any(text.contains); + } + + static bool _looksLikeLanguage(String text, String code) { + if (code == 'ar') { + final arabic = RegExp(r'[\u0600-\u06FF]'); + final matches = arabic.allMatches(text).length; + return matches >= 5; + } + + final words = text + .split(RegExp(r'\s+')) + .where((word) => word.length >= 2) + .toList(growable: false); + + final markers = switch (code) { + 'fr' => const { + 'le', 'la', 'les', 'un', 'une', 'des', 'du', 'de', 'dans', 'avec', + 'pour', 'sur', 'est', 'sont', 'comment', 'pourquoi', 'nouveau', + 'nouvelle', 'francais', 'français', 'voici', 'tout', 'tous', + }, + 'en' => const { + 'the', 'and', 'with', 'for', 'from', 'this', 'that', 'how', 'why', + 'what', 'new', 'best', 'review', 'explained', 'english', + }, + 'de' => const { + 'der', 'die', 'das', 'und', 'mit', 'für', 'fur', 'von', 'ist', 'wie', + 'warum', 'neu', 'neue', 'deutsch', 'erklart', 'erklärt', + }, + 'es' => const { + 'el', 'la', 'los', 'las', 'un', 'una', 'con', 'para', 'por', 'como', + 'cómo', 'nuevo', 'nueva', 'espanol', 'español', 'explicado', + }, + _ => const {}, + }; + + var hits = 0; + for (final word in words) { + if (markers.contains(word)) { + hits++; + } + } + + return hits >= 2; + } + + static String _normalize(String value) { + return ' ${value.toLowerCase()}' + .replaceAll(RegExp(r'[^a-z0-9à-ÿ\u0600-\u06ff\[\]\(\)]+'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trimRight() + + ' '; + } +} diff --git a/lib/services/radar_service.dart b/lib/services/radar_service.dart new file mode 100644 index 0000000..f017a79 --- /dev/null +++ b/lib/services/radar_service.dart @@ -0,0 +1,1616 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/radar_recommendation.dart'; +import '../models/video_item.dart'; +import 'hidden_channels_service.dart'; +import 'library_service.dart'; +import 'youtube_service.dart'; + +class RadarService { + RadarService._(); + + static final RadarService instance = RadarService._(); + + static const String _dismissedKey = + 'youtube_personal.radar.dismissed.v1'; + static const String _reducedAuthorsKey = + 'youtube_personal.radar.reduced_authors.v1'; + + static const Duration _dismissDuration = + Duration(days: 30); + + final SharedPreferencesAsync _preferences = + SharedPreferencesAsync(); + + final YoutubeService _youtube = YoutubeService(); + + final Map + _cache = {}; + + int _shuffleCounter = 0; + RadarFeed? _lastFeed; + + RadarFeed? get lastFeed => _lastFeed; + + Future generate({ + required RadarSessionMode mode, + bool force = false, + String interest = 'Tous', + }) async { + final history = + LibraryService.instance.history.value; + final favorites = + LibraryService.instance.favorites.value; + + if (history.isEmpty && favorites.isEmpty) { + final emptyFeed = RadarFeed( + mode: mode, + generatedAt: DateTime.now(), + profileTopics: const [], + primary: const [], + continueTopic: const [], + discovery: const [], + categories: + const >{}, + ); + + _lastFeed = emptyFeed; + return emptyFeed; + } + + await HiddenChannelsService.instance.ensureLoaded(); + + final dismissed = + await _readDismissed(); + final reducedAuthors = + await _readReducedAuthors(); + + final fingerprint = _fingerprint( + history, + favorites, + dismissed, + reducedAuthors, + HiddenChannelsService.instance.revision.value, + ); + + final normalizedInterest = + _normalizeInterest(interest); + + final cacheKey = + '${mode.name}|$normalizedInterest'; + + final cached = _cache[cacheKey]; + + if (!force && + cached != null && + cached.fingerprint == fingerprint) { + _lastFeed = cached.feed; + return cached.feed; + } + + if (force) { + _shuffleCounter++; + } + + final profile = _buildProfile( + history, + favorites, + ); + + final queries = _buildQueries( + profile, + mode, + interest: normalizedInterest, + ); + + final candidateById = + {}; + + final results = await Future.wait( + queries.map( + (query) async { + try { + return await _youtube.searchVideos(query); + } catch (_) { + return const []; + } + }, + ), + ); + + for (final list in results) { + for (final video in list) { + candidateById.putIfAbsent( + video.id, + () => video, + ); + } + } + + final seenVideoIds = { + ...history.map((video) => video.id), + ...favorites.map((video) => video.id), + }; + + final now = DateTime.now(); + + final scoredCandidates = + candidateById.values + .where( + (video) => + !seenVideoIds.contains(video.id) && + !HiddenChannelsService.instance.isHiddenVideo(video) && + !_isDismissed( + video.id, + dismissed, + now, + ), + ) + .map( + (video) => _score( + video: video, + profile: profile, + mode: mode, + reducedAuthors: reducedAuthors, + ), + ) + .where( + (item) => item.score >= 18, + ) + .toList(); + + final matchingInterest = + normalizedInterest == 'Tous' + ? scoredCandidates + : scoredCandidates + .where( + (item) => + _classifyVideo( + item.video, + ) == + normalizedInterest, + ) + .toList(); + + final candidates = + normalizedInterest != 'Tous' && + matchingInterest.length >= 4 + ? matchingInterest + : scoredCandidates; + + final random = Random( + DateTime.now().day + + mode.index * 7919 + + _shuffleCounter * 104729, + ); + + final jitterByVideo = { + for (final item in candidates) + item.video.id: random.nextInt(5), + }; + + candidates.sort( + (a, b) { + final aJitter = + jitterByVideo[a.video.id] ?? 0; + final bJitter = + jitterByVideo[b.video.id] ?? 0; + + return (b.score + bJitter) + .compareTo(a.score + aJitter); + }, + ); + + final diversified = + _diversify(candidates); + + final primary = + []; + final continueTopic = + []; + final discovery = + []; + + final used = {}; + + void takeInto( + List target, + Iterable source, + int limit, + ) { + for (final item in source) { + if (target.length >= limit) { + return; + } + + if (used.add(item.video.id)) { + target.add(item); + } + } + } + + takeInto( + primary, + diversified, + 8, + ); + + takeInto( + continueTopic, + diversified.where( + (item) => + item.lane == + RadarLane.continueTopic, + ), + 10, + ); + + takeInto( + discovery, + diversified.where( + (item) => + item.lane == + RadarLane.discovery, + ), + 10, + ); + + // Si un lane est trop petit, on le complète sans dupliquer + // les cartes déjà utilisées. + if (continueTopic.length < 4) { + takeInto( + continueTopic, + diversified.where( + (item) => + item.matchedTerms.isNotEmpty && + !item.isNewAuthor, + ), + 8, + ); + } + + if (discovery.length < 4) { + takeInto( + discovery, + diversified.where( + (item) => item.isNewAuthor, + ), + 8, + ); + } + + final categories = + _buildCategories(diversified); + + final feed = RadarFeed( + mode: mode, + generatedAt: now, + profileTopics: + profile.topTerms.take(6).toList( + growable: false, + ), + primary: + List.unmodifiable(primary), + continueTopic: + List.unmodifiable(continueTopic), + discovery: + List.unmodifiable(discovery), + categories: categories, + ); + + _lastFeed = feed; + + _cache[cacheKey] = _CachedRadar( + fingerprint: fingerprint, + feed: feed, + ); + + return feed; + } + + Future dismissVideo( + String videoId, + ) async { + final dismissed = + await _readDismissed(); + + dismissed[videoId] = + DateTime.now().millisecondsSinceEpoch; + + await _preferences.setString( + _dismissedKey, + jsonEncode(dismissed), + ); + + _cache.clear(); + } + + Future reduceAuthor( + String author, + ) async { + final normalized = + _normalizeAuthor(author); + + if (normalized.isEmpty) { + return; + } + + final authors = + await _readReducedAuthors(); + + authors[normalized] = + (authors[normalized] ?? 0) + 1; + + await _preferences.setString( + _reducedAuthorsKey, + jsonEncode(authors), + ); + + _cache.clear(); + } + + Future resetFeedback() async { + await _preferences.remove( + _dismissedKey, + ); + await _preferences.remove( + _reducedAuthorsKey, + ); + _cache.clear(); + } + + _RadarProfile _buildProfile( + List history, + List favorites, + ) { + final topicWeights = + {}; + final favoriteWeights = + {}; + final authorWeights = + {}; + + void addTerms( + VideoItem video, + double weight, { + Map? target, + }) { + final destination = + target ?? topicWeights; + + for (final term in _terms( + video.title, + )) { + destination.update( + term, + (value) => value + weight, + ifAbsent: () => weight, + ); + } + } + + for (var index = 0; + index < history.length && + index < 30; + index++) { + final recency = + max(0.25, 1.0 - index * 0.035); + + addTerms( + history[index], + recency, + ); + + final author = _normalizeAuthor( + history[index].author, + ); + + if (author.isNotEmpty) { + authorWeights.update( + author, + (value) => value + recency, + ifAbsent: () => recency, + ); + } + } + + for (var index = 0; + index < favorites.length && + index < 20; + index++) { + final weight = + max(0.7, 1.35 - index * 0.025); + + addTerms( + favorites[index], + weight, + ); + + addTerms( + favorites[index], + weight, + target: favoriteWeights, + ); + + final author = _normalizeAuthor( + favorites[index].author, + ); + + if (author.isNotEmpty) { + authorWeights.update( + author, + (value) => value + weight * 1.35, + ifAbsent: () => weight * 1.35, + ); + } + } + + final sortedTerms = + topicWeights.entries.toList() + ..sort( + (a, b) => + b.value.compareTo(a.value), + ); + + final recent = history.isNotEmpty + ? history.first + : favorites.first; + + final recentTerms = + _terms(recent.title).toSet(); + + return _RadarProfile( + topicWeights: topicWeights, + favoriteWeights: favoriteWeights, + authorWeights: authorWeights, + knownAuthors: + authorWeights.keys.toSet(), + recentAuthor: + _normalizeAuthor(recent.author), + recentTerms: recentTerms, + topTerms: sortedTerms + .map((entry) => entry.key) + .toList(growable: false), + history: history, + favorites: favorites, + ); + } + + List _buildQueries( + _RadarProfile profile, + RadarSessionMode mode, { + String interest = 'Tous', + }) { + final queries = []; + + String applyMode( + String query, + ) { + switch (mode) { + case RadarSessionMode.learn: + return '$query tutorial explained'; + case RadarSessionMode.discovery: + return '$query découverte nouvelles chaînes'; + case RadarSessionMode.relax: + return '$query relaxing'; + case RadarSessionMode.short: + return '$query court'; + case RadarSessionMode.long: + return '$query long format'; + case RadarSessionMode.normal: + return query; + } + } + + if (interest != 'Tous') { + final interestQuery = + _interestSearchQueries[interest]; + + if (interestQuery != null && + interestQuery.isNotEmpty) { + queries.add( + applyMode(interestQuery), + ); + } + } + + final seeds = [ + ...profile.history.take(3), + ...profile.favorites.take(2), + ]; + + for (final video in seeds) { + final words = _terms(video.title) + .take(4) + .toList(); + + if (words.isEmpty) { + continue; + } + + var query = words.join(' '); + + if (mode == + RadarSessionMode.discovery && + profile.topTerms.length >= 2) { + query = + '${profile.topTerms.take(2).join(' ')} $query'; + } + + queries.add( + applyMode(query), + ); + + if (queries.length >= 4) { + break; + } + } + + if (queries.length < 4 && + profile.topTerms.isNotEmpty) { + queries.add( + applyMode( + profile.topTerms + .take(3) + .join(' '), + ), + ); + } + + return queries + .where( + (query) => query.trim().isNotEmpty, + ) + .toSet() + .take(4) + .toList(growable: false); + } + + RadarRecommendation _score({ + required VideoItem video, + required _RadarProfile profile, + required RadarSessionMode mode, + required Map + reducedAuthors, + }) { + final terms = + _terms(video.title).toSet(); + + final matchedTerms = + terms + .where( + profile.topicWeights + .containsKey, + ) + .toList() + ..sort( + (a, b) => + (profile.topicWeights[b] ?? 0) + .compareTo( + profile.topicWeights[a] ?? 0, + ), + ); + + final topicRaw = matchedTerms.fold( + 0, + (sum, term) => + sum + + (profile.topicWeights[term] ?? 0), + ); + + final maxTopicWeight = + profile.topicWeights.values.isEmpty + ? 1.0 + : profile.topicWeights.values + .reduce(max); + + final topicPoints = + min( + 35, + (topicRaw / + maxTopicWeight * + 13) + .round(), + ); + + final author = + _normalizeAuthor(video.author); + + final authorRaw = + profile.authorWeights[author] ?? 0; + + final maxAuthorWeight = + profile.authorWeights.values.isEmpty + ? 1.0 + : profile.authorWeights.values + .reduce(max); + + final authorPoints = + min( + 20, + (authorRaw / + maxAuthorWeight * + 20) + .round(), + ); + + final favoriteRaw = terms.fold( + 0, + (sum, term) => + sum + + (profile.favoriteWeights[term] ?? + 0), + ); + + final maxFavoriteWeight = + profile.favoriteWeights.values.isEmpty + ? 1.0 + : profile.favoriteWeights.values + .reduce(max); + + final favoritePoints = + min( + 15, + (favoriteRaw / + maxFavoriteWeight * + 10) + .round(), + ); + + final isNewAuthor = + author.isNotEmpty && + !profile.knownAuthors.contains(author); + + final discoveryPoints = + isNewAuthor ? 8 : 0; + + final durationPoints = + _durationScore( + video.duration, + mode, + ); + + final modePoints = + _modeTextScore( + terms, + mode, + ); + + final currentTopicPoints = + terms + .intersection( + profile.recentTerms, + ) + .isNotEmpty + ? 8 + : 0; + + final reducedPenalty = + min( + 30, + (reducedAuthors[author] ?? 0) * + 12, + ); + + var total = 12 + + topicPoints + + authorPoints + + favoritePoints + + discoveryPoints + + durationPoints + + modePoints + + currentTopicPoints - + reducedPenalty; + + if (mode == + RadarSessionMode.discovery && + isNewAuthor) { + total += 10; + } + + total = total.clamp(1, 99).toInt(); + + final reasons = []; + + if (currentTopicPoints > 0) { + reasons.add( + 'Proche de ce que tu regardes maintenant', + ); + } + + if (matchedTerms.isNotEmpty) { + reasons.add( + 'Thèmes communs : ' + '${matchedTerms.take(3).join(', ')}', + ); + } + + if (authorPoints >= 8) { + reasons.add( + 'Chaîne proche de tes habitudes', + ); + } + + if (favoritePoints >= 5) { + reasons.add( + 'Sujet proche de tes favoris', + ); + } + + if (isNewAuthor) { + reasons.add( + 'Nouvelle chaîne pour toi', + ); + } + + if (durationPoints >= 7) { + reasons.add( + _durationReason(mode), + ); + } + + if (reducedPenalty > 0) { + reasons.add( + 'Score réduit selon ton feedback précédent', + ); + } + + if (reasons.isEmpty) { + reasons.add( + 'Proposition issue du mélange de ton Radar', + ); + } + + RadarLane lane = + RadarLane.forYou; + + if (isNewAuthor && + matchedTerms.isNotEmpty) { + lane = RadarLane.discovery; + } else if (currentTopicPoints > 0 || + author == + profile.recentAuthor) { + lane = + RadarLane.continueTopic; + } + + return RadarRecommendation( + video: video, + score: total, + reasons: + List.unmodifiable(reasons), + breakdown: Map.unmodifiable( + { + 'Sujet': topicPoints, + 'Habitudes': authorPoints, + 'Favoris': favoritePoints, + 'Découverte': + discoveryPoints, + 'Session': + durationPoints + + modePoints + + currentTopicPoints, + if (reducedPenalty > 0) + 'Feedback': -reducedPenalty, + }, + ), + lane: lane, + matchedTerms: + matchedTerms.take(5).toList( + growable: false, + ), + isNewAuthor: isNewAuthor, + ); + } + + int _durationScore( + Duration? duration, + RadarSessionMode mode, + ) { + if (duration == null) { + return 0; + } + + final minutes = + duration.inMinutes; + + switch (mode) { + case RadarSessionMode.short: + if (minutes <= 8) { + return 15; + } + if (minutes <= 15) { + return 9; + } + return -8; + + case RadarSessionMode.long: + if (minutes >= 30) { + return 15; + } + if (minutes >= 20) { + return 9; + } + return -5; + + case RadarSessionMode.relax: + if (minutes >= 8 && + minutes <= 45) { + return 7; + } + return 2; + + case RadarSessionMode.learn: + if (minutes >= 6 && + minutes <= 60) { + return 5; + } + return 1; + + case RadarSessionMode.discovery: + case RadarSessionMode.normal: + return 3; + } + } + + int _modeTextScore( + Set terms, + RadarSessionMode mode, + ) { + switch (mode) { + case RadarSessionMode.learn: + const learningTerms = { + 'tutorial', + 'guide', + 'explained', + 'learn', + 'course', + 'cours', + 'tuto', + 'شرح', + 'تعلم', + }; + + return terms + .intersection( + learningTerms, + ) + .isNotEmpty + ? 12 + : 0; + + case RadarSessionMode.discovery: + return 2; + + case RadarSessionMode.relax: + const relaxTerms = { + 'relax', + 'relaxing', + 'chill', + 'calm', + 'music', + 'musique', + 'ambient', + }; + + return terms + .intersection( + relaxTerms, + ) + .isNotEmpty + ? 10 + : 0; + + case RadarSessionMode.short: + case RadarSessionMode.long: + case RadarSessionMode.normal: + return 0; + } + } + + String _durationReason( + RadarSessionMode mode, + ) { + switch (mode) { + case RadarSessionMode.short: + return 'Durée adaptée au mode Courtes vidéos'; + case RadarSessionMode.long: + return 'Long format adapté à cette session'; + case RadarSessionMode.learn: + return 'Format adapté à une session d’apprentissage'; + case RadarSessionMode.relax: + return 'Durée adaptée au mode Détente'; + case RadarSessionMode.discovery: + case RadarSessionMode.normal: + return 'Durée adaptée à cette session'; + } + } + + Map> + _buildCategories( + List recommendations, + ) { + final buckets = + >{}; + + // Use a larger candidate pool than the three headline lanes so category + // browsing feels like a real second way to explore the Radar. + for (final item + in recommendations.take(60)) { + final category = + _classifyVideo(item.video); + + buckets.putIfAbsent( + category, + () => [], + ); + + final list = buckets[category]!; + + if (list.length < 12) { + list.add(item); + } + } + + final entries = + buckets.entries + .where( + (entry) => + entry.value.isNotEmpty, + ) + .toList() + ..sort( + (a, b) { + final aScore = a.value.fold( + 0, + (sum, item) => + sum + item.score, + ); + final bScore = b.value.fold( + 0, + (sum, item) => + sum + item.score, + ); + + final countCompare = + b.value.length.compareTo( + a.value.length, + ); + + if (countCompare != 0) { + return countCompare; + } + + return bScore.compareTo(aScore); + }, + ); + + return Map.unmodifiable( + >{ + for (final entry in entries) + entry.key: + List.unmodifiable(entry.value), + }, + ); + } + + String _classifyVideo( + VideoItem video, + ) { + final haystack = + '${video.title} ' + '${video.author} ' + '${video.description}' + .toLowerCase(); + + var bestCategory = 'Divers'; + var bestScore = 0; + + for (final entry + in _categoryKeywords.entries) { + var score = 0; + + for (final keyword in entry.value) { + if (haystack.contains(keyword)) { + // Longer phrases are more specific and receive a little more + // weight than generic single words. + score += keyword.contains(' ') + ? 3 + : 2; + } + } + + if (score > bestScore) { + bestScore = score; + bestCategory = entry.key; + } + } + + return bestCategory; + } + + static const Map> + _categoryKeywords = + >{ + 'Jeux vidéo': [ + 'jeu vidéo', + 'jeux vidéo', + 'gaming', + 'gameplay', + 'gamer', + 'fortnite', + 'minecraft', + 'roblox', + 'gta', + 'call of duty', + 'playstation', + 'ps5', + 'xbox', + 'nintendo', + 'switch', + 'steam', + 'pokemon', + 'elden ring', + ], + 'Culture & histoire': [ + 'culture', + 'histoire', + 'history', + 'historique', + 'documentaire', + 'documentary', + 'empire', + 'civilisation', + 'civilization', + 'archéologie', + 'archeology', + 'mythologie', + 'mythology', + 'art ', + 'littérature', + 'literature', + 'philosophie', + ], + 'OVNI & mystères': [ + 'ovni', + 'ufo', + 'uap', + 'alien', + 'extraterrestre', + 'extraterrestrial', + 'paranormal', + 'mystère', + 'mystery', + 'mysterious', + 'inexpliqué', + 'unexplained', + 'roswell', + 'area 51', + 'zone 51', + 'crop circle', + 'conspiracy', + 'complot', + ], + 'Technologie & IA': [ + 'technologie', + 'technology', + 'tech ', + 'intelligence artificielle', + 'artificial intelligence', + 'chatgpt', + 'openai', + 'android', + 'windows', + 'linux', + 'xiaomi', + 'samsung', + 'iphone', + 'apple', + 'gpu', + 'nvidia', + 'amd', + 'ordinateur', + 'smartphone', + 'robot', + ], + 'Science & espace': [ + 'science', + 'espace', + 'space', + 'nasa', + 'spacex', + 'astronomie', + 'astronomy', + 'physique', + 'physics', + 'quantique', + 'quantum', + 'mars ', + 'lune', + 'moon', + 'univers', + 'universe', + 'telescope', + 'télescope', + ], + 'Auto & transport': [ + 'voiture', + 'automobile', + 'car ', + 'cars ', + 'tesla', + 'bmw', + 'mercedes', + 'audi', + 'porsche', + 'moto', + 'motorcycle', + 'train', + 'avion', + 'airplane', + 'aviation', + ], + 'Musique': [ + 'music', + 'musique', + 'song', + 'chanson', + 'album', + 'concert', + 'live music', + 'piano', + 'guitar', + 'guitare', + 'rap ', + 'hip hop', + 'jazz', + ], + 'Politique': [ + 'politique', + 'politics', + 'political', + 'gouvernement', + 'government', + 'parlement', + 'parliament', + 'président', + 'president', + 'élection', + 'election', + 'élections', + 'elections', + 'géopolitique', + 'geopolitics', + 'diplomatie', + 'diplomacy', + 'ministre', + 'minister', + 'الانتخابات', + 'سياسة', + 'السياسة', + 'حكومة', + 'الحكومة', + ], + 'Finance': [ + 'finance', + 'financial', + 'investissement', + 'investment', + 'investing', + 'investisseur', + 'investor', + 'bourse', + 'stock market', + 'stocks', + 'actions', + 'etf', + 'ipo', + 'dividende', + 'dividend', + 'trading', + 'trader', + 'forex', + 'crypto', + 'bitcoin', + 'ethereum', + 'banque', + 'banking', + 'épargne', + 'epargne', + 'portefeuille', + 'portfolio', + 'nasdaq', + 's&p 500', + 'sp500', + 'cac 40', + 'masi', + 'bvc', + 'استثمار', + 'الاستثمار', + 'البورصة', + 'الأسهم', + 'تداول', + 'التداول', + 'بيتكوين', + ], + 'Société & actualité': [ + 'actualité', + 'actualités', + 'news', + 'société', + 'society', + 'économie', + 'economy', + 'reportage', + 'interview', + 'social', + 'actualité internationale', + ], + 'Tutoriels & apprentissage': [ + 'tutorial', + 'tutoriel', + 'tuto ', + 'guide', + 'cours', + 'course', + 'learn', + 'apprendre', + 'expliqué', + 'explained', + 'how to', + 'comment ', + 'formation', + ], + }; + + String _normalizeInterest( + String value, + ) { + final trimmed = + value.trim(); + + return _interestSearchQueries + .containsKey(trimmed) + ? trimmed + : 'Tous'; + } + + static const Map + _interestSearchQueries = + { + 'Tous': '', + 'Politique': + 'politique géopolitique actualité gouvernement élections', + 'Finance': + 'finance investissement bourse ETF marchés financiers', + 'Technologie & IA': + 'technologie intelligence artificielle informatique', + 'Jeux vidéo': + 'jeux vidéo gaming gameplay nouveautés', + 'Culture & histoire': + 'culture histoire documentaire civilisation', + 'Science & espace': + 'science espace astronomie univers', + 'OVNI & mystères': + 'ovni uap mystères inexpliqué', + 'Auto & transport': + 'automobile voiture transport technologie', + 'Musique': + 'musique artistes concert nouveautés', + 'Société & actualité': + 'société actualité reportage économie', + 'Tutoriels & apprentissage': + 'tutoriel apprendre guide formation', + }; + + List _diversify( + List source, + ) { + final result = + []; + final authorCounts = + {}; + + for (final item in source) { + final author = + _normalizeAuthor( + item.video.author, + ); + + final count = + authorCounts[author] ?? 0; + + if (count >= 2) { + continue; + } + + result.add(item); + + if (author.isNotEmpty) { + authorCounts[author] = + count + 1; + } + } + + return result; + } + + List _terms(String value) { + final normalized = value + .toLowerCase() + .replaceAll( + RegExp( + r"[^a-z0-9à-ÿ\u0600-\u06ff]+", + caseSensitive: false, + ), + ' ', + ); + + return normalized + .split(RegExp(r'\s+')) + .map((word) => word.trim()) + .where( + (word) => + word.length >= 3 && + !_stopWords.contains(word), + ) + .toList(growable: false); + } + + String _normalizeAuthor( + String value, + ) { + return value + .trim() + .toLowerCase(); + } + + Future> + _readReducedAuthors() async { + final raw = + await _preferences.getString( + _reducedAuthorsKey, + ); + + if (raw == null || + raw.isEmpty) { + return {}; + } + + try { + final decoded = + jsonDecode(raw); + + if (decoded is! Map) { + return {}; + } + + final result = + {}; + + for (final entry + in decoded.entries) { + if (entry.key is String && + entry.value is num) { + result[entry.key as String] = + (entry.value as num).toInt(); + } + } + + return result; + } catch (_) { + return {}; + } + } + + Future> + _readDismissed() async { + final raw = + await _preferences.getString( + _dismissedKey, + ); + + if (raw == null || + raw.isEmpty) { + return {}; + } + + try { + final decoded = + jsonDecode(raw); + + if (decoded is! Map) { + return {}; + } + + final result = + {}; + + for (final entry + in decoded.entries) { + if (entry.key is String && + entry.value is num) { + result[entry.key as String] = + (entry.value as num).toInt(); + } + } + + final cutoff = DateTime.now() + .subtract(_dismissDuration) + .millisecondsSinceEpoch; + + result.removeWhere( + (key, value) => + value < cutoff, + ); + + return result; + } catch (_) { + return {}; + } + } + + bool _isDismissed( + String videoId, + Map dismissed, + DateTime now, + ) { + final value = + dismissed[videoId]; + + if (value == null) { + return false; + } + + final dismissedAt = + DateTime.fromMillisecondsSinceEpoch( + value, + ); + + return now.difference(dismissedAt) <= + _dismissDuration; + } + + String _fingerprint( + List history, + List favorites, + Map dismissed, + Map reducedAuthors, + int hiddenRevision, + ) { + return [ + ...history + .take(20) + .map((video) => video.id), + '|', + ...favorites + .take(20) + .map((video) => video.id), + '|', + ...dismissed.keys, + '|', + ...reducedAuthors.entries.map( + (entry) => + '${entry.key}:${entry.value}', + ), + '|hidden:$hiddenRevision', + ].join(';'); + } + + static const Set _stopWords = + { + 'the', + 'and', + 'for', + 'with', + 'from', + 'this', + 'that', + 'you', + 'your', + 'are', + 'how', + 'why', + 'what', + 'into', + 'une', + 'des', + 'les', + 'pour', + 'avec', + 'dans', + 'sur', + 'est', + 'que', + 'qui', + 'pas', + 'plus', + 'tout', + 'tous', + 'cette', + 'ces', + 'aux', + 'du', + 'de', + 'la', + 'le', + 'un', + 'en', + 'der', + 'die', + 'das', + 'und', + 'mit', + 'für', + 'von', + 'ist', + 'ein', + 'eine', + 'wie', + 'den', + 'dem', + 'auf', + 'los', + 'las', + 'una', + 'uno', + 'con', + 'para', + 'por', + 'como', + 'del', + 'este', + 'esta', + 'هذا', + 'هذه', + 'على', + 'من', + 'في', + 'الى', + 'إلى', + 'كيف', + 'مع', + 'عن', + 'كل', + 'official', + 'video', + 'youtube', + }; +} + +class _RadarProfile { + const _RadarProfile({ + required this.topicWeights, + required this.favoriteWeights, + required this.authorWeights, + required this.knownAuthors, + required this.recentAuthor, + required this.recentTerms, + required this.topTerms, + required this.history, + required this.favorites, + }); + + final Map topicWeights; + final Map favoriteWeights; + final Map authorWeights; + final Set knownAuthors; + final String recentAuthor; + final Set recentTerms; + final List topTerms; + final List history; + final List favorites; +} + +class _CachedRadar { + const _CachedRadar({ + required this.fingerprint, + required this.feed, + }); + + final String fingerprint; + final RadarFeed feed; +} diff --git a/lib/services/sponsor_block_service.dart b/lib/services/sponsor_block_service.dart new file mode 100644 index 0000000..a05c8b1 --- /dev/null +++ b/lib/services/sponsor_block_service.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; + +class SponsorSegment { + const SponsorSegment({ + required this.id, + required this.start, + required this.end, + }); + + final String id; + final Duration start; + final Duration end; + + Duration get duration => end - start; + + bool contains(Duration position) { + return position >= start && position < end; + } +} + +class SponsorBlockService { + SponsorBlockService._(); + + static final SponsorBlockService instance = + SponsorBlockService._(); + + final Map>> _cache = + >>{}; + + Future> getSponsorSegments( + String videoId, + ) { + return _cache.putIfAbsent( + videoId, + () => _fetch(videoId), + ); + } + + Future> _fetch(String videoId) async { + try { + // SponsorBlock's k-anonymity endpoint receives only the first four + // hexadecimal characters of SHA-256(videoId), not the exact ID. + final fullHash = sha256 + .convert(utf8.encode(videoId)) + .toString(); + final prefix = fullHash.substring(0, 4); + + final uri = Uri.https( + 'sponsor.ajay.app', + '/api/skipSegments/$prefix', + { + 'service': 'YouTube', + 'categories': jsonEncode(['sponsor']), + }, + ); + + final response = await http + .get( + uri, + headers: const { + 'Accept': 'application/json', + 'User-Agent': 'YouTubePersonal/1.0', + }, + ) + .timeout(const Duration(seconds: 6)); + + if (response.statusCode == 404) { + return const []; + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body); + if (decoded is! List) { + return const []; + } + + Map? matchingVideo; + + for (final item in decoded) { + if (item is! Map) { + continue; + } + + final responseVideoId = item['videoID']; + final responseHash = item['hash']; + + if (responseVideoId == videoId || responseHash == fullHash) { + matchingVideo = item; + break; + } + } + + if (matchingVideo == null) { + return const []; + } + + final rawSegments = matchingVideo['segments']; + if (rawSegments is! List) { + return const []; + } + + final result = []; + + for (final item in rawSegments) { + if (item is! Map) { + continue; + } + + if (item['category'] != 'sponsor') { + continue; + } + + final actionType = item['actionType']; + if (actionType is String && actionType != 'skip') { + continue; + } + + final segment = item['segment']; + if (segment is! List || segment.length < 2) { + continue; + } + + final start = segment[0]; + final end = segment[1]; + + if (start is! num || end is! num || end <= start) { + continue; + } + + final id = item['UUID'] is String + ? item['UUID'] as String + : '${start.toDouble()}-${end.toDouble()}'; + + result.add( + SponsorSegment( + id: id, + start: Duration( + milliseconds: (start.toDouble() * 1000).round(), + ), + end: Duration( + milliseconds: (end.toDouble() * 1000).round(), + ), + ), + ); + } + + result.sort((a, b) => a.start.compareTo(b.start)); + return List.unmodifiable(result); + } catch (_) { + // Sponsor skipping must never block or break normal playback. + return const []; + } + } +} diff --git a/lib/services/windows_channel_metadata_service.dart b/lib/services/windows_channel_metadata_service.dart new file mode 100644 index 0000000..d3738a1 --- /dev/null +++ b/lib/services/windows_channel_metadata_service.dart @@ -0,0 +1,324 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../models/channel_details.dart'; + +/// Robust public channel metadata fallback for Windows. +/// +/// youtube_explode_dart currently returns enough metadata for some channels, +/// but on current YouTube layouts subscriber/about fields may be null. The +/// project already ships a current yt-dlp nightly for HD playback, so Windows +/// can reuse it without adding another dependency or a Google login. +class WindowsChannelMetadataService { + WindowsChannelMetadataService._(); + + static final WindowsChannelMetadataService instance = + WindowsChannelMetadataService._(); + + final Map> _cache = + >{}; + + Future getDetails( + String channelId, + ) { + if (!Platform.isWindows || channelId.trim().isEmpty) { + return Future.value(null); + } + + return _cache.putIfAbsent( + channelId, + () => _load(channelId), + ); + } + + void invalidate(String channelId) { + _cache.remove(channelId); + } + + Future _load( + String channelId, + ) async { + final ytDlp = _resolveYtDlp(); + + if (!await ytDlp.exists()) { + debugPrint( + '[CHANNEL-YTDLP][18.5.2] yt-dlp introuvable: ${ytDlp.path}', + ); + return null; + } + + Map? videosData; + Map? aboutData; + + try { + videosData = await _extract( + ytDlp, + 'https://www.youtube.com/channel/$channelId/videos', + ); + } catch (error) { + debugPrint( + '[CHANNEL-YTDLP][18.5.2] /videos échoué: $error', + ); + } + + // Current yt-dlp channel extraction normally provides followers on the + // channel URL. The About route can additionally expose channel views and + // description on layouts where /videos omits them. It is intentionally + // best-effort because YouTube has changed the About page several times. + if (_intValue(videosData?['view_count']) == null || + _stringValue(videosData?['description']) == null) { + try { + aboutData = await _extract( + ytDlp, + 'https://www.youtube.com/channel/$channelId/about', + ); + } catch (error) { + debugPrint( + '[CHANNEL-YTDLP][18.5.2] /about ignoré: $error', + ); + } + } + + final data = _mergeMaps( + primary: videosData, + secondary: aboutData, + ); + + if (data == null) { + return null; + } + + final subscribers = + _intValue(data['channel_follower_count']); + final views = _intValue(data['view_count']); + final rawVideoCount = + _intValue(data['playlist_count']); + final videoCount = + rawVideoCount != null && rawVideoCount > 0 + ? rawVideoCount + : null; + + final title = + _stringValue(data['channel']) ?? + _stringValue(data['uploader']) ?? + _stringValue(data['title']) ?? + ''; + + final description = + _stringValue(data['description']) ?? ''; + + final thumbnail = _bestThumbnail( + data['thumbnails'], + ); + + debugPrint( + '[CHANNEL-YTDLP][18.5.2] channel=$channelId ' + 'subs=${subscribers ?? 'NA'} ' + 'views=${views ?? 'NA'} ' + 'videos=${videoCount ?? 'NA'} ' + 'title=${title.isEmpty ? 'NA' : title}', + ); + + if (subscribers == null && + views == null && + videoCount == null && + title.isEmpty && + description.isEmpty) { + return null; + } + + return ChannelDetails( + channelId: channelId, + title: title, + description: description, + thumbnailUrl: thumbnail ?? '', + viewCount: views, + subscriberCount: subscribers, + hiddenSubscriberCount: subscribers == null, + videoCount: videoCount, + ); + } + + Future?> _extract( + File ytDlp, + String url, + ) async { + final args = [ + '--ignore-config', + '--quiet', + '--no-warnings', + '--encoding', + 'utf-8', + '--socket-timeout', + '15', + '--retries', + '2', + '--flat-playlist', + '--playlist-items', + '0', + '--skip-download', + '--dump-single-json', + url, + ]; + + final result = await Process.run( + ytDlp.path, + args, + workingDirectory: ytDlp.parent.path, + runInShell: false, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ).timeout( + const Duration(seconds: 25), + ); + + if (result.exitCode != 0) { + throw StateError( + _compactError(result.stderr?.toString() ?? ''), + ); + } + + final stdout = result.stdout?.toString().trim() ?? ''; + + if (stdout.isEmpty) { + return null; + } + + // Normally --dump-single-json produces one JSON document. If yt-dlp ever + // prints a harmless prefix, recover from the first opening brace. + final jsonStart = stdout.indexOf('{'); + final jsonText = jsonStart >= 0 + ? stdout.substring(jsonStart) + : stdout; + + final decoded = jsonDecode(jsonText); + + if (decoded is Map) { + return Map.from(decoded); + } + + return null; + } + + Map? _mergeMaps({ + required Map? primary, + required Map? secondary, + }) { + if (primary == null && secondary == null) { + return null; + } + + final merged = {}; + + if (secondary != null) { + merged.addAll(secondary); + } + + if (primary != null) { + // Prefer non-null values from /videos, but don't overwrite useful + // About metadata with nulls. + for (final entry in primary.entries) { + if (entry.value != null) { + merged[entry.key] = entry.value; + } + } + } + + return merged; + } + + File _resolveYtDlp() { + final executableDirectory = + File(Platform.resolvedExecutable).parent; + + final bundled = File( + '${executableDirectory.path}' + '${Platform.pathSeparator}data' + '${Platform.pathSeparator}flutter_assets' + '${Platform.pathSeparator}assets' + '${Platform.pathSeparator}windows_hd' + '${Platform.pathSeparator}yt-dlp.exe', + ); + + if (bundled.existsSync()) { + return bundled; + } + + return File( + '${Directory.current.path}' + '${Platform.pathSeparator}assets' + '${Platform.pathSeparator}windows_hd' + '${Platform.pathSeparator}yt-dlp.exe', + ); + } + + int? _intValue(dynamic value) { + if (value is int) { + return value; + } + + if (value is num) { + return value.toInt(); + } + + if (value is String) { + return int.tryParse(value); + } + + return null; + } + + String? _stringValue(dynamic value) { + if (value == null) { + return null; + } + + final text = value.toString().trim(); + return text.isEmpty ? null : text; + } + + String? _bestThumbnail(dynamic raw) { + if (raw is! List || raw.isEmpty) { + return null; + } + + String? bestUrl; + int bestArea = -1; + + for (final item in raw) { + if (item is! Map) { + continue; + } + + final url = _stringValue(item['url']); + if (url == null) { + continue; + } + + final width = _intValue(item['width']) ?? 0; + final height = _intValue(item['height']) ?? 0; + final area = width * height; + + if (bestUrl == null || area > bestArea) { + bestUrl = url; + bestArea = area; + } + } + + return bestUrl; + } + + String _compactError(String value) { + final normalized = value + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + if (normalized.length <= 350) { + return normalized; + } + + return '${normalized.substring(0, 350)}…'; + } +} diff --git a/lib/services/windows_channel_video_catalog_service.dart b/lib/services/windows_channel_video_catalog_service.dart new file mode 100644 index 0000000..f3841f5 --- /dev/null +++ b/lib/services/windows_channel_video_catalog_service.dart @@ -0,0 +1,457 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../models/video_item.dart'; + +/// Bulk channel-video metadata for Windows. +/// +/// One yt-dlp process reads the flat channel playlist instead of issuing one +/// YouTube watch-page request for every video. This prevents the request storm +/// that caused RequestLimitExceededException in step 18.7. +class WindowsChannelVideoCatalogService { + WindowsChannelVideoCatalogService._(); + + static final WindowsChannelVideoCatalogService instance = + WindowsChannelVideoCatalogService._(); + + final Map>> _cache = + >>{}; + + Future> getMetadata( + String channelId, { + bool force = false, + }) { + if (!Platform.isWindows || + channelId.trim().isEmpty) { + return Future>.value( + const {}, + ); + } + + if (force) { + _cache.remove(channelId); + } + + return _cache.putIfAbsent( + channelId, + () => _load(channelId), + ); + } + + Future> _load( + String channelId, + ) async { + final ytDlp = _resolveYtDlp(); + + if (!await ytDlp.exists()) { + debugPrint( + '[CHANNEL-CATALOG][18.7.6] ' + 'yt-dlp introuvable: ${ytDlp.path}', + ); + + return const {}; + } + + final args = [ + '--ignore-config', + '--quiet', + '--no-warnings', + '--encoding', + 'utf-8', + '--socket-timeout', + '15', + '--retries', + '1', + '--flat-playlist', + '--extractor-args', + 'youtubetab:approximate_date', + '--dump-json', + 'https://www.youtube.com/channel/$channelId/videos', + ]; + + debugPrint( + '[CHANNEL-CATALOG][18.7.6] ' + 'bulk start channel=$channelId', + ); + + Process? process; + + try { + process = await Process.start( + ytDlp.path, + args, + workingDirectory: + ytDlp.parent.path, + runInShell: false, + mode: + ProcessStartMode.normal, + ); + + final result = + {}; + + final stderr = StringBuffer(); + + final stderrSubscription = + process.stderr + .transform(utf8.decoder) + .listen( + (text) { + if (stderr.length < 3000) { + stderr.write(text); + } + }, + ); + + final completer = + Completer(); + + final stdoutSubscription = + process.stdout + .transform(utf8.decoder) + .transform( + const LineSplitter(), + ) + .listen( + (line) { + final text = line.trim(); + + if (text.isEmpty || + !text.startsWith('{')) { + return; + } + + try { + final decoded = + jsonDecode(text); + + if (decoded is! Map) { + return; + } + + final data = + Map.from( + decoded, + ); + + final video = + _fromFlatJson( + data, + ); + + if (video != null) { + result[video.id] = video; + } + } catch (_) { + // One malformed flat entry must not abort the catalogue. + } + }, + onDone: () { + if (!completer.isCompleted) { + completer.complete(); + } + }, + onError: (Object error) { + if (!completer.isCompleted) { + completer.completeError(error); + } + }, + cancelOnError: false, + ); + + final exitCodeFuture = + process.exitCode; + + await Future.wait([ + completer.future, + exitCodeFuture.then( + (_) {}, + ), + ]).timeout( + const Duration(seconds: 35), + onTimeout: () { + process?.kill(); + + throw TimeoutException( + 'Catalogue yt-dlp > 35 s', + ); + }, + ); + + await stdoutSubscription.cancel(); + await stderrSubscription.cancel(); + + final exitCode = + await exitCodeFuture; + + if (exitCode != 0 && + result.isEmpty) { + final errorText = stderr + .toString() + .replaceAll( + RegExp(r'\s+'), + ' ', + ) + .trim(); + + debugPrint( + '[CHANNEL-CATALOG][18.7.6] ' + 'bulk code=$exitCode ' + '${errorText.isEmpty ? '' : errorText}', + ); + + return const {}; + } + + debugPrint( + '[CHANNEL-CATALOG][18.7.6] ' + 'bulk ready channel=$channelId ' + 'videos=${result.length}', + ); + + return Map.unmodifiable(result); + } catch (error) { + process?.kill(); + + debugPrint( + '[CHANNEL-CATALOG][18.7.6] ' + 'bulk fallback: $error', + ); + + return const {}; + } + } + + VideoItem? _fromFlatJson( + Map data, + ) { + final id = + _string(data['id']); + + if (id == null || + id.isEmpty) { + return null; + } + + final title = + _string(data['title']) ?? + 'Vidéo YouTube'; + + final channelId = + _string(data['channel_id']) ?? + _string(data['uploader_id']); + + final author = + _string(data['channel']) ?? + _string(data['uploader']) ?? + ''; + + final durationSeconds = + _number(data['duration']); + + final timestamp = + _number(data['timestamp']) ?? + _number( + data['release_timestamp'], + ); + + final uploadDate = + timestamp != null + ? DateTime + .fromMillisecondsSinceEpoch( + (timestamp * 1000) + .round(), + isUtc: true, + ) + .toLocal() + : _parseUploadDate( + _string( + data['upload_date'], + ), + ); + + final thumbnail = + _bestThumbnail( + data['thumbnails'], + ) ?? + 'https://i.ytimg.com/vi/$id/hqdefault.jpg'; + + return VideoItem( + id: id, + title: title, + author: author, + channelId: channelId, + thumbnailUrl: thumbnail, + duration: durationSeconds != null && + durationSeconds > 0 + ? Duration( + milliseconds: + (durationSeconds * 1000) + .round(), + ) + : null, + viewCount: + _integer(data['view_count']), + likeCount: + _integer(data['like_count']), + commentCount: + _integer(data['comment_count']), + description: + _string(data['description']) ?? + '', + uploadDate: uploadDate, + ); + } + + DateTime? _parseUploadDate( + String? raw, + ) { + if (raw == null || + raw.length != 8) { + return null; + } + + final year = + int.tryParse( + raw.substring(0, 4), + ); + final month = + int.tryParse( + raw.substring(4, 6), + ); + final day = + int.tryParse( + raw.substring(6, 8), + ); + + if (year == null || + month == null || + day == null) { + return null; + } + + try { + return DateTime( + year, + month, + day, + ); + } catch (_) { + return null; + } + } + + String? _bestThumbnail( + dynamic raw, + ) { + if (raw is! List || + raw.isEmpty) { + return null; + } + + String? best; + int bestArea = -1; + + for (final item in raw) { + if (item is! Map) { + continue; + } + + final url = + _string(item['url']); + + if (url == null) { + continue; + } + + final width = + _integer(item['width']) ?? + 0; + final height = + _integer(item['height']) ?? + 0; + + final area = + width * height; + + if (best == null || + area > bestArea) { + best = url; + bestArea = area; + } + } + + return best; + } + + int? _integer(dynamic value) { + if (value is int) { + return value; + } + + if (value is num) { + return value.toInt(); + } + + if (value is String) { + return int.tryParse(value); + } + + return null; + } + + double? _number(dynamic value) { + if (value is num) { + return value.toDouble(); + } + + if (value is String) { + return double.tryParse(value); + } + + return null; + } + + String? _string(dynamic value) { + if (value == null) { + return null; + } + + final text = + value.toString().trim(); + + return text.isEmpty + ? null + : text; + } + + File _resolveYtDlp() { + final executableDirectory = + File( + Platform.resolvedExecutable, + ).parent; + + final bundled = File( + '${executableDirectory.path}' + '${Platform.pathSeparator}data' + '${Platform.pathSeparator}flutter_assets' + '${Platform.pathSeparator}assets' + '${Platform.pathSeparator}windows_hd' + '${Platform.pathSeparator}yt-dlp.exe', + ); + + if (bundled.existsSync()) { + return bundled; + } + + return File( + '${Directory.current.path}' + '${Platform.pathSeparator}assets' + '${Platform.pathSeparator}windows_hd' + '${Platform.pathSeparator}yt-dlp.exe', + ); + } +} diff --git a/lib/services/windows_embedded_cache_relay_service.dart b/lib/services/windows_embedded_cache_relay_service.dart new file mode 100644 index 0000000..c5f8c28 --- /dev/null +++ b/lib/services/windows_embedded_cache_relay_service.dart @@ -0,0 +1,646 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +class WindowsEmbeddedCacheSession { + const WindowsEmbeddedCacheSession({ + required this.videoUrl, + required this.audioUrl, + }); + + final String videoUrl; + final String audioUrl; +} + +/// Cache progressif Windows pour le chemin `web_embedded`. +/// +/// Le test manuel a démontré que yt-dlp télécharge intégralement : +/// - le format vidéo 299 (1080p60) ; +/// - le format audio 251 (Opus). +/// +/// Au lieu de donner les URLs googlevideo à libmpv, cette classe lance les +/// mêmes commandes yt-dlp validées vers deux fichiers temporaires qui +/// grandissent pendant le téléchargement. +/// +/// libmpv lit uniquement un serveur HTTP loopback : +/// +/// libmpv -> 127.0.0.1 -> fichier temporaire en croissance +/// ^ +/// | +/// yt-dlp web_embedded +/// | +/// googlevideo +/// +/// Le lecteur peut commencer avant la fin du téléchargement complet. +/// Lors d'un seek vers une zone qui n'est pas encore en cache, le serveur +/// attend simplement que yt-dlp atteigne cette zone. +class WindowsEmbeddedCacheRelayService { + HttpServer? _server; + StreamSubscription? _serverSubscription; + Directory? _sessionDirectory; + + final Map _sources = + {}; + + Future start({ + required String ytDlpPath, + required String denoPath, + required String watchUrl, + required String videoFormatId, + required String audioFormatId, + required int videoLength, + required int audioLength, + required String videoExt, + required String audioExt, + required String videoMimeType, + required String audioMimeType, + }) async { + if (!Platform.isWindows) { + throw UnsupportedError( + 'Le cache web_embedded est réservé à Windows.', + ); + } + + await stop(); + + final ytDlp = File(ytDlpPath); + final deno = File(denoPath); + + if (!await ytDlp.exists()) { + throw StateError( + 'yt-dlp.exe introuvable : $ytDlpPath', + ); + } + + if (!await deno.exists()) { + throw StateError( + 'deno.exe introuvable : $denoPath', + ); + } + + if (videoLength <= 0 || audioLength <= 0) { + throw StateError( + 'Tailles web_embedded invalides : ' + 'video=$videoLength audio=$audioLength', + ); + } + + final sessionDirectory = + await Directory.systemTemp.createTemp( + 'youtube_personal_embedded_cache_', + ); + + final videoFile = File( + '${sessionDirectory.path}' + '${Platform.pathSeparator}' + 'video.$videoExt', + ); + + final audioFile = File( + '${sessionDirectory.path}' + '${Platform.pathSeparator}' + 'audio.$audioExt', + ); + + final video = _GrowingSource( + id: 'video', + file: videoFile, + formatId: videoFormatId, + length: videoLength, + contentType: videoMimeType, + ); + + final audio = _GrowingSource( + id: 'audio', + file: audioFile, + formatId: audioFormatId, + length: audioLength, + contentType: audioMimeType, + ); + + _sessionDirectory = sessionDirectory; + + _sources + ..clear() + ..['/video'] = video + ..['/audio'] = audio; + + final server = await HttpServer.bind( + InternetAddress.loopbackIPv4, + 0, + shared: false, + ); + + _server = server; + + _serverSubscription = server.listen( + (request) { + unawaited(_handleRequest(request)); + }, + onError: (Object error, StackTrace stackTrace) { + debugPrint( + '[HD-CACHE][17.15] erreur serveur local: $error', + ); + }, + cancelOnError: false, + ); + + // On lance vidéo et audio en parallèle. C'est volontaire : + // yt-dlp peut imposer quelques secondes de sleep avant le téléchargement. + // L'audio 251 est petit et sera normalement entièrement mis en cache + // très rapidement, pendant que la vidéo continue à grossir. + await Future.wait([ + _startYtDlp( + source: video, + ytDlpPath: ytDlp.path, + denoPath: deno.path, + watchUrl: watchUrl, + ), + _startYtDlp( + source: audio, + ytDlpPath: ytDlp.path, + denoPath: deno.path, + watchUrl: watchUrl, + ), + ]); + + final base = + 'http://127.0.0.1:${server.port}'; + + debugPrint( + '[HD-CACHE][17.15] actif sur 127.0.0.1:${server.port} ' + 'video=$videoFormatId audio=$audioFormatId', + ); + + return WindowsEmbeddedCacheSession( + videoUrl: '$base/video', + audioUrl: '$base/audio', + ); + } + + Future stop() async { + final subscription = _serverSubscription; + _serverSubscription = null; + + if (subscription != null) { + await subscription.cancel(); + } + + final server = _server; + _server = null; + + if (server != null) { + await server.close(force: true); + } + + final currentSources = + _sources.values.toList(growable: false); + + for (final source in currentSources) { + final process = source.process; + + if (process != null) { + process.kill(); + } + } + + // Laisse Windows libérer les handles de fichiers/processus. + await Future.delayed( + const Duration(milliseconds: 120), + ); + + _sources.clear(); + + final directory = _sessionDirectory; + _sessionDirectory = null; + + if (directory != null) { + try { + if (await directory.exists()) { + await directory.delete( + recursive: true, + ); + } + } catch (error) { + debugPrint( + '[HD-CACHE][17.15] nettoyage différé: $error', + ); + } + } + } + + Future _startYtDlp({ + required _GrowingSource source, + required String ytDlpPath, + required String denoPath, + required String watchUrl, + }) async { + final args = [ + '--ignore-config', + '--no-playlist', + '--no-warnings', + '--no-progress', + '--socket-timeout', + '20', + '--retries', + '5', + '--fragment-retries', + '5', + '--js-runtimes', + 'deno:$denoPath', + '--extractor-args', + 'youtube:player_client=web_embedded', + '--force-overwrites', + '--no-part', + '-f', + source.formatId, + '-o', + source.file.path, + watchUrl, + ]; + + debugPrint( + '[HD-CACHE][17.15] lancement yt-dlp ' + '${source.id} format=${source.formatId}', + ); + + final process = await Process.start( + ytDlpPath, + args, + workingDirectory: + File(ytDlpPath).parent.path, + runInShell: false, + mode: ProcessStartMode.normal, + ); + + source.process = process; + + process.stdout + .transform( + const SystemEncoding().decoder, + ) + .listen( + (text) { + final compact = + text.replaceAll(RegExp(r'\s+'), ' ').trim(); + + if (compact.isNotEmpty && + compact.toLowerCase().contains('error')) { + debugPrint( + '[HD-CACHE][17.15] ${source.id} stdout: $compact', + ); + } + }, + ); + + process.stderr + .transform( + const SystemEncoding().decoder, + ) + .listen( + (text) { + if (source.stderr.length < 6000) { + final remaining = + 6000 - source.stderr.length; + + source.stderr.write( + text.length <= remaining + ? text + : text.substring(0, remaining), + ); + } + }, + ); + + unawaited( + process.exitCode.then( + (code) async { + source.exitCode = code; + + if (!source.done.isCompleted) { + source.done.complete(code); + } + + final size = await _safeLength( + source.file, + ); + + if (code == 0) { + debugPrint( + '[HD-CACHE][17.15] ${source.id} téléchargé: ' + '$size/${source.length} octets', + ); + } else { + final stderr = source.stderr + .toString() + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + debugPrint( + '[HD-CACHE][17.15] ${source.id} yt-dlp code=$code' + '${stderr.isEmpty ? '' : ': $stderr'}', + ); + } + }, + ), + ); + } + + Future _handleRequest( + HttpRequest request, + ) async { + final source = _sources[request.uri.path]; + + if (source == null) { + request.response.statusCode = + HttpStatus.notFound; + await request.response.close(); + return; + } + + if (request.method != 'GET' && + request.method != 'HEAD') { + request.response.statusCode = + HttpStatus.methodNotAllowed; + await request.response.close(); + return; + } + + final rangeHeader = + request.headers.value( + HttpHeaders.rangeHeader, + ); + + final parsed = _parseRange( + rangeHeader, + source.length, + ); + + if (rangeHeader != null && parsed == null) { + request.response.statusCode = + HttpStatus.requestedRangeNotSatisfiable; + + request.response.headers.set( + 'content-range', + 'bytes */${source.length}', + ); + + await request.response.close(); + return; + } + + final start = parsed?.$1 ?? 0; + final end = + parsed?.$2 ?? source.length - 1; + + final response = request.response; + + response.statusCode = parsed == null + ? HttpStatus.ok + : HttpStatus.partialContent; + + response.headers.set( + 'accept-ranges', + 'bytes', + ); + + response.headers.set( + HttpHeaders.cacheControlHeader, + 'no-store', + ); + + response.headers.set( + HttpHeaders.contentTypeHeader, + source.contentType, + ); + + final responseLength = + end - start + 1; + + response.contentLength = + responseLength; + + if (parsed != null) { + response.headers.set( + 'content-range', + 'bytes $start-$end/${source.length}', + ); + } + + debugPrint( + '[HD-CACHE][17.15] ${request.uri.path} ' + '${request.method} bytes=$start-$end', + ); + + if (request.method == 'HEAD') { + await response.close(); + return; + } + + try { + await _streamGrowingFile( + source: source, + start: start, + end: end, + response: response, + ); + } on Object catch (error) { + debugPrint( + '[HD-CACHE][17.15] ${request.uri.path} échec: $error', + ); + + try { + await response.close(); + } catch (_) { + // libmpv peut fermer une requête lors d'un seek. + } + } + } + + Future _streamGrowingFile({ + required _GrowingSource source, + required int start, + required int end, + required HttpResponse response, + }) async { + var cursor = start; + RandomAccessFile? raf; + + try { + while (cursor <= end) { + final available = + await _safeLength(source.file); + + if (available > cursor) { + raf ??= + await source.file.open( + mode: FileMode.read, + ); + + final readable = math.min( + available - cursor, + end - cursor + 1, + ); + + final count = math.min( + readable, + 512 * 1024, + ); + + await raf.setPosition(cursor); + + final data = + await raf.read(count); + + if (data.isEmpty) { + await Future.delayed( + const Duration(milliseconds: 30), + ); + continue; + } + + response.add(data); + await response.flush(); + + cursor += data.length; + continue; + } + + final exitCode = source.exitCode; + + if (exitCode != null) { + if (exitCode != 0) { + final stderr = source.stderr + .toString() + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + throw HttpException( + 'yt-dlp ${source.id} code=$exitCode' + '${stderr.isEmpty ? '' : ': $stderr'}', + ); + } + + final finalLength = + await _safeLength(source.file); + + if (finalLength <= cursor) { + throw HttpException( + 'Cache ${source.id} terminé à ' + '$finalLength/${source.length} octets, ' + 'mais mpv demande l’offset $cursor.', + ); + } + } + + // Le téléchargement est encore en cours et n'a pas atteint la zone. + await Future.delayed( + const Duration(milliseconds: 35), + ); + } + + await response.close(); + } finally { + await raf?.close(); + } + } + + (int, int)? _parseRange( + String? value, + int totalLength, + ) { + if (value == null || value.trim().isEmpty) { + return null; + } + + final match = RegExp( + r'^bytes=(\d*)-(\d*)$', + caseSensitive: false, + ).firstMatch(value.trim()); + + if (match == null) { + return null; + } + + final startText = match.group(1) ?? ''; + final endText = match.group(2) ?? ''; + + if (startText.isEmpty) { + final suffix = int.tryParse(endText); + + if (suffix == null || suffix <= 0) { + return null; + } + + final start = math.max( + 0, + totalLength - suffix, + ); + + return (start, totalLength - 1); + } + + final start = int.tryParse(startText); + + if (start == null || + start < 0 || + start >= totalLength) { + return null; + } + + final parsedEnd = endText.isEmpty + ? totalLength - 1 + : int.tryParse(endText); + + if (parsedEnd == null || + parsedEnd < start) { + return null; + } + + final end = math.min( + parsedEnd, + totalLength - 1, + ); + + return (start, end); + } + + Future _safeLength( + File file, + ) async { + try { + if (!await file.exists()) { + return 0; + } + + return await file.length(); + } catch (_) { + return 0; + } + } +} + +class _GrowingSource { + _GrowingSource({ + required this.id, + required this.file, + required this.formatId, + required this.length, + required this.contentType, + }); + + final String id; + final File file; + final String formatId; + final int length; + final String contentType; + + Process? process; + int? exitCode; + final Completer done = + Completer(); + final StringBuffer stderr = + StringBuffer(); +} diff --git a/lib/services/windows_embedded_download_service.dart b/lib/services/windows_embedded_download_service.dart new file mode 100644 index 0000000..1cb88b3 --- /dev/null +++ b/lib/services/windows_embedded_download_service.dart @@ -0,0 +1,518 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import 'media_cache_settings_service.dart'; + +typedef WindowsDownloadProgress = void Function( + String track, + double percent, +); + +class WindowsEmbeddedLocalMedia { + const WindowsEmbeddedLocalMedia({ + required this.videoUri, + required this.audioUri, + required this.videoPath, + required this.audioPath, + required this.videoFromCache, + required this.audioFromCache, + }); + + final String videoUri; + final String audioUri; + final String videoPath; + final String audioPath; + final bool videoFromCache; + final bool audioFromCache; +} + +/// Mode Windows fiable et plus rapide. +/// +/// Le transport reste exactement celui validé en 17.16 : yt-dlp télécharge +/// complètement les pistes, puis media_kit ouvre uniquement des fichiers +/// locaux. +/// +/// Optimisations 17.17 : +/// - cache persistant vérifié sous LOCALAPPDATA ; +/// - un fichier déjà téléchargé n'est jamais repris du réseau ; +/// - vidéo & audio manquants sont téléchargés en parallèle ; +/// - les anciennes vidéos sont automatiquement limitées aux 5 plus récentes ; +/// - callback de progression pour que l'UI affiche l'avancement. +class WindowsEmbeddedDownloadService { + final Set _activeProcesses = + {}; + + Future download({ + required String videoId, + required String ytDlpPath, + required String denoPath, + required String watchUrl, + required String videoFormatId, + required String audioFormatId, + required String videoExt, + required String audioExt, + required int expectedVideoLength, + required int expectedAudioLength, + WindowsDownloadProgress? onProgress, + }) async { + if (!Platform.isWindows) { + throw UnsupportedError( + 'Le téléchargement web_embedded est réservé à Windows.', + ); + } + + await stop(); + + final ytDlp = File(ytDlpPath); + final deno = File(denoPath); + + if (!await ytDlp.exists()) { + throw StateError( + 'yt-dlp.exe introuvable : $ytDlpPath', + ); + } + + if (!await deno.exists()) { + throw StateError( + 'deno.exe introuvable : $denoPath', + ); + } + + final cacheSettings = MediaCacheSettingsService.instance; + final cacheRoot = await cacheSettings.resolveCacheRoot(); + final safeVideoId = _safeName(videoId); + + await cacheSettings.enforceLimit(keepVideoId: safeVideoId); + + final videoDirectory = Directory( + '${cacheRoot.path}' + '${Platform.pathSeparator}' + '$safeVideoId', + ); + + await videoDirectory.create( + recursive: true, + ); + + final videoFile = File( + '${videoDirectory.path}' + '${Platform.pathSeparator}' + 'video_${_safeName(videoFormatId)}.$videoExt', + ); + + final audioFile = File( + '${videoDirectory.path}' + '${Platform.pathSeparator}' + 'audio_${_safeName(audioFormatId)}.$audioExt', + ); + + final videoHit = await _isValidCacheFile( + videoFile, + expectedVideoLength, + ); + + final audioHit = await _isValidCacheFile( + audioFile, + expectedAudioLength, + ); + + debugPrint( + '[HD-CACHE][17.17] ' + 'video=${videoHit ? 'HIT' : 'MISS'} ' + 'audio=${audioHit ? 'HIT' : 'MISS'} ' + 'dossier=${videoDirectory.path}', + ); + + if (videoHit) { + onProgress?.call('video', 100); + } + + if (audioHit) { + onProgress?.call('audio', 100); + } + + final futures = >[]; + + if (!videoHit) { + await _deleteIfExists(videoFile); + + futures.add( + _downloadOne( + id: 'video', + ytDlpPath: ytDlp.path, + denoPath: deno.path, + watchUrl: watchUrl, + formatId: videoFormatId, + outputFile: videoFile, + expectedLength: expectedVideoLength, + onProgress: onProgress, + ), + ); + } + + if (!audioHit) { + await _deleteIfExists(audioFile); + + futures.add( + _downloadOne( + id: 'audio', + ytDlpPath: ytDlp.path, + denoPath: deno.path, + watchUrl: watchUrl, + formatId: audioFormatId, + outputFile: audioFile, + expectedLength: expectedAudioLength, + onProgress: onProgress, + ), + ); + } + + if (futures.isNotEmpty) { + debugPrint( + '[HD-DOWNLOAD][17.17] téléchargement ' + '${videoHit ? '' : 'video=$videoFormatId '}' + '${audioHit ? '' : 'audio=$audioFormatId'}', + ); + + final results = + await Future.wait<_DownloadResult>( + futures, + ); + + final failed = results + .where((result) => !result.ok) + .toList(growable: false); + + if (failed.isNotEmpty) { + throw StateError( + failed.map((result) => result.error).join(' | '), + ); + } + } + + final videoLength = await _safeLength(videoFile); + final audioLength = await _safeLength(audioFile); + + if (!_lengthMatches( + videoLength, + expectedVideoLength, + ) || + !_lengthMatches( + audioLength, + expectedAudioLength, + )) { + throw StateError( + 'Cache final invalide: ' + 'video=$videoLength/$expectedVideoLength ' + 'audio=$audioLength/$expectedAudioLength', + ); + } + + // Marque ce cache comme récemment utilisé. + // + // On utilise un petit fichier marqueur dont la date de modification + // sert de timestamp LRU pour le nettoyage des anciens caches. + final accessMarker = File( + '${videoDirectory.path}' + '${Platform.pathSeparator}' + '.last_access', + ); + + try { + await accessMarker.writeAsString( + DateTime.now().toIso8601String(), + flush: true, + ); + } catch (_) { + // Non bloquant. + } + + await cacheSettings.touchVideoDirectory(videoDirectory); + await cacheSettings.enforceLimit(keepVideoId: safeVideoId); + + debugPrint( + '[HD-DOWNLOAD][17.17] prêts: ' + 'video=$videoLength/$expectedVideoLength ' + 'audio=$audioLength/$expectedAudioLength', + ); + + return WindowsEmbeddedLocalMedia( + videoUri: Uri.file( + videoFile.path, + windows: true, + ).toString(), + audioUri: Uri.file( + audioFile.path, + windows: true, + ).toString(), + videoPath: videoFile.path, + audioPath: audioFile.path, + videoFromCache: videoHit, + audioFromCache: audioHit, + ); + } + + Future<_DownloadResult> _downloadOne({ + required String id, + required String ytDlpPath, + required String denoPath, + required String watchUrl, + required String formatId, + required File outputFile, + required int expectedLength, + required WindowsDownloadProgress? onProgress, + }) async { + final args = [ + '--ignore-config', + '--no-playlist', + '--no-warnings', + '--newline', + '--socket-timeout', + '20', + '--retries', + '5', + '--fragment-retries', + '5', + '--js-runtimes', + 'deno:$denoPath', + '--extractor-args', + 'youtube:player_client=web_embedded', + '--force-overwrites', + '--no-part', + '-f', + formatId, + '-o', + outputFile.path, + watchUrl, + ]; + + final process = await Process.start( + ytDlpPath, + args, + workingDirectory: + File(ytDlpPath).parent.path, + runInShell: false, + mode: ProcessStartMode.normal, + ); + + _activeProcesses.add(process); + + final stderr = StringBuffer(); + var lastLoggedBucket = -1; + + final stdoutSubscription = process.stdout + .transform( + const SystemEncoding().decoder, + ) + .listen( + (text) { + for (final line + in text.split(RegExp(r'[\r\n]+'))) { + final match = RegExp( + r'\[download\]\s+(\d+(?:\.\d+)?)%', + ).firstMatch(line); + + if (match == null) { + continue; + } + + final value = double.tryParse( + match.group(1) ?? '', + ); + + if (value == null) { + continue; + } + + onProgress?.call(id, value); + + final bucket = value >= 100 + ? 10 + : value.floor() ~/ 10; + + if (bucket > lastLoggedBucket) { + lastLoggedBucket = bucket; + + debugPrint( + '[HD-DOWNLOAD][17.17] ' + '$id ${value.toStringAsFixed(1)}%', + ); + } + } + }, + ); + + final stderrSubscription = process.stderr + .transform( + const SystemEncoding().decoder, + ) + .listen( + (text) { + if (stderr.length < 5000) { + final remaining = + 5000 - stderr.length; + + stderr.write( + text.length <= remaining + ? text + : text.substring(0, remaining), + ); + } + }, + ); + + final exitCode = await process.exitCode; + + await stdoutSubscription.cancel(); + await stderrSubscription.cancel(); + + _activeProcesses.remove(process); + + final actualLength = + await _safeLength(outputFile); + + if (exitCode != 0) { + final compact = stderr + .toString() + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + await _deleteIfExists(outputFile); + + return _DownloadResult( + ok: false, + error: + '$id yt-dlp code=$exitCode' + '${compact.isEmpty ? '' : ': $compact'}', + ); + } + + if (!_lengthMatches( + actualLength, + expectedLength, + )) { + await _deleteIfExists(outputFile); + + return _DownloadResult( + ok: false, + error: + '$id taille invalide ' + '$actualLength/$expectedLength', + ); + } + + onProgress?.call(id, 100); + + debugPrint( + '[HD-DOWNLOAD][17.17] $id terminé: ' + '$actualLength/$expectedLength octets', + ); + + return const _DownloadResult( + ok: true, + error: '', + ); + } + + String _safeName(String value) { + final safe = value.replaceAll( + RegExp(r'[^A-Za-z0-9._-]'), + '_', + ); + + return safe.isEmpty ? 'media' : safe; + } + + bool _lengthMatches( + int actual, + int expected, + ) { + if (actual <= 0) { + return false; + } + + if (expected <= 0) { + return true; + } + + return actual == expected; + } + + Future _isValidCacheFile( + File file, + int expectedLength, + ) async { + final length = await _safeLength(file); + + if (_lengthMatches( + length, + expectedLength, + )) { + return true; + } + + if (length > 0) { + await _deleteIfExists(file); + } + + return false; + } + + Future _safeLength( + File file, + ) async { + try { + if (!await file.exists()) { + return 0; + } + + return await file.length(); + } catch (_) { + return 0; + } + } + + Future _deleteIfExists( + File file, + ) async { + try { + if (await file.exists()) { + await file.delete(); + } + } catch (_) { + // Le prochain téléchargement donnera une erreur explicite si besoin. + } + } + + Future stop() async { + final processes = + _activeProcesses.toList(growable: false); + + _activeProcesses.clear(); + + for (final process in processes) { + process.kill(); + } + + if (processes.isNotEmpty) { + await Future.delayed( + const Duration(milliseconds: 120), + ); + } + + // Important : le cache persistant n'est PAS supprimé ici. + // C'est ce qui rend une seconde lecture quasi immédiate. + } +} + +class _DownloadResult { + const _DownloadResult({ + required this.ok, + required this.error, + }); + + final bool ok; + final String error; +} diff --git a/lib/services/windows_embedded_progressive_service.dart b/lib/services/windows_embedded_progressive_service.dart new file mode 100644 index 0000000..502a20d --- /dev/null +++ b/lib/services/windows_embedded_progressive_service.dart @@ -0,0 +1,937 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +import 'media_cache_settings_service.dart'; + +class WindowsEmbeddedProgressiveSession { + const WindowsEmbeddedProgressiveSession({ + required this.videoUrl, + required this.audioUri, + required this.videoFromCache, + required this.audioFromCache, + }); + + final String videoUrl; + final String audioUri; + final bool videoFromCache; + final bool audioFromCache; +} + +class WindowsEmbeddedProgressiveStatus { + const WindowsEmbeddedProgressiveStatus({ + required this.videoBytes, + required this.videoLength, + required this.audioBytes, + required this.audioLength, + required this.videoComplete, + required this.audioComplete, + required this.estimatedBufferedDuration, + }); + + final int videoBytes; + final int videoLength; + final int audioBytes; + final int audioLength; + final bool videoComplete; + final bool audioComplete; + final Duration estimatedBufferedDuration; +} + +/// Streaming progressif Windows basé sur le transport déjà validé yt-dlp. +/// +/// yt-dlp télécharge le fichier web_embedded pendant que libmpv lit une +/// façade HTTP locale du fichier qui grandit. +/// +/// Différence critique avec l'étape 17.15 : +/// on N'OUVRE PLUS libmpv lorsque le fichier fait 0 octet. Le lecteur 360p +/// reste actif jusqu'à ce qu'une vraie réserve HD soit disponible autour du +/// timestamp courant. +/// +/// Les fichiers restent dans le cache persistant : +/// %LOCALAPPDATA%\YouTubePersonal\media_cache\ +class WindowsEmbeddedProgressiveService { + HttpServer? _server; + StreamSubscription? _serverSubscription; + + final Set _activeProcesses = + {}; + + _GrowingSource? _video; + _GrowingSource? _audio; + String? _activeVideoCacheId; + Directory? _activeVideoDirectory; + + Future start({ + required String videoId, + required String ytDlpPath, + required String denoPath, + required String watchUrl, + required String videoFormatId, + required String audioFormatId, + required String videoExt, + required String audioExt, + required String videoMimeType, + required int videoLength, + required int audioLength, + }) async { + if (!Platform.isWindows) { + throw UnsupportedError( + 'Le streaming progressif HD est réservé à Windows.', + ); + } + + await stop(); + + final ytDlp = File(ytDlpPath); + final deno = File(denoPath); + + if (!await ytDlp.exists()) { + throw StateError( + 'yt-dlp.exe introuvable : $ytDlpPath', + ); + } + + if (!await deno.exists()) { + throw StateError( + 'deno.exe introuvable : $denoPath', + ); + } + + if (videoLength <= 0 || audioLength <= 0) { + throw StateError( + 'Tailles web_embedded invalides : ' + 'video=$videoLength audio=$audioLength', + ); + } + + final cacheSettings = MediaCacheSettingsService.instance; + final cacheRoot = await cacheSettings.resolveCacheRoot(); + final safeVideoId = _safeName(videoId); + + await cacheSettings.enforceLimit(keepVideoId: safeVideoId); + + final videoDirectory = Directory( + '${cacheRoot.path}' + '${Platform.pathSeparator}' + '$safeVideoId', + ); + + await videoDirectory.create( + recursive: true, + ); + _activeVideoCacheId = safeVideoId; + _activeVideoDirectory = videoDirectory; + await cacheSettings.touchVideoDirectory(videoDirectory); + + final videoFile = File( + '${videoDirectory.path}' + '${Platform.pathSeparator}' + 'video_${_safeName(videoFormatId)}.$videoExt', + ); + + final audioFile = File( + '${videoDirectory.path}' + '${Platform.pathSeparator}' + 'audio_${_safeName(audioFormatId)}.$audioExt', + ); + + final videoHit = + await _isComplete( + videoFile, + videoLength, + ); + + final audioHit = + await _isComplete( + audioFile, + audioLength, + ); + + // Un ancien fichier incomplet ne doit pas être pris pour un HIT. + // On le recrée proprement; yt-dlp continuera ensuite à le remplir + // pendant la lecture. + if (!videoHit) { + await _deleteIfExists(videoFile); + } + + if (!audioHit) { + await _deleteIfExists(audioFile); + } + + final video = _GrowingSource( + id: 'video', + file: videoFile, + expectedLength: videoLength, + formatId: videoFormatId, + ); + + final audio = _GrowingSource( + id: 'audio', + file: audioFile, + expectedLength: audioLength, + formatId: audioFormatId, + ); + + if (videoHit) { + video.exitCode = 0; + } + + if (audioHit) { + audio.exitCode = 0; + } + + _video = video; + _audio = audio; + + final server = await HttpServer.bind( + InternetAddress.loopbackIPv4, + 0, + shared: false, + ); + + _server = server; + + _serverSubscription = server.listen( + (request) { + unawaited( + _handleVideoRequest( + request, + video, + videoMimeType, + ), + ); + }, + onError: (Object error, StackTrace stackTrace) { + debugPrint( + '[HD-PROGRESSIVE][17.19] serveur local: $error', + ); + }, + cancelOnError: false, + ); + + if (!videoHit) { + await _startDownload( + source: video, + ytDlpPath: ytDlp.path, + denoPath: deno.path, + watchUrl: watchUrl, + ); + } + + if (!audioHit) { + await _startDownload( + source: audio, + ytDlpPath: ytDlp.path, + denoPath: deno.path, + watchUrl: watchUrl, + ); + } + + final base = + 'http://127.0.0.1:${server.port}'; + + debugPrint( + '[HD-PROGRESSIVE][17.19] session prête ' + 'video=${videoHit ? 'HIT' : 'STREAM'} ' + 'audio=${audioHit ? 'HIT' : 'DOWNLOAD'} ' + 'video=$videoFormatId audio=$audioFormatId', + ); + + return WindowsEmbeddedProgressiveSession( + videoUrl: '$base/video', + audioUri: Uri.file( + audioFile.path, + windows: true, + ).toString(), + videoFromCache: videoHit, + audioFromCache: audioHit, + ); + } + + Future status({ + required Duration mediaDuration, + }) async { + final video = _video; + final audio = _audio; + + if (video == null || audio == null) { + throw StateError( + 'Aucune session progressive active.', + ); + } + + final videoBytes = + await _safeLength(video.file); + + final audioBytes = + await _safeLength(audio.file); + + final videoComplete = + videoBytes == video.expectedLength; + + final audioComplete = + audioBytes == audio.expectedLength; + + Duration estimated = Duration.zero; + + if (mediaDuration > Duration.zero && + video.expectedLength > 0 && + videoBytes > 0) { + final ratio = ( + videoBytes / video.expectedLength + ).clamp(0.0, 1.0); + + estimated = Duration( + milliseconds: + (mediaDuration.inMilliseconds * ratio) + .round(), + ); + } + + return WindowsEmbeddedProgressiveStatus( + videoBytes: videoBytes, + videoLength: video.expectedLength, + audioBytes: audioBytes, + audioLength: audio.expectedLength, + videoComplete: videoComplete, + audioComplete: audioComplete, + estimatedBufferedDuration: estimated, + ); + } + + /// Vrai lorsque le fichier vidéo contient suffisamment d'octets pour + /// couvrir approximativement [position] + [ahead], avec une marge. + /// + /// On n'utilise pas --download-sections ici : le fichier DASH continue de + /// grandir normalement, ce qui évite une découpe/re-encapsulation ffmpeg. + Future hasEnoughBuffer({ + required Duration position, + required Duration mediaDuration, + Duration ahead = const Duration(seconds: 20), + }) async { + final video = _video; + final audio = _audio; + + if (video == null || audio == null) { + return false; + } + + final videoBytes = + await _safeLength(video.file); + + final audioBytes = + await _safeLength(audio.file); + + // L'audio 251 est petit. Pour garantir le son lors de la bascule, + // on attend qu'il soit complet. + if (audioBytes != audio.expectedLength) { + return false; + } + + if (videoBytes == video.expectedLength) { + return true; + } + + if (mediaDuration <= Duration.zero) { + return videoBytes >= 24 * 1024 * 1024; + } + + final wantedPosition = Duration( + milliseconds: math.min( + mediaDuration.inMilliseconds, + position.inMilliseconds + + ahead.inMilliseconds, + ), + ); + + final ratio = ( + wantedPosition.inMilliseconds / + mediaDuration.inMilliseconds + ).clamp(0.0, 1.0); + + // 6 MiB de marge pour l'init/index + variations de bitrate. + final estimatedRequired = + (video.expectedLength * ratio).ceil() + + 6 * 1024 * 1024; + + // Même au début, on exige un prébuffer réel pour ne pas reproduire + // l'échec 17.15 où mpv ouvrait le flux alors qu'il était vide. + final required = math.min( + video.expectedLength, + math.max( + 16 * 1024 * 1024, + estimatedRequired, + ), + ); + + return videoBytes >= required; + } + + Future stop() async { + final subscription = + _serverSubscription; + _serverSubscription = null; + + if (subscription != null) { + await subscription.cancel(); + } + + final server = _server; + _server = null; + + if (server != null) { + await server.close( + force: true, + ); + } + + final processes = + _activeProcesses.toList( + growable: false, + ); + + _activeProcesses.clear(); + + for (final process in processes) { + process.kill(); + } + + if (processes.isNotEmpty) { + await Future.delayed( + const Duration(milliseconds: 150), + ); + } + + // Les fichiers COMPLETS restent en cache. + // Les fichiers incomplets sont supprimés pour éviter un faux HIT. + final video = _video; + final audio = _audio; + + _video = null; + _audio = null; + + if (video != null) { + final length = + await _safeLength(video.file); + + if (length != video.expectedLength) { + await _deleteIfExists(video.file); + } + } + + if (audio != null) { + final length = + await _safeLength(audio.file); + + if (length != audio.expectedLength) { + await _deleteIfExists(audio.file); + } + } + + final cacheId = _activeVideoCacheId; + final cacheDirectory = _activeVideoDirectory; + _activeVideoCacheId = null; + _activeVideoDirectory = null; + + if (cacheDirectory != null) { + final cacheSettings = MediaCacheSettingsService.instance; + await cacheSettings.touchVideoDirectory(cacheDirectory); + await cacheSettings.enforceLimit(keepVideoId: cacheId); + } + } + + Future _startDownload({ + required _GrowingSource source, + required String ytDlpPath, + required String denoPath, + required String watchUrl, + }) async { + final args = [ + '--ignore-config', + '--no-playlist', + '--no-warnings', + '--newline', + '--socket-timeout', + '20', + '--retries', + '5', + '--fragment-retries', + '5', + '--js-runtimes', + 'deno:$denoPath', + '--extractor-args', + 'youtube:player_client=web_embedded', + '--force-overwrites', + '--no-part', + '-f', + source.formatId, + '-o', + source.file.path, + watchUrl, + ]; + + debugPrint( + '[HD-PROGRESSIVE][17.19] yt-dlp ' + '${source.id} format=${source.formatId}', + ); + + final process = await Process.start( + ytDlpPath, + args, + workingDirectory: + File(ytDlpPath).parent.path, + runInShell: false, + mode: ProcessStartMode.normal, + ); + + source.process = process; + _activeProcesses.add(process); + + process.stdout + .transform( + const SystemEncoding().decoder, + ) + .listen( + (_) { + // Le player affiche l'état à partir de la taille réelle du fichier, + // plus fiable que les lignes de progression yt-dlp. + }, + ); + + process.stderr + .transform( + const SystemEncoding().decoder, + ) + .listen( + (text) { + if (source.stderr.length < 5000) { + final remaining = + 5000 - source.stderr.length; + + source.stderr.write( + text.length <= remaining + ? text + : text.substring(0, remaining), + ); + } + }, + ); + + unawaited( + process.exitCode.then( + (code) async { + _activeProcesses.remove(process); + source.exitCode = code; + + final length = + await _safeLength(source.file); + + if (code == 0 && + length == source.expectedLength) { + debugPrint( + '[HD-PROGRESSIVE][17.19] ' + '${source.id} complet: ' + '$length/${source.expectedLength}', + ); + } else { + final stderr = source.stderr + .toString() + .replaceAll( + RegExp(r'\s+'), + ' ', + ) + .trim(); + + debugPrint( + '[HD-PROGRESSIVE][17.19] ' + '${source.id} fin code=$code ' + '$length/${source.expectedLength}' + '${stderr.isEmpty ? '' : ': $stderr'}', + ); + } + }, + ), + ); + } + + Future _handleVideoRequest( + HttpRequest request, + _GrowingSource source, + String contentType, + ) async { + if (request.uri.path != '/video') { + request.response.statusCode = + HttpStatus.notFound; + await request.response.close(); + return; + } + + if (request.method != 'GET' && + request.method != 'HEAD') { + request.response.statusCode = + HttpStatus.methodNotAllowed; + await request.response.close(); + return; + } + + final rangeHeader = + request.headers.value( + HttpHeaders.rangeHeader, + ); + + final parsed = _parseRange( + rangeHeader, + source.expectedLength, + ); + + if (rangeHeader != null && + parsed == null) { + request.response.statusCode = + HttpStatus.requestedRangeNotSatisfiable; + + request.response.headers.set( + 'content-range', + 'bytes */${source.expectedLength}', + ); + + await request.response.close(); + return; + } + + final start = parsed?.$1 ?? 0; + final end = + parsed?.$2 ?? + source.expectedLength - 1; + + // Ne pas envoyer de réponse vide à mpv. + // Si mpv demande directement un offset légèrement au-delà du buffer + // estimé, on attend que yt-dlp atteigne réellement cet offset. + await _waitForOffset( + source, + start, + ); + + final response = request.response; + + response.statusCode = parsed == null + ? HttpStatus.ok + : HttpStatus.partialContent; + + response.headers.set( + 'accept-ranges', + 'bytes', + ); + + response.headers.set( + HttpHeaders.cacheControlHeader, + 'no-store', + ); + + response.headers.set( + HttpHeaders.contentTypeHeader, + contentType, + ); + + response.contentLength = + end - start + 1; + + if (parsed != null) { + response.headers.set( + 'content-range', + 'bytes $start-$end/' + '${source.expectedLength}', + ); + } + + debugPrint( + '[HD-PROGRESSIVE][17.19] /video ' + '${request.method} bytes=$start-$end', + ); + + if (request.method == 'HEAD') { + await response.close(); + return; + } + + try { + await _streamGrowingFile( + source: source, + start: start, + end: end, + response: response, + ); + } on Object catch (error) { + debugPrint( + '[HD-PROGRESSIVE][17.19] /video: $error', + ); + + try { + await response.close(); + } catch (_) { + // mpv peut fermer la connexion lors d'un seek/changement source. + } + } + } + + Future _waitForOffset( + _GrowingSource source, + int offset, + ) async { + final deadline = + DateTime.now().add( + const Duration(seconds: 45), + ); + + while (true) { + final available = + await _safeLength(source.file); + + if (available > offset || + available == source.expectedLength) { + return; + } + + final exitCode = source.exitCode; + + if (exitCode != null && + exitCode != 0) { + throw HttpException( + 'yt-dlp ${source.id} code=$exitCode', + ); + } + + if (DateTime.now().isAfter(deadline)) { + throw TimeoutException( + 'Offset $offset non disponible ' + 'après 45 s.', + ); + } + + await Future.delayed( + const Duration(milliseconds: 40), + ); + } + } + + Future _streamGrowingFile({ + required _GrowingSource source, + required int start, + required int end, + required HttpResponse response, + }) async { + var cursor = start; + RandomAccessFile? raf; + + try { + while (cursor <= end) { + final available = + await _safeLength(source.file); + + if (available > cursor) { + raf ??= + await source.file.open( + mode: FileMode.read, + ); + + final readable = math.min( + available - cursor, + end - cursor + 1, + ); + + final count = math.min( + readable, + 512 * 1024, + ); + + await raf.setPosition(cursor); + + final data = + await raf.read(count); + + if (data.isEmpty) { + await Future.delayed( + const Duration(milliseconds: 20), + ); + continue; + } + + response.add(data); + await response.flush(); + + cursor += data.length; + continue; + } + + final exitCode = source.exitCode; + + if (exitCode != null) { + if (exitCode != 0) { + throw HttpException( + 'yt-dlp ${source.id} code=$exitCode', + ); + } + + if (available <= cursor) { + throw HttpException( + 'EOF progressif à $available, ' + 'offset demandé=$cursor.', + ); + } + } + + // Le Player a rejoint le bord du téléchargement. + // On attend que yt-dlp reprenne de l'avance. + await Future.delayed( + const Duration(milliseconds: 30), + ); + } + + await response.close(); + } finally { + await raf?.close(); + } + } + + (int, int)? _parseRange( + String? value, + int totalLength, + ) { + if (value == null || + value.trim().isEmpty) { + return null; + } + + final match = RegExp( + r'^bytes=(\d*)-(\d*)$', + caseSensitive: false, + ).firstMatch( + value.trim(), + ); + + if (match == null) { + return null; + } + + final startText = + match.group(1) ?? ''; + final endText = + match.group(2) ?? ''; + + if (startText.isEmpty) { + final suffix = + int.tryParse(endText); + + if (suffix == null || + suffix <= 0) { + return null; + } + + final start = math.max( + 0, + totalLength - suffix, + ); + + return ( + start, + totalLength - 1, + ); + } + + final start = + int.tryParse(startText); + + if (start == null || + start < 0 || + start >= totalLength) { + return null; + } + + final parsedEnd = + endText.isEmpty + ? totalLength - 1 + : int.tryParse(endText); + + if (parsedEnd == null || + parsedEnd < start) { + return null; + } + + return ( + start, + math.min( + parsedEnd, + totalLength - 1, + ), + ); + } + + String _safeName( + String value, + ) { + final safe = + value.replaceAll( + RegExp(r'[^A-Za-z0-9._-]'), + '_', + ); + + return safe.isEmpty + ? 'media' + : safe; + } + + Future _isComplete( + File file, + int expectedLength, + ) async { + return await _safeLength(file) == + expectedLength; + } + + Future _safeLength( + File file, + ) async { + try { + if (!await file.exists()) { + return 0; + } + + return await file.length(); + } catch (_) { + return 0; + } + } + + Future _deleteIfExists( + File file, + ) async { + try { + if (await file.exists()) { + await file.delete(); + } + } catch (_) { + // Une erreur ultérieure sera plus explicite. + } + } +} + +class _GrowingSource { + _GrowingSource({ + required this.id, + required this.file, + required this.expectedLength, + required this.formatId, + }); + + final String id; + final File file; + final int expectedLength; + final String formatId; + + Process? process; + int? exitCode; + final StringBuffer stderr = + StringBuffer(); +} diff --git a/lib/services/windows_hd_service.dart b/lib/services/windows_hd_service.dart new file mode 100644 index 0000000..d990195 --- /dev/null +++ b/lib/services/windows_hd_service.dart @@ -0,0 +1,1972 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:media_kit/media_kit.dart'; + +/// Extraction HD/4K locale pour Windows. +/// +/// yt-dlp choisit les URLs vidéo et audio. Cette version renvoie également +/// les en-têtes HTTP associés aux formats, puis les applique à libmpv avant +/// lecture. +/// +/// Aucun cookie Google de l'application et aucun jeton OAuth YouTube Data API +/// n'est transmis à yt-dlp. +class WindowsHdService { + WindowsHdService._(); + + static final WindowsHdService instance = + WindowsHdService._(); + + /// Extraction prioritaire Windows via `web_embedded`. + /// + /// Ce client est particulièrement intéressant ici car yt-dlp n'exige + /// actuellement pas de PO Token GVS pour les vidéos embeddables. + /// + /// Le test manuel de l'étape 17.14 a confirmé pour la vidéo de validation : + /// - audio 251 Opus disponible et téléchargeable intégralement ; + /// - 1080p60 format 299 ; + /// - 1440p60 format 308 ; + /// - 2160p60 format 315. + Future?> getEmbeddedHdStream({ + required String videoId, + required int targetHeight, + String preferredLanguage = 'auto', + String originalLanguageHint = 'auto', + }) async { + if (!Platform.isWindows) { + return null; + } + + final tools = _resolveBundledTools(); + final ytDlp = tools.$1; + final deno = tools.$2; + + if (!await ytDlp.exists()) { + throw StateError( + 'yt-dlp.exe absent. Lance PREPARER_HD_WINDOWS.ps1.', + ); + } + + if (!await deno.exists()) { + throw StateError( + 'deno.exe absent. Lance PREPARER_HD_WINDOWS.ps1.', + ); + } + + final watchUrl = Uri.https( + 'www.youtube.com', + '/watch', + { + 'v': videoId, + }, + ).toString(); + + final arguments = [ + '--ignore-config', + '--no-playlist', + '--no-warnings', + '--encoding', + 'utf-8', + '--socket-timeout', + '20', + '--retries', + '2', + '--js-runtimes', + 'deno:${deno.path}', + '--extractor-args', + 'youtube:player_client=web_embedded', + '--skip-download', + '--dump-single-json', + watchUrl, + ]; + + debugPrint( + '[HD-WINDOWS][17.14] extraction web_embedded ' + 'cible=$targetHeight', + ); + + final result = await Process.run( + ytDlp.path, + arguments, + workingDirectory: ytDlp.parent.path, + runInShell: false, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ).timeout( + const Duration(seconds: 45), + ); + + if (result.exitCode != 0) { + final stderr = _compactError( + result.stderr?.toString() ?? '', + ); + + debugPrint( + '[HD-WINDOWS][17.14] web_embedded indisponible: ' + '${stderr.isEmpty ? 'code ${result.exitCode}' : stderr}', + ); + + return null; + } + + final stdout = + result.stdout?.toString().trim() ?? ''; + + if (stdout.isEmpty) { + return null; + } + + final dynamic decoded; + + try { + decoded = jsonDecode(stdout); + } on Object catch (error) { + debugPrint( + '[HD-WINDOWS][17.14] JSON web_embedded invalide: $error', + ); + return null; + } + + if (decoded is! Map) { + return null; + } + + final rawFormats = decoded['formats']; + + if (rawFormats is! List) { + return null; + } + + final formats = rawFormats + .whereType() + .map( + (format) => format.map( + (key, value) => MapEntry( + key.toString(), + value, + ), + ), + ) + .toList(growable: false); + + final effectiveTarget = + targetHeight <= 0 ? 2160 : targetHeight; + + final availableHeights = formats + .where(_isVideoOnly) + .where(_hasHttpUrl) + .map((format) => _intValue(format['height'])) + .where((height) => height >= 360) + .toSet() + .toList() + ..sort(); + + final maxAvailableHeight = + availableHeights.isEmpty + ? 0 + : availableHeights.last; + + final availableAudioTracks = + _collectAvailableAudioTracks(formats); + + if (targetHeight > 0 && + maxAvailableHeight > 0 && + maxAvailableHeight < targetHeight) { + debugPrint( + '[HD-WINDOWS][17.21] cible ${targetHeight}p absente ' + 'pour web_embedded; disponibles=$availableHeights', + ); + } + + final video = _selectVideo( + formats, + effectiveTarget, + ); + + final audio = _selectEmbeddedAudio( + formats, + preferredLanguage: preferredLanguage, + originalLanguageHint: originalLanguageHint, + ); + + if (video == null || audio == null) { + debugPrint( + '[HD-WINDOWS][17.14] paire web_embedded absente ' + 'jusqu’à ${effectiveTarget}p', + ); + return null; + } + + final videoUrl = _stringValue(video['url']); + final audioUrl = _stringValue(audio['url']); + + if (videoUrl == null || + videoUrl.isEmpty || + audioUrl == null || + audioUrl.isEmpty) { + return null; + } + + final videoHeaders = {}; + final audioHeaders = {}; + + _mergeHeaders( + videoHeaders, + decoded['http_headers'], + overwrite: false, + ); + + _mergeHeaders( + videoHeaders, + video['http_headers'], + overwrite: true, + ); + + _mergeHeaders( + audioHeaders, + decoded['http_headers'], + overwrite: false, + ); + + _mergeHeaders( + audioHeaders, + audio['http_headers'], + overwrite: true, + ); + + final combinedHeaders = { + ...videoHeaders, + }; + + _mergeHeaders( + combinedHeaders, + audioHeaders, + overwrite: false, + ); + + final width = _intValue(video['width']); + final height = _intValue(video['height']); + final fps = _intValue(video['fps']); + final videoCodec = + _stringValue(video['vcodec']) ?? 'inconnu'; + final audioCodec = + _stringValue(audio['acodec']) ?? 'inconnu'; + final videoFormatId = + _stringValue(video['format_id']) ?? 'inconnu'; + final audioFormatId = + _stringValue(audio['format_id']) ?? 'inconnu'; + + final audioLanguage = _audioLanguageCode(audio); + final audioTrackName = _audioTrackLabel(audio); + final preferred = _normalizeLanguageCode(preferredLanguage); + final preferredAudioMatched = + preferred != 'auto' && + preferred != 'original' && + _matchesAudioLanguage(audio, preferred); + final originalAudioSelected = _isOriginalAudioFormat(audio); + + final videoClient = + _youtubeClientFromUrl(videoUrl); + final audioClient = + _youtubeClientFromUrl(audioUrl); + + final videoLength = _formatLength( + video, + videoUrl, + ); + + final audioLength = _formatLength( + audio, + audioUrl, + ); + + final videoExt = + _stringValue(video['ext']) ?? 'mp4'; + + final audioExt = + _stringValue(audio['ext']) ?? 'webm'; + + final videoMimeType = _formatMimeType( + video, + isVideo: true, + ); + + final audioMimeType = _formatMimeType( + audio, + isVideo: false, + ); + + debugPrint( + '[HD-WINDOWS][17.14] paire embedded: ' + '${width}x$height ' + '${fps > 0 ? '${fps}fps ' : ''}' + 'video=$videoFormatId/$videoCodec ' + 'audio=$audioFormatId/$audioCodec', + ); + + debugPrint( + '[HD-WINDOWS][17.14] embedded GVS: ' + 'videoClient=${videoClient ?? 'inconnu'} ' + 'videoPOT=${_hasPoToken(videoUrl)} ' + 'audioClient=${audioClient ?? 'inconnu'} ' + 'audioPOT=${_hasPoToken(audioUrl)}', + ); + + return { + 'videoUrl': videoUrl, + 'audioUrl': audioUrl, + 'width': width, + 'height': height, + 'fps': fps, + 'videoCodec': videoCodec, + 'audioCodec': audioCodec, + 'videoFormatId': videoFormatId, + 'audioFormatId': audioFormatId, + 'audioLanguage': audioLanguage, + 'audioTrackName': audioTrackName, + 'preferredAudioMatched': preferredAudioMatched, + 'originalAudioSelected': originalAudioSelected, + 'availableAudioTracks': availableAudioTracks, + 'videoHeaders': videoHeaders, + 'audioHeaders': audioHeaders, + 'httpHeaders': combinedHeaders, + 'youtubeClient': videoClient, + 'audioYoutubeClient': audioClient, + 'videoLength': videoLength, + 'audioLength': audioLength, + 'videoExt': videoExt, + 'audioExt': audioExt, + 'videoMimeType': videoMimeType, + 'audioMimeType': audioMimeType, + 'availableHeights': availableHeights, + 'maxAvailableHeight': maxAvailableHeight, + 'ytDlpPath': ytDlp.path, + 'denoPath': deno.path, + 'watchUrl': watchUrl, + 'source': 'web_embedded qualités strictes 17.21', + }; + } + + /// Tente d'obtenir un flux HLS muxé (vidéo + audio) via le client + /// `web_safari`. + /// + /// yt-dlp documente ce client comme pouvant exposer des formats HLS + /// pré-muxés jusqu'à 1080p. Cela évite complètement la paire DASH + /// vidéo/audio séparée et donc le problème GVS/PO Token observé sur mweb. + /// + /// Depuis 2026.07 YouTube ne renvoie ces HLS que pour certaines sessions. + /// Cette méthode est donc opportuniste : si aucun HLS n'est disponible, + /// elle retourne null et le pipeline mweb existant prend le relais. + Future?> getMuxedHlsStream({ + required String videoId, + required int targetHeight, + String preferredLanguage = 'auto', + String originalLanguageHint = 'auto', + }) async { + if (!Platform.isWindows) { + return null; + } + + final tools = _resolveBundledTools(); + final ytDlp = tools.$1; + final deno = tools.$2; + + if (!await ytDlp.exists() || !await deno.exists()) { + return null; + } + + final effectiveTarget = + targetHeight <= 0 + ? 1080 + : targetHeight.clamp(720, 1080); + + final watchUrl = Uri.https( + 'www.youtube.com', + '/watch', + { + 'v': videoId, + }, + ).toString(); + + final arguments = [ + '--ignore-config', + '--no-playlist', + '--no-warnings', + '--encoding', + 'utf-8', + '--socket-timeout', + '20', + '--retries', + '2', + '--js-runtimes', + 'deno:${deno.path}', + '--extractor-args', + 'youtube:player_client=web_safari', + '--skip-download', + '--dump-single-json', + watchUrl, + ]; + + debugPrint( + '[HD-WINDOWS][17.13] tentative HLS muxé web_safari ' + 'cible=${effectiveTarget}p', + ); + + ProcessResult result; + + try { + result = await Process.run( + ytDlp.path, + arguments, + workingDirectory: ytDlp.parent.path, + runInShell: false, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ).timeout( + const Duration(seconds: 45), + ); + } on Object catch (error) { + debugPrint( + '[HD-WINDOWS][17.13] web_safari indisponible: $error', + ); + return null; + } + + if (result.exitCode != 0) { + debugPrint( + '[HD-WINDOWS][17.13] web_safari sans HLS exploitable: ' + '${_compactError(result.stderr?.toString() ?? '')}', + ); + return null; + } + + final stdout = + result.stdout?.toString().trim() ?? ''; + + if (stdout.isEmpty) { + return null; + } + + dynamic decoded; + + try { + decoded = jsonDecode(stdout); + } on Object catch (_) { + return null; + } + + if (decoded is! Map) { + return null; + } + + final rawFormats = decoded['formats']; + + if (rawFormats is! List) { + return null; + } + + var candidates = rawFormats + .whereType() + .map( + (format) => format.map( + (key, value) => MapEntry( + key.toString(), + value, + ), + ), + ) + .where((format) { + final url = _stringValue(format['url']); + final height = _intValue(format['height']); + final protocol = + (_stringValue(format['protocol']) ?? '') + .toLowerCase(); + final videoCodec = + _stringValue(format['vcodec']); + final audioCodec = + _stringValue(format['acodec']); + + return url != null && + url.isNotEmpty && + height >= 720 && + height <= effectiveTarget && + protocol.contains('m3u8') && + videoCodec != null && + videoCodec != 'none' && + audioCodec != null && + audioCodec != 'none'; + }) + .toList(); + + candidates = _preferAudioLanguageCandidates( + candidates, + preferredLanguage: preferredLanguage, + originalLanguageHint: originalLanguageHint, + ); + + if (candidates.isEmpty) { + debugPrint( + '[HD-WINDOWS][17.13] aucun HLS muxé web_safari ' + '720-${effectiveTarget}p disponible', + ); + return null; + } + + candidates.sort((a, b) { + var result = _intValue(a['height']).compareTo( + _intValue(b['height']), + ); + + if (result != 0) { + return result; + } + + result = _intValue(a['fps']).compareTo( + _intValue(b['fps']), + ); + + if (result != 0) { + return result; + } + + return _doubleValue(a['tbr']).compareTo( + _doubleValue(b['tbr']), + ); + }); + + final selected = candidates.last; + final url = _stringValue(selected['url']); + + if (url == null || url.isEmpty) { + return null; + } + + final headers = {}; + + _mergeHeaders( + headers, + decoded['http_headers'], + overwrite: false, + ); + + _mergeHeaders( + headers, + selected['http_headers'], + overwrite: true, + ); + + final width = _intValue(selected['width']); + final height = _intValue(selected['height']); + final fps = _intValue(selected['fps']); + final videoCodec = + _stringValue(selected['vcodec']) ?? 'inconnu'; + final audioCodec = + _stringValue(selected['acodec']) ?? 'inconnu'; + final formatId = + _stringValue(selected['format_id']) ?? 'inconnu'; + + debugPrint( + '[HD-WINDOWS][17.13] HLS muxé trouvé: ' + '${width}x$height ' + '${fps > 0 ? '${fps}fps ' : ''}' + 'format=$formatId ' + 'video=$videoCodec ' + 'audio=$audioCodec', + ); + + return { + 'url': url, + 'width': width, + 'height': height, + 'fps': fps, + 'videoCodec': videoCodec, + 'audioCodec': audioCodec, + 'formatId': formatId, + 'httpHeaders': headers, + 'source': 'web_safari HLS muxé', + }; + } + + Future?> getHdStream({ + required String videoId, + required int targetHeight, + String preferredLanguage = 'auto', + String originalLanguageHint = 'auto', + }) async { + if (!Platform.isWindows) { + return null; + } + + final tools = _resolveBundledTools(); + final ytDlp = tools.$1; + final deno = tools.$2; + + if (!await ytDlp.exists()) { + throw StateError( + 'yt-dlp.exe absent. Lance PREPARER_HD_WINDOWS.ps1 ' + 'puis reconstruis l’application Windows.', + ); + } + + if (!await deno.exists()) { + throw StateError( + 'deno.exe absent. Lance PREPARER_HD_WINDOWS.ps1 ' + 'puis reconstruis l’application Windows.', + ); + } + + final provider = _resolvePoTokenProvider(); + + if (!await provider.pluginArchive.exists()) { + throw StateError( + 'Plugin PO Token absent. Lance ' + 'PREPARER_PO_TOKEN_WINDOWS.ps1 une fois.', + ); + } + + if (!await provider.serverDirectory.exists()) { + throw StateError( + 'Provider BgUtils absent. Lance ' + 'PREPARER_PO_TOKEN_WINDOWS.ps1 une fois.', + ); + } + + final watchUrl = Uri.https( + 'www.youtube.com', + '/watch', + { + 'v': videoId, + }, + ).toString(); + + final arguments = [ + '--ignore-config', + '--no-playlist', + '--no-warnings', + '--encoding', + 'utf-8', + '--socket-timeout', + '20', + '--retries', + '2', + '--js-runtimes', + 'deno:${deno.path}', + '--plugin-dirs', + provider.pluginDirectory.path, + '--extractor-args', + 'youtube:player_client=mweb;fetch_pot=auto;pot_trace=true', + '--extractor-args', + 'youtubepot-bgutilscript:server_home=${provider.serverDirectory.path}', + '--skip-download', + '--dump-single-json', + watchUrl, + ]; + + debugPrint( + '[HD-WINDOWS] yt-dlp extraction locale ' + 'cible=$targetHeight', + ); + + final result = await Process.run( + ytDlp.path, + arguments, + workingDirectory: ytDlp.parent.path, + runInShell: false, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ).timeout( + const Duration(seconds: 45), + ); + + if (result.exitCode != 0) { + final stderr = _compactError( + result.stderr?.toString() ?? '', + ); + + throw StateError( + 'yt-dlp code=${result.exitCode}' + '${stderr.isEmpty ? '' : ': $stderr'}', + ); + } + + final stdout = + result.stdout?.toString().trim() ?? ''; + + if (stdout.isEmpty) { + throw StateError( + 'yt-dlp n’a retourné aucune donnée.', + ); + } + + final dynamic decoded; + try { + decoded = jsonDecode(stdout); + } catch (error) { + throw FormatException( + 'Réponse yt-dlp JSON invalide: $error', + ); + } + + if (decoded is! Map) { + throw const FormatException( + 'Réponse yt-dlp inattendue.', + ); + } + + final rawFormats = decoded['formats']; + + if (rawFormats is! List) { + throw const FormatException( + 'Liste des formats absente.', + ); + } + + final formats = rawFormats + .whereType() + .map( + (format) => format.map( + (key, value) => MapEntry( + key.toString(), + value, + ), + ), + ) + .toList(growable: false); + + final availableAudioTracks = + _collectAvailableAudioTracks(formats); + + final effectiveTarget = + targetHeight <= 0 ? 2160 : targetHeight; + + final video = _selectVideo( + formats, + effectiveTarget, + ); + + final audio = _selectAudio( + formats, + preferredLanguage: preferredLanguage, + originalLanguageHint: originalLanguageHint, + ); + + if (video == null) { + throw StateError( + 'Aucun flux vidéo direct >=720p ' + 'jusqu’à ${effectiveTarget}p.', + ); + } + + if (audio == null) { + throw StateError( + 'Aucun flux audio direct exploitable.', + ); + } + + final videoUrl = video['url']; + final audioUrl = audio['url']; + + if (videoUrl is! String || + videoUrl.isEmpty || + audioUrl is! String || + audioUrl.isEmpty) { + throw StateError( + 'URL vidéo/audio directe absente.', + ); + } + + final height = _intValue(video['height']); + final width = _intValue(video['width']); + final fps = _intValue(video['fps']); + + final videoCodec = + _stringValue(video['vcodec']) ?? 'inconnu'; + + final audioCodec = + _stringValue(audio['acodec']) ?? 'inconnu'; + final audioLanguage = _audioLanguageCode(audio); + final audioTrackName = _audioTrackLabel(audio); + final preferred = _normalizeLanguageCode(preferredLanguage); + final preferredAudioMatched = + preferred != 'auto' && + preferred != 'original' && + _matchesAudioLanguage(audio, preferred); + final originalAudioSelected = _isOriginalAudioFormat(audio); + + final videoHeaders = {}; + final audioHeaders = {}; + + _mergeHeaders( + videoHeaders, + decoded['http_headers'], + overwrite: false, + ); + + _mergeHeaders( + videoHeaders, + video['http_headers'], + overwrite: true, + ); + + _mergeHeaders( + audioHeaders, + decoded['http_headers'], + overwrite: false, + ); + + _mergeHeaders( + audioHeaders, + audio['http_headers'], + overwrite: true, + ); + + // Conservé pour compatibilité avec les étapes précédentes. + final headers = { + ...videoHeaders, + }; + + _mergeHeaders( + headers, + audioHeaders, + overwrite: false, + ); + + final videoLength = _formatLength( + video, + videoUrl, + ); + + final audioLength = _formatLength( + audio, + audioUrl, + ); + + final videoChunkSize = _formatChunkSize(video); + final audioChunkSize = _formatChunkSize(audio); + + final videoMimeType = _formatMimeType( + video, + isVideo: true, + ); + + final audioMimeType = _formatMimeType( + audio, + isVideo: false, + ); + + final selectedClient = _youtubeClientFromUrl( + videoUrl, + ); + + final audioClient = _youtubeClientFromUrl( + audioUrl, + ); + + final videoHasPoToken = + _hasPoToken(videoUrl); + + final audioHasPoToken = + _hasPoToken(audioUrl); + + debugPrint( + '[HD-WINDOWS][17.9] extraction mweb + PO Token provider local', + ); + + debugPrint( + '[HD-WINDOWS][17.9] provider plugin=${provider.pluginArchive.path}', + ); + + debugPrint( + '[HD-WINDOWS] flux sélectionné: ' + '${width}x$height ' + '${fps > 0 ? '${fps}fps ' : ''}' + 'video=$videoCodec ' + 'audio=$audioCodec ' + 'client=${selectedClient ?? 'inconnu'}', + ); + + debugPrint( + '[HD-WINDOWS][17.11] GVS: ' + 'videoClient=${selectedClient ?? 'inconnu'} ' + 'videoPOT=$videoHasPoToken ' + 'audioClient=${audioClient ?? 'inconnu'} ' + 'audioPOT=$audioHasPoToken', + ); + + debugPrint( + '[HD-WINDOWS] headers sélectionnés: ' + '${headers.keys.join(', ')}', + ); + + debugPrint( + '[HD-WINDOWS][17.10] transport yt-dlp: ' + 'video=${videoLength}o chunk=${videoChunkSize}o ' + 'audio=${audioLength}o chunk=${audioChunkSize}o', + ); + + return { + 'videoUrl': videoUrl, + 'audioUrl': audioUrl, + 'width': width, + 'height': height, + 'fps': fps, + 'videoCodec': videoCodec, + 'audioCodec': audioCodec, + 'audioLanguage': audioLanguage, + 'audioTrackName': audioTrackName, + 'preferredAudioMatched': preferredAudioMatched, + 'originalAudioSelected': originalAudioSelected, + 'availableAudioTracks': availableAudioTracks, + 'targetHeight': targetHeight, + 'httpHeaders': headers, + 'videoHeaders': videoHeaders, + 'audioHeaders': audioHeaders, + 'videoLength': videoLength, + 'audioLength': audioLength, + 'videoChunkSize': videoChunkSize, + 'audioChunkSize': audioChunkSize, + 'videoMimeType': videoMimeType, + 'audioMimeType': audioMimeType, + 'youtubeClient': selectedClient, + 'audioYoutubeClient': audioClient, + 'videoHasPoToken': videoHasPoToken, + 'audioHasPoToken': audioHasPoToken, + 'ytDlpPath': ytDlp.path, + 'source': 'yt-dlp mweb + PO Token + relais piloté par yt-dlp 17.12', + }; + } + + int _formatLength( + Map format, + String url, + ) { + final exact = _intValue(format['filesize']); + + if (exact > 0) { + return exact; + } + + final approximate = + _intValue(format['filesize_approx']); + + if (approximate > 0) { + return approximate; + } + + final uri = Uri.tryParse(url); + final clen = uri?.queryParameters['clen']; + + if (clen != null) { + final parsed = int.tryParse(clen); + + if (parsed != null && parsed > 0) { + return parsed; + } + } + + return 0; + } + + int _formatChunkSize( + Map format, + ) { + final options = format['downloader_options']; + + if (options is Map) { + final raw = options['http_chunk_size']; + final parsed = _intValue(raw); + + if (parsed > 0) { + // yt-dlp documente actuellement 10 MiB pour YouTube. + // On ne dépasse jamais cette valeur dans notre relais. + return parsed.clamp( + 256 * 1024, + 10 * 1024 * 1024, + ); + } + } + + return 10 * 1024 * 1024; + } + + String _formatMimeType( + Map format, { + required bool isVideo, + }) { + final explicit = + _stringValue(format['mime_type']); + + if (explicit != null && + explicit.contains('/')) { + return explicit; + } + + final ext = + (_stringValue(format['ext']) ?? '') + .toLowerCase(); + + if (isVideo) { + if (ext == 'webm') { + return 'video/webm'; + } + + return 'video/mp4'; + } + + if (ext == 'webm') { + return 'audio/webm'; + } + + return 'audio/mp4'; + } + + bool _hasPoToken(String url) { + final uri = Uri.tryParse(url); + + if (uri == null) { + return false; + } + + final value = uri.queryParameters['pot']; + + return value != null && + value.trim().isNotEmpty; + } + + String? _youtubeClientFromUrl(String url) { + final uri = Uri.tryParse(url); + + if (uri == null) { + return null; + } + + final client = uri.queryParameters['c']; + + if (client == null || client.trim().isEmpty) { + return null; + } + + return client.trim(); + } + + /// Reproduit côté libmpv les headers réseau essentiels utilisés par + /// l'intégration yt-dlp de mpv : User-Agent, Referer, Cookie et + /// X-Forwarded-For. + /// Applique globalement à libmpv les headers exacts fournis par yt-dlp. + /// + /// C'est volontairement GLOBAL sur cette instance mpv : + /// - Media(videoUrl, httpHeaders: ...) couvre la vidéo ; + /// - AudioTrack.uri(audioUrl) ne permet pas de passer des headers ; + /// - les propriétés mpv ci-dessous couvrent donc aussi l'audio externe. + Future applyHttpHeaders( + NativePlayer player, + Map headers, + ) async { + String? userAgent; + + for (final entry in headers.entries) { + if (entry.key.toLowerCase() == 'user-agent') { + userAgent = entry.value; + break; + } + } + + // IMPORTANT : + // NativePlayer.setProperty attend le nom de propriété mpv lui-même. + // Il ne faut PAS préfixer avec "options/" ici. + if (userAgent != null && userAgent.isNotEmpty) { + await player.setProperty( + 'user-agent', + userAgent, + ); + } else { + await player.setProperty( + 'user-agent', + '', + ); + } + + // mpv traite http-header-fields comme une string-list séparée par + // des virgules. Une virgule présente DANS une valeur de header doit + // donc être échappée, sinon la liste est coupée au mauvais endroit. + // + // On transmet tous les headers yt-dlp sauf User-Agent, qui possède + // sa propriété mpv dédiée. + final fields = []; + + for (final entry in headers.entries) { + if (entry.key.toLowerCase() == 'user-agent') { + continue; + } + + final key = entry.key.trim(); + final value = entry.value.trim(); + + if (key.isEmpty || value.isEmpty) { + continue; + } + + final escapedValue = value + .replaceAll(r'\', r'\\') + .replaceAll(',', r'\,'); + + fields.add( + '$key: $escapedValue', + ); + } + + await player.setProperty( + 'http-header-fields', + fields.join(','), + ); + + debugPrint( + '[HD-WINDOWS][17.7] headers globaux mpv appliqués: ' + 'UA=${userAgent != null && userAgent.isNotEmpty}, ' + 'champs=${fields.length}', + ); + + if (fields.isNotEmpty) { + debugPrint( + '[HD-WINDOWS][17.7] noms headers mpv: ' + '${headers.keys.where((key) => key.toLowerCase() != 'user-agent').join(', ')}', + ); + } + } + + Map? _selectVideo( + List> formats, + int targetHeight, + ) { + final candidates = formats + .where(_isVideoOnly) + .where((format) { + final height = _intValue(format['height']); + + return height >= 720 && + height <= targetHeight && + _hasHttpUrl(format); + }) + .toList(); + + if (candidates.isEmpty) { + return null; + } + + candidates.sort( + (a, b) => _compareVideo( + a, + b, + targetHeight, + ), + ); + + return candidates.last; + } + + int _compareVideo( + Map a, + Map b, + int targetHeight, + ) { + var result = _intValue(a['height']).compareTo( + _intValue(b['height']), + ); + + if (result != 0) { + return result; + } + + result = _videoCodecScore( + a, + targetHeight, + ).compareTo( + _videoCodecScore( + b, + targetHeight, + ), + ); + + if (result != 0) { + return result; + } + + result = _intValue(a['fps']).compareTo( + _intValue(b['fps']), + ); + + if (result != 0) { + return result; + } + + result = _protocolScore(a).compareTo( + _protocolScore(b), + ); + + if (result != 0) { + return result; + } + + return _doubleValue(a['tbr']).compareTo( + _doubleValue(b['tbr']), + ); + } + + Map? _selectEmbeddedFastVideo( + List> formats, + int targetHeight, + ) { + final candidates = formats + .where(_isVideoOnly) + .where(_hasHttpUrl) + .where((format) { + final height = _intValue(format['height']); + + return height >= 720 && + height <= targetHeight; + }) + .toList(); + + if (candidates.isEmpty) { + return null; + } + + // On garde d'abord la meilleure résolution réellement disponible. + final bestHeight = candidates + .map((format) => _intValue(format['height'])) + .reduce((a, b) => a > b ? a : b); + + final atBestHeight = candidates + .where( + (format) => + _intValue(format['height']) == bestHeight, + ) + .toList(); + + // Puis le meilleur FPS à cette résolution. + final bestFps = atBestHeight + .map((format) => _intValue(format['fps'])) + .reduce((a, b) => a > b ? a : b); + + final atBestFps = atBestHeight + .where( + (format) => + _intValue(format['fps']) == bestFps, + ) + .toList(); + + // Pour le mode "téléchargement complet", le temps de démarrage dépend + // directement du nombre d'octets à récupérer. + // + // Les formats AV1 YouTube sont nettement plus compacts sur les vidéos + // testées. On les privilégie donc, puis VP9, puis AVC. À codec égal, + // on prend le plus petit fichier connu. + atBestFps.sort((a, b) { + int efficiencyScore( + Map format, + ) { + final codec = + (_stringValue(format['vcodec']) ?? '') + .toLowerCase(); + + if (codec.contains('av01') || + codec.contains('av1')) { + return 30; + } + + if (codec.contains('vp9') || + codec.contains('vp09')) { + return 20; + } + + if (codec.contains('avc') || + codec.contains('h264') || + codec.contains('h.264')) { + return 10; + } + + return 0; + } + + var result = efficiencyScore(a).compareTo( + efficiencyScore(b), + ); + + if (result != 0) { + return result; + } + + int knownLength( + Map format, + ) { + final exact = + _intValue(format['filesize']); + + if (exact > 0) { + return exact; + } + + return _intValue( + format['filesize_approx'], + ); + } + + final aLength = knownLength(a); + final bLength = knownLength(b); + + // candidates.sort(...); on retourne le dernier. + // Un fichier plus petit doit donc être considéré "meilleur". + if (aLength > 0 && bLength > 0) { + return bLength.compareTo(aLength); + } + + if (aLength > 0) { + return 1; + } + + if (bLength > 0) { + return -1; + } + + // À défaut de taille, bitrate plus bas = téléchargement plus court. + return _doubleValue(b['tbr']).compareTo( + _doubleValue(a['tbr']), + ); + }); + + final selected = atBestFps.last; + + debugPrint( + '[HD-WINDOWS][17.17] format rapide sélectionné: ' + '${_stringValue(selected['format_id']) ?? '?'} ' + '${_intValue(selected['width'])}x' + '${_intValue(selected['height'])} ' + '${_intValue(selected['fps'])}fps ' + '${_stringValue(selected['vcodec']) ?? '?'} ' + 'taille≈${_intValue(selected['filesize']) > 0 ? _intValue(selected['filesize']) : _intValue(selected['filesize_approx'])}o', + ); + + return selected; + } + + Map? _selectEmbeddedAudio( + List> formats, { + String preferredLanguage = 'auto', + String originalLanguageHint = 'auto', + }) { + var candidates = formats + .where(_isAudioOnly) + .where(_hasHttpUrl) + .toList(); + + candidates = _preferAudioLanguageCandidates( + candidates, + preferredLanguage: preferredLanguage, + originalLanguageHint: originalLanguageHint, + ); + + if (candidates.isEmpty) { + return null; + } + + candidates.sort((a, b) { + int embeddedScore(Map format) { + final id = + _stringValue(format['format_id']) ?? ''; + + // 251 a été téléchargé entièrement pendant le test utilisateur. + if (id == '251' || id.startsWith('251-')) { + return 100; + } + + if (id == '140' || id.startsWith('140-')) { + return 90; + } + + final codec = + (_stringValue(format['acodec']) ?? '') + .toLowerCase(); + + if (codec.contains('opus')) { + return 80; + } + + if (codec.contains('mp4a') || + codec.contains('aac')) { + return 70; + } + + return 10; + } + + var result = + embeddedScore(a).compareTo( + embeddedScore(b), + ); + + if (result != 0) { + return result; + } + + return _doubleValue(a['abr']).compareTo( + _doubleValue(b['abr']), + ); + }); + + return candidates.last; + } + + Map? _selectAudio( + List> formats, { + String preferredLanguage = 'auto', + String originalLanguageHint = 'auto', + }) { + var candidates = formats + .where(_isAudioOnly) + .where(_hasHttpUrl) + .toList(); + + candidates = _preferAudioLanguageCandidates( + candidates, + preferredLanguage: preferredLanguage, + originalLanguageHint: originalLanguageHint, + ); + + if (candidates.isEmpty) { + return null; + } + + candidates.sort((a, b) { + var result = + _audioCodecScore(a).compareTo( + _audioCodecScore(b), + ); + + if (result != 0) { + return result; + } + + result = _protocolScore(a).compareTo( + _protocolScore(b), + ); + + if (result != 0) { + return result; + } + + return _doubleValue(a['abr']).compareTo( + _doubleValue(b['abr']), + ); + }); + + return candidates.last; + } + + List> _preferAudioLanguageCandidates( + List> candidates, { + required String preferredLanguage, + required String originalLanguageHint, + }) { + if (candidates.isEmpty) { + return candidates; + } + + final preferred = _normalizeLanguageCode(preferredLanguage); + + // Une langue explicitement choisie par l'utilisateur reste prioritaire. + if (preferred != 'auto') { + final matches = candidates + .where((format) => _matchesAudioLanguage(format, preferred)) + .toList(); + + if (matches.isNotEmpty) { + debugPrint( + '[HD-WINDOWS][18.7.25] audio langue préférée=$preferred ' + 'candidats=${matches.length}', + ); + return matches; + } + } + + // En mode Auto, toujours préférer la piste marquée "original" par + // YouTube/yt-dlp. Les variantes 251-0, 251-1, etc. ne sont plus choisies + // uniquement parce qu'elles utilisent Opus. + final originals = candidates + .where(_isOriginalAudioFormat) + .toList(); + + if (originals.isNotEmpty) { + debugPrint( + '[HD-WINDOWS][18.7.25] piste audio originale détectée ' + 'candidats=${originals.length}', + ); + return originals; + } + + // Certains clients YouTube ne transmettent pas le mot "original". Dans + // ce cas, le script du titre/auteur sert uniquement de dernier indice. + final hint = _normalizeLanguageCode(originalLanguageHint); + if (hint != 'auto') { + final matches = candidates + .where((format) => _matchesAudioLanguage(format, hint)) + .toList(); + + if (matches.isNotEmpty) { + debugPrint( + '[HD-WINDOWS][18.7.25] audio original estimé=$hint ' + 'candidats=${matches.length}', + ); + return matches; + } + } + + return candidates; + } + + bool _isOriginalAudioFormat(Map format) { + final text = [ + _stringValue(format['format']) ?? '', + _stringValue(format['format_note']) ?? '', + _stringValue(format['audio_track']) ?? '', + _stringValue(format['audio_track_name']) ?? '', + ].join(' ').toLowerCase(); + + return text.contains('original') || + text.contains('الأصلية') || + text.contains('اصلي') || + text.contains('أصلي'); + } + + bool _matchesAudioLanguage( + Map format, + String language, + ) { + final wanted = _normalizeLanguageCode(language); + if (wanted == 'auto') { + return false; + } + + final direct = _normalizeLanguageCode( + _stringValue(format['language']) ?? '', + ); + if (direct == wanted) { + return true; + } + + final text = [ + _stringValue(format['format']) ?? '', + _stringValue(format['format_note']) ?? '', + _stringValue(format['audio_track']) ?? '', + _stringValue(format['audio_track_name']) ?? '', + ].join(' ').toLowerCase(); + + switch (wanted) { + case 'ar': + return text.contains('[ar]') || + text.contains('arabic') || + text.contains('arabe') || + text.contains('العربية') || + text.contains('عربي'); + case 'en': + return text.contains('[en]') || + text.contains('english') || + text.contains('anglais'); + case 'fr': + return text.contains('[fr]') || + text.contains('français') || + text.contains('francais') || + text.contains('french'); + case 'de': + return text.contains('[de]') || + text.contains('deutsch') || + text.contains('german'); + case 'es': + return text.contains('[es]') || + text.contains('español') || + text.contains('espanol') || + text.contains('spanish'); + default: + return false; + } + } + + String _normalizeLanguageCode(String language) { + final raw = language.trim().toLowerCase(); + if (raw.isEmpty || raw == 'auto' || raw == 'und') { + return 'auto'; + } + + final normalized = raw.replaceAll('_', '-'); + final separator = normalized.indexOf('-'); + return separator > 0 + ? normalized.substring(0, separator) + : normalized; + } + + List> _collectAvailableAudioTracks( + List> formats, + ) { + final byKey = >{}; + + for (final format in formats) { + if (!_isAudioOnly(format) || !_hasHttpUrl(format)) { + continue; + } + + final original = _isOriginalAudioFormat(format); + final language = _audioLanguageCode(format); + final key = original + ? 'original' + : language != 'und' + ? language + : ''; + + if (key.isEmpty) { + continue; + } + + byKey.putIfAbsent( + key, + () => { + 'key': key, + 'language': language, + 'label': _audioTrackLabel(format), + 'original': original, + }, + ); + } + + return byKey.values.toList(growable: false); + } + + String _audioLanguageCode(Map format) { + final direct = _normalizeLanguageCode( + _stringValue(format['language']) ?? '', + ); + if (direct != 'auto') { + return direct; + } + + for (final code in const ['ar', 'en', 'fr', 'de', 'es']) { + if (_matchesAudioLanguage(format, code)) { + return code; + } + } + + return 'und'; + } + + String _audioTrackLabel(Map format) { + final candidates = [ + _stringValue(format['audio_track_name']), + _stringValue(format['audio_track']), + _stringValue(format['format_note']), + ]; + + for (final value in candidates) { + final text = value?.trim() ?? ''; + if (text.isNotEmpty) { + return text; + } + } + + final language = _audioLanguageCode(format); + return language == 'und' ? 'Piste audio' : language.toUpperCase(); + } + + bool _isVideoOnly(Map format) { + final videoCodec = + _stringValue(format['vcodec']); + + final audioCodec = + _stringValue(format['acodec']); + + return videoCodec != null && + videoCodec != 'none' && + (audioCodec == null || audioCodec == 'none'); + } + + bool _isAudioOnly(Map format) { + final videoCodec = + _stringValue(format['vcodec']); + + final audioCodec = + _stringValue(format['acodec']); + + return audioCodec != null && + audioCodec != 'none' && + (videoCodec == null || videoCodec == 'none'); + } + + bool _hasHttpUrl(Map format) { + final url = _stringValue(format['url']); + + if (url == null || url.isEmpty) { + return false; + } + + final uri = Uri.tryParse(url); + + return uri != null && + (uri.scheme == 'https' || uri.scheme == 'http'); + } + + int _videoCodecScore( + Map format, + int targetHeight, + ) { + final codec = + (_stringValue(format['vcodec']) ?? '') + .toLowerCase(); + + final isAvc = + codec.contains('avc') || + codec.contains('h264') || + codec.contains('h.264'); + + final isVp9 = + codec.contains('vp9') || + codec.contains('vp09'); + + final isAv1 = + codec.contains('av01') || + codec.contains('av1'); + + if (targetHeight <= 1080) { + if (isAvc) { + return 40; + } + if (isVp9) { + return 30; + } + if (isAv1) { + return 20; + } + return 10; + } + + if (isVp9) { + return 40; + } + if (isAv1) { + return 30; + } + if (isAvc) { + return 20; + } + + return 10; + } + + int _audioCodecScore( + Map format, + ) { + final codec = + (_stringValue(format['acodec']) ?? '') + .toLowerCase(); + + if (codec.contains('mp4a') || + codec.contains('aac')) { + return 40; + } + + if (codec.contains('opus')) { + return 30; + } + + return 10; + } + + int _protocolScore( + Map format, + ) { + final protocol = + (_stringValue(format['protocol']) ?? '') + .toLowerCase(); + + if (protocol == 'https' || + protocol == 'http') { + return 40; + } + + if (protocol.contains('dash')) { + return 30; + } + + if (protocol.contains('m3u8')) { + return 20; + } + + return 10; + } + + void _mergeHeaders( + Map output, + dynamic source, { + required bool overwrite, + }) { + if (source is! Map) { + return; + } + + for (final entry in source.entries) { + final key = entry.key.toString().trim(); + final value = + entry.value?.toString().trim() ?? ''; + + if (key.isEmpty || value.isEmpty) { + continue; + } + + if (overwrite || !output.containsKey(key)) { + output[key] = value; + } + } + } + + _WindowsPoTokenProvider _resolvePoTokenProvider() { + final localAppData = + Platform.environment['LOCALAPPDATA']; + + if (localAppData == null || + localAppData.trim().isEmpty) { + throw StateError( + 'LOCALAPPDATA est indisponible sous Windows.', + ); + } + + final root = Directory( + '$localAppData' + '${Platform.pathSeparator}YouTubePersonal' + '${Platform.pathSeparator}windows_hd', + ); + + final pluginDirectory = Directory( + '${root.path}' + '${Platform.pathSeparator}yt-dlp-plugins', + ); + + final pluginArchive = File( + '${pluginDirectory.path}' + '${Platform.pathSeparator}bgutil-ytdlp-pot-provider.zip', + ); + + final serverDirectory = Directory( + '${root.path}' + '${Platform.pathSeparator}bgutil-provider' + '${Platform.pathSeparator}server', + ); + + return _WindowsPoTokenProvider( + pluginDirectory: pluginDirectory, + pluginArchive: pluginArchive, + serverDirectory: serverDirectory, + ); + } + + (File, File) _resolveBundledTools() { + final executableDirectory = + File(Platform.resolvedExecutable).parent; + + final bundledDirectory = Directory( + '${executableDirectory.path}' + '${Platform.pathSeparator}data' + '${Platform.pathSeparator}flutter_assets' + '${Platform.pathSeparator}assets' + '${Platform.pathSeparator}windows_hd', + ); + + final bundledYtDlp = File( + '${bundledDirectory.path}' + '${Platform.pathSeparator}yt-dlp.exe', + ); + + final bundledDeno = File( + '${bundledDirectory.path}' + '${Platform.pathSeparator}deno.exe', + ); + + if (bundledYtDlp.existsSync() && + bundledDeno.existsSync()) { + return (bundledYtDlp, bundledDeno); + } + + final projectDirectory = Directory( + '${Directory.current.path}' + '${Platform.pathSeparator}assets' + '${Platform.pathSeparator}windows_hd', + ); + + return ( + File( + '${projectDirectory.path}' + '${Platform.pathSeparator}yt-dlp.exe', + ), + File( + '${projectDirectory.path}' + '${Platform.pathSeparator}deno.exe', + ), + ); + } + + String? _stringValue(dynamic value) { + if (value == null) { + return null; + } + + final text = value.toString().trim(); + + return text.isEmpty ? null : text; + } + + int _intValue(dynamic value) { + if (value is int) { + return value; + } + + if (value is num) { + return value.round(); + } + + return int.tryParse( + value?.toString() ?? '', + ) ?? + 0; + } + + double _doubleValue(dynamic value) { + if (value is num) { + return value.toDouble(); + } + + return double.tryParse( + value?.toString() ?? '', + ) ?? + 0; + } + + String _compactError(String value) { + final normalized = value + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + if (normalized.length <= 500) { + return normalized; + } + + return '${normalized.substring(0, 500)}…'; + } +} + +class _WindowsPoTokenProvider { + const _WindowsPoTokenProvider({ + required this.pluginDirectory, + required this.pluginArchive, + required this.serverDirectory, + }); + + final Directory pluginDirectory; + final File pluginArchive; + final Directory serverDirectory; +} diff --git a/lib/services/windows_media_relay_service.dart b/lib/services/windows_media_relay_service.dart new file mode 100644 index 0000000..4579c2b --- /dev/null +++ b/lib/services/windows_media_relay_service.dart @@ -0,0 +1,619 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +class WindowsRelaySession { + const WindowsRelaySession({ + required this.videoUrl, + required this.audioUrl, + }); + + final String videoUrl; + final String audioUrl; +} + +/// Relais HTTP local Windows dont le transport upstream est exécuté par +/// yt-dlp lui-même. +/// +/// Architecture : +/// +/// libmpv -> 127.0.0.1 -> yt-dlp HttpFD -> googlevideo +/// +/// Les étapes 17.10/17.11 ont démontré que : +/// - libmpv lit correctement le 1080p depuis localhost ; +/// - le HttpClient Dart n'est pas accepté de façon stable par Googlevideo ; +/// - yt-dlp, lui, sait télécharger le même format. +/// +/// Pour chaque GET/Range demandé par mpv, le relais construit un petit +/// info-json local contenant l'URL signée, les headers et la plage demandée, +/// puis lance le yt-dlp déjà empaqueté avec `--load-info-json` et `-o -`. +/// +/// yt-dlp applique alors exactement son propre downloader HTTP, y compris +/// la logique officielle de reprise/chunking et son stack réseau. +class WindowsMediaRelayService { + HttpServer? _server; + StreamSubscription? _serverSubscription; + + final Map _sources = + {}; + + final Set _activeProcesses = + {}; + + Directory? _sessionDirectory; + + Future start({ + required String ytDlpPath, + required String videoUrl, + required String audioUrl, + required Map videoHeaders, + required Map audioHeaders, + required int videoLength, + required int audioLength, + required int videoChunkSize, + required int audioChunkSize, + required String videoMimeType, + required String audioMimeType, + }) async { + if (!Platform.isWindows) { + throw UnsupportedError( + 'Le relais HD local est réservé à Windows.', + ); + } + + await stop(); + + final ytDlp = File(ytDlpPath); + + if (!await ytDlp.exists()) { + throw StateError( + 'yt-dlp.exe introuvable pour le relais : $ytDlpPath', + ); + } + + if (videoLength <= 0 || audioLength <= 0) { + throw StateError( + 'Taille vidéo/audio inconnue : ' + 'video=$videoLength audio=$audioLength', + ); + } + + final videoUri = Uri.tryParse(videoUrl); + final audioUri = Uri.tryParse(audioUrl); + + if (videoUri == null || + !videoUri.hasScheme || + audioUri == null || + !audioUri.hasScheme) { + throw StateError( + 'URL vidéo/audio invalide pour le relais.', + ); + } + + final sessionDirectory = + await Directory.systemTemp.createTemp( + 'youtube_personal_ytdlp_relay_', + ); + + final server = await HttpServer.bind( + InternetAddress.loopbackIPv4, + 0, + shared: false, + ); + + _sessionDirectory = sessionDirectory; + _server = server; + + _sources + ..clear() + ..['/video'] = _RelaySource( + id: 'video', + ytDlpPath: ytDlp.path, + upstream: videoUri, + headers: videoHeaders, + length: videoLength, + chunkSize: _safeChunkSize(videoChunkSize), + contentType: videoMimeType, + ext: _extensionForMime( + videoMimeType, + fallback: 'mp4', + ), + ) + ..['/audio'] = _RelaySource( + id: 'audio', + ytDlpPath: ytDlp.path, + upstream: audioUri, + headers: audioHeaders, + length: audioLength, + chunkSize: _safeChunkSize(audioChunkSize), + contentType: audioMimeType, + ext: _extensionForMime( + audioMimeType, + fallback: 'm4a', + ), + ); + + _serverSubscription = server.listen( + (request) { + unawaited(_handleRequest(request)); + }, + onError: (Object error, StackTrace stackTrace) { + debugPrint( + '[HD-RELAY] erreur serveur local: $error', + ); + }, + cancelOnError: false, + ); + + final base = + 'http://127.0.0.1:${server.port}'; + + debugPrint( + '[HD-RELAY][17.12] actif sur 127.0.0.1:${server.port} ' + '(transport upstream = yt-dlp HttpFD)', + ); + + return WindowsRelaySession( + videoUrl: '$base/video', + audioUrl: '$base/audio', + ); + } + + Future stop() async { + final subscription = _serverSubscription; + _serverSubscription = null; + + if (subscription != null) { + await subscription.cancel(); + } + + final server = _server; + _server = null; + + if (server != null) { + await server.close(force: true); + } + + final processes = + _activeProcesses.toList(growable: false); + + _activeProcesses.clear(); + + for (final process in processes) { + process.kill(); + } + + final sessionDirectory = _sessionDirectory; + _sessionDirectory = null; + + if (sessionDirectory != null) { + try { + if (await sessionDirectory.exists()) { + await sessionDirectory.delete( + recursive: true, + ); + } + } catch (_) { + // Windows peut garder brièvement un handle après l'arrêt de yt-dlp. + } + } + + _sources.clear(); + } + + int _safeChunkSize(int requested) { + if (requested <= 0) { + return 10 * 1024 * 1024; + } + + return requested.clamp( + 256 * 1024, + 10 * 1024 * 1024, + ); + } + + String _extensionForMime( + String mime, { + required String fallback, + }) { + final normalized = mime.toLowerCase(); + + if (normalized.contains('webm')) { + return 'webm'; + } + + if (normalized.contains('mp4') || + normalized.contains('m4a')) { + return fallback; + } + + return fallback; + } + + Future _handleRequest( + HttpRequest request, + ) async { + final source = _sources[request.uri.path]; + + if (source == null) { + request.response.statusCode = + HttpStatus.notFound; + await request.response.close(); + return; + } + + if (request.method != 'GET' && + request.method != 'HEAD') { + request.response.statusCode = + HttpStatus.methodNotAllowed; + request.response.headers.set( + 'allow', + 'GET, HEAD', + ); + await request.response.close(); + return; + } + + final rangeHeader = + request.headers.value( + HttpHeaders.rangeHeader, + ); + + final parsed = _parseRange( + rangeHeader, + source.length, + ); + + if (rangeHeader != null && parsed == null) { + request.response.statusCode = + HttpStatus.requestedRangeNotSatisfiable; + request.response.headers.set( + 'content-range', + 'bytes */${source.length}', + ); + await request.response.close(); + return; + } + + final start = parsed?.$1 ?? 0; + final end = + parsed?.$2 ?? source.length - 1; + + final response = request.response; + + response.statusCode = parsed == null + ? HttpStatus.ok + : HttpStatus.partialContent; + + response.headers.set( + 'accept-ranges', + 'bytes', + ); + + response.headers.set( + HttpHeaders.cacheControlHeader, + 'no-store', + ); + + response.headers.set( + HttpHeaders.contentTypeHeader, + source.contentType, + ); + + final responseLength = + end - start + 1; + + response.contentLength = + responseLength; + + if (parsed != null) { + response.headers.set( + 'content-range', + 'bytes $start-$end/${source.length}', + ); + } + + debugPrint( + '[HD-RELAY][17.12] ${request.uri.path} ' + '${request.method} bytes=$start-$end ' + '(${responseLength}o)', + ); + + if (request.method == 'HEAD') { + await response.close(); + return; + } + + try { + await _pipeWithYtDlp( + source: source, + start: start, + end: end, + downstream: response, + ); + } on Object catch (error) { + debugPrint( + '[HD-RELAY][17.12] ${request.uri.path} échec: $error', + ); + + try { + await response.close(); + } catch (_) { + // Le lecteur peut avoir fermé la connexion lors d'un seek. + } + } + } + + (int, int)? _parseRange( + String? value, + int totalLength, + ) { + if (value == null || value.trim().isEmpty) { + return null; + } + + final match = RegExp( + r'^bytes=(\d*)-(\d*)$', + caseSensitive: false, + ).firstMatch(value.trim()); + + if (match == null) { + return null; + } + + final startText = match.group(1) ?? ''; + final endText = match.group(2) ?? ''; + + if (startText.isEmpty) { + final suffix = int.tryParse(endText); + + if (suffix == null || suffix <= 0) { + return null; + } + + final start = + (totalLength - suffix).clamp( + 0, + totalLength - 1, + ); + + return (start, totalLength - 1); + } + + final start = int.tryParse(startText); + + if (start == null || + start < 0 || + start >= totalLength) { + return null; + } + + final parsedEnd = endText.isEmpty + ? totalLength - 1 + : int.tryParse(endText); + + if (parsedEnd == null || + parsedEnd < start) { + return null; + } + + final end = parsedEnd.clamp( + start, + totalLength - 1, + ); + + return (start, end); + } + + Future _pipeWithYtDlp({ + required _RelaySource source, + required int start, + required int end, + required HttpResponse downstream, + }) async { + final sessionDirectory = _sessionDirectory; + + if (sessionDirectory == null) { + throw StateError( + 'Session relais yt-dlp fermée.', + ); + } + + final rangeValue = + 'bytes=$start-$end'; + + final infoHeaders = + { + ...source.headers, + 'Accept-Encoding': 'identity', + 'Range': rangeValue, + }; + + final info = { + 'id': + 'youtube-personal-${source.id}', + 'title': + 'YouTube Personal ${source.id}', + 'ext': source.ext, + 'protocol': 'https', + 'url': source.upstream.toString(), + 'http_headers': infoHeaders, + 'filesize': source.length, + 'downloader_options': + { + 'http_chunk_size': + source.chunkSize, + }, + }; + + final stamp = + '${DateTime.now().microsecondsSinceEpoch}' + '-${requestCounter++}'; + + final infoFile = File( + '${sessionDirectory.path}' + '${Platform.pathSeparator}' + '${source.id}-$stamp.info.json', + ); + + await infoFile.writeAsString( + jsonEncode(info), + encoding: utf8, + flush: true, + ); + + final process = await Process.start( + source.ytDlpPath, + [ + '--ignore-config', + '--no-warnings', + '--no-progress', + '--socket-timeout', + '20', + '--retries', + '2', + '--no-part', + '--load-info-json', + infoFile.path, + '-o', + '-', + ], + workingDirectory: + File(source.ytDlpPath).parent.path, + runInShell: false, + mode: ProcessStartMode.normal, + ); + + _activeProcesses.add(process); + + final stderrBuffer = + StringBuffer(); + + final stderrSubscription = + process.stderr + .transform(utf8.decoder) + .listen( + (text) { + if (stderrBuffer.length < 4000) { + final remaining = + 4000 - stderrBuffer.length; + + stderrBuffer.write( + text.length <= remaining + ? text + : text.substring(0, remaining), + ); + } + }, + ); + + var written = 0; + Object? streamError; + + debugPrint( + '[HD-RELAY][17.12] ${source.id}: ' + 'yt-dlp démarre Range=$rangeValue', + ); + + try { + await for (final data in process.stdout) { + if (written >= end - start + 1) { + break; + } + + final remaining = + end - start + 1 - written; + + if (data.length <= remaining) { + downstream.add(data); + written += data.length; + } else { + downstream.add( + data.sublist(0, remaining), + ); + written += remaining; + } + + // Backpressure : permet au lecteur de contrôler la vitesse + // au lieu de remplir massivement la mémoire. + await downstream.flush(); + + if (written >= end - start + 1) { + break; + } + } + } on Object catch (error) { + streamError = error; + } + + if (written >= end - start + 1) { + // La plage demandée est complète. Si yt-dlp n'a pas encore quitté + // (par ex. sortie stdout plus large), on peut arrêter le process. + process.kill(); + } + + final exitCode = await process.exitCode; + + await stderrSubscription.cancel(); + _activeProcesses.remove(process); + + try { + if (await infoFile.exists()) { + await infoFile.delete(); + } + } catch (_) { + // Non bloquant. + } + + if (streamError != null) { + throw streamError; + } + + if (written != end - start + 1) { + final stderr = stderrBuffer + .toString() + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + throw HttpException( + 'yt-dlp a fourni $written/' + '${end - start + 1} octets ' + '(code=$exitCode)' + '${stderr.isEmpty ? '' : ': $stderr'}', + ); + } + + debugPrint( + '[HD-RELAY][17.12] ${source.id}: ' + 'yt-dlp OK ${written}o', + ); + + await downstream.close(); + } + + int requestCounter = 0; +} + +class _RelaySource { + const _RelaySource({ + required this.id, + required this.ytDlpPath, + required this.upstream, + required this.headers, + required this.length, + required this.chunkSize, + required this.contentType, + required this.ext, + }); + + final String id; + final String ytDlpPath; + final Uri upstream; + final Map headers; + final int length; + final int chunkSize; + final String contentType; + final String ext; +} diff --git a/lib/services/young_audience_filter_service.dart b/lib/services/young_audience_filter_service.dart new file mode 100644 index 0000000..ca0a226 --- /dev/null +++ b/lib/services/young_audience_filter_service.dart @@ -0,0 +1,179 @@ +import '../models/video_item.dart'; + +class YoungAudienceFilterService { + YoungAudienceFilterService._(); + + static bool shouldHide(VideoItem video, int level) { + if (level <= 0) { + return false; + } + + final score = scoreVideo(video); + final threshold = level >= 2 ? 4 : 8; + return score >= threshold; + } + + static int scoreVideo(VideoItem video) { + final author = _normalize(video.author); + final title = _normalize(video.title); + final description = _normalize(video.description); + final combined = ' $author $title $description '; + + var score = 0; + + for (final marker in _knownYoungAudienceBrands) { + if (author.contains(marker) || title.contains(marker)) { + score += 12; + break; + } + } + + for (final marker in _strongSignals) { + if (combined.contains(' $marker ')) { + score += 5; + } + } + + for (final marker in _phraseSignals) { + if (combined.contains(marker)) { + score += 6; + } + } + + for (final marker in _softSignals) { + if (combined.contains(' $marker ')) { + score += 2; + } + } + + return score.clamp(0, 30).toInt(); + } + + static const List _knownYoungAudienceBrands = [ + 'cocomelon', + 'babybus', + 'little angel', + 'kids diana', + 'vlad and niki', + 'like nastya', + 'ryans world', + 'ryan world', + 'toys and colors', + 'chuchu tv', + 'pinkfong', + 'super simple songs', + 'blippi', + 'peppa pig', + 'masha and the bear', + 'vlad a4', + 'vlad ara', + 'ninja kidz tv', + 'kids fun tv', + 'a for adley', + 'sis vs bro', + 'salish matter', + ]; + + static const List _strongSignals = [ + 'kids', + 'kid', + 'children', + 'child', + 'baby', + 'babies', + 'toddler', + 'toddlers', + 'preschool', + 'nursery', + 'toys', + 'toy', + 'enfant', + 'enfants', + 'bebe', + 'bebes', + 'jouet', + 'jouets', + 'comptine', + 'comptines', + 'maternelle', + 'creche', + 'ninos', + 'juguetes', + 'kinder', + 'spielzeug', + 'اطفال', + 'أطفال', + 'طفل', + 'رضيع', + 'العاب', + 'ألعاب', + 'اناشيد', + 'أناشيد', + ]; + + static const List _phraseSignals = [ + 'nursery rhyme', + 'nursery rhymes', + 'kids song', + 'kids songs', + 'baby song', + 'baby songs', + 'toy review', + 'for kids', + 'for children', + 'pour enfants', + 'pour bebe', + 'pour les enfants', + 'dessin anime enfant', + 'dessins animes enfants', + ]; + + static const List _softSignals = [ + 'school', + 'cartoon', + 'animation', + 'family', + 'familyfun', + 'playtime', + 'playhouse', + 'slime', + 'challenge', + ]; + + static String _normalize(String value) { + var text = value.toLowerCase(); + + const replacements = { + 'à': 'a', + 'â': 'a', + 'ä': 'a', + 'á': 'a', + 'ã': 'a', + 'ç': 'c', + 'é': 'e', + 'è': 'e', + 'ê': 'e', + 'ë': 'e', + 'î': 'i', + 'ï': 'i', + 'í': 'i', + 'ô': 'o', + 'ö': 'o', + 'ó': 'o', + 'ù': 'u', + 'û': 'u', + 'ü': 'u', + 'ú': 'u', + 'ÿ': 'y', + }; + + replacements.forEach((source, target) { + text = text.replaceAll(source, target); + }); + + return text + .replaceAll(RegExp(r'[^a-z0-9\u0600-\u06ff]+'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } +} diff --git a/lib/services/youtube_account_service.dart b/lib/services/youtube_account_service.dart new file mode 100644 index 0000000..8e5b46d --- /dev/null +++ b/lib/services/youtube_account_service.dart @@ -0,0 +1,830 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/channel_details.dart'; +import '../models/subscription_channel.dart'; +import '../models/video_item.dart'; + +class YoutubeAccountService { + Future> fetchSubscriptions( + String accessToken, + ) async { + final result = []; + String? pageToken; + + do { + final decoded = await _authorizedGetJson( + accessToken, + '/youtube/v3/subscriptions', + { + 'part': 'snippet', + 'mine': 'true', + 'maxResults': '50', + 'order': 'alphabetical', + if (pageToken != null) + 'pageToken': pageToken, + }, + ); + + final items = decoded['items']; + + if (items is List) { + for (final item in items) { + if (item is! Map) { + continue; + } + + final snippet = item['snippet']; + + if (snippet is! Map) { + continue; + } + + final resourceId = snippet['resourceId']; + final thumbnails = snippet['thumbnails']; + + final channelId = resourceId is Map + ? resourceId['channelId'] + : null; + + final title = snippet['title']; + + if (channelId is! String || + title is! String) { + continue; + } + + result.add( + SubscriptionChannel( + channelId: channelId, + title: title, + thumbnailUrl: + _bestThumbnailUrl(thumbnails), + description: + snippet['description'] is String + ? snippet['description'] as String + : '', + ), + ); + } + } + + final next = decoded['nextPageToken']; + + pageToken = + next is String && next.isNotEmpty + ? next + : null; + } while (pageToken != null); + + return result; + } + + Future> fetchRecentVideosForChannel( + String accessToken, + String channelId, { + int maxResults = 30, + }) async { + final safeMaxResults = maxResults.clamp(1, 50); + + // 1. Récupère la playlist système "uploads" de la chaîne. + final channelData = await _authorizedGetJson( + accessToken, + '/youtube/v3/channels', + { + 'part': 'contentDetails', + 'id': channelId, + }, + ); + + final channelItems = channelData['items']; + + if (channelItems is! List || + channelItems.isEmpty || + channelItems.first is! Map) { + return const []; + } + + final channel = channelItems.first as Map; + final contentDetails = channel['contentDetails']; + + if (contentDetails is! Map) { + return const []; + } + + final relatedPlaylists = + contentDetails['relatedPlaylists']; + + if (relatedPlaylists is! Map) { + return const []; + } + + final uploadsPlaylistId = + relatedPlaylists['uploads']; + + if (uploadsPlaylistId is! String || + uploadsPlaylistId.isEmpty) { + return const []; + } + + // 2. Les éléments de cette playlist sont les mises en ligne, + // dans l'ordre de la playlist YouTube. + final playlistData = await _authorizedGetJson( + accessToken, + '/youtube/v3/playlistItems', + { + 'part': 'snippet,contentDetails', + 'playlistId': uploadsPlaylistId, + 'maxResults': '$safeMaxResults', + }, + ); + + final playlistItems = playlistData['items']; + + if (playlistItems is! List || + playlistItems.isEmpty) { + return const []; + } + + final rawVideos = <_RawChannelVideo>[]; + + for (final item in playlistItems) { + if (item is! Map) { + continue; + } + + final content = item['contentDetails']; + final snippet = item['snippet']; + + if (content is! Map || snippet is! Map) { + continue; + } + + final videoId = content['videoId']; + final title = snippet['title']; + + if (videoId is! String || + videoId.isEmpty || + title is! String || + title.isEmpty) { + continue; + } + + // Les entrées privées/supprimées peuvent rester dans une + // playlist d'uploads. On évite de les présenter comme vidéos. + if (title == 'Private video' || + title == 'Deleted video') { + continue; + } + + rawVideos.add( + _RawChannelVideo( + id: videoId, + title: title, + author: + snippet['videoOwnerChannelTitle'] + is String + ? snippet[ + 'videoOwnerChannelTitle'] + as String + : snippet['channelTitle'] + is String + ? snippet['channelTitle'] + as String + : '', + thumbnailUrl: _bestThumbnailUrl( + snippet['thumbnails'], + ), + ), + ); + } + + if (rawVideos.isEmpty) { + return const []; + } + + // 3. playlistItems ne donne pas la durée. videos.list permet + // de récupérer contentDetails.duration pour tous les IDs en + // une seule requête (jusqu'à 50 IDs). + final videoData = await _authorizedGetJson( + accessToken, + '/youtube/v3/videos', + { + 'part': 'contentDetails,status', + 'id': rawVideos + .map((video) => video.id) + .join(','), + }, + ); + + final durationById = {}; + final availableIds = {}; + + final videoItems = videoData['items']; + + if (videoItems is List) { + for (final item in videoItems) { + if (item is! Map) { + continue; + } + + final id = item['id']; + + if (id is! String || id.isEmpty) { + continue; + } + + final status = item['status']; + + if (status is Map && + status['privacyStatus'] == 'private') { + continue; + } + + availableIds.add(id); + + final details = item['contentDetails']; + final durationText = + details is Map + ? details['duration'] + : null; + + durationById[id] = + durationText is String + ? _parseIso8601Duration( + durationText, + ) + : null; + } + } + + return rawVideos + .where( + (video) => availableIds.contains(video.id), + ) + .map( + (video) => VideoItem( + id: video.id, + title: video.title, + author: video.author, + thumbnailUrl: video.thumbnailUrl, + duration: durationById[video.id], + ), + ) + .toList(growable: false); + } + + Future fetchChannelDetails( + String accessToken, + String channelId, + ) async { + final decoded = await _authorizedGetJson( + accessToken, + '/youtube/v3/channels', + { + 'part': 'snippet,statistics', + 'id': channelId, + }, + ); + + final items = decoded['items']; + + if (items is! List || + items.isEmpty || + items.first is! Map) { + return null; + } + + final item = items.first as Map; + final snippet = item['snippet']; + final statistics = item['statistics']; + + if (snippet is! Map) { + return null; + } + + final hiddenSubscriberCount = + statistics is Map && + statistics['hiddenSubscriberCount'] == true; + + return ChannelDetails( + channelId: channelId, + title: snippet['title'] is String + ? snippet['title'] as String + : '', + description: snippet['description'] is String + ? snippet['description'] as String + : '', + thumbnailUrl: + _bestThumbnailUrl(snippet['thumbnails']), + publishedAt: snippet['publishedAt'] is String + ? DateTime.tryParse( + snippet['publishedAt'] as String, + ) + : null, + viewCount: statistics is Map + ? _parseInt(statistics['viewCount']) + : null, + subscriberCount: + hiddenSubscriberCount || statistics is! Map + ? null + : _parseInt( + statistics['subscriberCount'], + ), + hiddenSubscriberCount: + hiddenSubscriberCount, + videoCount: statistics is Map + ? _parseInt(statistics['videoCount']) + : null, + country: snippet['country'] is String + ? snippet['country'] as String + : null, + ); + } + + /// Streams all public uploads from a channel through YouTube Data API v3. + /// + /// Unlike the old 30-video helper, this follows playlistItems.nextPageToken + /// until the uploads playlist is exhausted and enriches each page with + /// duration, public view count and publication date. + Stream streamAllVideosForChannel( + String accessToken, + String channelId, + ) async* { + final channelData = await _authorizedGetJson( + accessToken, + '/youtube/v3/channels', + { + 'part': 'contentDetails', + 'id': channelId, + }, + ); + + final channelItems = channelData['items']; + if (channelItems is! List || + channelItems.isEmpty || + channelItems.first is! Map) { + return; + } + + final contentDetails = + (channelItems.first as Map)['contentDetails']; + if (contentDetails is! Map) { + return; + } + + final relatedPlaylists = contentDetails['relatedPlaylists']; + if (relatedPlaylists is! Map) { + return; + } + + final uploadsPlaylistId = relatedPlaylists['uploads']; + if (uploadsPlaylistId is! String || uploadsPlaylistId.isEmpty) { + return; + } + + String? pageToken; + + do { + final playlistData = await _authorizedGetJson( + accessToken, + '/youtube/v3/playlistItems', + { + 'part': 'snippet,contentDetails', + 'playlistId': uploadsPlaylistId, + 'maxResults': '50', + if (pageToken != null) 'pageToken': pageToken, + }, + ); + + final playlistItems = playlistData['items']; + final rawVideos = <_RawFullChannelVideo>[]; + + if (playlistItems is List) { + for (final item in playlistItems) { + if (item is! Map) { + continue; + } + + final content = item['contentDetails']; + final snippet = item['snippet']; + + if (content is! Map || snippet is! Map) { + continue; + } + + final videoId = content['videoId']; + final title = snippet['title']; + + if (videoId is! String || + videoId.isEmpty || + title is! String || + title.isEmpty || + title == 'Private video' || + title == 'Deleted video') { + continue; + } + + rawVideos.add( + _RawFullChannelVideo( + id: videoId, + title: title, + author: snippet['videoOwnerChannelTitle'] is String + ? snippet['videoOwnerChannelTitle'] as String + : snippet['channelTitle'] is String + ? snippet['channelTitle'] as String + : '', + thumbnailUrl: _bestThumbnailUrl(snippet['thumbnails']), + description: snippet['description'] is String + ? snippet['description'] as String + : '', + uploadDate: snippet['publishedAt'] is String + ? DateTime.tryParse(snippet['publishedAt'] as String) + : null, + ), + ); + } + } + + if (rawVideos.isNotEmpty) { + final videoData = await _authorizedGetJson( + accessToken, + '/youtube/v3/videos', + { + 'part': 'contentDetails,status,statistics,snippet', + 'id': rawVideos.map((video) => video.id).join(','), + 'maxResults': '50', + }, + ); + + final byId = {}; + final videoItems = videoData['items']; + + if (videoItems is List) { + for (final item in videoItems) { + if (item is! Map) { + continue; + } + + final id = item['id']; + if (id is String && id.isNotEmpty) { + byId[id] = item; + } + } + } + + for (final raw in rawVideos) { + final item = byId[raw.id]; + if (item == null) { + continue; + } + + final status = item['status']; + if (status is Map && status['privacyStatus'] == 'private') { + continue; + } + + final details = item['contentDetails']; + final durationText = + details is Map ? details['duration'] : null; + + final statistics = item['statistics']; + final viewCountText = + statistics is Map ? statistics['viewCount'] : null; + final likeCountText = + statistics is Map ? statistics['likeCount'] : null; + final commentCountText = + statistics is Map ? statistics['commentCount'] : null; + + final apiSnippet = item['snippet']; + final apiPublishedAt = apiSnippet is Map + ? apiSnippet['publishedAt'] + : null; + + yield VideoItem( + id: raw.id, + title: raw.title, + author: raw.author, + channelId: channelId, + thumbnailUrl: raw.thumbnailUrl, + description: raw.description, + duration: durationText is String + ? _parseIso8601Duration(durationText) + : null, + viewCount: _parseInt(viewCountText), + likeCount: _parseInt(likeCountText), + commentCount: _parseInt(commentCountText), + uploadDate: apiPublishedAt is String + ? DateTime.tryParse(apiPublishedAt) + : raw.uploadDate, + ); + } + } + + final next = playlistData['nextPageToken']; + pageToken = next is String && next.isNotEmpty ? next : null; + } while (pageToken != null); + } + + int? _parseInt(dynamic value) { + if (value is int) { + return value; + } + + if (value is num) { + return value.toInt(); + } + + if (value is String) { + return int.tryParse(value); + } + + return null; + } + + Future> _authorizedGetJson( + String accessToken, + String path, + Map query, + ) async { + final uri = Uri.https( + 'www.googleapis.com', + path, + query, + ); + + final response = await http.get( + uri, + headers: { + 'Authorization': 'Bearer $accessToken', + 'Accept': 'application/json', + }, + ); + + if (response.statusCode == 401) { + throw StateError( + 'La session Google a expiré. ' + 'Déconnecte puis reconnecte ton compte.', + ); + } + + if (response.statusCode < 200 || + response.statusCode >= 300) { + final apiError = _parseApiError( + response.statusCode, + response.body, + ); + + print( + '[YT-ACCOUNT][API-ERROR] ' + 'status=${response.statusCode} ' + 'reason=${apiError.reason} ' + 'message=${apiError.message}', + ); + + throw StateError( + _friendlyErrorMessage( + response.statusCode, + apiError, + ), + ); + } + + final decoded = jsonDecode(response.body); + + if (decoded is! Map) { + throw StateError( + 'Réponse YouTube Data API invalide.', + ); + } + + return decoded; + } + + String _bestThumbnailUrl(dynamic thumbnails) { + if (thumbnails is! Map) { + return ''; + } + + for (final key in const [ + 'maxres', + 'standard', + 'high', + 'medium', + 'default', + ]) { + final value = thumbnails[key]; + + if (value is Map && + value['url'] is String) { + return value['url'] as String; + } + } + + return ''; + } + + Duration? _parseIso8601Duration( + String value, + ) { + final match = RegExp( + r'^P(?:(\d+)D)?' + r'(?:T' + r'(?:(\d+)H)?' + r'(?:(\d+)M)?' + r'(?:(\d+(?:\.\d+)?)S)?' + r')?$', + ).firstMatch(value); + + if (match == null) { + return null; + } + + final days = + int.tryParse(match.group(1) ?? '') ?? 0; + final hours = + int.tryParse(match.group(2) ?? '') ?? 0; + final minutes = + int.tryParse(match.group(3) ?? '') ?? 0; + final seconds = + double.tryParse(match.group(4) ?? '') ?? 0; + + final totalMilliseconds = + (((days * 24 + hours) * 60 + minutes) * + 60 * + 1000 + + seconds * 1000) + .round(); + + return Duration( + milliseconds: totalMilliseconds, + ); + } + + _YoutubeApiError _parseApiError( + int statusCode, + String body, + ) { + try { + final decoded = jsonDecode(body); + + if (decoded is Map) { + final error = decoded['error']; + + if (error is Map) { + final topMessage = + error['message'] is String + ? error['message'] as String + : ''; + + final errors = error['errors']; + + if (errors is List && + errors.isNotEmpty) { + final first = errors.first; + + if (first is Map) { + return _YoutubeApiError( + reason: + first['reason'] is String + ? first['reason'] as String + : 'unknown', + message: + first['message'] is String + ? first['message'] as String + : topMessage, + ); + } + } + + final details = error['details']; + + if (details is List) { + for (final detail in details) { + if (detail is! Map) { + continue; + } + + final reason = detail['reason']; + + if (reason is String && + reason.isNotEmpty) { + return _YoutubeApiError( + reason: reason, + message: topMessage, + ); + } + } + } + + return _YoutubeApiError( + reason: 'unknown', + message: topMessage, + ); + } + } + } catch (_) { + // Le corps brut sera repris ci-dessous. + } + + return _YoutubeApiError( + reason: 'unknown', + message: body, + ); + } + + String _friendlyErrorMessage( + int statusCode, + _YoutubeApiError error, + ) { + final reason = error.reason; + + if (statusCode == 403) { + switch (reason) { + case 'insufficientPermissions': + return 'Google a accepté la connexion, ' + 'mais le jeton OAuth ne contient pas ' + 'la permission YouTube nécessaire ' + '(youtube.readonly). Ajoute ce scope ' + 'dans Google Auth Platform > Accès aux ' + 'données, puis déconnecte et reconnecte ' + 'le compte dans l’application.'; + + case 'accessNotConfigured': + case 'serviceDisabled': + case 'apiDisabled': + return 'YouTube Data API v3 est désactivée ' + 'pour ce projet Google Cloud.'; + + case 'quotaExceeded': + case 'dailyLimitExceeded': + return 'Le quota YouTube Data API du projet ' + 'est temporairement épuisé.'; + + case 'accountClosed': + return 'Le compte ou la chaîne YouTube ' + 'associée est fermé(e).'; + + case 'accountSuspended': + return 'YouTube indique que la chaîne ' + 'associée au compte est suspendue.'; + + case 'subscriptionForbidden': + return 'Google a authentifié le compte, ' + 'mais YouTube refuse l’accès aux ' + 'abonnements pour ce compte.'; + + default: + return 'YouTube Data API refuse la requête ' + '(403, raison: $reason). ' + '${error.message}'; + } + } + + return 'YouTube Data API $statusCode ' + '(raison: $reason): ${error.message}'; + } +} + +class _RawChannelVideo { + const _RawChannelVideo({ + required this.id, + required this.title, + required this.author, + required this.thumbnailUrl, + }); + + final String id; + final String title; + final String author; + final String thumbnailUrl; +} + +class _RawFullChannelVideo { + const _RawFullChannelVideo({ + required this.id, + required this.title, + required this.author, + required this.thumbnailUrl, + required this.description, + required this.uploadDate, + }); + + final String id; + final String title; + final String author; + final String thumbnailUrl; + final String description; + final DateTime? uploadDate; +} + +class _YoutubeApiError { + const _YoutubeApiError({ + required this.reason, + required this.message, + }); + + final String reason; + final String message; +} diff --git a/lib/services/youtube_service.dart b/lib/services/youtube_service.dart new file mode 100644 index 0000000..4d847d5 --- /dev/null +++ b/lib/services/youtube_service.dart @@ -0,0 +1,481 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +import '../models/channel_details.dart'; +import '../models/subscription_channel.dart'; +import '../models/video_item.dart'; +import 'windows_channel_metadata_service.dart'; + +enum ChannelUploadSort { + newest, + popularity, + oldest, +} + +class ChannelUploadBatch { + const ChannelUploadBatch({ + required this.videos, + required this.hasMore, + }); + + final List videos; + final bool hasMore; +} + +class YoutubeService { + final YoutubeExplode _youtube = YoutubeExplode(); + + ChannelUploadsList? _channelUploadsPage; + String? _channelUploadsChannelId; + ChannelUploadSort? _channelUploadsSort; + bool _channelUploadsExhausted = false; + + Future> searchVideos(String query) async { + final normalizedQuery = query.trim(); + + if (normalizedQuery.isEmpty) { + return const []; + } + + try { + final results = await _youtube.search.search(normalizedQuery); + + return results + .map(_toVideoItem) + .toList(growable: false); + } catch (error) { + return _searchWithYtDlp(normalizedQuery); + } + } + + Future> _searchWithYtDlp(String query) async { + try { + final result = await Process.run('yt-dlp', [ + '--flat-playlist', + '-j', + 'ytsearch10:$query', + ]); + + if (result.exitCode != 0) { + return const []; + } + + final lines = (result.stdout as String) + .split('\n') + .where((l) => l.trim().isNotEmpty) + .toList(); + + final videos = []; + for (final line in lines) { + try { + final json = jsonDecode(line) as Map; + videos.add(VideoItem( + id: json['id'] as String? ?? '', + title: json['title'] as String? ?? '', + author: json['channel'] as String? ?? json['uploader'] as String? ?? '', + channelId: json['channel_id'] as String? ?? '', + duration: json['duration'] != null + ? Duration(seconds: (json['duration'] as num).toInt()) + : null, + viewCount: json['view_count'] as num?, + likeCount: null, + description: json['description'] as String? ?? '', + uploadDate: null, + thumbnailUrl: json['thumbnails'] != null + ? (json['thumbnails'] as List).isNotEmpty + ? (json['thumbnails'][0] as Map)['url'] as String? ?? + 'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg' + : 'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg' + : 'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg', + )); + } catch (_) { + continue; + } + } + + return videos; + } catch (_) { + return const []; + } + } + + Future getVideoDetails( + String videoId, + ) async { + final video = + await _youtube.videos.get(videoId); + + return _toVideoItem(video); + } + + /// Resolves the uploader directly from the video. + /// + /// This is intentionally independent from Google OAuth, so clicking the + /// channel works for search results, Radar recommendations, history and + /// favorites on both Windows and Android. + Future getChannelForVideo( + String videoId, + ) async { + final channel = + await _youtube.channels.getByVideo(videoId); + + return _toSubscriptionChannel(channel); + } + + Future getChannel( + String channelId, + ) async { + final channel = + await _youtube.channels.get(channelId); + + return _toSubscriptionChannel(channel); + } + + Future getChannelPreviewDetails( + String channelId, + ) async { + if (channelId.trim().isEmpty) { + return null; + } + + try { + final channel = + await _youtube.channels.get(channelId); + + return ChannelDetails( + channelId: channel.id.value, + title: channel.title, + description: '', + thumbnailUrl: channel.logoUrl, + subscriberCount: channel.subscribersCount, + hiddenSubscriberCount: + channel.subscribersCount == null, + ); + } catch (_) { + return null; + } + } + + /// Public channel metadata that does NOT require Google OAuth. + /// + /// youtube_explode_dart exposes the channel's approximate subscriber + /// count and the public About page exposes description, channel views, + /// join date and country. + Future getPublicChannelDetails( + String channelId, + ) async { + Channel? channel; + ChannelAbout? about; + + try { + channel = await _youtube.channels.get(channelId); + } catch (error) { + // The Windows yt-dlp fallback below can still recover channel metadata. + // ignore: avoid_print + print( + '[CHANNEL-PUBLIC][18.5.2] Channel.get échoué: $error', + ); + } + + try { + about = await _youtube.channels.getAboutPage(channelId); + } catch (error) { + // Current YouTube layouts sometimes make the public About parser return + // nothing. This is expected; yt-dlp is our Windows fallback. + // ignore: avoid_print + print( + '[CHANNEL-PUBLIC][18.5.2] About indisponible: $error', + ); + } + + final publicDetails = ChannelDetails( + channelId: channel?.id.value ?? channelId, + title: channel?.title ?? about?.title ?? '', + description: about?.description ?? '', + thumbnailUrl: channel?.logoUrl ?? + (about != null && about.thumbnails.isNotEmpty + ? about.thumbnails.last.url.toString() + : ''), + joinDateLabel: about?.joinDate, + viewCount: about?.viewCount, + subscriberCount: channel?.subscribersCount, + hiddenSubscriberCount: + channel?.subscribersCount == null, + country: about?.country, + ); + + final windowsDetails = + await WindowsChannelMetadataService.instance + .getDetails(channelId); + + final merged = _mergeChannelDetails( + publicDetails, + windowsDetails, + ); + + // ignore: avoid_print + print( + '[CHANNEL-META][18.5.2] id=$channelId ' + 'subs=${merged.subscriberCount ?? 'NA'} ' + 'views=${merged.viewCount ?? 'NA'} ' + 'videos=${merged.videoCount ?? 'NA'} ' + 'date=${merged.publishedAt?.toIso8601String() ?? merged.joinDateLabel ?? 'NA'}', + ); + + return merged; + } + + ChannelDetails _mergeChannelDetails( + ChannelDetails primary, + ChannelDetails? fallback, + ) { + if (fallback == null) { + return primary; + } + + String chooseString( + String first, + String second, + ) { + return first.trim().isNotEmpty + ? first + : second; + } + + return ChannelDetails( + channelId: chooseString( + primary.channelId, + fallback.channelId, + ), + title: chooseString( + primary.title, + fallback.title, + ), + description: chooseString( + primary.description, + fallback.description, + ), + thumbnailUrl: chooseString( + primary.thumbnailUrl, + fallback.thumbnailUrl, + ), + publishedAt: + primary.publishedAt ?? fallback.publishedAt, + joinDateLabel: + primary.joinDateLabel ?? fallback.joinDateLabel, + viewCount: + primary.viewCount ?? fallback.viewCount, + subscriberCount: + primary.subscriberCount ?? fallback.subscriberCount, + hiddenSubscriberCount: + primary.subscriberCount == null && + fallback.subscriberCount == null, + videoCount: + primary.videoCount ?? fallback.videoCount, + country: + primary.country ?? fallback.country, + ); + } + + VideoSorting _videoSortingFor(ChannelUploadSort sort) { + return switch (sort) { + ChannelUploadSort.newest => VideoSorting.newest, + ChannelUploadSort.popularity => VideoSorting.popularity, + ChannelUploadSort.oldest => VideoSorting.oldest, + }; + } + + /// Loads ONE public channel page at a time. + /// + /// youtube_explode_dart exposes ChannelUploadsList.nextPage(), so the UI + /// can keep the first ~30 entries fast and explicitly request more instead + /// of pretending that the first page is the whole channel. + Future getChannelUploadsPage( + String channelId, { + ChannelUploadSort sort = ChannelUploadSort.newest, + bool reset = false, + }) async { + final normalizedId = channelId.trim(); + + if (normalizedId.isEmpty) { + return const ChannelUploadBatch( + videos: [], + hasMore: false, + ); + } + + final mustReset = + reset || + _channelUploadsPage == null || + _channelUploadsChannelId != normalizedId || + _channelUploadsSort != sort; + + ChannelUploadsList? page; + + if (mustReset) { + _channelUploadsPage = + await _youtube.channels.getUploadsFromPage( + normalizedId, + videoSorting: _videoSortingFor(sort), + ); + _channelUploadsChannelId = normalizedId; + _channelUploadsSort = sort; + _channelUploadsExhausted = false; + page = _channelUploadsPage; + } else { + if (_channelUploadsExhausted) { + return const ChannelUploadBatch( + videos: [], + hasMore: false, + ); + } + + final current = _channelUploadsPage; + if (current == null) { + return const ChannelUploadBatch( + videos: [], + hasMore: false, + ); + } + + page = await current.nextPage(); + if (page == null) { + _channelUploadsExhausted = true; + return const ChannelUploadBatch( + videos: [], + hasMore: false, + ); + } + + _channelUploadsPage = page; + } + + final resolvedPage = page; + if (resolvedPage == null) { + _channelUploadsExhausted = true; + return const ChannelUploadBatch( + videos: [], + hasMore: false, + ); + } + + final videos = resolvedPage + .map(_toVideoItem) + .toList(growable: false); + + // YouTube channel pages are normally around 30 items. There is no public + // `hasNextPage` flag on ChannelUploadsList, only nextPage(). A short page + // is therefore considered final; an exact full page keeps the button. + // If the last page contains exactly 30 entries, the next click simply + // receives null and hides the button without losing any data. + final hasMore = videos.length >= 30; + if (!hasMore) { + _channelUploadsExhausted = true; + } + + // ignore: avoid_print + print( + '[CHANNEL-VIDEOS][18.7.20] ' + 'channel=$normalizedId ' + 'sort=${sort.name} ' + 'pageVideos=${videos.length} ' + 'hasMore=$hasMore', + ); + + return ChannelUploadBatch( + videos: videos, + hasMore: hasMore, + ); + } + + /// Streams the full public upload catalogue when a caller really needs it. + /// The channel UI uses [getChannelUploadsPage] instead for progressive load. + Stream getChannelUploads( + String channelId, { + ChannelUploadSort sort = ChannelUploadSort.newest, + }) async* { + var page = await _youtube.channels.getUploadsFromPage( + channelId, + videoSorting: _videoSortingFor(sort), + ); + + while (true) { + for (final video in page) { + yield _toVideoItem(video); + } + + final next = await page.nextPage(); + if (next == null) { + break; + } + page = next; + } + } + + Future getPlayableMuxedUrl( + String videoId, + ) async { + // Stable progressive fallback used by the existing player. + final manifest = + await _youtube.videos.streams.getManifest(videoId); + + final muxed = + manifest.muxed.toList(growable: false); + + if (muxed.isEmpty) { + throw StateError( + 'Aucun flux audio + vidéo compatible n’est disponible.', + ); + } + + final sorted = [...muxed] + ..sort((a, b) { + final height = + b.videoResolution.height.compareTo( + a.videoResolution.height, + ); + + if (height != 0) { + return height; + } + + return b.bitrate.bitsPerSecond.compareTo( + a.bitrate.bitsPerSecond, + ); + }); + + return sorted.first.url.toString(); + } + + VideoItem _toVideoItem(Video video) { + return VideoItem( + id: video.id.value, + title: video.title, + author: video.author, + channelId: video.channelId.value, + duration: video.duration, + viewCount: video.engagement.viewCount, + likeCount: video.engagement.likeCount, + description: video.description, + uploadDate: video.uploadDate, + thumbnailUrl: + 'https://i.ytimg.com/vi/${video.id.value}/hqdefault.jpg', + ); + } + + SubscriptionChannel _toSubscriptionChannel( + Channel channel, + ) { + return SubscriptionChannel( + channelId: channel.id.value, + title: channel.title, + thumbnailUrl: channel.logoUrl, + ); + } + + void close() { + _youtube.close(); + } +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..829c0c7 --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + AppTheme._(); + + static const Color accent = Color(0xFFFF4E45); + + static ThemeData get dark { + final scheme = ColorScheme.fromSeed( + seedColor: accent, + brightness: Brightness.dark, + ).copyWith( + surface: const Color(0xFF181818), + surfaceContainerLowest: const Color(0xFF0F0F0F), + surfaceContainerLow: const Color(0xFF181818), + surfaceContainer: const Color(0xFF202020), + surfaceContainerHigh: const Color(0xFF252525), + surfaceContainerHighest: const Color(0xFF303030), + outlineVariant: const Color(0xFF3A3A3A), + ); + + return ThemeData( + brightness: Brightness.dark, + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: const Color(0xFF0F0F0F), + dividerColor: scheme.outlineVariant, + navigationBarTheme: NavigationBarThemeData( + backgroundColor: const Color(0xFF181818), + indicatorColor: accent.withValues(alpha: 0.18), + ), + navigationRailTheme: NavigationRailThemeData( + backgroundColor: const Color(0xFF181818), + indicatorColor: accent.withValues(alpha: 0.18), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + ), + ); + } + + static ThemeData get light { + final scheme = ColorScheme.fromSeed( + seedColor: accent, + brightness: Brightness.light, + ).copyWith( + surface: const Color(0xFFFFFFFF), + surfaceContainerLowest: const Color(0xFFFFFFFF), + surfaceContainerLow: const Color(0xFFF7F7F8), + surfaceContainer: const Color(0xFFF2F2F4), + surfaceContainerHigh: const Color(0xFFECECEF), + surfaceContainerHighest: const Color(0xFFE4E4E8), + outlineVariant: const Color(0xFFD3D3D8), + ); + + return ThemeData( + brightness: Brightness.light, + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: const Color(0xFFF9F9FA), + dividerColor: scheme.outlineVariant, + navigationBarTheme: NavigationBarThemeData( + backgroundColor: const Color(0xFFFFFFFF), + indicatorColor: accent.withValues(alpha: 0.14), + ), + navigationRailTheme: NavigationRailThemeData( + backgroundColor: const Color(0xFFFFFFFF), + indicatorColor: accent.withValues(alpha: 0.14), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + ), + ); + } +} diff --git a/lib/widgets/memory_transfer_settings.dart b/lib/widgets/memory_transfer_settings.dart new file mode 100644 index 0000000..cda8ac7 --- /dev/null +++ b/lib/widgets/memory_transfer_settings.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; + +import '../services/memory_backup_service.dart'; + +class MemoryTransferSettings extends StatefulWidget { + const MemoryTransferSettings({super.key}); + + @override + State createState() => _MemoryTransferSettingsState(); +} + +class _MemoryTransferSettingsState extends State { + final MemoryBackupService _memory = MemoryBackupService.instance; + + bool _busy = false; + int? _entryCount; + + @override + void initState() { + super.initState(); + _refreshCount(); + } + + Future _refreshCount() async { + try { + final count = await _memory.memoryEntryCount(); + if (!mounted) return; + setState(() => _entryCount = count); + } catch (_) { + // Le compteur est informatif. Une erreur ici ne bloque pas les actions. + } + } + + Future _export() async { + if (_busy) return; + setState(() => _busy = true); + + try { + final result = await _memory.exportMemory(); + if (!mounted || result == null) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Mémoire exportée : ${result.entryCount} bloc(s).'), + ), + ); + } catch (error) { + if (!mounted) return; + _showError('Export impossible', error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _import() async { + if (_busy) return; + setState(() => _busy = true); + + try { + final candidate = await _memory.pickMemoryBackup(); + if (!mounted || candidate == null) return; + + final mode = await _chooseImportMode(candidate); + if (!mounted || mode == null) return; + + if (mode == MemoryImportMode.replace) { + final confirmed = await _confirmReplace(); + if (!mounted || !confirmed) return; + } + + final result = await _memory.importMemory(candidate, mode: mode); + if (!mounted) return; + + await _refreshCount(); + if (!mounted) return; + + await showDialog( + context: context, + builder: (context) => AlertDialog( + icon: const Icon(Icons.check_circle_outline), + title: const Text('Mémoire importée'), + content: Text( + '${result.importedCount} bloc(s) de mémoire ont été importés.\n\n' + 'Ferme complètement puis rouvre YouTube Personal pour que ' + 'l’historique, les favoris et le Radar rechargent la nouvelle mémoire.', + ), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } catch (error) { + if (!mounted) return; + _showError('Import impossible', error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _chooseImportMode(MemoryImportCandidate candidate) { + final exported = candidate.exportedAtUtc == null + ? 'Date inconnue' + : _formatDate(candidate.exportedAtUtc!.toLocal()); + final source = _platformLabel(candidate.sourcePlatform); + + return showDialog( + context: context, + builder: (context) => AlertDialog( + icon: const Icon(Icons.memory_rounded), + title: const Text('Importer cette mémoire ?'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Source : $source'), + const SizedBox(height: 4), + Text('Export : $exported'), + const SizedBox(height: 4), + Text('${candidate.entryCount} bloc(s) de mémoire'), + const SizedBox(height: 18), + const Text( + 'Fusionner garde les blocs locaux absents de la sauvegarde. ' + 'Pour les blocs présents des deux côtés, la sauvegarde importée ' + 'devient la référence.', + ), + const SizedBox(height: 10), + const Text( + 'Remplacer efface d’abord la mémoire comportementale locale, ' + 'puis restaure exactement cette sauvegarde.', + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + OutlinedButton.icon( + onPressed: () => Navigator.of(context).pop(MemoryImportMode.merge), + icon: const Icon(Icons.merge_type_rounded), + label: const Text('Fusionner'), + ), + FilledButton.icon( + onPressed: () => + Navigator.of(context).pop(MemoryImportMode.replace), + icon: const Icon(Icons.restore_rounded), + label: const Text('Remplacer'), + ), + ], + ), + ); + } + + Future _confirmReplace() async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + icon: const Icon(Icons.warning_amber_rounded), + title: const Text('Remplacer la mémoire locale ?'), + content: const Text( + 'L’historique, les favoris, les reprises, les compteurs, les ' + 'chaînes masquées et l’apprentissage du Radar présents sur cet ' + 'appareil seront remplacés par la sauvegarde sélectionnée.\n\n' + 'La connexion Google et les données OAuth ne sont pas touchées.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Oui, remplacer'), + ), + ], + ), + ) ?? + false; + } + + void _showError(String title, Object error) { + showDialog( + context: context, + builder: (context) => AlertDialog( + icon: const Icon(Icons.error_outline), + title: Text(title), + content: Text('$error'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + ], + ), + ); + } + + String _formatDate(DateTime value) { + String two(int number) => number.toString().padLeft(2, '0'); + return '${two(value.day)}/${two(value.month)}/${value.year} ' + '${two(value.hour)}:${two(value.minute)}'; + } + + String _platformLabel(String platform) { + switch (platform.toLowerCase()) { + case 'windows': + return 'Windows'; + case 'android': + return 'Android'; + case 'linux': + return 'Linux'; + case 'macos': + return 'macOS'; + default: + return platform; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.55), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.sync_alt_rounded, + color: colors.onPrimaryContainer, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Mémoire & transfert', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 3), + Text( + _entryCount == null + ? 'Mémoire locale de ton profil utilisateur' + : '$_entryCount bloc(s) de mémoire transférable(s)', + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 12.5, + ), + ), + ], + ), + ), + if (_busy) + const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2.4), + ), + ], + ), + const SizedBox(height: 14), + Text( + 'Transfère l’apprentissage de YouTube Personal entre Windows, ' + 'Android et plus tard Linux : historique, favoris, reprises, ' + 'habitudes du Radar, catégories et chaînes masquées.', + style: TextStyle( + color: colors.onSurfaceVariant, + height: 1.45, + fontSize: 12.5, + ), + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.lock_outline_rounded, + size: 17, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 7), + Expanded( + child: Text( + 'La connexion Google, les tokens OAuth, les secrets et les ' + 'chemins de cache ne sont jamais inclus.', + style: TextStyle( + color: colors.onSurfaceVariant, + height: 1.4, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _busy ? null : _export, + icon: const Icon(Icons.upload_file_rounded), + label: const Text('Exporter la mémoire'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _import, + icon: const Icon(Icons.download_rounded), + label: const Text('Importer une mémoire'), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Format versionné V1, prévu pour servir de base à une future ' + 'synchronisation automatique multi-appareils.', + style: TextStyle( + color: colors.onSurfaceVariant.withValues(alpha: 0.76), + fontSize: 11.5, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/video_result_card.dart b/lib/widgets/video_result_card.dart new file mode 100644 index 0000000..a341cd0 --- /dev/null +++ b/lib/widgets/video_result_card.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; + +import '../models/video_item.dart'; + +class VideoResultCard extends StatelessWidget { + const VideoResultCard({ + super.key, + required this.video, + required this.onTap, + this.trailing, + }); + + final VideoItem video; + final VoidCallback onTap; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final isWide = MediaQuery.sizeOf(context).width >= 700; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: isWide ? _buildWide(context) : _buildCompact(context)), + if (trailing != null) trailing!, + ], + ), + ), + ), + ); + } + + Widget _buildWide(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 300, + child: _Thumbnail(video: video), + ), + SizedBox(width: 18), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: 4, right: 12), + child: _VideoText(video: video), + ), + ), + ], + ); + } + + Widget _buildCompact(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Thumbnail(video: video), + SizedBox(height: 10), + Padding( + padding: EdgeInsets.symmetric(horizontal: 2), + child: _VideoText(video: video), + ), + ], + ); + } +} + +class _Thumbnail extends StatelessWidget { + const _Thumbnail({required this.video}); + + final VideoItem video; + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: Image.network( + video.thumbnailUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Icon( + Icons.broken_image_outlined, + size: 42, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + ), + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; + } + + return Center( + child: CircularProgressIndicator(strokeWidth: 2), + ); + }, + ), + ), + if (video.durationLabel.isNotEmpty) + Positioned( + right: 7, + bottom: 7, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(5), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + child: Text( + video.durationLabel, + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _VideoText extends StatelessWidget { + const _VideoText({required this.video}); + + final VideoItem video; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + video.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + height: 1.25, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 7), + Row( + children: [ + Icon( + Icons.account_circle_outlined, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + ), + SizedBox(width: 6), + Expanded( + child: Text( + video.author, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + fontSize: 14, + ), + ), + ), + ], + ), + if (video.viewCount != null || + video.likeCount != null) ...[ + SizedBox(height: 6), + Text( + [ + if (video.viewCount != null) + '${_compact(video.viewCount!)} vues', + if (video.likeCount != null) + '${_compact(video.likeCount!)} likes', + ].join(' • '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Color(0x73FFFFFF), + fontSize: 12, + ), + ), + ], + if (video.description.trim().isNotEmpty) ...[ + SizedBox(height: 6), + Text( + video.description + .replaceAll(RegExp(r'\s+'), ' ') + .trim(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), + fontSize: 12, + height: 1.3, + ), + ), + ], + ], + ); + } + + String _compact(int value) { + if (value >= 1000000000) { + return '${(value / 1000000000).toStringAsFixed(value >= 10000000000 ? 0 : 1)} Md'; + } + if (value >= 1000000) { + return '${(value / 1000000).toStringAsFixed(value >= 10000000 ? 0 : 1)} M'; + } + if (value >= 1000) { + return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)} k'; + } + return '$value'; + } +} diff --git a/library_pages.dart b/library_pages.dart new file mode 100644 index 0000000..e19efa3 --- /dev/null +++ b/library_pages.dart @@ -0,0 +1,490 @@ +import 'package:flutter/material.dart'; + +import '../models/video_item.dart'; +import '../services/library_service.dart'; +import '../widgets/video_result_card.dart'; +import 'channels_page.dart'; +import 'video_player_page.dart'; + +class LibraryHubPage extends StatelessWidget { + const LibraryHubPage({super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: ValueListenableBuilder>( + valueListenable: LibraryService.instance.history, + builder: (context, history, _) { + return ValueListenableBuilder>( + valueListenable: LibraryService.instance.favorites, + builder: (context, favorites, __) { + final recentHistory = history.take(3).toList(growable: false); + final recentFavorites = + favorites.take(3).toList(growable: false); + + return ListView( + padding: EdgeInsets.fromLTRB(18, 20, 18, 28), + children: [ + Text( + 'Bibliothèque', + style: + Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 6), + Text( + 'Retrouve rapidement ton historique, tes favoris et tes chaînes.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 13, + ), + ), + SizedBox(height: 18), + LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final crossAxisCount = width >= 1100 + ? 3 + : width >= 720 + ? 2 + : 1; + final mainAxisExtent = width >= 1100 + ? 220.0 + : width >= 720 + ? 210.0 + : 196.0; + + return GridView.count( + crossAxisCount: crossAxisCount, + shrinkWrap: true, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + mainAxisExtent: mainAxisExtent, + physics: NeverScrollableScrollPhysics(), + children: [ + _LibraryHubCard( + icon: Icons.history, + title: 'Historique', + value: '${history.length}', + subtitle: history.isEmpty + ? 'Aucune vidéo lue pour le moment.' + : 'Tes vidéos récemment ouvertes.', + onTap: () => _openPage( + context, + HistoryPage(), + ), + ), + _LibraryHubCard( + icon: Icons.favorite, + title: 'Favoris', + value: '${favorites.length}', + subtitle: favorites.isEmpty + ? 'Aucune vidéo en favori.' + : 'Tes vidéos enregistrées pour plus tard.', + onTap: () => _openPage( + context, + FavoritesPage(), + ), + ), + _LibraryHubCard( + icon: Icons.video_library, + title: 'Chaînes', + value: 'Explorer', + subtitle: + 'Accède à tes chaînes personnelles et au catalogue.', + onTap: () => _openPage( + context, + ChannelsPage(), + ), + ), + ], + ); + }, + ), + SizedBox(height: 24), + if (recentHistory.isNotEmpty) ...[ + _SectionHeader( + title: 'Reprendre vite', + actionLabel: 'Voir tout', + onTap: () => _openPage(context, HistoryPage()), + ), + SizedBox(height: 8), + ...recentHistory.map( + (video) => Padding( + padding: EdgeInsets.only(bottom: 6), + child: VideoResultCard( + video: video, + onTap: () => _openVideo(context, video), + ), + ), + ), + SizedBox(height: 16), + ], + if (recentFavorites.isNotEmpty) ...[ + _SectionHeader( + title: 'Favoris récents', + actionLabel: 'Voir tout', + onTap: () => _openPage(context, FavoritesPage()), + ), + SizedBox(height: 8), + ...recentFavorites.map( + (video) => Padding( + padding: EdgeInsets.only(bottom: 6), + child: VideoResultCard( + video: video, + onTap: () => _openVideo(context, video), + ), + ), + ), + ], + ], + ); + }, + ); + }, + ), + ); + } + + void _openPage(BuildContext context, Widget page) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => page), + ); + } + + void _openVideo(BuildContext context, VideoItem video) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VideoPlayerPage(video: video), + ), + ); + } +} + +class HistoryPage extends StatelessWidget { + const HistoryPage({super.key}); + + @override + Widget build(BuildContext context) { + return _LibraryPage( + title: 'Historique', + emptyIcon: Icons.history, + emptyTitle: 'Aucun historique', + emptySubtitle: + 'Les vidéos que tu regardes apparaîtront ici automatiquement.', + listenable: LibraryService.instance.history, + onClear: LibraryService.instance.clearHistory, + ); + } +} + +class FavoritesPage extends StatelessWidget { + const FavoritesPage({super.key}); + + @override + Widget build(BuildContext context) { + return _LibraryPage( + title: 'Favoris', + emptyIcon: Icons.favorite_border, + emptyTitle: 'Aucun favori', + emptySubtitle: 'Ajoute des vidéos aux favoris depuis le lecteur.', + listenable: LibraryService.instance.favorites, + onClear: LibraryService.instance.clearFavorites, + ); + } +} + +class _LibraryHubCard extends StatelessWidget { + const _LibraryHubCard({ + required this.icon, + required this.title, + required this.value, + required this.subtitle, + required this.onTap, + }); + + final IconData icon; + final String title; + final String value; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(22), + child: InkWell( + borderRadius: BorderRadius.circular(22), + onTap: onTap, + child: Ink( + padding: EdgeInsets.all(18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: Color(0x22FF6B63), + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, color: Color(0xFFFF7A73)), + ), + SizedBox(height: 16), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 4), + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 10), + Expanded( + child: Align( + alignment: Alignment.topLeft, + child: Text( + subtitle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), + fontSize: 12.5, + height: 1.35, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.title, + required this.actionLabel, + required this.onTap, + }); + + final String title; + final String actionLabel; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ), + TextButton(onPressed: onTap, child: Text(actionLabel)), + ], + ); + } +} + +class _LibraryPage extends StatelessWidget { + const _LibraryPage({ + required this.title, + required this.emptyIcon, + required this.emptyTitle, + required this.emptySubtitle, + required this.listenable, + required this.onClear, + }); + + final String title; + final IconData emptyIcon; + final String emptyTitle; + final String emptySubtitle; + final ValueNotifier> listenable; + final Future Function() onClear; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: ValueListenableBuilder>( + valueListenable: listenable, + builder: (context, videos, child) { + final canGoBack = Navigator.of(context).canPop(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(20, 18, 14, 10), + child: Row( + children: [ + if (canGoBack) ...[ + IconButton( + tooltip: 'Retour', + onPressed: () => Navigator.of(context).pop(), + icon: Icon(Icons.arrow_back), + ), + SizedBox(width: 4), + ], + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + if (videos.isNotEmpty) + IconButton( + tooltip: 'Tout effacer', + onPressed: () => _confirmClear(context), + icon: Icon(Icons.delete_outline), + ), + ], + ), + ), + Expanded( + child: videos.isEmpty + ? _EmptyLibrary( + icon: emptyIcon, + title: emptyTitle, + subtitle: emptySubtitle, + ) + : ListView.separated( + padding: EdgeInsets.fromLTRB(12, 4, 12, 24), + itemCount: videos.length, + separatorBuilder: (context, index) => + SizedBox(height: 4), + itemBuilder: (context, index) { + final video = videos[index]; + + return VideoResultCard( + video: video, + onTap: () => _openVideo(context, video), + trailing: listenable == LibraryService.instance.history + ? IconButton( + tooltip: "Supprimer de l'historique", + icon: Icon(Icons.remove_circle_outline), + onPressed: () => LibraryService.instance.removeFromHistory(video.id), + ) + : null, + ); + }, + ), + ), + ], + ); + }, + ), + ); + } + + void _openVideo( + BuildContext context, + VideoItem video, + ) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => VideoPlayerPage(video: video), + ), + ); + } + + Future _confirmClear(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text('Effacer $title ?'), + content: Text( + 'Cette action supprimera cette liste uniquement sur cet appareil.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text('Effacer'), + ), + ], + ); + }, + ); + + if (confirmed == true) { + await onClear(); + } + } +} + +class _EmptyLibrary extends StatelessWidget { + const _EmptyLibrary({ + required this.icon, + required this.title, + required this.subtitle, + }); + + final IconData icon; + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 64, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.44), + ), + SizedBox(height: 16), + Text( + title, + style: TextStyle( + fontSize: 21, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8), + Text( + subtitle, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), + fontSize: 15, + ), + ), + ], + ), + ), + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..fff7a19 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "youtube_personal") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.studioweb.youtube_personal") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..40aee17 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,39 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_saver_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin"); + file_saver_plugin_register_with_registrar(file_saver_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..1dade9a --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,31 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_saver + file_selector_linux + media_kit_libs_linux + media_kit_video + screen_retriever_linux + url_launcher_linux + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..6143974 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "youtube_personal"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "youtube_personal"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..7338139 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1114 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + audio_service: + dependency: "direct main" + description: + name: audio_service + sha256: "95f3267f3449eb5cf71c8fcf1d556f57af1e898e2dc5815fb168d1843653edb7" + url: "https://pub.dev" + source: hosted + version: "0.18.19" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df + url: "https://pub.dev" + source: hosted + version: "0.1.4" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: f9e7711a0e24ca8b40f5d7ac374c3c6e55016f3157912badd18199cdbbca5c4d + url: "https://pub.dev" + source: hosted + version: "0.2.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + dio: + dependency: transitive + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_saver: + dependency: "direct main" + description: + name: file_saver + sha256: "9d93db09bd4da9e43238f9dd485360fc51a5c138eea5ef5f407ec56e58079ac0" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "1d45e9910f68c16eb0c74f0b10097ad81aed516ea28054c027137e8f7d75e840" + url: "https://pub.dev" + source: hosted + version: "0.5.2+9" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" + url: "https://pub.dev" + source: hosted + version: "3.4.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e" + url: "https://pub.dev" + source: hosted + version: "7.2.16" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" + url: "https://pub.dev" + source: hosted + version: "2.0.6" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + youtube_explode_dart: + dependency: "direct main" + description: + name: youtube_explode_dart + sha256: "10134a53989b2f3dc576121735aec8fc6d88784956f4a2ad1a2525b006373a76" + url: "https://pub.dev" + source: hosted + version: "3.1.0" +sdks: + dart: ">=3.12.1 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.lock.before_memory_18_10_3 b/pubspec.lock.before_memory_18_10_3 new file mode 100644 index 0000000..a01e6cf --- /dev/null +++ b/pubspec.lock.before_memory_18_10_3 @@ -0,0 +1,1082 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "665a5a57dfca27f91a715d300e4852a784f9f98e503dcff281bec9afb55767be" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + audio_service: + dependency: "direct main" + description: + name: audio_service + sha256: "95f3267f3449eb5cf71c8fcf1d556f57af1e898e2dc5815fb168d1843653edb7" + url: "https://pub.dev" + source: hosted + version: "0.18.19" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df + url: "https://pub.dev" + source: hosted + version: "0.1.4" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: f9e7711a0e24ca8b40f5d7ac374c3c6e55016f3157912badd18199cdbbca5c4d + url: "https://pub.dev" + source: hosted + version: "0.2.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: afbaa8015d9efabd224f41084ed9fdeddfa65389ebd7cd3a9eb1476aca66b46d + url: "https://pub.dev" + source: hosted + version: "12.0.0" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: "5d87d156c1d63920447a662b7117d3498c67e3444a44d2cb01ed955d6efa62ef" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: "93d3f62f97c657053e7b184fe0f5e22347d85067c053640a30b1ac8ad7844e3b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: "9e7a7e01e179929241f0afeb2c8c69ac95e29e17c0abea36ed193881c6bef90c" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: f1af38b3c91fafe0ca97f659b5c6818a057473ef09bb8b722f9f3f5364aa7eed + url: "https://pub.dev" + source: hosted + version: "3.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" + url: "https://pub.dev" + source: hosted + version: "3.4.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e" + url: "https://pub.dev" + source: hosted + version: "7.2.16" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" + url: "https://pub.dev" + source: hosted + version: "2.0.6" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "7253bca0fcf40d8413ddfcf4d2a1fa0a82475e79be25a4f2c564b695c9351486" + url: "https://pub.dev" + source: hosted + version: "1.7.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "72cf23466e146f2c0f19e1d78be97ff6409dc15b0c090f8eb284f94f4a33de26" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + youtube_explode_dart: + dependency: "direct main" + description: + name: youtube_explode_dart + sha256: "10134a53989b2f3dc576121735aec8fc6d88784956f4a2ad1a2525b006373a76" + url: "https://pub.dev" + source: hosted + version: "3.1.0" +sdks: + dart: ">=3.12.1 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.lock.before_memory_18_10_4 b/pubspec.lock.before_memory_18_10_4 new file mode 100644 index 0000000..c20c906 --- /dev/null +++ b/pubspec.lock.before_memory_18_10_4 @@ -0,0 +1,1114 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + audio_service: + dependency: "direct main" + description: + name: audio_service + sha256: "95f3267f3449eb5cf71c8fcf1d556f57af1e898e2dc5815fb168d1843653edb7" + url: "https://pub.dev" + source: hosted + version: "0.18.19" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df + url: "https://pub.dev" + source: hosted + version: "0.1.4" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: f9e7711a0e24ca8b40f5d7ac374c3c6e55016f3157912badd18199cdbbca5c4d + url: "https://pub.dev" + source: hosted + version: "0.2.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + dio: + dependency: transitive + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_saver: + dependency: "direct main" + description: + name: file_saver + sha256: "9d93db09bd4da9e43238f9dd485360fc51a5c138eea5ef5f407ec56e58079ac0" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "1d45e9910f68c16eb0c74f0b10097ad81aed516ea28054c027137e8f7d75e840" + url: "https://pub.dev" + source: hosted + version: "0.5.2+9" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" + url: "https://pub.dev" + source: hosted + version: "3.4.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e" + url: "https://pub.dev" + source: hosted + version: "7.2.16" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" + url: "https://pub.dev" + source: hosted + version: "2.0.6" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + youtube_explode_dart: + dependency: "direct main" + description: + name: youtube_explode_dart + sha256: "10134a53989b2f3dc576121735aec8fc6d88784956f4a2ad1a2525b006373a76" + url: "https://pub.dev" + source: hosted + version: "3.1.0" +sdks: + dart: ">=3.12.1 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.lock.before_memory_18_10_5 b/pubspec.lock.before_memory_18_10_5 new file mode 100644 index 0000000..c20c906 --- /dev/null +++ b/pubspec.lock.before_memory_18_10_5 @@ -0,0 +1,1114 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + audio_service: + dependency: "direct main" + description: + name: audio_service + sha256: "95f3267f3449eb5cf71c8fcf1d556f57af1e898e2dc5815fb168d1843653edb7" + url: "https://pub.dev" + source: hosted + version: "0.18.19" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df + url: "https://pub.dev" + source: hosted + version: "0.1.4" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: f9e7711a0e24ca8b40f5d7ac374c3c6e55016f3157912badd18199cdbbca5c4d + url: "https://pub.dev" + source: hosted + version: "0.2.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + dio: + dependency: transitive + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_saver: + dependency: "direct main" + description: + name: file_saver + sha256: "9d93db09bd4da9e43238f9dd485360fc51a5c138eea5ef5f407ec56e58079ac0" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "1d45e9910f68c16eb0c74f0b10097ad81aed516ea28054c027137e8f7d75e840" + url: "https://pub.dev" + source: hosted + version: "0.5.2+9" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" + url: "https://pub.dev" + source: hosted + version: "3.4.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e" + url: "https://pub.dev" + source: hosted + version: "7.2.16" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" + url: "https://pub.dev" + source: hosted + version: "2.0.6" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + youtube_explode_dart: + dependency: "direct main" + description: + name: youtube_explode_dart + sha256: "10134a53989b2f3dc576121735aec8fc6d88784956f4a2ad1a2525b006373a76" + url: "https://pub.dev" + source: hosted + version: "3.1.0" +sdks: + dart: ">=3.12.1 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.lock.before_memory_18_10_6 b/pubspec.lock.before_memory_18_10_6 new file mode 100644 index 0000000..3771452 --- /dev/null +++ b/pubspec.lock.before_memory_18_10_6 @@ -0,0 +1,1114 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + audio_service: + dependency: "direct main" + description: + name: audio_service + sha256: "95f3267f3449eb5cf71c8fcf1d556f57af1e898e2dc5815fb168d1843653edb7" + url: "https://pub.dev" + source: hosted + version: "0.18.19" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df + url: "https://pub.dev" + source: hosted + version: "0.1.4" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: f9e7711a0e24ca8b40f5d7ac374c3c6e55016f3157912badd18199cdbbca5c4d + url: "https://pub.dev" + source: hosted + version: "0.2.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + dio: + dependency: transitive + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_saver: + dependency: "direct main" + description: + name: file_saver + sha256: "9d93db09bd4da9e43238f9dd485360fc51a5c138eea5ef5f407ec56e58079ac0" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "1d45e9910f68c16eb0c74f0b10097ad81aed516ea28054c027137e8f7d75e840" + url: "https://pub.dev" + source: hosted + version: "0.5.2+9" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" + url: "https://pub.dev" + source: hosted + version: "3.4.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e" + url: "https://pub.dev" + source: hosted + version: "7.2.16" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" + url: "https://pub.dev" + source: hosted + version: "2.0.6" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + youtube_explode_dart: + dependency: "direct main" + description: + name: youtube_explode_dart + sha256: "10134a53989b2f3dc576121735aec8fc6d88784956f4a2ad1a2525b006373a76" + url: "https://pub.dev" + source: hosted + version: "3.1.0" +sdks: + dart: ">=3.12.1 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.lock.before_wakelock_18_10_2 b/pubspec.lock.before_wakelock_18_10_2 new file mode 100644 index 0000000..a01e6cf --- /dev/null +++ b/pubspec.lock.before_wakelock_18_10_2 @@ -0,0 +1,1082 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "665a5a57dfca27f91a715d300e4852a784f9f98e503dcff281bec9afb55767be" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + audio_service: + dependency: "direct main" + description: + name: audio_service + sha256: "95f3267f3449eb5cf71c8fcf1d556f57af1e898e2dc5815fb168d1843653edb7" + url: "https://pub.dev" + source: hosted + version: "0.18.19" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df + url: "https://pub.dev" + source: hosted + version: "0.1.4" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: f9e7711a0e24ca8b40f5d7ac374c3c6e55016f3157912badd18199cdbbca5c4d + url: "https://pub.dev" + source: hosted + version: "0.2.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: afbaa8015d9efabd224f41084ed9fdeddfa65389ebd7cd3a9eb1476aca66b46d + url: "https://pub.dev" + source: hosted + version: "12.0.0" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: "5d87d156c1d63920447a662b7117d3498c67e3444a44d2cb01ed955d6efa62ef" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: "93d3f62f97c657053e7b184fe0f5e22347d85067c053640a30b1ac8ad7844e3b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: "9e7a7e01e179929241f0afeb2c8c69ac95e29e17c0abea36ed193881c6bef90c" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: f1af38b3c91fafe0ca97f659b5c6818a057473ef09bb8b722f9f3f5364aa7eed + url: "https://pub.dev" + source: hosted + version: "3.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" + url: "https://pub.dev" + source: hosted + version: "3.4.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e" + url: "https://pub.dev" + source: hosted + version: "7.2.16" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" + url: "https://pub.dev" + source: hosted + version: "2.0.6" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "7253bca0fcf40d8413ddfcf4d2a1fa0a82475e79be25a4f2c564b695c9351486" + url: "https://pub.dev" + source: hosted + version: "1.7.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "72cf23466e146f2c0f19e1d78be97ff6409dc15b0c090f8eb284f94f4a33de26" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + youtube_explode_dart: + dependency: "direct main" + description: + name: youtube_explode_dart + sha256: "10134a53989b2f3dc576121735aec8fc6d88784956f4a2ad1a2525b006373a76" + url: "https://pub.dev" + source: hosted + version: "3.1.0" +sdks: + dart: ">=3.12.1 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..8f068f5 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,44 @@ +name: youtube_personal +description: "Lecteur YouTube personnel pour Android et Windows." +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.12.1 + +dependencies: + file_saver: 0.3.1 + file_selector: 1.1.0 + package_info_plus: 8.3.1 + wakelock_plus: 1.3.3 + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + youtube_explode_dart: ^3.1.0 + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.7 + shared_preferences: ^2.5.5 + window_manager: ^0.5.2 + google_sign_in: ^7.2.0 + http: ^1.5.0 + crypto: ^3.0.6 + url_launcher: ^6.3.2 + audio_service: ^0.18.19 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/windows_hd/ + + + + + + diff --git a/pubspec.yaml.before_file_picker_12 b/pubspec.yaml.before_file_picker_12 new file mode 100644 index 0000000..1b3b2ab --- /dev/null +++ b/pubspec.yaml.before_file_picker_12 @@ -0,0 +1,35 @@ +name: youtube_personal +description: "Lecteur YouTube personnel pour Android et Windows." +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.12.1 + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + youtube_explode_dart: ^3.1.0 + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.7 + shared_preferences: ^2.5.5 + window_manager: ^0.5.2 + google_sign_in: ^7.2.0 + http: ^1.5.0 + crypto: ^3.0.6 + url_launcher: ^6.3.2 + audio_service: ^0.18.19 + file_picker: 11.0.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/windows_hd/ diff --git a/pubspec.yaml.before_memory_18_10_3 b/pubspec.yaml.before_memory_18_10_3 new file mode 100644 index 0000000..adfb7d6 --- /dev/null +++ b/pubspec.yaml.before_memory_18_10_3 @@ -0,0 +1,38 @@ +name: youtube_personal +description: "Lecteur YouTube personnel pour Android et Windows." +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.12.1 + +dependencies: + wakelock_plus: 1.5.2 + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + youtube_explode_dart: ^3.1.0 + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.7 + shared_preferences: ^2.5.5 + window_manager: ^0.5.2 + google_sign_in: ^7.2.0 + http: ^1.5.0 + crypto: ^3.0.6 + url_launcher: ^6.3.2 + audio_service: ^0.18.19 + file_picker: 12.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/windows_hd/ + + diff --git a/pubspec.yaml.before_memory_18_10_4 b/pubspec.yaml.before_memory_18_10_4 new file mode 100644 index 0000000..c593cba --- /dev/null +++ b/pubspec.yaml.before_memory_18_10_4 @@ -0,0 +1,41 @@ +name: youtube_personal +description: "Lecteur YouTube personnel pour Android et Windows." +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.12.1 + +dependencies: + file_saver: 0.3.1 + file_selector: 1.1.0 + package_info_plus: 9.0.1 + wakelock_plus: 1.5.2 + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + youtube_explode_dart: ^3.1.0 + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.7 + shared_preferences: ^2.5.5 + window_manager: ^0.5.2 + google_sign_in: ^7.2.0 + http: ^1.5.0 + crypto: ^3.0.6 + url_launcher: ^6.3.2 + audio_service: ^0.18.19 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/windows_hd/ + + + diff --git a/pubspec.yaml.before_memory_18_10_5 b/pubspec.yaml.before_memory_18_10_5 new file mode 100644 index 0000000..8bfb4cd --- /dev/null +++ b/pubspec.yaml.before_memory_18_10_5 @@ -0,0 +1,42 @@ +name: youtube_personal +description: "Lecteur YouTube personnel pour Android et Windows." +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.12.1 + +dependencies: + file_saver: 0.3.1 + file_selector: 1.1.0 + package_info_plus: 8.3.1 + wakelock_plus: 1.5.2 + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + youtube_explode_dart: ^3.1.0 + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.7 + shared_preferences: ^2.5.5 + window_manager: ^0.5.2 + google_sign_in: ^7.2.0 + http: ^1.5.0 + crypto: ^3.0.6 + url_launcher: ^6.3.2 + audio_service: ^0.18.19 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/windows_hd/ + + + + diff --git a/pubspec.yaml.before_memory_18_10_6 b/pubspec.yaml.before_memory_18_10_6 new file mode 100644 index 0000000..8e8f79a --- /dev/null +++ b/pubspec.yaml.before_memory_18_10_6 @@ -0,0 +1,43 @@ +name: youtube_personal +description: "Lecteur YouTube personnel pour Android et Windows." +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.12.1 + +dependencies: + file_saver: 0.3.1 + file_selector: 1.1.0 + package_info_plus: 8.3.1 + wakelock_plus: 1.3.3 + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + youtube_explode_dart: ^3.1.0 + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.7 + shared_preferences: ^2.5.5 + window_manager: ^0.5.2 + google_sign_in: ^7.2.0 + http: ^1.5.0 + crypto: ^3.0.6 + url_launcher: ^6.3.2 + audio_service: ^0.18.19 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/windows_hd/ + + + + + diff --git a/pubspec.yaml.before_wakelock_18_10_2 b/pubspec.yaml.before_wakelock_18_10_2 new file mode 100644 index 0000000..b9435ed --- /dev/null +++ b/pubspec.yaml.before_wakelock_18_10_2 @@ -0,0 +1,36 @@ +name: youtube_personal +description: "Lecteur YouTube personnel pour Android et Windows." +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.12.1 + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + youtube_explode_dart: ^3.1.0 + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.7 + shared_preferences: ^2.5.5 + window_manager: ^0.5.2 + google_sign_in: ^7.2.0 + http: ^1.5.0 + crypto: ^3.0.6 + url_launcher: ^6.3.2 + audio_service: ^0.18.19 + file_picker: 12.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/windows_hd/ + diff --git a/restore_18_7_5.ps1 b/restore_18_7_5.ps1 new file mode 100644 index 0000000..d32ad88 --- /dev/null +++ b/restore_18_7_5.ps1 @@ -0,0 +1,41 @@ +param( + [Parameter(Mandatory=$false)] + [string]$ProjectPath = "D:\07-Logiciels\23-youtube_personal" +) + +$ErrorActionPreference = "Stop" +$stamp = Get-Date -Format "yyyyMMdd_HHmmss" +$backupRoot = Join-Path $ProjectPath "_backup_before_restore_18_7_5_$stamp" + +$files = @( + "lib\screens\video_player_page.dart", + "lib\services\windows_hd_service.dart" +) + +Write-Host "Baseline protegee YouTube Personal 18.7.5" +Write-Host "Projet : $ProjectPath" +Write-Host "Sauvegarde actuelle : $backupRoot" + +foreach ($relative in $files) { + $current = Join-Path $ProjectPath $relative + if (Test-Path $current) { + $backup = Join-Path $backupRoot $relative + New-Item -ItemType Directory -Force -Path (Split-Path $backup) | Out-Null + Copy-Item -Force $current $backup + } +} + +foreach ($relative in $files) { + $source = Join-Path $PSScriptRoot $relative + $target = Join-Path $ProjectPath $relative + if (-not (Test-Path $source)) { + throw "Fichier baseline introuvable : $source" + } + New-Item -ItemType Directory -Force -Path (Split-Path $target) | Out-Null + Copy-Item -Force $source $target + Write-Host "Restaure : $relative" +} + +Write-Host "" +Write-Host "Restauration 18.7.5 terminee." +Write-Host "Ancienne version sauvegardee dans : $backupRoot" diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..1061a14 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:youtube_personal/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/verify_18_7_5.ps1 b/verify_18_7_5.ps1 new file mode 100644 index 0000000..b85d4a6 --- /dev/null +++ b/verify_18_7_5.ps1 @@ -0,0 +1,39 @@ +param( + [Parameter(Mandatory=$false)] + [string]$ProjectPath = "D:\07-Logiciels\23-youtube_personal" +) + +$expected = @{ + "lib\screens\video_player_page.dart" = "881f8283cc55bcdfd55798d09b0e720c515cccc3b92e0ff81e5c0c4460df8984" + "lib\services\windows_hd_service.dart" = "c4173bcafc9345a57ee84cdc105a05f5d9fa3eee6b7a688613c7c7cba6785ea6" +} + +$allOk = $true +foreach ($relative in $expected.Keys) { + $path = Join-Path $ProjectPath $relative + if (-not (Test-Path $path)) { + Write-Host "MANQUANT : $relative" + $allOk = $false + continue + } + + $actual = (Get-FileHash -Algorithm SHA256 $path).Hash.ToLowerInvariant() + if ($actual -eq $expected[$relative]) { + Write-Host "OK : $relative" + } else { + Write-Host "MODIFIE : $relative" + Write-Host " attendu: $($expected[$relative])" + Write-Host " actuel : $actual" + $allOk = $false + } +} + +if ($allOk) { + Write-Host "" + Write-Host "La baseline 18.7.5 est intacte." + exit 0 +} + +Write-Host "" +Write-Host "Le projet differe de la baseline 18.7.5. Cela peut etre volontaire si une nouvelle version a ete validee." +exit 1 diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..ec4098a --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..9f1b21d --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(youtube_personal LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "youtube_personal") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..efb62eb --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..7732441 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,32 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSaverPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSaverPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..675ae5d --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,31 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_saver + file_selector_windows + media_kit_libs_windows_video + media_kit_video + screen_retriever_windows + url_launcher_windows + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..2041a04 --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..b8f6b9e --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.studioweb" "\0" + VALUE "FileDescription", "youtube_personal" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "youtube_personal" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.studioweb. All rights reserved." "\0" + VALUE "OriginalFilename", "youtube_personal.exe" "\0" + VALUE "ProductName", "youtube_personal" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..c819cb0 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..28c2383 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..1844e41 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"youtube_personal", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..ddc7f3e --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..573b602 Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..4b962bb --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..b7f62cc --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3f0e05c --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..b5ba2a0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..49b847f --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/ϼÏ▒ϿϬ Ϻ┘äÏ╣ϺϿ ┘å┘è┘åϬ┘åÏ»┘ê.. Ï╣┘ä┘ë Ï░┘ê┘é ┘àϬϺϿÏ╣! [QVWPEsWRHbg].mp4 b/ϼÏ▒ϿϬ Ϻ┘äÏ╣ϺϿ ┘å┘è┘åϬ┘åÏ»┘ê.. Ï╣┘ä┘ë Ï░┘ê┘é ┘àϬϺϿÏ╣! [QVWPEsWRHbg].mp4 new file mode 100644 index 0000000..23097a2 Binary files /dev/null and b/ϼÏ▒ϿϬ Ϻ┘äÏ╣ϺϿ ┘å┘è┘åϬ┘åÏ»┘ê.. Ï╣┘ä┘ë Ï░┘ê┘é ┘àϬϺϿÏ╣! [QVWPEsWRHbg].mp4 differ diff --git a/جربت العاب نينتندو.. على ذوق متابع! [QVWPEsWRHbg].mp4 b/جربت العاب نينتندو.. على ذوق متابع! [QVWPEsWRHbg].mp4 new file mode 100644 index 0000000..23097a2 Binary files /dev/null and b/جربت العاب نينتندو.. على ذوق متابع! [QVWPEsWRHbg].mp4 differ