feat: auto picture-in-picture

close #527
This commit is contained in:
edde746
2026-03-01 10:52:31 +01:00
parent 3a5cab956d
commit 138c8f9fd8
24 changed files with 213 additions and 57 deletions
@@ -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)
}
}
}
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -197,6 +197,8 @@
"maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시",
"autoPip": "자동 PIP 모드",
"autoPipDescription": "재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환",
"matchContentFrameRate": "콘텐츠 프레임 레이트 맞춤",
"matchContentFrameRateDescription": "비디오 콘텐츠에 맞게 디스플레이 주사율을 조정하여 떨림을 줄이고 배터리를 절약합니다",
"tunneledPlayback": "Tunneled Playback",
+2
View File
@@ -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",
+2
View File
@@ -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';
+8
View File
@@ -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',
+2
View File
@@ -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';
+2
View File
@@ -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';
+2
View File
@@ -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';
+2
View File
@@ -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';
+2
View File
@@ -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';
+2
View File
@@ -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';
+2
View File
@@ -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';
+2
View File
@@ -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",
+2
View File
@@ -197,6 +197,8 @@
"maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord 动态状态",
"discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容",
"autoPip": "自动画中画",
"autoPipDescription": "在播放期间离开应用时自动进入画中画模式",
"matchContentFrameRate": "匹配内容帧率",
"matchContentFrameRateDescription": "调整显示刷新率以匹配视频内容,减少画面抖动并节省电量",
"tunneledPlayback": "Tunneled Playback",
+17
View File
@@ -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),
+26 -1
View File
@@ -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 {
+15
View File
@@ -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) {
+11
View File
@@ -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),
+37 -23
View File
@@ -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);
}
}