feat(macos): add picture-in-picture support

This commit is contained in:
edde746
2026-03-02 18:19:36 +01:00
parent 7ebbfb9ef7
commit 8d3f448fdf
31 changed files with 559 additions and 37 deletions
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Video-Zeitleiste",
"volumeSlider": "Lautstärkepegel",
"endsAt": "Endet um ${time}",
"pipActive": "Wiedergabe im Bild-in-Bild-Modus",
"pipFailed": "Bild-in-Bild konnte nicht gestartet werden",
"pipErrors": {
"androidVersion": "Erfordert Android 8.0 oder neuer",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Video timeline",
"volumeSlider": "Volume level",
"endsAt": "Ends at ${time}",
"pipActive": "Playing in Picture-in-Picture",
"pipFailed": "Picture-in-picture failed to start",
"pipErrors": {
"androidVersion": "Requires Android 8.0 or newer",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Línea de tiempo del video",
"volumeSlider": "Nivel de volumen",
"endsAt": "Termina a las ${time}",
"pipActive": "Reproduciendo en Imagen en Imagen",
"pipFailed": "Error al iniciar Imagen en Imagen",
"pipErrors": {
"androidVersion": "Requiere Android 8.0 o más reciente",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Timeline vidéo",
"volumeSlider": "Niveau sonore",
"endsAt": "Fin à ${time}",
"pipActive": "Lecture en mode image dans l'image",
"pipFailed": "Échec du démarrage du mode image dans l'image",
"pipErrors": {
"androidVersion": "Nécessite Android 8.0 ou plus récent",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Timeline video",
"volumeSlider": "Livello volume",
"endsAt": "Finisce alle ${time}",
"pipActive": "Riproduzione in Picture-in-Picture",
"pipFailed": "Impossibile avviare la modalità Picture-in-Picture",
"pipErrors": {
"androidVersion": "Richiede Android 8.0 o versioni successive",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "타임라인",
"volumeSlider": "볼륨 조절",
"endsAt": "${time}에 종료",
"pipActive": "화면 속 화면으로 재생 중",
"pipFailed": "화면 속 화면 모드를 시작할 수 없습니다",
"pipErrors": {
"androidVersion": "Android 8.0 이상이 필요합니다",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Videotijdlijn",
"volumeSlider": "Volumeniveau",
"endsAt": "Eindigt om ${time}",
"pipActive": "Afspelen in beeld-in-beeld",
"pipFailed": "Beeld-in-beeld kon niet worden gestart",
"pipErrors": {
"androidVersion": "Vereist Android 8.0 of nieuwer",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 9
/// Strings: 7263 (807 per locale)
/// Strings: 7272 (808 per locale)
///
/// Built on 2026-03-02 at 13:31 UTC
/// Built on 2026-03-02 at 16:42 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsDe implements TranslationsVideoControlsEn {
@override String get timelineSlider => 'Video-Zeitleiste';
@override String get volumeSlider => 'Lautstärkepegel';
@override String endsAt({required Object time}) => 'Endet um ${time}';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => 'Bild-in-Bild konnte nicht gestartet werden';
@override late final _TranslationsVideoControlsPipErrorsDe pipErrors = _TranslationsVideoControlsPipErrorsDe._(_root);
@override String get chapters => 'Kapitel';
@@ -1627,6 +1628,7 @@ extension on TranslationsDe {
'videoControls.timelineSlider' => 'Video-Zeitleiste',
'videoControls.volumeSlider' => 'Lautstärkepegel',
'videoControls.endsAt' => ({required Object time}) => 'Endet um ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Bild-in-Bild konnte nicht gestartet werden',
'videoControls.pipErrors.androidVersion' => 'Erfordert Android 8.0 oder neuer',
'videoControls.pipErrors.iosVersion' => 'Erfordert iOS 15.0 oder neuer',
@@ -1815,9 +1817,9 @@ extension on TranslationsDe {
'liveTv.noDvr' => 'Kein DVR auf einem Server konfiguriert',
'liveTv.tuneFailed' => 'Kanal konnte nicht eingestellt werden',
'liveTv.loading' => 'Kanäle werden geladen...',
'liveTv.nowPlaying' => 'Läuft gerade',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'Läuft gerade',
'liveTv.noPrograms' => 'Keine Programmdaten verfügbar',
'liveTv.channelNumber' => ({required Object number}) => 'Kanal ${number}',
'liveTv.live' => 'LIVE',
+5 -1
View File
@@ -1119,6 +1119,9 @@ class TranslationsVideoControlsEn {
/// en: 'Ends at ${time}'
String endsAt({required Object time}) => 'Ends at ${time}';
/// en: 'Playing in Picture-in-Picture'
String get pipActive => 'Playing in Picture-in-Picture';
/// en: 'Picture-in-picture failed to start'
String get pipFailed => 'Picture-in-picture failed to start';
@@ -3248,6 +3251,7 @@ extension on Translations {
'videoControls.timelineSlider' => 'Video timeline',
'videoControls.volumeSlider' => 'Volume level',
'videoControls.endsAt' => ({required Object time}) => 'Ends at ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Picture-in-picture failed to start',
'videoControls.pipErrors.androidVersion' => 'Requires Android 8.0 or newer',
'videoControls.pipErrors.iosVersion' => 'Requires iOS 15.0 or newer',
@@ -3436,9 +3440,9 @@ extension on Translations {
'liveTv.noDvr' => 'No DVR configured on any server',
'liveTv.tuneFailed' => 'Failed to tune channel',
'liveTv.loading' => 'Loading channels...',
'liveTv.nowPlaying' => 'Now Playing',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'Now Playing',
'liveTv.noPrograms' => 'No program data available',
'liveTv.channelNumber' => ({required Object number}) => 'Ch. ${number}',
'liveTv.live' => 'LIVE',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsEs implements TranslationsVideoControlsEn {
@override String get timelineSlider => 'Línea de tiempo del video';
@override String get volumeSlider => 'Nivel de volumen';
@override String endsAt({required Object time}) => 'Termina a las ${time}';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => 'Error al iniciar Imagen en Imagen';
@override late final _TranslationsVideoControlsPipErrorsEs pipErrors = _TranslationsVideoControlsPipErrorsEs._(_root);
@override String get chapters => 'Capítulos';
@@ -1627,6 +1628,7 @@ extension on TranslationsEs {
'videoControls.timelineSlider' => 'Línea de tiempo del video',
'videoControls.volumeSlider' => 'Nivel de volumen',
'videoControls.endsAt' => ({required Object time}) => 'Termina a las ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Error al iniciar Imagen en Imagen',
'videoControls.pipErrors.androidVersion' => 'Requiere Android 8.0 o más reciente',
'videoControls.pipErrors.iosVersion' => 'Requiere iOS 15.0 o más reciente',
@@ -1815,9 +1817,9 @@ extension on TranslationsEs {
'liveTv.noDvr' => 'No hay DVR configurado en ningún servidor',
'liveTv.tuneFailed' => 'Error al sintonizar el canal',
'liveTv.loading' => 'Cargando canales...',
'liveTv.nowPlaying' => 'Reproduciendo ahora',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'Reproduciendo ahora',
'liveTv.noPrograms' => 'No hay datos de programación disponibles',
'liveTv.channelNumber' => ({required Object number}) => 'Canal ${number}',
'liveTv.live' => 'EN VIVO',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsFr implements TranslationsVideoControlsEn {
@override String get timelineSlider => 'Timeline vidéo';
@override String get volumeSlider => 'Niveau sonore';
@override String endsAt({required Object time}) => 'Fin à ${time}';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => 'Échec du démarrage du mode image dans l\'image';
@override late final _TranslationsVideoControlsPipErrorsFr pipErrors = _TranslationsVideoControlsPipErrorsFr._(_root);
@override String get chapters => 'Chapitres';
@@ -1627,6 +1628,7 @@ extension on TranslationsFr {
'videoControls.timelineSlider' => 'Timeline vidéo',
'videoControls.volumeSlider' => 'Niveau sonore',
'videoControls.endsAt' => ({required Object time}) => 'Fin à ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Échec du démarrage du mode image dans l\'image',
'videoControls.pipErrors.androidVersion' => 'Nécessite Android 8.0 ou plus récent',
'videoControls.pipErrors.iosVersion' => 'Nécessite iOS 15.0 ou plus récent',
@@ -1815,9 +1817,9 @@ extension on TranslationsFr {
'liveTv.noDvr' => 'Aucun DVR configuré sur les serveurs',
'liveTv.tuneFailed' => 'Impossible de syntoniser la chaîne',
'liveTv.loading' => 'Chargement des chaînes...',
'liveTv.nowPlaying' => 'En cours de lecture',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'En cours de lecture',
'liveTv.noPrograms' => 'Aucune donnée de programme disponible',
'liveTv.channelNumber' => ({required Object number}) => 'Ch. ${number}',
'liveTv.live' => 'EN DIRECT',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsIt implements TranslationsVideoControlsEn {
@override String get timelineSlider => 'Timeline video';
@override String get volumeSlider => 'Livello volume';
@override String endsAt({required Object time}) => 'Finisce alle ${time}';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => 'Impossibile avviare la modalità Picture-in-Picture';
@override late final _TranslationsVideoControlsPipErrorsIt pipErrors = _TranslationsVideoControlsPipErrorsIt._(_root);
@override String get chapters => 'Capitoli';
@@ -1627,6 +1628,7 @@ extension on TranslationsIt {
'videoControls.timelineSlider' => 'Timeline video',
'videoControls.volumeSlider' => 'Livello volume',
'videoControls.endsAt' => ({required Object time}) => 'Finisce alle ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Impossibile avviare la modalità Picture-in-Picture',
'videoControls.pipErrors.androidVersion' => 'Richiede Android 8.0 o versioni successive',
'videoControls.pipErrors.iosVersion' => 'Richiede iOS 15.0 o versioni successive',
@@ -1815,9 +1817,9 @@ extension on TranslationsIt {
'liveTv.noDvr' => 'Nessun DVR configurato su nessun server',
'liveTv.tuneFailed' => 'Impossibile sintonizzare il canale',
'liveTv.loading' => 'Caricamento canali...',
'liveTv.nowPlaying' => 'In riproduzione',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'In riproduzione',
'liveTv.noPrograms' => 'Nessun dato di programma disponibile',
'liveTv.channelNumber' => ({required Object number}) => 'Canale ${number}',
'liveTv.live' => 'IN DIRETTA',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsKo implements TranslationsVideoControlsEn {
@override String get timelineSlider => '타임라인';
@override String get volumeSlider => '볼륨 조절';
@override String endsAt({required Object time}) => '${time}에 종료';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => '화면 속 화면 모드를 시작할 수 없습니다';
@override late final _TranslationsVideoControlsPipErrorsKo pipErrors = _TranslationsVideoControlsPipErrorsKo._(_root);
@override String get chapters => '챕터';
@@ -1627,6 +1628,7 @@ extension on TranslationsKo {
'videoControls.timelineSlider' => '타임라인',
'videoControls.volumeSlider' => '볼륨 조절',
'videoControls.endsAt' => ({required Object time}) => '${time}에 종료',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => '화면 속 화면 모드를 시작할 수 없습니다',
'videoControls.pipErrors.androidVersion' => 'Android 8.0 이상이 필요합니다',
'videoControls.pipErrors.iosVersion' => 'iOS 15.0 이상이 필요합니다',
@@ -1815,9 +1817,9 @@ extension on TranslationsKo {
'liveTv.noDvr' => '서버에 DVR이 구성되어 있지 않습니다',
'liveTv.tuneFailed' => '채널 튜닝에 실패했습니다',
'liveTv.loading' => '채널 로딩 중...',
'liveTv.nowPlaying' => '현재 재생 중',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => '현재 재생 중',
'liveTv.noPrograms' => '프로그램 데이터가 없습니다',
'liveTv.channelNumber' => ({required Object number}) => '채널 ${number}',
'liveTv.live' => '실시간',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsNl implements TranslationsVideoControlsEn {
@override String get timelineSlider => 'Videotijdlijn';
@override String get volumeSlider => 'Volumeniveau';
@override String endsAt({required Object time}) => 'Eindigt om ${time}';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => 'Beeld-in-beeld kon niet worden gestart';
@override late final _TranslationsVideoControlsPipErrorsNl pipErrors = _TranslationsVideoControlsPipErrorsNl._(_root);
@override String get chapters => 'Hoofdstukken';
@@ -1627,6 +1628,7 @@ extension on TranslationsNl {
'videoControls.timelineSlider' => 'Videotijdlijn',
'videoControls.volumeSlider' => 'Volumeniveau',
'videoControls.endsAt' => ({required Object time}) => 'Eindigt om ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Beeld-in-beeld kon niet worden gestart',
'videoControls.pipErrors.androidVersion' => 'Vereist Android 8.0 of nieuwer',
'videoControls.pipErrors.iosVersion' => 'Vereist iOS 15.0 of nieuwer',
@@ -1815,9 +1817,9 @@ extension on TranslationsNl {
'liveTv.noDvr' => 'Geen DVR geconfigureerd op een server',
'liveTv.tuneFailed' => 'Kan zender niet afstemmen',
'liveTv.loading' => 'Zenders laden...',
'liveTv.nowPlaying' => 'Nu aan het afspelen',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'Nu aan het afspelen',
'liveTv.noPrograms' => 'Geen programmagegevens beschikbaar',
'liveTv.channelNumber' => ({required Object number}) => 'Kanaal ${number}',
'liveTv.live' => 'LIVE',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsSv implements TranslationsVideoControlsEn {
@override String get timelineSlider => 'Videotidslinje';
@override String get volumeSlider => 'Volymnivå';
@override String endsAt({required Object time}) => 'Slutar ${time}';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => 'Bild-i-bild kunde inte starta';
@override late final _TranslationsVideoControlsPipErrorsSv pipErrors = _TranslationsVideoControlsPipErrorsSv._(_root);
@override String get chapters => 'Kapitel';
@@ -1627,6 +1628,7 @@ extension on TranslationsSv {
'videoControls.timelineSlider' => 'Videotidslinje',
'videoControls.volumeSlider' => 'Volymnivå',
'videoControls.endsAt' => ({required Object time}) => 'Slutar ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Bild-i-bild kunde inte starta',
'videoControls.pipErrors.androidVersion' => 'Kräver Android 8.0 eller nyare',
'videoControls.pipErrors.iosVersion' => 'Kräver iOS 15.0 eller nyare',
@@ -1815,9 +1817,9 @@ extension on TranslationsSv {
'liveTv.noDvr' => 'Ingen DVR konfigurerad på någon server',
'liveTv.tuneFailed' => 'Kunde inte ställa in kanalen',
'liveTv.loading' => 'Laddar kanaler...',
'liveTv.nowPlaying' => 'Spelas nu',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'Spelas nu',
'liveTv.noPrograms' => 'Ingen programdata tillgänglig',
'liveTv.channelNumber' => ({required Object number}) => 'Kanal ${number}',
'liveTv.live' => 'LIVE',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsZh implements TranslationsVideoControlsEn {
@override String get timelineSlider => '视频时间轴';
@override String get volumeSlider => '音量调节';
@override String endsAt({required Object time}) => '${time} 结束';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => '画中画启动失败';
@override late final _TranslationsVideoControlsPipErrorsZh pipErrors = _TranslationsVideoControlsPipErrorsZh._(_root);
@override String get chapters => '章节';
@@ -1627,6 +1628,7 @@ extension on TranslationsZh {
'videoControls.timelineSlider' => '视频时间轴',
'videoControls.volumeSlider' => '音量调节',
'videoControls.endsAt' => ({required Object time}) => '${time} 结束',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => '画中画启动失败',
'videoControls.pipErrors.androidVersion' => '需要 Android 8.0 或更高版本',
'videoControls.pipErrors.iosVersion' => '需要 iOS 15.0 或更高版本',
@@ -1815,9 +1817,9 @@ extension on TranslationsZh {
'liveTv.noDvr' => '没有服务器配置了DVR',
'liveTv.tuneFailed' => '无法调谐频道',
'liveTv.loading' => '正在加载频道...',
'liveTv.nowPlaying' => '正在播放',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => '正在播放',
'liveTv.noPrograms' => '没有可用的节目数据',
'liveTv.channelNumber' => ({required Object number}) => '频道 ${number}',
'liveTv.live' => '直播',
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Videotidslinje",
"volumeSlider": "Volymnivå",
"endsAt": "Slutar ${time}",
"pipActive": "Spelar i bild-i-bild",
"pipFailed": "Bild-i-bild kunde inte starta",
"pipErrors": {
"androidVersion": "Kräver Android 8.0 eller nyare",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "视频时间轴",
"volumeSlider": "音量调节",
"endsAt": "${time} 结束",
"pipActive": "正在画中画模式中播放",
"pipFailed": "画中画启动失败",
"pipErrors": {
"androidVersion": "需要 Android 8.0 或更高版本",
+1 -1
View File
@@ -495,7 +495,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
await _settingsService.setEnableHardwareDecoding(value);
},
),
if (Platform.isAndroid || Platform.isIOS)
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)
SwitchListTile(
focusNode: _focusTracker.get(_kAutoPip),
secondary: const AppIcon(Symbols.picture_in_picture_alt_rounded, fill: 1),
+32 -1
View File
@@ -188,7 +188,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// iOS auto-PiP is system-initiated during the background transition, so
/// isPipActive may not be true yet — we also check the auto-PiP setting.
bool get _shouldSkipForPip =>
PipService().isPipActive.value || (Platform.isIOS && _autoPipEnabled);
PipService().isPipActive.value ||
((Platform.isIOS || Platform.isMacOS) && _autoPipEnabled);
// Services
MediaControlsManager? _mediaControlsManager;
@@ -2567,6 +2568,36 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
},
child: Stack(
children: [
// macOS PiP placeholder — video is in PiP window, show background with icon
// Placed before Video so controls render on top
if (Platform.isMacOS)
ValueListenableBuilder<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
if (!isInPip) return const SizedBox.shrink();
return Positioned.fill(
child: Container(
color: Colors.black,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.picture_in_picture_alt_rounded, size: 48, color: Colors.white.withValues(alpha: 0.5)),
const SizedBox(height: 12),
Text(
t.videoControls.pipActive,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
),
],
),
),
),
);
},
),
// Video player
Center(
child: LayoutBuilder(
+1
View File
@@ -61,6 +61,7 @@ class PipService {
return switch (errorCode) {
'android_version' => t.videoControls.pipErrors.androidVersion,
'ios_version' => t.videoControls.pipErrors.iosVersion,
'macos_version' => t.videoControls.pipErrors.notSupported,
'permission_disabled' => t.videoControls.pipErrors.permissionDisabled,
'not_supported' => t.videoControls.pipErrors.notSupported,
'vo_switch_failed' => t.videoControls.pipErrors.voSwitchFailed,
+3 -2
View File
@@ -1009,8 +1009,9 @@ class SettingsService extends BaseSharedPreferencesService {
}
bool getAutoPip() {
if (!Platform.isAndroid && !Platform.isIOS) return false;
return prefs.getBool(_keyAutoPip) ?? true; // Default enabled
if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) return false;
// Default enabled on mobile, disabled on macOS
return prefs.getBool(_keyAutoPip) ?? !Platform.isMacOS;
}
// Match Content Frame Rate (Android only)
+1 -1
View File
@@ -63,7 +63,7 @@ class VideoPIPManager {
// Reset video filter to contain mode BEFORE entering PiP (Android only —
// iOS switches VO entirely so the filter is irrelevant)
if (!Platform.isIOS) {
if (Platform.isAndroid) {
onBeforeEnterPip?.call();
// Wait a frame for the filter change to take effect
await Future.delayed(const Duration(milliseconds: 50));
@@ -681,7 +681,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
isFullscreen: widget.isFullscreen,
isAlwaysOnTop: widget.isAlwaysOnTop,
serverId: widget.serverId,
onTogglePIPMode: null, // PIP not supported on desktop
onTogglePIPMode: widget.onTogglePIPMode,
onCycleBoxFitMode: widget.onCycleBoxFitMode,
onToggleFullscreen: widget.onToggleFullscreen,
onToggleAlwaysOnTop: widget.onToggleAlwaysOnTop,
+35 -11
View File
@@ -198,6 +198,7 @@ class PlexVideoControls extends StatefulWidget {
class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListener, WidgetsBindingObserver {
bool _showControls = true;
bool _forceShowControls = false;
List<PlexChapter> _chapters = [];
bool _chaptersLoaded = false;
Timer? _hideTimer;
@@ -296,6 +297,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
widget.hasFirstFrame?.addListener(_onFirstFrameReady);
// Listen for external requests to show controls (e.g. screen-level focus recovery)
widget.controlsVisible?.addListener(_onControlsVisibleExternal);
// On macOS, show controls and disable auto-hide when PiP activates
if (Platform.isMacOS) {
_pipService.isPipActive.addListener(_onMacPipChanged);
}
// Defer context-dependent initialization to after first build
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -621,6 +626,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
windowManager.removeListener(this);
}
if (Platform.isMacOS) {
_pipService.isPipActive.removeListener(_onMacPipChanged);
}
super.dispose();
}
@@ -685,7 +693,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
/// Shared hide logic: hides controls, notifies parent, updates traffic lights, restores focus.
void _hideControls() {
if (!mounted || !_showControls) return;
if (!mounted || !_showControls || _forceShowControls) return;
setState(() {
_showControls = false;
// Dismiss skip button with controls — after this it only re-appears with controls
@@ -721,6 +729,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
final hasFrame = widget.hasFirstFrame?.value ?? true;
if (!hasFrame) return;
if (_forceShowControls) return;
// Only auto-hide if playing
if (widget.player.state.playing) {
_hideTimer = Timer(_hideDelay, () {
@@ -736,6 +746,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
/// Auto-hide controls after pause (does not check playing state in callback).
void _startPausedHideTimer() {
_hideTimer?.cancel();
if (_forceShowControls) return;
_hideTimer = Timer(_hideDelay, () {
_hideControls();
});
@@ -830,13 +841,13 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// user can reach them without the controls-hide-on-mouse-leave race.
// In normal windowed mode, toggle with controls as before.
final isMaximizedOrFullscreen = await windowManager.isMaximized() || await windowManager.isFullScreen();
final visible = isMaximizedOrFullscreen ? true : _showControls;
final visible = isMaximizedOrFullscreen || _forceShowControls ? true : _showControls;
await MacOSWindowService.setTrafficLightsVisible(visible);
}
/// Check whether PiP is supported on this device
Future<void> _checkPipSupport() async {
if (!Platform.isAndroid && !Platform.isIOS) {
if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) {
return;
}
@@ -852,6 +863,19 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
}
/// macOS PiP changed — force controls visible while PiP is active
void _onMacPipChanged() {
if (!mounted) return;
final inPip = _pipService.isPipActive.value;
setState(() => _forceShowControls = inPip);
if (inPip) {
_hideTimer?.cancel();
widget.controlsVisible?.value = true;
} else {
_startHideTimer();
}
}
Future<void> _loadPlaybackExtras() async {
// Live TV metadata uses EPG rating keys, not library items
if (widget.isLive) return;
@@ -952,7 +976,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
subtitleSyncOffset: _subtitleSyncOffset,
isRotationLocked: _isRotationLocked,
isFullscreen: _isFullscreen,
onTogglePIPMode: (_isPipSupported && (Platform.isAndroid || Platform.isIOS)) ? widget.onTogglePIPMode : null,
onTogglePIPMode: (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) ? widget.onTogglePIPMode : null,
onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null,
onToggleRotationLock: _toggleRotationLock,
onToggleFullscreen: _toggleFullscreen,
@@ -1583,11 +1607,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// Use desktop controls for desktop platforms AND Android TV
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
// Hide ALL controls when in PiP mode
// Hide ALL controls when in PiP mode (except macOS where main window stays visible)
return ValueListenableBuilder<bool>(
valueListenable: _pipService.isPipActive,
builder: (context, isInPip, _) {
if (isInPip) return const SizedBox.shrink();
if (isInPip && !Platform.isMacOS) return const SizedBox.shrink();
return Focus(
focusNode: _focusNode,
autofocus: true,
@@ -1716,7 +1740,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onPointerHover: (_) => _showControlsFromPointerActivity(),
onPointerSignal: _handlePointerSignal,
child: MouseRegion(
cursor: _showControls ? SystemMouseCursors.basic : SystemMouseCursors.none,
cursor: (_showControls || _forceShowControls) ? SystemMouseCursors.basic : SystemMouseCursors.none,
onHover: (_) => _showControlsFromPointerActivity(),
onExit: (_) => _hideControlsFromPointerExit(),
child: Stack(
@@ -1825,9 +1849,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
ignoring: !_showControls,
child: FocusScope(
// Prevent focus from entering controls when hidden
canRequestFocus: _showControls,
canRequestFocus: _showControls || _forceShowControls,
child: AnimatedOpacity(
opacity: _showControls ? 1.0 : 0.0,
opacity: (_showControls || _forceShowControls) ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: LayoutBuilder(
builder: (context, constraints) {
@@ -1946,7 +1970,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
Widget _buildDesktopControlsListener() {
final pipMode = (_isPipSupported && (Platform.isAndroid || Platform.isIOS)) ? widget.onTogglePIPMode : null;
final pipMode = (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) ? widget.onTogglePIPMode : null;
final boxFitMode = widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null;
final playbackState = context.watch<PlaybackStateProvider>();
@@ -1982,7 +2006,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onTogglePIPMode: pipMode,
onCycleBoxFitMode: boxFitMode,
onToggleFullscreen: _toggleFullscreen,
onToggleAlwaysOnTop: _toggleAlwaysOnTop,
onToggleAlwaysOnTop: Platform.isMacOS ? null : _toggleAlwaysOnTop,
onSwitchVersion: _switchMediaVersion,
onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: widget.onSubtitleTrackChanged,
+36
View File
@@ -33,6 +33,7 @@
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; };
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */; };
6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */; };
6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */; };
DF7527C52F58F90C45939A79 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 81BACC65DEDF97C1CA1F025F /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
@@ -89,6 +90,8 @@
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = "<group>"; };
6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = "<group>"; };
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; };
6AD8B16C2ED7B50000E9E1B7 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
6E770EFFB2EDFFA0C566F238 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
81BACC65DEDF97C1CA1F025F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -203,6 +206,7 @@
6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */,
6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */,
6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */,
6AD8B16C2ED7B50000E9E1B7 /* Runner-Bridging-Header.h */,
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
33E51914231749380026EE4D /* Release.entitlements */,
33CC11242044D66E0003C045 /* Resources */,
@@ -215,6 +219,7 @@
isa = PBXGroup;
children = (
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */,
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */,
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */,
);
path = MpvPlayer;
@@ -468,6 +473,7 @@
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */,
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */,
6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */,
6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -602,12 +608,22 @@
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"/System/Library/PrivateFrameworks",
);
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-weak_framework",
PIP,
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
};
name = Profile;
@@ -734,12 +750,22 @@
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"/System/Library/PrivateFrameworks",
);
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-weak_framework",
PIP,
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
@@ -754,12 +780,22 @@
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"/System/Library/PrivateFrameworks",
);
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-weak_framework",
PIP,
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
};
name = Release;
@@ -0,0 +1,150 @@
import Cocoa
/// Delegate to notify the plugin of PiP lifecycle events
protocol MpvPipDelegate: AnyObject {
func pipWillStart()
func pipDidStart()
/// Called when PiP stops. `restored` is true if the user pressed the close button (restore UI).
func pipDidStop(restored: Bool)
/// Forward play/pause commands from PiP overlay to mpv
func pipSetPlaying(_ playing: Bool)
/// Query whether mpv is currently playing
var isPipPlaying: Bool { get }
}
/// Encapsulates macOS Picture-in-Picture using the private PIP.framework (PIPViewController).
/// This approach wraps the existing Metal rendering view in PiP no VO switching needed.
/// mpv continues rendering to its CAMetalLayer throughout PiP.
class MpvPipController: NSObject, PIPViewControllerDelegate {
// MARK: - Properties
private lazy var pip: PIPViewController = {
let vc = PIPViewController()
vc.delegate = self
return vc
}()
private var pipVideoVC: NSViewController?
private var pipVideoView: NSView?
weak var delegate: MpvPipDelegate?
private(set) var isActive = false
var autoPipEnabled = false
// Keep reference to the window for restore animation
private weak var sourceWindow: NSWindow?
// MARK: - Public API
static var isSupported: Bool { true }
/// Enter PiP by wrapping the given Metal layer in a view and presenting it.
/// The layer continues receiving mpv frames no VO switch needed.
func startPip(metalLayer: CAMetalLayer, window: NSWindow, aspectRatio: NSSize) {
guard !isActive else { return }
sourceWindow = window
// Create a layer-hosting wrapper view for the Metal layer.
// PIPViewController resizes the view (and its root layer) as the PiP window resizes.
let videoView = NSView(frame: NSRect(origin: .zero, size: aspectRatio))
videoView.wantsLayer = true
videoView.layer = metalLayer
// Reset drawableSize to zero so it auto-derives from the layer's bounds.
// Without this, the explicit drawableSize set by updateFrame() (main window size)
// persists and causes mpv/MoltenVK to render at the wrong resolution in PiP.
metalLayer.drawableSize = .zero
// Create a view controller for PIPViewController
let vc = NSViewController()
vc.view = videoView
pipVideoVC = vc
pipVideoView = videoView
// Configure PiP
pip.playing = delegate?.isPipPlaying ?? false
pip.aspectRatio = aspectRatio
pip.replacementWindow = window
pip.replacementRect = window.contentView?.frame ?? .zero
delegate?.pipWillStart()
// Present PiP
pip.presentAsPicture(inPicture: vc)
isActive = true
delegate?.pipDidStart()
}
func stopPip() {
guard isActive else { return }
pip.dismiss(pipVideoVC!)
}
/// Update the play/pause button state in the PiP overlay
func setPlaying(_ playing: Bool) {
pip.playing = playing
}
/// Update the aspect ratio (e.g., when video track changes)
func setAspectRatio(_ size: NSSize) {
pip.aspectRatio = size
}
func setAutoStart(_ enabled: Bool) {
autoPipEnabled = enabled
}
/// Clean up after PiP closes detaches the Metal layer from the wrapper view
/// so MpvPlayerCore can re-add it to the main window.
/// Returns the Metal layer that was hosted in PiP.
@discardableResult
func detachLayer() -> CAMetalLayer? {
let metalLayer = pipVideoView?.layer as? CAMetalLayer
pipVideoView?.layer = CALayer() // detach before removing
pipVideoView = nil
pipVideoVC = nil
return metalLayer
}
// MARK: - PIPViewControllerDelegate
func pipShouldClose(_ pip: PIPViewController) -> Bool {
prepareForClose()
return true
}
func pipWillClose(_ pip: PIPViewController) {
prepareForClose()
}
func pipDidClose(_ pip: PIPViewController) {
isActive = false
delegate?.pipDidStop(restored: true)
}
func pipActionPlay(_ pip: PIPViewController) {
delegate?.pipSetPlaying(true)
}
func pipActionPause(_ pip: PIPViewController) {
delegate?.pipSetPlaying(false)
}
func pipActionStop(_ pip: PIPViewController) {
delegate?.pipSetPlaying(false)
}
// MARK: - Private
private func prepareForClose() {
guard let window = sourceWindow else { return }
pip.replacementWindow = window
pip.replacementRect = window.contentView?.frame ?? .zero
// Bring the main window forward for the restore animation
NSApp.activate(ignoringOtherApps: true)
window.deminiaturize(nil)
}
}
+65 -7
View File
@@ -13,7 +13,8 @@ private class MetalLayer: CAMetalLayer {
override var drawableSize: CGSize {
get { return super.drawableSize }
set {
if Int(newValue.width) > 1 && Int(newValue.height) > 1 {
// Allow .zero (auto-derive from bounds) or valid sizes > 1x1
if newValue == .zero || (Int(newValue.width) > 1 && Int(newValue.height) > 1) {
super.drawableSize = newValue
}
}
@@ -49,6 +50,9 @@ class MpvPlayerCore: NSObject {
private(set) var isInitialized = false
// PiP state
var isPipActive = false
// HDR settings
private var hdrEnabled = true // User preference for HDR
private var lastSigPeak: Double = 0.0 // Last known sig-peak for re-evaluation
@@ -119,25 +123,25 @@ class MpvPlayerCore: NSObject {
// MARK: - Fullscreen Transition Handling
@objc private func windowWillEnterFullScreen(_ notification: Notification) {
guard mpv != nil else { return }
guard mpv != nil, !isPipActive else { return }
print("[MpvPlayerCore] willEnterFullScreen — disabling video output")
mpv_set_property_string(mpv, "vid", "no")
}
@objc private func windowDidEnterFullScreen(_ notification: Notification) {
guard mpv != nil else { return }
guard mpv != nil, !isPipActive else { return }
print("[MpvPlayerCore] didEnterFullScreen — re-enabling video output")
mpv_set_property_string(mpv, "vid", "auto")
}
@objc private func windowWillExitFullScreen(_ notification: Notification) {
guard mpv != nil else { return }
guard mpv != nil, !isPipActive else { return }
print("[MpvPlayerCore] willExitFullScreen — disabling video output")
mpv_set_property_string(mpv, "vid", "no")
}
@objc private func windowDidExitFullScreen(_ notification: Notification) {
guard mpv != nil else { return }
guard mpv != nil, !isPipActive else { return }
print("[MpvPlayerCore] didExitFullScreen — re-enabling video output")
mpv_set_property_string(mpv, "vid", "auto")
}
@@ -300,10 +304,64 @@ class MpvPlayerCore: NSObject {
}
}
// MARK: - PiP Support
/// The Metal layer used for video rendering, exposed for PiP.
/// PiP moves this layer to its own window; mpv continues rendering to it.
var videoLayer: CAMetalLayer? { metalLayer }
/// Re-attach the Metal layer to the main window after PiP exits.
func reattachMetalLayer() {
guard let metalLayer = metalLayer, let contentView = window?.contentView else { return }
if metalLayer.superlayer == nil {
contentView.wantsLayer = true
contentView.layer?.insertSublayer(metalLayer, at: 0)
metalLayer.frame = contentView.bounds
if let screen = window?.screen ?? NSScreen.main {
metalLayer.contentsScale = screen.backingScaleFactor
metalLayer.drawableSize = CGSize(
width: contentView.bounds.width * screen.backingScaleFactor,
height: contentView.bounds.height * screen.backingScaleFactor
)
}
}
print("[MpvPlayerCore] Metal layer reattached to window")
}
/// Force a redraw (useful after PiP exit when paused).
/// Uses a seek to the current position to trigger a frame render.
func forceDraw() {
command(["seek", "0", "relative+exact"])
}
/// Whether mpv is currently paused
var isPaused: Bool {
guard let mpv = mpv else { return true }
var flag: Int32 = 0
mpv_get_property(mpv, "pause", MPV_FORMAT_FLAG, &flag)
return flag != 0
}
/// Current playback duration in seconds
var duration: Double {
guard let mpv = mpv else { return 0 }
var value: Double = 0
mpv_get_property(mpv, "duration", MPV_FORMAT_DOUBLE, &value)
return value
}
/// Current playback time in seconds
var timePos: Double {
guard let mpv = mpv else { return 0 }
var value: Double = 0
mpv_get_property(mpv, "time-pos", MPV_FORMAT_DOUBLE, &value)
return value
}
// MARK: - Visibility
func setVisible(_ visible: Bool) {
guard let layer = metalLayer else { return }
guard let layer = metalLayer, !isPipActive else { return }
if visible {
// Re-insert after background layer but before Flutter control views
@@ -321,7 +379,7 @@ class MpvPlayerCore: NSObject {
}
func updateFrame(_ frame: CGRect? = nil) {
guard let metalLayer = metalLayer else { return }
guard let metalLayer = metalLayer, !isPipActive else { return }
if let frame = frame {
metalLayer.frame = frame
+162 -2
View File
@@ -11,6 +11,12 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
private weak var registrar: FlutterPluginRegistrar?
private var nameToId: [String: Int] = [:]
// PiP
private var pipController: MpvPipController?
private var pipChannel: FlutterMethodChannel?
private var autoPipEnabled = false
private var enteredPipViaAuto = false
// MARK: - FlutterPlugin Registration
static func register(with registrar: FlutterPluginRegistrar) {
@@ -26,11 +32,18 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
binaryMessenger: registrar.messenger
)
let pipChannel = FlutterMethodChannel(
name: "app.plezy/pip",
binaryMessenger: registrar.messenger
)
let instance = MpvPlayerPlugin()
instance.registrar = registrar
instance.pipChannel = pipChannel
registrar.addMethodCallDelegate(instance, channel: methodChannel)
eventChannel.setStreamHandler(instance)
pipChannel.setMethodCallHandler(instance.handlePipCall)
print("[MpvPlayerPlugin] Registered with Flutter")
}
@@ -88,6 +101,99 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
}
}
// MARK: - PiP
private func ensurePipController() -> MpvPipController {
if let existing = pipController { return existing }
let controller = MpvPipController()
controller.delegate = self
pipController = controller
return controller
}
private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "isSupported":
result(MpvPipController.isSupported)
case "enter":
enterPip(manual: true, result: result)
case "setAutoPipReady":
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
autoPipEnabled = ready
let pip = ensurePipController()
pip.setAutoStart(ready)
if ready {
// Observe app resigning active to auto-enter PiP
NotificationCenter.default.removeObserver(self, name: NSApplication.didResignActiveNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(appDidResignActive), name: NSApplication.didResignActiveNotification, object: nil)
// Observe app becoming active to auto-exit PiP
NotificationCenter.default.removeObserver(self, name: NSApplication.didBecomeActiveNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive), name: NSApplication.didBecomeActiveNotification, object: nil)
} else {
NotificationCenter.default.removeObserver(self, name: NSApplication.didResignActiveNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: NSApplication.didBecomeActiveNotification, object: nil)
}
}
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
/// Enter PiP by moving the Metal rendering layer to a PiP window.
/// No VO switching mpv keeps rendering to the same Metal layer.
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
guard let playerCore = playerCore else {
result?(["success": false, "errorCode": "failed", "errorMessage": "Player not initialized"])
return
}
guard let metalLayer = playerCore.videoLayer else {
result?(["success": false, "errorCode": "failed", "errorMessage": "No video layer"])
return
}
guard let window = findFlutterWindow()?.0 else {
result?(["success": false, "errorCode": "failed", "errorMessage": "No window"])
return
}
let pip = ensurePipController()
guard !pip.isActive else {
result?(["success": false, "errorCode": "failed", "errorMessage": "PiP already active"])
return
}
// Get video dimensions for aspect ratio
var aspectRatio = NSSize(width: 16, height: 9) // default
if let w = playerCore.getProperty("width"), let h = playerCore.getProperty("height"),
let width = Double(w), let height = Double(h), width > 0 && height > 0 {
aspectRatio = NSSize(width: width, height: height)
}
enteredPipViaAuto = !manual
playerCore.isPipActive = true
pip.startPip(metalLayer: metalLayer, window: window, aspectRatio: aspectRatio)
pipChannel?.invokeMethod("onPipChanged", arguments: true)
result?(["success": true])
}
/// App resigned active auto-enter PiP if enabled and playing
@objc private func appDidResignActive() {
guard autoPipEnabled,
let pc = playerCore,
!pc.isPipActive,
!pc.isPaused,
pipController?.autoPipEnabled == true else { return }
print("[MpvPlayerPlugin] Auto-PiP: app resigned active, entering PiP")
enterPip(manual: false)
}
/// App became active auto-exit PiP if it was entered automatically
@objc private func appDidBecomeActive() {
guard enteredPipViaAuto, let pip = pipController, pip.isActive else { return }
print("[MpvPlayerPlugin] Auto-PiP: app became active, exiting PiP")
pip.stopPip()
}
// MARK: - Method Handlers
private func handleInitialize(result: @escaping FlutterResult) {
@@ -133,8 +239,17 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
private func handleDispose(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
self?.playerCore?.dispose()
self?.playerCore = nil
guard let self = self else { result(nil); return }
if let pip = self.pipController, pip.isActive {
pip.stopPip()
pip.detachLayer()
}
self.pipController = nil
self.autoPipEnabled = false
NotificationCenter.default.removeObserver(self, name: NSApplication.didResignActiveNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: NSApplication.didBecomeActiveNotification, object: nil)
self.playerCore?.dispose()
self.playerCore = nil
print("[MpvPlayerPlugin] Disposed")
result(nil)
}
@@ -149,6 +264,12 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
}
playerCore?.setProperty(name, value: value)
if name == "pause" {
let isPlaying = value == "no"
pipController?.setPlaying(isPlaying)
}
result(nil)
}
@@ -280,3 +401,42 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
return nil
}
}
// MARK: - MpvPipDelegate
extension MpvPlayerPlugin: MpvPipDelegate {
func pipWillStart() {
print("[MpvPlayerPlugin] PiP will start")
}
func pipDidStart() {
print("[MpvPlayerPlugin] PiP did start")
}
func pipDidStop(restored: Bool) {
print("[MpvPlayerPlugin] PiP did stop (restored: \(restored))")
playerCore?.isPipActive = false
enteredPipViaAuto = false
// Detach the Metal layer from the PiP wrapper view
pipController?.detachLayer()
// Re-attach the Metal layer to the main window
playerCore?.reattachMetalLayer()
// Force a redraw if paused (prevents black frame after PiP exit)
if playerCore?.isPaused == true {
playerCore?.forceDraw()
}
pipChannel?.invokeMethod("onPipChanged", arguments: false)
}
func pipSetPlaying(_ playing: Bool) {
playerCore?.setProperty("pause", value: playing ? "no" : "yes")
pipController?.setPlaying(playing)
}
var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) }
}
+32
View File
@@ -0,0 +1,32 @@
#import <Cocoa/Cocoa.h>
// MARK: - PIP.framework (Private Framework)
@protocol PIPViewControllerDelegate;
@interface PIPViewController : NSViewController
@property (nonatomic, copy, nullable) NSString *name;
@property (nonatomic, weak, nullable) id<PIPViewControllerDelegate> delegate;
@property (nonatomic, weak, nullable) NSWindow *replacementWindow;
@property (nonatomic) NSRect replacementRect;
@property (nonatomic) bool playing;
@property (nonatomic) NSSize aspectRatio;
- (void)presentViewControllerAsPictureInPicture:(NSViewController *)viewController;
@end
@protocol PIPViewControllerDelegate <NSObject>
@optional
// macOS 10.12-10.14
- (BOOL)pipShouldClose:(PIPViewController *)pip;
// macOS 10.15+
- (void)pipWillClose:(PIPViewController *)pip;
- (void)pipDidClose:(PIPViewController *)pip;
- (void)pipActionPlay:(PIPViewController *)pip;
- (void)pipActionPause:(PIPViewController *)pip;
- (void)pipActionStop:(PIPViewController *)pip;
@end