feat(macos): add picture-in-picture support
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"timelineSlider": "타임라인",
|
||||
"volumeSlider": "볼륨 조절",
|
||||
"endsAt": "${time}에 종료",
|
||||
"pipActive": "화면 속 화면으로 재생 중",
|
||||
"pipFailed": "화면 속 화면 모드를 시작할 수 없습니다",
|
||||
"pipErrors": {
|
||||
"androidVersion": "Android 8.0 이상이 필요합니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '실시간',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '直播',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"timelineSlider": "视频时间轴",
|
||||
"volumeSlider": "音量调节",
|
||||
"endsAt": "${time} 结束",
|
||||
"pipActive": "正在画中画模式中播放",
|
||||
"pipFailed": "画中画启动失败",
|
||||
"pipErrors": {
|
||||
"androidVersion": "需要 Android 8.0 或更高版本",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user