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