@@ -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<Int>("width") ?: 16
|
||||
val height = call.argument<Int>("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<Boolean>("ready") ?: false
|
||||
autoPipWidth = call.argument<Int>("width") ?: 16
|
||||
autoPipHeight = call.argument<Int>("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<Int, Int> {
|
||||
val ratio = width.toFloat() / height.toFloat()
|
||||
return when {
|
||||
ratio < 0.42f -> Pair(5, 12)
|
||||
ratio > 2.39f -> Pair(12, 5)
|
||||
else -> Pair(width, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -197,6 +197,8 @@
|
||||
"maxVolumePercent": "${percent}%",
|
||||
"discordRichPresence": "Discord Rich Presence",
|
||||
"discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시",
|
||||
"autoPip": "자동 PIP 모드",
|
||||
"autoPipDescription": "재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환",
|
||||
"matchContentFrameRate": "콘텐츠 프레임 레이트 맞춤",
|
||||
"matchContentFrameRateDescription": "비디오 콘텐츠에 맞게 디스플레이 주사율을 조정하여 떨림을 줄이고 배터리를 절약합니다",
|
||||
"tunneledPlayback": "Tunneled Playback",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -197,6 +197,8 @@
|
||||
"maxVolumePercent": "${percent}%",
|
||||
"discordRichPresence": "Discord 动态状态",
|
||||
"discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容",
|
||||
"autoPip": "自动画中画",
|
||||
"autoPipDescription": "在播放期间离开应用时自动进入画中画模式",
|
||||
"matchContentFrameRate": "匹配内容帧率",
|
||||
"matchContentFrameRateDescription": "调整显示刷新率以匹配视频内容,减少画面抖动并节省电量",
|
||||
"tunneledPlayback": "Tunneled Playback",
|
||||
|
||||
@@ -77,6 +77,7 @@ class _SettingsScreenState extends State<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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),
|
||||
|
||||
@@ -180,6 +180,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
// App lifecycle state tracking
|
||||
bool _wasPlayingBeforeInactive = false;
|
||||
bool _autoPipEnabled = false;
|
||||
|
||||
// Services
|
||||
MediaControlsManager? _mediaControlsManager;
|
||||
@@ -315,7 +316,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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 {
|
||||
|
||||
@@ -17,12 +17,18 @@ class PipService {
|
||||
/// ValueNotifier for PiP state - widgets can listen to this
|
||||
final ValueNotifier<bool> isPipActive = ValueNotifier<bool>(false);
|
||||
|
||||
/// Callback invoked when native side is about to auto-enter PiP (API 26-30 path)
|
||||
static VoidCallback? onAutoPipEntering;
|
||||
|
||||
Future<dynamic> _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<bool>('isSupported') ?? false;
|
||||
}
|
||||
|
||||
/// Tell the native side whether auto-PiP is ready and the current video dimensions
|
||||
static Future<void> 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<Map>('enter', {'width': width, 'height': height});
|
||||
if (result == null) {
|
||||
|
||||
@@ -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<void> 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<void> 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),
|
||||
|
||||
@@ -22,20 +22,8 @@ class VideoPIPManager {
|
||||
/// Access PiP state from the service
|
||||
ValueNotifier<bool> 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<void> 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<void> disableAutoPip() async {
|
||||
await PipService.setAutoPipReady(ready: false);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user