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