diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index b929e00b..be0de8ab 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -32,6 +32,11 @@ class MainActivity : FlutterActivity() { private val THEME_CHANNEL = "app.plezy/theme" private var watchNextPlugin: WatchNextPlugin? = null + // Auto PiP state + private var autoPipReady = false + private var autoPipWidth: Int = 16 + private var autoPipHeight: Int = 9 + override fun onCreate(savedInstanceState: Bundle?) { // Apply persisted theme color to the window background before anything // else renders. This prevents a white flash between the native splash @@ -211,15 +216,7 @@ class MainActivity : FlutterActivity() { return@setMethodCallHandler } - // Check if PiP permission is granted via AppOpsManager - val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager - val pipAllowed = appOpsManager.checkOpNoThrow( - AppOpsManager.OPSTR_PICTURE_IN_PICTURE, - applicationInfo.uid, - packageName - ) == AppOpsManager.MODE_ALLOWED - - if (!pipAllowed) { + if (!isPipPermissionGranted()) { result.success(mapOf("success" to false, "errorCode" to "permission_disabled")) return@setMethodCallHandler } @@ -227,31 +224,10 @@ class MainActivity : FlutterActivity() { try { val width = call.argument("width") ?: 16 val height = call.argument("height") ?: 9 - - // Android PiP requires aspect ratio between 0.418410 (5:12) and 2.39 (12:5) - val ratio = width.toFloat() / height.toFloat() - val clampedWidth: Int - val clampedHeight: Int - - when { - ratio < 0.42f -> { - // Too tall - clamp to minimum ratio (5:12) - clampedWidth = 5 - clampedHeight = 12 - } - ratio > 2.39f -> { - // Too wide - clamp to maximum ratio (12:5) - clampedWidth = 12 - clampedHeight = 5 - } - else -> { - clampedWidth = width - clampedHeight = height - } - } + val clamped = clampAspectRatio(width, height) val params = PictureInPictureParams.Builder() - .setAspectRatio(Rational(clampedWidth, clampedHeight)) + .setAspectRatio(Rational(clamped.first, clamped.second)) .build() val success = enterPictureInPictureMode(params) if (success) { @@ -264,7 +240,25 @@ class MainActivity : FlutterActivity() { } catch (e: Exception) { result.success(mapOf("success" to false, "errorCode" to "unknown", "errorMessage" to (e.message ?: "Unknown error"))) } - } else -> result.notImplemented() + } + "setAutoPipReady" -> { + autoPipReady = call.argument("ready") ?: false + autoPipWidth = call.argument("width") ?: 16 + autoPipHeight = call.argument("height") ?: 9 + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + val clamped = clampAspectRatio(autoPipWidth, autoPipHeight) + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(clamped.first, clamped.second)) + .setAutoEnterEnabled(autoPipReady) + .build() + setPictureInPictureParams(params) + } catch (_: Exception) {} + } + result.success(true) + } + else -> result.notImplemented() } } } @@ -278,4 +272,42 @@ class MainActivity : FlutterActivity() { (plugin as? ExoPlayerPlugin)?.onPipModeChanged(isInPictureInPictureMode) } } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + // Auto PiP for API 26-30 (API 31+ uses setAutoEnterEnabled) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + Build.VERSION.SDK_INT < Build.VERSION_CODES.S && + autoPipReady && isPipPermissionGranted()) { + try { + // Notify Flutter to prepare video filter before PiP + flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> + MethodChannel(messenger, PIP_CHANNEL).invokeMethod("onAutoPipEntering", null) + } + val clamped = clampAspectRatio(autoPipWidth, autoPipHeight) + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(clamped.first, clamped.second)) + .build() + enterPictureInPictureMode(params) + } catch (_: Exception) {} + } + } + + private fun isPipPermissionGranted(): Boolean { + val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager + return appOpsManager.checkOpNoThrow( + AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + applicationInfo.uid, + packageName + ) == AppOpsManager.MODE_ALLOWED + } + + private fun clampAspectRatio(width: Int, height: Int): Pair { + val ratio = width.toFloat() / height.toFloat() + return when { + ratio < 0.42f -> Pair(5, 12) + ratio > 2.39f -> Pair(12, 5) + else -> Pair(width, height) + } + } } diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 5330581f..784bcf46 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -197,6 +197,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Zeige auf Discord, was du gerade schaust", + "autoPip": "Automatisches Bild-in-Bild", + "autoPipDescription": "Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird", "matchContentFrameRate": "Inhalts-Bildrate anpassen", "matchContentFrameRateDescription": "Bildwiederholfrequenz des Displays an den Videoinhalt anpassen, reduziert Ruckeln und spart Akku", "tunneledPlayback": "Tunneled Playback", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index bdd987ab..769d8fe4 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -197,6 +197,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Show what you're watching on Discord", + "autoPip": "Auto Picture-in-Picture", + "autoPipDescription": "Automatically enter picture-in-picture when leaving the app during playback", "matchContentFrameRate": "Match Content Frame Rate", "matchContentFrameRateDescription": "Adjust display refresh rate to match video content, reducing judder and saving battery", "tunneledPlayback": "Tunneled Playback", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 7bb302ee..ad346a68 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -197,6 +197,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Presencia de Discord", "discordRichPresenceDescription": "Mostrar lo que estás viendo en Discord", + "autoPip": "Imagen en imagen automática", + "autoPipDescription": "Activar automáticamente imagen en imagen al salir de la app durante la reproducción", "matchContentFrameRate": "Ajustar frecuencia de actualización", "matchContentFrameRateDescription": "Ajustar la frecuencia de actualización de la pantalla para que coincida con el video, reduciendo tirones y ahorrando batería", "tunneledPlayback": "Tunneled Playback", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index ed0d490b..a417243d 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -197,6 +197,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Montrez ce que vous regardez sur Discord", + "autoPip": "Image dans l'image automatique", + "autoPipDescription": "Activer automatiquement l'image dans l'image en quittant l'application pendant la lecture", "matchContentFrameRate": "Fréquence d'images du contenu correspondant", "matchContentFrameRateDescription": "Ajustez la fréquence de rafraîchissement de l'écran en fonction du contenu vidéo, ce qui réduit les saccades et économise la batterie", "tunneledPlayback": "Tunneled Playback", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index ecfdeaf4..167aadc0 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -197,6 +197,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Mostra su Discord cosa stai guardando", + "autoPip": "Picture-in-Picture automatico", + "autoPipDescription": "Attiva automaticamente il picture-in-picture quando si esce dall'app durante la riproduzione", "matchContentFrameRate": "Adatta frequenza fotogrammi", "matchContentFrameRateDescription": "Regola la frequenza di aggiornamento del display in base al contenuto video, riducendo i tremolii e risparmiando batteria", "tunneledPlayback": "Tunneled Playback", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 6bf5b667..143df37e 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -197,6 +197,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시", + "autoPip": "자동 PIP 모드", + "autoPipDescription": "재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환", "matchContentFrameRate": "콘텐츠 프레임 레이트 맞춤", "matchContentFrameRateDescription": "비디오 콘텐츠에 맞게 디스플레이 주사율을 조정하여 떨림을 줄이고 배터리를 절약합니다", "tunneledPlayback": "Tunneled Playback", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index afbe1774..072182b0 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -197,6 +197,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Toon op Discord wat je aan het kijken bent", + "autoPip": "Automatische beeld-in-beeld", + "autoPipDescription": "Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen", "matchContentFrameRate": "Inhoudsframesnelheid afstemmen", "matchContentFrameRateDescription": "Pas de schermverversingssnelheid aan op de video-inhoud, vermindert haperingen en bespaart batterij", "tunneledPlayback": "Tunneled Playback", diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index cd7dbf3a..f7e47cb6 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -316,6 +316,8 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Zeige auf Discord, was du gerade schaust'; + @override String get autoPip => 'Automatisches Bild-in-Bild'; + @override String get autoPipDescription => 'Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird'; @override String get matchContentFrameRate => 'Inhalts-Bildrate anpassen'; @override String get matchContentFrameRateDescription => 'Bildwiederholfrequenz des Displays an den Videoinhalt anpassen, reduziert Ruckeln und spart Akku'; @override String get tunneledPlayback => 'Tunneled Playback'; diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index a3c76800..98b64839 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -694,6 +694,12 @@ class TranslationsSettingsEn { /// en: 'Show what you're watching on Discord' String get discordRichPresenceDescription => 'Show what you\'re watching on Discord'; + /// en: 'Auto Picture-in-Picture' + String get autoPip => 'Auto Picture-in-Picture'; + + /// en: 'Automatically enter picture-in-picture when leaving the app during playback' + String get autoPipDescription => 'Automatically enter picture-in-picture when leaving the app during playback'; + /// en: 'Match Content Frame Rate' String get matchContentFrameRate => 'Match Content Frame Rate'; @@ -3095,6 +3101,8 @@ extension on Translations { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresenceDescription' => 'Show what you\'re watching on Discord', + 'settings.autoPip' => 'Auto Picture-in-Picture', + 'settings.autoPipDescription' => 'Automatically enter picture-in-picture when leaving the app during playback', 'settings.matchContentFrameRate' => 'Match Content Frame Rate', 'settings.matchContentFrameRateDescription' => 'Adjust display refresh rate to match video content, reducing judder and saving battery', 'settings.tunneledPlayback' => 'Tunneled Playback', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 4d6db313..31e81bd2 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -316,6 +316,8 @@ class _TranslationsSettingsEs implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Presencia de Discord'; @override String get discordRichPresenceDescription => 'Mostrar lo que estás viendo en Discord'; + @override String get autoPip => 'Imagen en imagen automática'; + @override String get autoPipDescription => 'Activar automáticamente imagen en imagen al salir de la app durante la reproducción'; @override String get matchContentFrameRate => 'Ajustar frecuencia de actualización'; @override String get matchContentFrameRateDescription => 'Ajustar la frecuencia de actualización de la pantalla para que coincida con el video, reduciendo tirones y ahorrando batería'; @override String get tunneledPlayback => 'Tunneled Playback'; diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 02c1e74f..bd90314c 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -316,6 +316,8 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Montrez ce que vous regardez sur Discord'; + @override String get autoPip => 'Image dans l\'image automatique'; + @override String get autoPipDescription => 'Activer automatiquement l\'image dans l\'image en quittant l\'application pendant la lecture'; @override String get matchContentFrameRate => 'Fréquence d\'images du contenu correspondant'; @override String get matchContentFrameRateDescription => 'Ajustez la fréquence de rafraîchissement de l\'écran en fonction du contenu vidéo, ce qui réduit les saccades et économise la batterie'; @override String get tunneledPlayback => 'Tunneled Playback'; diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 1242b7f3..073bee03 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -316,6 +316,8 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Mostra su Discord cosa stai guardando'; + @override String get autoPip => 'Picture-in-Picture automatico'; + @override String get autoPipDescription => 'Attiva automaticamente il picture-in-picture quando si esce dall\'app durante la riproduzione'; @override String get matchContentFrameRate => 'Adatta frequenza fotogrammi'; @override String get matchContentFrameRateDescription => 'Regola la frequenza di aggiornamento del display in base al contenuto video, riducendo i tremolii e risparmiando batteria'; @override String get tunneledPlayback => 'Tunneled Playback'; diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 730da709..d014707e 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -316,6 +316,8 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Discord에서 시청 중인 콘텐츠 표시'; + @override String get autoPip => '자동 PIP 모드'; + @override String get autoPipDescription => '재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환'; @override String get matchContentFrameRate => '콘텐츠 프레임 레이트 맞춤'; @override String get matchContentFrameRateDescription => '비디오 콘텐츠에 맞게 디스플레이 주사율을 조정하여 떨림을 줄이고 배터리를 절약합니다'; @override String get tunneledPlayback => 'Tunneled Playback'; diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index b83be9f9..77865c8c 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -316,6 +316,8 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Toon op Discord wat je aan het kijken bent'; + @override String get autoPip => 'Automatische beeld-in-beeld'; + @override String get autoPipDescription => 'Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen'; @override String get matchContentFrameRate => 'Inhoudsframesnelheid afstemmen'; @override String get matchContentFrameRateDescription => 'Pas de schermverversingssnelheid aan op de video-inhoud, vermindert haperingen en bespaart batterij'; @override String get tunneledPlayback => 'Tunneled Playback'; diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index c60a4334..1b90d63f 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -316,6 +316,8 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Visa vad du tittar på i Discord'; + @override String get autoPip => 'Automatisk bild-i-bild'; + @override String get autoPipDescription => 'Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning'; @override String get matchContentFrameRate => 'Matcha innehållets bildfrekvens'; @override String get matchContentFrameRateDescription => 'Justera skärmens uppdateringsfrekvens för att matcha videoinnehållet, minskar hackighet och sparar batteri'; @override String get tunneledPlayback => 'Tunneled Playback'; diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 676a0684..7b4e22bd 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -316,6 +316,8 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord 动态状态'; @override String get discordRichPresenceDescription => '在 Discord 上显示您正在观看的内容'; + @override String get autoPip => '自动画中画'; + @override String get autoPipDescription => '在播放期间离开应用时自动进入画中画模式'; @override String get matchContentFrameRate => '匹配内容帧率'; @override String get matchContentFrameRateDescription => '调整显示刷新率以匹配视频内容,减少画面抖动并节省电量'; @override String get tunneledPlayback => 'Tunneled Playback'; diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index ffe11772..151b5c20 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -197,6 +197,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Visa vad du tittar på i Discord", + "autoPip": "Automatisk bild-i-bild", + "autoPipDescription": "Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning", "matchContentFrameRate": "Matcha innehållets bildfrekvens", "matchContentFrameRateDescription": "Justera skärmens uppdateringsfrekvens för att matcha videoinnehållet, minskar hackighet och sparar batteri", "tunneledPlayback": "Tunneled Playback", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 1466a6b5..ff6687e8 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -197,6 +197,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord 动态状态", "discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容", + "autoPip": "自动画中画", + "autoPipDescription": "在播放期间离开应用时自动进入画中画模式", "matchContentFrameRate": "匹配内容帧率", "matchContentFrameRateDescription": "调整显示刷新率以匹配视频内容,减少画面抖动并节省电量", "tunneledPlayback": "Tunneled Playback", diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 196a90f0..992bfa3d 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -77,6 +77,7 @@ class _SettingsScreenState extends State with FocusableTab { static const _kPlayerBackend = 'player_backend'; static const _kExternalPlayer = 'external_player'; static const _kHardwareDecoding = 'hardware_decoding'; + static const _kAutoPip = 'auto_pip'; static const _kMatchContentFrameRate = 'match_content_frame_rate'; static const _kTunneledPlayback = 'tunneled_playback'; static const _kBufferSize = 'buffer_size'; @@ -121,6 +122,7 @@ class _SettingsScreenState extends State with FocusableTab { bool _videoPlayerNavigationEnabled = false; int _maxVolume = 100; bool _enableDiscordRPC = false; + bool _autoPip = true; bool _matchContentFrameRate = false; bool _tunneledPlayback = true; bool _useExoPlayer = true; // Android only: ExoPlayer vs MPV @@ -196,6 +198,7 @@ class _SettingsScreenState extends State with FocusableTab { _videoPlayerNavigationEnabled = _settingsService.getVideoPlayerNavigationEnabled(); _maxVolume = _settingsService.getMaxVolume(); _enableDiscordRPC = _settingsService.getEnableDiscordRPC(); + _autoPip = _settingsService.getAutoPip(); _matchContentFrameRate = _settingsService.getMatchContentFrameRate(); _tunneledPlayback = _settingsService.getTunneledPlayback(); _useExoPlayer = _settingsService.getUseExoPlayer(); @@ -487,6 +490,20 @@ class _SettingsScreenState extends State with FocusableTab { await _settingsService.setEnableHardwareDecoding(value); }, ), + if (Platform.isAndroid) + SwitchListTile( + focusNode: _focusTracker.get(_kAutoPip), + secondary: const AppIcon(Symbols.picture_in_picture_alt_rounded, fill: 1), + title: Text(t.settings.autoPip), + subtitle: Text(t.settings.autoPipDescription), + value: _autoPip, + onChanged: (value) async { + setState(() { + _autoPip = value; + }); + await _settingsService.setAutoPip(value); + }, + ), if (Platform.isAndroid) SwitchListTile( focusNode: _focusTracker.get(_kMatchContentFrameRate), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index ba769f72..9969e8f5 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -180,6 +180,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // App lifecycle state tracking bool _wasPlayingBeforeInactive = false; + bool _autoPipEnabled = false; // Services MediaControlsManager? _mediaControlsManager; @@ -315,7 +316,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin case AppLifecycleState.hidden: // App is being hidden (user is switching away) // Pause video since we don't support background playback (mobile only) + // Skip if entering PiP mode - video should keep playing if (PlatformDetector.isMobile(context)) { + if (Platform.isAndroid && PipService().isPipActive.value) break; if (player != null && _isPlayerInitialized) { _wasPlayingBeforeInactive = player!.state.playing; if (_wasPlayingBeforeInactive) { @@ -326,6 +329,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin } break; case AppLifecycleState.paused: + // Skip if in PiP mode - video should keep playing + if (Platform.isAndroid && PipService().isPipActive.value) break; // Clear media controls when app truly goes to background // (we don't support background playback) OsMediaControls.clear(); @@ -394,6 +399,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Load buffer size from settings final settingsService = await SettingsService.getInstance(); _videoPlayerNavigationEnabled = settingsService.getVideoPlayerNavigationEnabled(); + if (Platform.isAndroid) { + _autoPipEnabled = settingsService.getAutoPip(); + } final bufferSizeMB = settingsService.getBufferSize(); final enableHardwareDecoding = settingsService.getEnableHardwareDecoding(); final debugLoggingEnabled = settingsService.getEnableDebugLogging(); @@ -1075,6 +1083,16 @@ class VideoPlayerScreenState extends State with WidgetsBindin }; _videoPIPManager!.isPipActive.addListener(_onPipStateChanged); + // Auto-PiP: set up callback for API 26-30 path and initial state + if (Platform.isAndroid && _autoPipEnabled) { + PipService.onAutoPipEntering = () { + _videoFilterManager?.enterPipMode(); + }; + if (player!.state.playing) { + _videoPIPManager!.updateAutoPipState(isPlaying: true); + } + } + // Shader Service (MPV only) _shaderService = ShaderService(player!); if (_shaderService!.isSupported) { @@ -1637,9 +1655,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin _sendLiveTimeline('stopped'); _stopLiveTimelineUpdates(); - // Remove PiP state listener, clear callback, and dispose video filter manager + // Remove PiP state listener, clear callbacks, disable auto-PiP, and dispose video filter manager _videoPIPManager?.isPipActive.removeListener(_onPipStateChanged); _videoPIPManager?.onBeforeEnterPip = null; + _videoPIPManager?.disableAutoPip(); + PipService.onAutoPipEntering = null; _videoFilterManager?.dispose(); // Release cached BIF thumbnail data @@ -1769,6 +1789,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin } else { DiscordRPCService.instance.pausePlayback(); } + + // Update auto-PiP readiness on Android + if (Platform.isAndroid && _autoPipEnabled) { + _videoPIPManager?.updateAutoPipState(isPlaying: isPlaying); + } } void _onVideoCompleted(bool completed) async { diff --git a/lib/services/pip_service.dart b/lib/services/pip_service.dart index 4680d4b1..8855f2bc 100644 --- a/lib/services/pip_service.dart +++ b/lib/services/pip_service.dart @@ -17,12 +17,18 @@ class PipService { /// ValueNotifier for PiP state - widgets can listen to this final ValueNotifier isPipActive = ValueNotifier(false); + /// Callback invoked when native side is about to auto-enter PiP (API 26-30 path) + static VoidCallback? onAutoPipEntering; + Future _handleMethodCall(MethodCall call) async { switch (call.method) { case 'onPipChanged': final isInPip = call.arguments as bool; isPipActive.value = isInPip; break; + case 'onAutoPipEntering': + onAutoPipEntering?.call(); + break; } } @@ -30,6 +36,15 @@ class PipService { return await _channel.invokeMethod('isSupported') ?? false; } + /// Tell the native side whether auto-PiP is ready and the current video dimensions + static Future setAutoPipReady({required bool ready, int? width, int? height}) async { + await _channel.invokeMethod('setAutoPipReady', { + 'ready': ready, + 'width': width, + 'height': height, + }); + } + static Future<(bool success, String? error)> enter({int? width, int? height}) async { final result = await _channel.invokeMethod('enter', {'width': width, 'height': height}); if (result == null) { diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index f050dcd8..1bde1a1b 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -64,6 +64,7 @@ class SettingsService extends BaseSharedPreferencesService { static const String _keyMpvConfigPresets = 'mpv_config_presets'; static const String _keyMaxVolume = 'max_volume'; static const String _keyEnableDiscordRPC = 'enable_discord_rpc'; + static const String _keyAutoPip = 'auto_pip'; static const String _keyMatchContentFrameRate = 'match_content_frame_rate'; static const String _keyTunneledPlayback = 'tunneled_playback'; static const String _keyDefaultPlaybackSpeed = 'default_playback_speed'; @@ -985,6 +986,15 @@ class SettingsService extends BaseSharedPreferencesService { return prefs.getBool(_keyEnableDiscordRPC) ?? false; // Default disabled } + // Auto Picture-in-Picture (Android only) + Future setAutoPip(bool enabled) async { + await prefs.setBool(_keyAutoPip, enabled); + } + + bool getAutoPip() { + return prefs.getBool(_keyAutoPip) ?? true; // Default enabled + } + // Match Content Frame Rate (Android only) Future setMatchContentFrameRate(bool enabled) async { await prefs.setBool(_keyMatchContentFrameRate, enabled); @@ -1211,6 +1221,7 @@ class SettingsService extends BaseSharedPreferencesService { prefs.remove(_keyMpvConfigEntries), prefs.remove(_keyMpvConfigPresets), prefs.remove(_keyEnableDiscordRPC), + prefs.remove(_keyAutoPip), prefs.remove(_keyMatchContentFrameRate), prefs.remove(_keyTunneledPlayback), prefs.remove(_keyDefaultPlaybackSpeed), diff --git a/lib/services/video_pip_manager.dart b/lib/services/video_pip_manager.dart index 6ce3f3b2..918d3b19 100644 --- a/lib/services/video_pip_manager.dart +++ b/lib/services/video_pip_manager.dart @@ -22,20 +22,8 @@ class VideoPIPManager { /// Access PiP state from the service ValueNotifier get isPipActive => PipService().isPipActive; - /// Toggle native PiP - /// Returns a tuple of (success, error message) for error handling - Future<(bool success, String? error)> togglePIP() async { - final supported = await PipService.isSupported(); - if (!supported) return (false, 'PiP not supported on this device'); - - // Reset video filter to contain mode BEFORE entering PiP - // This prevents the zoomed/cropped view from being shown in PiP - onBeforeEnterPip?.call(); - - // Wait a frame for the filter change to take effect - await Future.delayed(const Duration(milliseconds: 50)); - - // Get display dimensions for correct aspect ratio (accounts for pixel aspect ratio) + /// Get current video dimensions (display or storage or fallback to viewport) + Future<(int? width, int? height)> _getVideoDimensions() async { int? width; int? height; @@ -46,11 +34,8 @@ class VideoPIPManager { width = int.tryParse(dwidth); height = int.tryParse(dheight); } - } catch (_) { - // Fall through to storage dimensions - } + } catch (_) {} - // Fallback to storage dimensions (less accurate for anamorphic content) if (width == null || height == null) { try { final videoWidth = await player.getProperty('width'); @@ -59,15 +44,44 @@ class VideoPIPManager { width = int.tryParse(videoWidth); height = int.tryParse(videoHeight); } - } catch (_) { - // Fall through to viewport size - } + } catch (_) {} } - // Fall back to viewport size if video dimensions unavailable width ??= _playerSize?.width.toInt(); height ??= _playerSize?.height.toInt(); - return await PipService.enter(width: width, height: height); + return (width, height); + } + + /// Toggle native PiP + /// Returns a tuple of (success, error message) for error handling + Future<(bool success, String? error)> togglePIP() async { + final supported = await PipService.isSupported(); + if (!supported) return (false, 'PiP not supported on this device'); + + // Reset video filter to contain mode BEFORE entering PiP + onBeforeEnterPip?.call(); + + // Wait a frame for the filter change to take effect + await Future.delayed(const Duration(milliseconds: 50)); + + final dims = await _getVideoDimensions(); + return await PipService.enter(width: dims.$1, height: dims.$2); + } + + /// Update auto-PiP readiness on the native side + Future updateAutoPipState({required bool isPlaying}) async { + if (!isPlaying) { + await PipService.setAutoPipReady(ready: false); + return; + } + + final dims = await _getVideoDimensions(); + await PipService.setAutoPipReady(ready: true, width: dims.$1, height: dims.$2); + } + + /// Disable auto-PiP (called on dispose or when leaving player) + Future disableAutoPip() async { + await PipService.setAutoPipReady(ready: false); } }