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

This commit is contained in:
edde746
2026-03-02 18:19:36 +01:00
parent 7ebbfb9ef7
commit 8d3f448fdf
31 changed files with 559 additions and 37 deletions
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Video-Zeitleiste",
"volumeSlider": "Lautstärkepegel",
"endsAt": "Endet um ${time}",
"pipActive": "Wiedergabe im Bild-in-Bild-Modus",
"pipFailed": "Bild-in-Bild konnte nicht gestartet werden",
"pipErrors": {
"androidVersion": "Erfordert Android 8.0 oder neuer",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Video timeline",
"volumeSlider": "Volume level",
"endsAt": "Ends at ${time}",
"pipActive": "Playing in Picture-in-Picture",
"pipFailed": "Picture-in-picture failed to start",
"pipErrors": {
"androidVersion": "Requires Android 8.0 or newer",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Línea de tiempo del video",
"volumeSlider": "Nivel de volumen",
"endsAt": "Termina a las ${time}",
"pipActive": "Reproduciendo en Imagen en Imagen",
"pipFailed": "Error al iniciar Imagen en Imagen",
"pipErrors": {
"androidVersion": "Requiere Android 8.0 o más reciente",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Timeline vidéo",
"volumeSlider": "Niveau sonore",
"endsAt": "Fin à ${time}",
"pipActive": "Lecture en mode image dans l'image",
"pipFailed": "Échec du démarrage du mode image dans l'image",
"pipErrors": {
"androidVersion": "Nécessite Android 8.0 ou plus récent",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Timeline video",
"volumeSlider": "Livello volume",
"endsAt": "Finisce alle ${time}",
"pipActive": "Riproduzione in Picture-in-Picture",
"pipFailed": "Impossibile avviare la modalità Picture-in-Picture",
"pipErrors": {
"androidVersion": "Richiede Android 8.0 o versioni successive",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "타임라인",
"volumeSlider": "볼륨 조절",
"endsAt": "${time}에 종료",
"pipActive": "화면 속 화면으로 재생 중",
"pipFailed": "화면 속 화면 모드를 시작할 수 없습니다",
"pipErrors": {
"androidVersion": "Android 8.0 이상이 필요합니다",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Videotijdlijn",
"volumeSlider": "Volumeniveau",
"endsAt": "Eindigt om ${time}",
"pipActive": "Afspelen in beeld-in-beeld",
"pipFailed": "Beeld-in-beeld kon niet worden gestart",
"pipErrors": {
"androidVersion": "Vereist Android 8.0 of nieuwer",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 9
/// Strings: 7263 (807 per locale)
/// Strings: 7272 (808 per locale)
///
/// Built on 2026-03-02 at 13:31 UTC
/// Built on 2026-03-02 at 16:42 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsDe implements TranslationsVideoControlsEn {
@override String get timelineSlider => 'Video-Zeitleiste';
@override String get volumeSlider => 'Lautstärkepegel';
@override String endsAt({required Object time}) => 'Endet um ${time}';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => 'Bild-in-Bild konnte nicht gestartet werden';
@override late final _TranslationsVideoControlsPipErrorsDe pipErrors = _TranslationsVideoControlsPipErrorsDe._(_root);
@override String get chapters => 'Kapitel';
@@ -1627,6 +1628,7 @@ extension on TranslationsDe {
'videoControls.timelineSlider' => 'Video-Zeitleiste',
'videoControls.volumeSlider' => 'Lautstärkepegel',
'videoControls.endsAt' => ({required Object time}) => 'Endet um ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Bild-in-Bild konnte nicht gestartet werden',
'videoControls.pipErrors.androidVersion' => 'Erfordert Android 8.0 oder neuer',
'videoControls.pipErrors.iosVersion' => 'Erfordert iOS 15.0 oder neuer',
@@ -1815,9 +1817,9 @@ extension on TranslationsDe {
'liveTv.noDvr' => 'Kein DVR auf einem Server konfiguriert',
'liveTv.tuneFailed' => 'Kanal konnte nicht eingestellt werden',
'liveTv.loading' => 'Kanäle werden geladen...',
'liveTv.nowPlaying' => 'Läuft gerade',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'Läuft gerade',
'liveTv.noPrograms' => 'Keine Programmdaten verfügbar',
'liveTv.channelNumber' => ({required Object number}) => 'Kanal ${number}',
'liveTv.live' => 'LIVE',
+5 -1
View File
@@ -1119,6 +1119,9 @@ class TranslationsVideoControlsEn {
/// en: 'Ends at ${time}'
String endsAt({required Object time}) => 'Ends at ${time}';
/// en: 'Playing in Picture-in-Picture'
String get pipActive => 'Playing in Picture-in-Picture';
/// en: 'Picture-in-picture failed to start'
String get pipFailed => 'Picture-in-picture failed to start';
@@ -3248,6 +3251,7 @@ extension on Translations {
'videoControls.timelineSlider' => 'Video timeline',
'videoControls.volumeSlider' => 'Volume level',
'videoControls.endsAt' => ({required Object time}) => 'Ends at ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Picture-in-picture failed to start',
'videoControls.pipErrors.androidVersion' => 'Requires Android 8.0 or newer',
'videoControls.pipErrors.iosVersion' => 'Requires iOS 15.0 or newer',
@@ -3436,9 +3440,9 @@ extension on Translations {
'liveTv.noDvr' => 'No DVR configured on any server',
'liveTv.tuneFailed' => 'Failed to tune channel',
'liveTv.loading' => 'Loading channels...',
'liveTv.nowPlaying' => 'Now Playing',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'Now Playing',
'liveTv.noPrograms' => 'No program data available',
'liveTv.channelNumber' => ({required Object number}) => 'Ch. ${number}',
'liveTv.live' => 'LIVE',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsEs implements TranslationsVideoControlsEn {
@override String get timelineSlider => 'Línea de tiempo del video';
@override String get volumeSlider => 'Nivel de volumen';
@override String endsAt({required Object time}) => 'Termina a las ${time}';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => 'Error al iniciar Imagen en Imagen';
@override late final _TranslationsVideoControlsPipErrorsEs pipErrors = _TranslationsVideoControlsPipErrorsEs._(_root);
@override String get chapters => 'Capítulos';
@@ -1627,6 +1628,7 @@ extension on TranslationsEs {
'videoControls.timelineSlider' => 'Línea de tiempo del video',
'videoControls.volumeSlider' => 'Nivel de volumen',
'videoControls.endsAt' => ({required Object time}) => 'Termina a las ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Error al iniciar Imagen en Imagen',
'videoControls.pipErrors.androidVersion' => 'Requiere Android 8.0 o más reciente',
'videoControls.pipErrors.iosVersion' => 'Requiere iOS 15.0 o más reciente',
@@ -1815,9 +1817,9 @@ extension on TranslationsEs {
'liveTv.noDvr' => 'No hay DVR configurado en ningún servidor',
'liveTv.tuneFailed' => 'Error al sintonizar el canal',
'liveTv.loading' => 'Cargando canales...',
'liveTv.nowPlaying' => 'Reproduciendo ahora',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'Reproduciendo ahora',
'liveTv.noPrograms' => 'No hay datos de programación disponibles',
'liveTv.channelNumber' => ({required Object number}) => 'Canal ${number}',
'liveTv.live' => 'EN VIVO',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsFr implements TranslationsVideoControlsEn {
@override String get timelineSlider => 'Timeline vidéo';
@override String get volumeSlider => 'Niveau sonore';
@override String endsAt({required Object time}) => 'Fin à ${time}';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => 'Échec du démarrage du mode image dans l\'image';
@override late final _TranslationsVideoControlsPipErrorsFr pipErrors = _TranslationsVideoControlsPipErrorsFr._(_root);
@override String get chapters => 'Chapitres';
@@ -1627,6 +1628,7 @@ extension on TranslationsFr {
'videoControls.timelineSlider' => 'Timeline vidéo',
'videoControls.volumeSlider' => 'Niveau sonore',
'videoControls.endsAt' => ({required Object time}) => 'Fin à ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Échec du démarrage du mode image dans l\'image',
'videoControls.pipErrors.androidVersion' => 'Nécessite Android 8.0 ou plus récent',
'videoControls.pipErrors.iosVersion' => 'Nécessite iOS 15.0 ou plus récent',
@@ -1815,9 +1817,9 @@ extension on TranslationsFr {
'liveTv.noDvr' => 'Aucun DVR configuré sur les serveurs',
'liveTv.tuneFailed' => 'Impossible de syntoniser la chaîne',
'liveTv.loading' => 'Chargement des chaînes...',
'liveTv.nowPlaying' => 'En cours de lecture',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'En cours de lecture',
'liveTv.noPrograms' => 'Aucune donnée de programme disponible',
'liveTv.channelNumber' => ({required Object number}) => 'Ch. ${number}',
'liveTv.live' => 'EN DIRECT',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsIt implements TranslationsVideoControlsEn {
@override String get timelineSlider => 'Timeline video';
@override String get volumeSlider => 'Livello volume';
@override String endsAt({required Object time}) => 'Finisce alle ${time}';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => 'Impossibile avviare la modalità Picture-in-Picture';
@override late final _TranslationsVideoControlsPipErrorsIt pipErrors = _TranslationsVideoControlsPipErrorsIt._(_root);
@override String get chapters => 'Capitoli';
@@ -1627,6 +1628,7 @@ extension on TranslationsIt {
'videoControls.timelineSlider' => 'Timeline video',
'videoControls.volumeSlider' => 'Livello volume',
'videoControls.endsAt' => ({required Object time}) => 'Finisce alle ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Impossibile avviare la modalità Picture-in-Picture',
'videoControls.pipErrors.androidVersion' => 'Richiede Android 8.0 o versioni successive',
'videoControls.pipErrors.iosVersion' => 'Richiede iOS 15.0 o versioni successive',
@@ -1815,9 +1817,9 @@ extension on TranslationsIt {
'liveTv.noDvr' => 'Nessun DVR configurato su nessun server',
'liveTv.tuneFailed' => 'Impossibile sintonizzare il canale',
'liveTv.loading' => 'Caricamento canali...',
'liveTv.nowPlaying' => 'In riproduzione',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'In riproduzione',
'liveTv.noPrograms' => 'Nessun dato di programma disponibile',
'liveTv.channelNumber' => ({required Object number}) => 'Canale ${number}',
'liveTv.live' => 'IN DIRETTA',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsKo implements TranslationsVideoControlsEn {
@override String get timelineSlider => '타임라인';
@override String get volumeSlider => '볼륨 조절';
@override String endsAt({required Object time}) => '${time}에 종료';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => '화면 속 화면 모드를 시작할 수 없습니다';
@override late final _TranslationsVideoControlsPipErrorsKo pipErrors = _TranslationsVideoControlsPipErrorsKo._(_root);
@override String get chapters => '챕터';
@@ -1627,6 +1628,7 @@ extension on TranslationsKo {
'videoControls.timelineSlider' => '타임라인',
'videoControls.volumeSlider' => '볼륨 조절',
'videoControls.endsAt' => ({required Object time}) => '${time}에 종료',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => '화면 속 화면 모드를 시작할 수 없습니다',
'videoControls.pipErrors.androidVersion' => 'Android 8.0 이상이 필요합니다',
'videoControls.pipErrors.iosVersion' => 'iOS 15.0 이상이 필요합니다',
@@ -1815,9 +1817,9 @@ extension on TranslationsKo {
'liveTv.noDvr' => '서버에 DVR이 구성되어 있지 않습니다',
'liveTv.tuneFailed' => '채널 튜닝에 실패했습니다',
'liveTv.loading' => '채널 로딩 중...',
'liveTv.nowPlaying' => '현재 재생 중',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => '현재 재생 중',
'liveTv.noPrograms' => '프로그램 데이터가 없습니다',
'liveTv.channelNumber' => ({required Object number}) => '채널 ${number}',
'liveTv.live' => '실시간',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsNl implements TranslationsVideoControlsEn {
@override String get timelineSlider => 'Videotijdlijn';
@override String get volumeSlider => 'Volumeniveau';
@override String endsAt({required Object time}) => 'Eindigt om ${time}';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => 'Beeld-in-beeld kon niet worden gestart';
@override late final _TranslationsVideoControlsPipErrorsNl pipErrors = _TranslationsVideoControlsPipErrorsNl._(_root);
@override String get chapters => 'Hoofdstukken';
@@ -1627,6 +1628,7 @@ extension on TranslationsNl {
'videoControls.timelineSlider' => 'Videotijdlijn',
'videoControls.volumeSlider' => 'Volumeniveau',
'videoControls.endsAt' => ({required Object time}) => 'Eindigt om ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Beeld-in-beeld kon niet worden gestart',
'videoControls.pipErrors.androidVersion' => 'Vereist Android 8.0 of nieuwer',
'videoControls.pipErrors.iosVersion' => 'Vereist iOS 15.0 of nieuwer',
@@ -1815,9 +1817,9 @@ extension on TranslationsNl {
'liveTv.noDvr' => 'Geen DVR geconfigureerd op een server',
'liveTv.tuneFailed' => 'Kan zender niet afstemmen',
'liveTv.loading' => 'Zenders laden...',
'liveTv.nowPlaying' => 'Nu aan het afspelen',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'Nu aan het afspelen',
'liveTv.noPrograms' => 'Geen programmagegevens beschikbaar',
'liveTv.channelNumber' => ({required Object number}) => 'Kanaal ${number}',
'liveTv.live' => 'LIVE',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsSv implements TranslationsVideoControlsEn {
@override String get timelineSlider => 'Videotidslinje';
@override String get volumeSlider => 'Volymnivå';
@override String endsAt({required Object time}) => 'Slutar ${time}';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => 'Bild-i-bild kunde inte starta';
@override late final _TranslationsVideoControlsPipErrorsSv pipErrors = _TranslationsVideoControlsPipErrorsSv._(_root);
@override String get chapters => 'Kapitel';
@@ -1627,6 +1628,7 @@ extension on TranslationsSv {
'videoControls.timelineSlider' => 'Videotidslinje',
'videoControls.volumeSlider' => 'Volymnivå',
'videoControls.endsAt' => ({required Object time}) => 'Slutar ${time}',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Bild-i-bild kunde inte starta',
'videoControls.pipErrors.androidVersion' => 'Kräver Android 8.0 eller nyare',
'videoControls.pipErrors.iosVersion' => 'Kräver iOS 15.0 eller nyare',
@@ -1815,9 +1817,9 @@ extension on TranslationsSv {
'liveTv.noDvr' => 'Ingen DVR konfigurerad på någon server',
'liveTv.tuneFailed' => 'Kunde inte ställa in kanalen',
'liveTv.loading' => 'Laddar kanaler...',
'liveTv.nowPlaying' => 'Spelas nu',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => 'Spelas nu',
'liveTv.noPrograms' => 'Ingen programdata tillgänglig',
'liveTv.channelNumber' => ({required Object number}) => 'Kanal ${number}',
'liveTv.live' => 'LIVE',
+3 -1
View File
@@ -506,6 +506,7 @@ class _TranslationsVideoControlsZh implements TranslationsVideoControlsEn {
@override String get timelineSlider => '视频时间轴';
@override String get volumeSlider => '音量调节';
@override String endsAt({required Object time}) => '${time} 结束';
@override String get pipActive => 'Playing in Picture-in-Picture';
@override String get pipFailed => '画中画启动失败';
@override late final _TranslationsVideoControlsPipErrorsZh pipErrors = _TranslationsVideoControlsPipErrorsZh._(_root);
@override String get chapters => '章节';
@@ -1627,6 +1628,7 @@ extension on TranslationsZh {
'videoControls.timelineSlider' => '视频时间轴',
'videoControls.volumeSlider' => '音量调节',
'videoControls.endsAt' => ({required Object time}) => '${time} 结束',
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => '画中画启动失败',
'videoControls.pipErrors.androidVersion' => '需要 Android 8.0 或更高版本',
'videoControls.pipErrors.iosVersion' => '需要 iOS 15.0 或更高版本',
@@ -1815,9 +1817,9 @@ extension on TranslationsZh {
'liveTv.noDvr' => '没有服务器配置了DVR',
'liveTv.tuneFailed' => '无法调谐频道',
'liveTv.loading' => '正在加载频道...',
'liveTv.nowPlaying' => '正在播放',
_ => null,
} ?? switch (path) {
'liveTv.nowPlaying' => '正在播放',
'liveTv.noPrograms' => '没有可用的节目数据',
'liveTv.channelNumber' => ({required Object number}) => '频道 ${number}',
'liveTv.live' => '直播',
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "Videotidslinje",
"volumeSlider": "Volymnivå",
"endsAt": "Slutar ${time}",
"pipActive": "Spelar i bild-i-bild",
"pipFailed": "Bild-i-bild kunde inte starta",
"pipErrors": {
"androidVersion": "Kräver Android 8.0 eller nyare",
+1
View File
@@ -351,6 +351,7 @@
"timelineSlider": "视频时间轴",
"volumeSlider": "音量调节",
"endsAt": "${time} 结束",
"pipActive": "正在画中画模式中播放",
"pipFailed": "画中画启动失败",
"pipErrors": {
"androidVersion": "需要 Android 8.0 或更高版本",
+1 -1
View File
@@ -495,7 +495,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
await _settingsService.setEnableHardwareDecoding(value);
},
),
if (Platform.isAndroid || Platform.isIOS)
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)
SwitchListTile(
focusNode: _focusTracker.get(_kAutoPip),
secondary: const AppIcon(Symbols.picture_in_picture_alt_rounded, fill: 1),
+32 -1
View File
@@ -188,7 +188,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// iOS auto-PiP is system-initiated during the background transition, so
/// isPipActive may not be true yet — we also check the auto-PiP setting.
bool get _shouldSkipForPip =>
PipService().isPipActive.value || (Platform.isIOS && _autoPipEnabled);
PipService().isPipActive.value ||
((Platform.isIOS || Platform.isMacOS) && _autoPipEnabled);
// Services
MediaControlsManager? _mediaControlsManager;
@@ -2567,6 +2568,36 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
},
child: Stack(
children: [
// macOS PiP placeholder — video is in PiP window, show background with icon
// Placed before Video so controls render on top
if (Platform.isMacOS)
ValueListenableBuilder<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
if (!isInPip) return const SizedBox.shrink();
return Positioned.fill(
child: Container(
color: Colors.black,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.picture_in_picture_alt_rounded, size: 48, color: Colors.white.withValues(alpha: 0.5)),
const SizedBox(height: 12),
Text(
t.videoControls.pipActive,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
),
],
),
),
),
);
},
),
// Video player
Center(
child: LayoutBuilder(
+1
View File
@@ -61,6 +61,7 @@ class PipService {
return switch (errorCode) {
'android_version' => t.videoControls.pipErrors.androidVersion,
'ios_version' => t.videoControls.pipErrors.iosVersion,
'macos_version' => t.videoControls.pipErrors.notSupported,
'permission_disabled' => t.videoControls.pipErrors.permissionDisabled,
'not_supported' => t.videoControls.pipErrors.notSupported,
'vo_switch_failed' => t.videoControls.pipErrors.voSwitchFailed,
+3 -2
View File
@@ -1009,8 +1009,9 @@ class SettingsService extends BaseSharedPreferencesService {
}
bool getAutoPip() {
if (!Platform.isAndroid && !Platform.isIOS) return false;
return prefs.getBool(_keyAutoPip) ?? true; // Default enabled
if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) return false;
// Default enabled on mobile, disabled on macOS
return prefs.getBool(_keyAutoPip) ?? !Platform.isMacOS;
}
// Match Content Frame Rate (Android only)
+1 -1
View File
@@ -63,7 +63,7 @@ class VideoPIPManager {
// Reset video filter to contain mode BEFORE entering PiP (Android only —
// iOS switches VO entirely so the filter is irrelevant)
if (!Platform.isIOS) {
if (Platform.isAndroid) {
onBeforeEnterPip?.call();
// Wait a frame for the filter change to take effect
await Future.delayed(const Duration(milliseconds: 50));
@@ -681,7 +681,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
isFullscreen: widget.isFullscreen,
isAlwaysOnTop: widget.isAlwaysOnTop,
serverId: widget.serverId,
onTogglePIPMode: null, // PIP not supported on desktop
onTogglePIPMode: widget.onTogglePIPMode,
onCycleBoxFitMode: widget.onCycleBoxFitMode,
onToggleFullscreen: widget.onToggleFullscreen,
onToggleAlwaysOnTop: widget.onToggleAlwaysOnTop,
+35 -11
View File
@@ -198,6 +198,7 @@ class PlexVideoControls extends StatefulWidget {
class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListener, WidgetsBindingObserver {
bool _showControls = true;
bool _forceShowControls = false;
List<PlexChapter> _chapters = [];
bool _chaptersLoaded = false;
Timer? _hideTimer;
@@ -296,6 +297,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
widget.hasFirstFrame?.addListener(_onFirstFrameReady);
// Listen for external requests to show controls (e.g. screen-level focus recovery)
widget.controlsVisible?.addListener(_onControlsVisibleExternal);
// On macOS, show controls and disable auto-hide when PiP activates
if (Platform.isMacOS) {
_pipService.isPipActive.addListener(_onMacPipChanged);
}
// Defer context-dependent initialization to after first build
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -621,6 +626,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
windowManager.removeListener(this);
}
if (Platform.isMacOS) {
_pipService.isPipActive.removeListener(_onMacPipChanged);
}
super.dispose();
}
@@ -685,7 +693,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
/// Shared hide logic: hides controls, notifies parent, updates traffic lights, restores focus.
void _hideControls() {
if (!mounted || !_showControls) return;
if (!mounted || !_showControls || _forceShowControls) return;
setState(() {
_showControls = false;
// Dismiss skip button with controls — after this it only re-appears with controls
@@ -721,6 +729,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
final hasFrame = widget.hasFirstFrame?.value ?? true;
if (!hasFrame) return;
if (_forceShowControls) return;
// Only auto-hide if playing
if (widget.player.state.playing) {
_hideTimer = Timer(_hideDelay, () {
@@ -736,6 +746,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
/// Auto-hide controls after pause (does not check playing state in callback).
void _startPausedHideTimer() {
_hideTimer?.cancel();
if (_forceShowControls) return;
_hideTimer = Timer(_hideDelay, () {
_hideControls();
});
@@ -830,13 +841,13 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// user can reach them without the controls-hide-on-mouse-leave race.
// In normal windowed mode, toggle with controls as before.
final isMaximizedOrFullscreen = await windowManager.isMaximized() || await windowManager.isFullScreen();
final visible = isMaximizedOrFullscreen ? true : _showControls;
final visible = isMaximizedOrFullscreen || _forceShowControls ? true : _showControls;
await MacOSWindowService.setTrafficLightsVisible(visible);
}
/// Check whether PiP is supported on this device
Future<void> _checkPipSupport() async {
if (!Platform.isAndroid && !Platform.isIOS) {
if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) {
return;
}
@@ -852,6 +863,19 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
}
/// macOS PiP changed — force controls visible while PiP is active
void _onMacPipChanged() {
if (!mounted) return;
final inPip = _pipService.isPipActive.value;
setState(() => _forceShowControls = inPip);
if (inPip) {
_hideTimer?.cancel();
widget.controlsVisible?.value = true;
} else {
_startHideTimer();
}
}
Future<void> _loadPlaybackExtras() async {
// Live TV metadata uses EPG rating keys, not library items
if (widget.isLive) return;
@@ -952,7 +976,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
subtitleSyncOffset: _subtitleSyncOffset,
isRotationLocked: _isRotationLocked,
isFullscreen: _isFullscreen,
onTogglePIPMode: (_isPipSupported && (Platform.isAndroid || Platform.isIOS)) ? widget.onTogglePIPMode : null,
onTogglePIPMode: (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) ? widget.onTogglePIPMode : null,
onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null,
onToggleRotationLock: _toggleRotationLock,
onToggleFullscreen: _toggleFullscreen,
@@ -1583,11 +1607,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// Use desktop controls for desktop platforms AND Android TV
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
// Hide ALL controls when in PiP mode
// Hide ALL controls when in PiP mode (except macOS where main window stays visible)
return ValueListenableBuilder<bool>(
valueListenable: _pipService.isPipActive,
builder: (context, isInPip, _) {
if (isInPip) return const SizedBox.shrink();
if (isInPip && !Platform.isMacOS) return const SizedBox.shrink();
return Focus(
focusNode: _focusNode,
autofocus: true,
@@ -1716,7 +1740,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onPointerHover: (_) => _showControlsFromPointerActivity(),
onPointerSignal: _handlePointerSignal,
child: MouseRegion(
cursor: _showControls ? SystemMouseCursors.basic : SystemMouseCursors.none,
cursor: (_showControls || _forceShowControls) ? SystemMouseCursors.basic : SystemMouseCursors.none,
onHover: (_) => _showControlsFromPointerActivity(),
onExit: (_) => _hideControlsFromPointerExit(),
child: Stack(
@@ -1825,9 +1849,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
ignoring: !_showControls,
child: FocusScope(
// Prevent focus from entering controls when hidden
canRequestFocus: _showControls,
canRequestFocus: _showControls || _forceShowControls,
child: AnimatedOpacity(
opacity: _showControls ? 1.0 : 0.0,
opacity: (_showControls || _forceShowControls) ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: LayoutBuilder(
builder: (context, constraints) {
@@ -1946,7 +1970,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
Widget _buildDesktopControlsListener() {
final pipMode = (_isPipSupported && (Platform.isAndroid || Platform.isIOS)) ? widget.onTogglePIPMode : null;
final pipMode = (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) ? widget.onTogglePIPMode : null;
final boxFitMode = widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null;
final playbackState = context.watch<PlaybackStateProvider>();
@@ -1982,7 +2006,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onTogglePIPMode: pipMode,
onCycleBoxFitMode: boxFitMode,
onToggleFullscreen: _toggleFullscreen,
onToggleAlwaysOnTop: _toggleAlwaysOnTop,
onToggleAlwaysOnTop: Platform.isMacOS ? null : _toggleAlwaysOnTop,
onSwitchVersion: _switchMediaVersion,
onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: widget.onSubtitleTrackChanged,