From 8d3f448fdf2dd8a56f345aa67ed74ced7c543b43 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:18:49 +0100 Subject: [PATCH] feat(macos): add picture-in-picture support --- lib/i18n/de.i18n.json | 1 + lib/i18n/en.i18n.json | 1 + lib/i18n/es.i18n.json | 1 + lib/i18n/fr.i18n.json | 1 + lib/i18n/it.i18n.json | 1 + lib/i18n/ko.i18n.json | 1 + lib/i18n/nl.i18n.json | 1 + lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_de.g.dart | 4 +- lib/i18n/strings_en.g.dart | 6 +- lib/i18n/strings_es.g.dart | 4 +- lib/i18n/strings_fr.g.dart | 4 +- lib/i18n/strings_it.g.dart | 4 +- lib/i18n/strings_ko.g.dart | 4 +- lib/i18n/strings_nl.g.dart | 4 +- lib/i18n/strings_sv.g.dart | 4 +- lib/i18n/strings_zh.g.dart | 4 +- lib/i18n/sv.i18n.json | 1 + lib/i18n/zh.i18n.json | 1 + lib/screens/settings/settings_screen.dart | 2 +- lib/screens/video_player_screen.dart | 33 +++- lib/services/pip_service.dart | 1 + lib/services/settings_service.dart | 5 +- lib/services/video_pip_manager.dart | 2 +- .../desktop_video_controls.dart | 2 +- .../video_controls/video_controls.dart | 46 +++-- macos/Runner.xcodeproj/project.pbxproj | 36 ++++ macos/Runner/MpvPlayer/MpvPipController.swift | 150 ++++++++++++++++ macos/Runner/MpvPlayer/MpvPlayerCore.swift | 72 +++++++- macos/Runner/MpvPlayer/MpvPlayerPlugin.swift | 164 +++++++++++++++++- macos/Runner/Runner-Bridging-Header.h | 32 ++++ 31 files changed, 559 insertions(+), 37 deletions(-) create mode 100644 macos/Runner/MpvPlayer/MpvPipController.swift create mode 100644 macos/Runner/Runner-Bridging-Header.h diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index d645939b..502e0e79 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index e3713205..48db9325 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 1b8b2d68..98ec64f4 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index c6b44f9d..8d02ebf8 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index d6b646c7..3ed6fa0b 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 91ce8e2b..b4c73a04 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -351,6 +351,7 @@ "timelineSlider": "타임라인", "volumeSlider": "볼륨 조절", "endsAt": "${time}에 종료", + "pipActive": "화면 속 화면으로 재생 중", "pipFailed": "화면 속 화면 모드를 시작할 수 없습니다", "pipErrors": { "androidVersion": "Android 8.0 이상이 필요합니다", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 5ccad643..36de0242 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 27a2cbde..25381e77 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 1c896f7f..cf4d5ae2 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 81e63b6f..bd09b010 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 1ab518e6..206f1c4a 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 4d540c1b..b20959bc 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 11e44e0b..f0aaa4d4 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -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', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index e518c5c3..713ce0f2 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -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' => '실시간', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 3f351e76..9f2c2ed6 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -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', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 4fed86c3..996a6c6f 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 2de5b598..1e4d57c5 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -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' => '直播', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 0804ba7f..99a4031d 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 5a202e54..6193d691 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -351,6 +351,7 @@ "timelineSlider": "视频时间轴", "volumeSlider": "音量调节", "endsAt": "${time} 结束", + "pipActive": "正在画中画模式中播放", "pipFailed": "画中画启动失败", "pipErrors": { "androidVersion": "需要 Android 8.0 或更高版本", diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 1b5d3732..3ae7cf7a 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -495,7 +495,7 @@ class _SettingsScreenState extends State 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), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 716dbabc..32758941 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -188,7 +188,8 @@ class VideoPlayerScreenState extends State 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 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( + 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( diff --git a/lib/services/pip_service.dart b/lib/services/pip_service.dart index e1f73777..346e462b 100644 --- a/lib/services/pip_service.dart +++ b/lib/services/pip_service.dart @@ -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, diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 5d9855b5..7231583c 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -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) diff --git a/lib/services/video_pip_manager.dart b/lib/services/video_pip_manager.dart index f2aabc69..5b5f3122 100644 --- a/lib/services/video_pip_manager.dart +++ b/lib/services/video_pip_manager.dart @@ -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)); diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 7b905984..b5ee9ef0 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -681,7 +681,7 @@ class DesktopVideoControlsState extends State { 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, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 897ee0f1..4fbff50c 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -198,6 +198,7 @@ class PlexVideoControls extends StatefulWidget { class _PlexVideoControlsState extends State with WindowListener, WidgetsBindingObserver { bool _showControls = true; + bool _forceShowControls = false; List _chapters = []; bool _chaptersLoaded = false; Timer? _hideTimer; @@ -296,6 +297,10 @@ class _PlexVideoControlsState extends State 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 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 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 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 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 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 _checkPipSupport() async { - if (!Platform.isAndroid && !Platform.isIOS) { + if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) { return; } @@ -852,6 +863,19 @@ class _PlexVideoControlsState extends State 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 _loadPlaybackExtras() async { // Live TV metadata uses EPG rating keys, not library items if (widget.isLive) return; @@ -952,7 +976,7 @@ class _PlexVideoControlsState extends State 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 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( 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 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 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 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(); @@ -1982,7 +2006,7 @@ class _PlexVideoControlsState extends State with WindowListen onTogglePIPMode: pipMode, onCycleBoxFitMode: boxFitMode, onToggleFullscreen: _toggleFullscreen, - onToggleAlwaysOnTop: _toggleAlwaysOnTop, + onToggleAlwaysOnTop: Platform.isMacOS ? null : _toggleAlwaysOnTop, onSwitchVersion: _switchMediaVersion, onAudioTrackChanged: widget.onAudioTrackChanged, onSubtitleTrackChanged: widget.onSubtitleTrackChanged, diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index e4bb018f..7f55c8e7 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = ""; }; 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = ""; }; + 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = ""; }; + 6AD8B16C2ED7B50000E9E1B7 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 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 = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 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; diff --git a/macos/Runner/MpvPlayer/MpvPipController.swift b/macos/Runner/MpvPlayer/MpvPipController.swift new file mode 100644 index 00000000..d5378c51 --- /dev/null +++ b/macos/Runner/MpvPlayer/MpvPipController.swift @@ -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) + } +} diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index 762d1512..5669ed22 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -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 diff --git a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift index 2fe0338e..2abc42bc 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -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) } +} diff --git a/macos/Runner/Runner-Bridging-Header.h b/macos/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..e260a0fa --- /dev/null +++ b/macos/Runner/Runner-Bridging-Header.h @@ -0,0 +1,32 @@ +#import + +// MARK: - PIP.framework (Private Framework) + +@protocol PIPViewControllerDelegate; + +@interface PIPViewController : NSViewController + +@property (nonatomic, copy, nullable) NSString *name; +@property (nonatomic, weak, nullable) id 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 + +@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