From bea485bddc4c70f600a432ba78be72ebf683d583 Mon Sep 17 00:00:00 2001 From: Dan Long <139961+dansl@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:23:39 -0600 Subject: [PATCH] Add Picture-in-Picture support for Android video playback Implemented native Picture-in-Picture (PiP) mode for Android by updating the AndroidManifest, MainActivity, and introducing PipService and VideoPIPManager. Updated video player screen and controls to support toggling PiP mode, and added localized strings for the PiP button in multiple languages. Relating to Issue #44: At this time PiP in Plezy only supports Android devices... iOS+MacOS will require significant changes to the video player for it to work properly... --- android/app/src/main/AndroidManifest.xml | 5 ++++ .../flutter_application_1/MainActivity.kt | 29 ++++++++++++++++++ lib/i18n/strings.g.dart | 14 +++++++++ lib/screens/video_player_screen.dart | 16 +++++++++- lib/services/pip_service.dart | 20 +++++++++++++ lib/services/video_pip_manager.dart | 28 +++++++++++++++++ .../desktop_video_controls.dart | 3 ++ .../video_controls/video_controls.dart | 30 +++++++++++++++++++ .../widgets/track_chapter_controls.dart | 21 +++++++++++++ 9 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 lib/services/pip_service.dart create mode 100644 lib/services/video_pip_manager.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3c0021f1..3a93af8d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,10 @@ + + + + @@ -28,6 +32,7 @@ android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" + android:supportsPictureInPicture="true" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> diff --git a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt index 857a5482..81c64d54 100644 --- a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt @@ -2,11 +2,18 @@ package com.edde746.plezy import android.os.Build import android.os.Bundle +import android.app.PictureInPictureParams +import android.content.res.Configuration +import android.util.Rational import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel import com.edde746.plezy.mpv.MpvPlayerPlugin class MainActivity : FlutterActivity() { + + private val PIP_CHANNEL = "app.plezy/pip" + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -20,5 +27,27 @@ class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) flutterEngine.plugins.add(MpvPlayerPlugin()) + + MethodChannel( flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL ).setMethodCallHandler { call, result -> + when (call.method) { + "isSupported" -> { + result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + } + "enter" -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val width = call.argument("width") ?: 16 + val height = call.argument("height") ?: 9 + val params = PictureInPictureParams.Builder().setAspectRatio(Rational(width, height)).build() + enterPictureInPictureMode(params) + } + result.success(null) + } else -> result.notImplemented() + } + } + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean,newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + MethodChannel( flutterEngine!!.dartExecutor.binaryMessenger, PIP_CHANNEL ).invokeMethod( "onPipChanged" , isInPictureInPictureMode) } } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index a94c5044..f4d9e9e0 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -532,6 +532,7 @@ class _StringsVideoControlsEn { String get subtitlesButton => 'Subtitles'; String get chaptersButton => 'Chapters'; String get versionsButton => 'Video versions'; + String get pipButton => 'Picture-in-Picture mode'; String get aspectRatioButton => 'Aspect ratio'; String get fullscreenButton => 'Enter fullscreen'; String get exitFullscreenButton => 'Exit fullscreen'; @@ -1404,6 +1405,7 @@ class _StringsVideoControlsDe implements _StringsVideoControlsEn { @override String get subtitlesButton => 'Untertitel'; @override String get chaptersButton => 'Kapitel'; @override String get versionsButton => 'Videoversionen'; + @override String get pipButton => 'Bild-in-Bild Modus'; @override String get aspectRatioButton => 'Seitenverhältnis'; @override String get fullscreenButton => 'Vollbild aktivieren'; @override String get exitFullscreenButton => 'Vollbild verlassen'; @@ -2277,6 +2279,7 @@ class _StringsVideoControlsIt implements _StringsVideoControlsEn { @override String get subtitlesButton => 'Sottotitoli'; @override String get chaptersButton => 'Capitoli'; @override String get versionsButton => 'Versioni video'; + @override String get pipButton => 'Modalità Picture-in-Picture'; @override String get aspectRatioButton => 'Proporzioni'; @override String get fullscreenButton => 'Attiva schermo intero'; @override String get exitFullscreenButton => 'Esci da schermo intero'; @@ -3150,6 +3153,7 @@ class _StringsVideoControlsKo implements _StringsVideoControlsEn { @override String get subtitlesButton => '자막'; @override String get chaptersButton => '챕터'; @override String get versionsButton => '동영상 버전'; + @override String get pipButton => '픽처 인 픽처 모드'; @override String get aspectRatioButton => '화면비율'; @override String get fullscreenButton => '전체화면'; @override String get exitFullscreenButton => '전체화면 종료'; @@ -4023,6 +4027,7 @@ class _StringsVideoControlsNl implements _StringsVideoControlsEn { @override String get subtitlesButton => 'Ondertitels'; @override String get chaptersButton => 'Hoofdstukken'; @override String get versionsButton => 'Videoversies'; + @override String get pipButton => 'Beeld-in-beeld modus'; @override String get aspectRatioButton => 'Beeldverhouding'; @override String get fullscreenButton => 'Volledig scherm activeren'; @override String get exitFullscreenButton => 'Volledig scherm verlaten'; @@ -4896,6 +4901,7 @@ class _StringsVideoControlsSv implements _StringsVideoControlsEn { @override String get subtitlesButton => 'Undertexter'; @override String get chaptersButton => 'Kapitel'; @override String get versionsButton => 'Videoversioner'; + @override String get pipButton => 'Bild-i-bild läge'; @override String get aspectRatioButton => 'Bildförhållande'; @override String get fullscreenButton => 'Aktivera helskärm'; @override String get exitFullscreenButton => 'Avsluta helskärm'; @@ -5769,6 +5775,7 @@ class _StringsVideoControlsZh implements _StringsVideoControlsEn { @override String get subtitlesButton => '字幕'; @override String get chaptersButton => '章节'; @override String get versionsButton => '视频版本'; + @override String get pipButton => '画中画模式'; @override String get aspectRatioButton => '宽高比'; @override String get fullscreenButton => '进入全屏'; @override String get exitFullscreenButton => '退出全屏'; @@ -6465,6 +6472,7 @@ extension on Translations { case 'videoControls.subtitlesButton': return 'Subtitles'; case 'videoControls.chaptersButton': return 'Chapters'; case 'videoControls.versionsButton': return 'Video versions'; + case 'videoControls.pipButton': return 'Picture-in-Picture mode'; case 'videoControls.aspectRatioButton': return 'Aspect ratio'; case 'videoControls.fullscreenButton': return 'Enter fullscreen'; case 'videoControls.exitFullscreenButton': return 'Exit fullscreen'; @@ -6979,6 +6987,7 @@ extension on _StringsDe { case 'videoControls.subtitlesButton': return 'Untertitel'; case 'videoControls.chaptersButton': return 'Kapitel'; case 'videoControls.versionsButton': return 'Videoversionen'; + case 'videoControls.pipButton': return 'Bild-in-Bild Modus'; case 'videoControls.aspectRatioButton': return 'Seitenverhältnis'; case 'videoControls.fullscreenButton': return 'Vollbild aktivieren'; case 'videoControls.exitFullscreenButton': return 'Vollbild verlassen'; @@ -7494,6 +7503,7 @@ extension on _StringsIt { case 'videoControls.subtitlesButton': return 'Sottotitoli'; case 'videoControls.chaptersButton': return 'Capitoli'; case 'videoControls.versionsButton': return 'Versioni video'; + case 'videoControls.pipButton': return 'Modalità Picture-in-Picture'; case 'videoControls.aspectRatioButton': return 'Proporzioni'; case 'videoControls.fullscreenButton': return 'Attiva schermo intero'; case 'videoControls.exitFullscreenButton': return 'Esci da schermo intero'; @@ -8009,6 +8019,7 @@ extension on _StringsKo { case 'videoControls.subtitlesButton': return '자막'; case 'videoControls.chaptersButton': return '챕터'; case 'videoControls.versionsButton': return '동영상 버전'; + case 'videoControls.pipButton': return '픽처 인 픽처 모드'; case 'videoControls.aspectRatioButton': return '화면비율'; case 'videoControls.fullscreenButton': return '전체화면'; case 'videoControls.exitFullscreenButton': return '전체화면 종료'; @@ -8524,6 +8535,7 @@ extension on _StringsNl { case 'videoControls.subtitlesButton': return 'Ondertitels'; case 'videoControls.chaptersButton': return 'Hoofdstukken'; case 'videoControls.versionsButton': return 'Videoversies'; + case 'videoControls.pipButton': return 'Beeld-in-beeld modus'; case 'videoControls.aspectRatioButton': return 'Beeldverhouding'; case 'videoControls.fullscreenButton': return 'Volledig scherm activeren'; case 'videoControls.exitFullscreenButton': return 'Volledig scherm verlaten'; @@ -9039,6 +9051,7 @@ extension on _StringsSv { case 'videoControls.subtitlesButton': return 'Undertexter'; case 'videoControls.chaptersButton': return 'Kapitel'; case 'videoControls.versionsButton': return 'Videoversioner'; + case 'videoControls.pipButton': return 'Bild-i-bild läge'; case 'videoControls.aspectRatioButton': return 'Bildförhållande'; case 'videoControls.fullscreenButton': return 'Aktivera helskärm'; case 'videoControls.exitFullscreenButton': return 'Avsluta helskärm'; @@ -9554,6 +9567,7 @@ extension on _StringsZh { case 'videoControls.subtitlesButton': return '字幕'; case 'videoControls.chaptersButton': return '章节'; case 'videoControls.versionsButton': return '视频版本'; + case 'videoControls.pipButton': return '画中画模式'; case 'videoControls.aspectRatioButton': return '宽高比'; case 'videoControls.fullscreenButton': return '进入全屏'; case 'videoControls.exitFullscreenButton': return '退出全屏'; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index f8e4b75a..3a21d0e0 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -29,6 +29,7 @@ import '../services/offline_watch_sync_service.dart'; import '../services/settings_service.dart'; import '../services/track_selection_service.dart'; import '../services/video_filter_manager.dart'; +import '../services/video_pip_manager.dart'; import '../providers/user_profile_provider.dart'; import '../utils/app_logger.dart'; import '../utils/orientation_helper.dart'; @@ -104,6 +105,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin MediaControlsManager? _mediaControlsManager; PlaybackProgressTracker? _progressTracker; VideoFilterManager? _videoFilterManager; + VideoPIPManager? _videoPIPManager; final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService(); // Watch Together provider reference (stored early to use in dispose) @@ -714,7 +716,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _currentMediaInfo = result.mediaInfo; }); - // Initialize video filter manager with player and available versions + // Initialize video PIP and filter manager with player and available versions if (player != null) { _videoFilterManager = VideoFilterManager( player: player!, @@ -723,6 +725,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); // Update video filter once dimensions are available _videoFilterManager!.updateVideoFilter(); + + // PIP Manager + _videoPIPManager = VideoPIPManager( + player: player! + ); } // Add external subtitles to the player @@ -782,6 +789,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); } + void _togglePIPMode() { + setState(() { + _videoPIPManager?.togglePIP(); + }); + } + /// Cycle through BoxFit modes: contain → cover → fill → contain (for button) void _cycleBoxFitMode() { setState(() { @@ -1519,6 +1532,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin onPrevious: (_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null, availableVersions: _availableVersions, selectedMediaIndex: widget.selectedMediaIndex, + onTogglePIPMode: _togglePIPMode, boxFitMode: _videoFilterManager?.boxFitMode ?? 0, onCycleBoxFitMode: _cycleBoxFitMode, onAudioTrackChanged: _onAudioTrackChanged, diff --git a/lib/services/pip_service.dart b/lib/services/pip_service.dart new file mode 100644 index 00000000..88b16328 --- /dev/null +++ b/lib/services/pip_service.dart @@ -0,0 +1,20 @@ +import 'package:flutter/services.dart'; + +class PipService { + static const MethodChannel _channel = + MethodChannel('app.plezy/pip'); + + static Future isSupported() async { + return await _channel.invokeMethod('isSupported') ?? false; + } + + static Future enter({ + int? width, + int? height, + }) async { + await _channel.invokeMethod('enter', { + 'width': width, + 'height': height, + }); + } +} \ No newline at end of file diff --git a/lib/services/video_pip_manager.dart b/lib/services/video_pip_manager.dart new file mode 100644 index 00000000..6bcb7a27 --- /dev/null +++ b/lib/services/video_pip_manager.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import '../mpv/mpv.dart'; +import '../services/pip_service.dart'; + +/// Manages video Picture-in-Picture mode + +class VideoPIPManager { + final Player player; + + bool isPIPactive = false; + + VideoPIPManager({required this.player}); + + Size? _playerSize; + Size? get playerSize => _playerSize; + + /// Toggle native PiP + Future togglePIP() async { + final supported = await PipService.isSupported(); + if (!supported) return; + + await PipService.enter( + width: _playerSize?.width.toInt(), + height: _playerSize?.height.toInt(), + ); + + } +} \ No newline at end of file diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 1d4afbb2..73f3574b 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -52,6 +52,7 @@ class DesktopVideoControls extends StatefulWidget { final int subtitleSyncOffset; final bool isFullscreen; final bool isAlwaysOnTop; + final VoidCallback? onTogglePIPMode; final VoidCallback? onCycleBoxFitMode; final VoidCallback? onToggleFullscreen; final VoidCallback? onToggleAlwaysOnTop; @@ -95,6 +96,7 @@ class DesktopVideoControls extends StatefulWidget { this.subtitleSyncOffset = 0, this.isFullscreen = false, this.isAlwaysOnTop = false, + this.onTogglePIPMode, this.onCycleBoxFitMode, this.onToggleFullscreen, this.onToggleAlwaysOnTop, @@ -524,6 +526,7 @@ class DesktopVideoControlsState extends State { isFullscreen: widget.isFullscreen, isAlwaysOnTop: widget.isAlwaysOnTop, serverId: widget.serverId, + onTogglePIPMode: null, // PIP not supported on desktop onCycleBoxFitMode: widget.onCycleBoxFitMode, onToggleFullscreen: widget.onToggleFullscreen, onToggleAlwaysOnTop: widget.onToggleAlwaysOnTop, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 03d82376..81dc7d0e 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -15,6 +15,7 @@ import 'package:flutter/services.dart' KeyDownEvent, HardwareKeyboard; import '../../services/macos_window_service.dart'; +import '../../services/pip_service.dart'; import 'package:window_manager/window_manager.dart'; import '../../mpv/mpv.dart'; @@ -51,6 +52,7 @@ Widget plexVideoControlsBuilder( VoidCallback? onPrevious, List? availableVersions, int? selectedMediaIndex, + VoidCallback? onTogglePIPMode, int boxFitMode = 0, VoidCallback? onCycleBoxFitMode, Function(AudioTrack)? onAudioTrackChanged, @@ -68,6 +70,7 @@ Widget plexVideoControlsBuilder( availableVersions: availableVersions ?? [], selectedMediaIndex: selectedMediaIndex ?? 0, boxFitMode: boxFitMode, + onTogglePIPMode: onTogglePIPMode, onCycleBoxFitMode: onCycleBoxFitMode, onAudioTrackChanged: onAudioTrackChanged, onSubtitleTrackChanged: onSubtitleTrackChanged, @@ -86,6 +89,7 @@ class PlexVideoControls extends StatefulWidget { final List availableVersions; final int selectedMediaIndex; final int boxFitMode; + final VoidCallback? onTogglePIPMode; final VoidCallback? onCycleBoxFitMode; final Function(AudioTrack)? onAudioTrackChanged; final Function(SubtitleTrack)? onSubtitleTrackChanged; @@ -111,6 +115,7 @@ class PlexVideoControls extends StatefulWidget { this.availableVersions = const [], this.selectedMediaIndex = 0, this.boxFitMode = 0, + this.onTogglePIPMode, this.onCycleBoxFitMode, this.onAudioTrackChanged, this.onSubtitleTrackChanged, @@ -186,6 +191,9 @@ class _PlexVideoControlsState extends State with WindowListen double? _rateBeforeLongPress; bool _showSpeedIndicator = false; + // PiP support + bool _isPipSupported = false; + @override void initState() { super.initState(); @@ -203,6 +211,7 @@ class _PlexVideoControlsState extends State with WindowListen _listenToPosition(); _listenToPlayingState(); _listenToCompleted(); + _checkPipSupport(); // Add lifecycle observer to reload settings when app resumes WidgetsBinding.instance.addObserver(this); // Add window listener for tracking fullscreen state (for button icon) @@ -210,6 +219,7 @@ class _PlexVideoControlsState extends State with WindowListen windowManager.addListener(this); _initAlwaysOnTopState(); } + // Focus play/pause button on first frame if in keyboard mode WidgetsBinding.instance.addPostFrameCallback((_) { _focusPlayPauseIfKeyboardMode(); @@ -636,6 +646,24 @@ class _PlexVideoControlsState extends State with WindowListen await MacOSWindowService.setTrafficLightsVisible(_showControls); } + /// Check whether PiP is supported on this device + Future _checkPipSupport() async { + if (!Platform.isAndroid) { + return; + } + + try { + final supported = await PipService.isSupported(); + if (mounted) { + setState(() { + _isPipSupported = supported; + }); + } + } catch (e) { + return; + } + } + Future _loadPlaybackExtras() async { try { appLogger.d('_loadPlaybackExtras: starting for ${widget.metadata.ratingKey}'); @@ -731,6 +759,7 @@ class _PlexVideoControlsState extends State with WindowListen subtitleSyncOffset: _subtitleSyncOffset, isRotationLocked: _isRotationLocked, isFullscreen: _isFullscreen, + onTogglePIPMode: (_isPipSupported && Platform.isAndroid) ? widget.onTogglePIPMode : null, onCycleBoxFitMode: widget.onCycleBoxFitMode, onToggleRotationLock: _toggleRotationLock, onToggleFullscreen: _toggleFullscreen, @@ -1498,6 +1527,7 @@ class _PlexVideoControlsState extends State with WindowListen subtitleSyncOffset: _subtitleSyncOffset, isFullscreen: _isFullscreen, isAlwaysOnTop: _isAlwaysOnTop, + onTogglePIPMode: (_isPipSupported && Platform.isAndroid) ? widget.onTogglePIPMode : null, onCycleBoxFitMode: widget.onCycleBoxFitMode, onToggleFullscreen: _toggleFullscreen, onToggleAlwaysOnTop: _toggleAlwaysOnTop, diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 0d6cbc16..ebab0ee7 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -32,6 +32,7 @@ class TrackChapterControls extends StatelessWidget { final bool isRotationLocked; final bool isFullscreen; final bool isAlwaysOnTop; + final VoidCallback? onTogglePIPMode; final VoidCallback? onCycleBoxFitMode; final VoidCallback? onToggleRotationLock; final VoidCallback? onToggleFullscreen; @@ -70,6 +71,7 @@ class TrackChapterControls extends StatelessWidget { required this.isFullscreen, required this.serverId, this.isAlwaysOnTop = false, + this.onTogglePIPMode, this.onCycleBoxFitMode, this.onToggleRotationLock, this.onToggleFullscreen, @@ -288,6 +290,24 @@ class TrackChapterControls extends StatelessWidget { buttonIndex++; } + // Picture-in-Picture mode + if (onTogglePIPMode != null) { + final currentIndex = buttonIndex; + buttons.add( + _buildTrackButton( + buttonIndex: currentIndex, + icon: Symbols.picture_in_picture_alt, + tooltip: t.videoControls.pipButton, + semanticLabel: t.videoControls.pipButton, + tracks: tracks, + isMobile: isMobile, + isDesktop: isDesktop, + onPressed: onTogglePIPMode, + ), + ); + buttonIndex++; + } + // BoxFit mode button if (onCycleBoxFitMode != null) { final currentIndex = buttonIndex; @@ -372,6 +392,7 @@ class TrackChapterControls extends StatelessWidget { if (_hasSubtitles(tracks)) count++; if (chapters.isNotEmpty) count++; if (availableVersions.length > 1 && onSwitchVersion != null) count++; + if (onTogglePIPMode != null) count++; if (onCycleBoxFitMode != null) count++; if (isMobile && !PlatformDetector.isTV()) count++; // Rotation lock (not on TV) if (isDesktop) count++; // Fullscreen