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,
|
||||
|
||||
Reference in New Issue
Block a user