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...
This commit is contained in:
Dan Long
2025-12-31 00:23:39 -06:00
parent 8b3b8ad596
commit bea485bddc
9 changed files with 165 additions and 1 deletions
+5
View File
@@ -3,6 +3,10 @@
<!-- Internet access permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- PIP Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<!-- Media session permissions for OS media controls -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
@@ -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">
@@ -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<Int>("width") ?: 16
val height = call.argument<Int>("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)
}
}
+14
View File
@@ -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 '退出全屏';
+15 -1
View File
@@ -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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
onPrevious: (_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
onTogglePIPMode: _togglePIPMode,
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
onCycleBoxFitMode: _cycleBoxFitMode,
onAudioTrackChanged: _onAudioTrackChanged,
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter/services.dart';
class PipService {
static const MethodChannel _channel =
MethodChannel('app.plezy/pip');
static Future<bool> isSupported() async {
return await _channel.invokeMethod<bool>('isSupported') ?? false;
}
static Future<void> enter({
int? width,
int? height,
}) async {
await _channel.invokeMethod('enter', {
'width': width,
'height': height,
});
}
}
+28
View File
@@ -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<void> togglePIP() async {
final supported = await PipService.isSupported();
if (!supported) return;
await PipService.enter(
width: _playerSize?.width.toInt(),
height: _playerSize?.height.toInt(),
);
}
}
@@ -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<DesktopVideoControls> {
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,
@@ -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<PlexMediaVersion>? 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<PlexMediaVersion> 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<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> with WindowListen
await MacOSWindowService.setTrafficLightsVisible(_showControls);
}
/// Check whether PiP is supported on this device
Future<void> _checkPipSupport() async {
if (!Platform.isAndroid) {
return;
}
try {
final supported = await PipService.isSupported();
if (mounted) {
setState(() {
_isPipSupported = supported;
});
}
} catch (e) {
return;
}
}
Future<void> _loadPlaybackExtras() async {
try {
appLogger.d('_loadPlaybackExtras: starting for ${widget.metadata.ratingKey}');
@@ -731,6 +759,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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<PlexVideoControls> with WindowListen
subtitleSyncOffset: _subtitleSyncOffset,
isFullscreen: _isFullscreen,
isAlwaysOnTop: _isAlwaysOnTop,
onTogglePIPMode: (_isPipSupported && Platform.isAndroid) ? widget.onTogglePIPMode : null,
onCycleBoxFitMode: widget.onCycleBoxFitMode,
onToggleFullscreen: _toggleFullscreen,
onToggleAlwaysOnTop: _toggleAlwaysOnTop,
@@ -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