diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart new file mode 100644 index 00000000..faf159f4 --- /dev/null +++ b/lib/screens/media_detail/action_buttons.dart @@ -0,0 +1,520 @@ +part of '../media_detail_screen.dart'; + +extension _MediaDetailActionButtons on _MediaDetailScreenState { + Widget _buildActionButtons(MediaItem metadata) { + final playButtonLabel = _getPlayButtonLabel(metadata); + final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: 20); + + Future onPlayPressed() async { + // For TV shows, play the OnDeck episode if available + // Otherwise, play the first episode of the first season + if (metadata.isShow) { + if (_onDeckEpisode != null) { + appLogger.d('Playing on deck episode: ${_onDeckEpisode!.title}'); + await navigateToVideoPlayerWithRefresh( + context, + metadata: _onDeckEpisode!, + isOffline: widget.isOffline, + onRefresh: _loadFullMetadata, + ); + } else { + // No on deck episode, fetch first episode of first season + await _playFirstEpisode(); + } + } else if (metadata.isSeason) { + // For seasons, play the first episode + if (_episodes.isNotEmpty) { + await navigateToVideoPlayerWithRefresh( + context, + metadata: _episodes.first, + isOffline: widget.isOffline, + onRefresh: _loadFullMetadata, + ); + } else { + await _playFirstEpisode(); + } + } else { + appLogger.d('Playing: ${metadata.title}'); + // For movies or episodes, play directly + await navigateToVideoPlayerWithRefresh( + context, + metadata: metadata, + isOffline: widget.isOffline, + onRefresh: _loadFullMetadata, + ); + } + } + + final primaryTrailer = _getPrimaryTrailer(); + + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + final colorScheme = Theme.of(context).colorScheme; + + // In keyboard/d-pad mode, focused buttons get a prominent style. + // overlayColor is set to transparent to prevent the Material focus + // overlay from dimming the background color we set. + final focusBg = colorScheme.inverseSurface; + final focusFg = colorScheme.onInverseSurface; + final tonalBg = colorScheme.secondaryContainer; + final tonalFg = colorScheme.onSecondaryContainer; + final noOverlay = WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.focused)) return Colors.transparent; + return null; // default for other states + }); + + ButtonStyle actionButtonStyle({Color? foregroundColor, EdgeInsetsGeometry? padding}) { + if (!isKeyboardMode) { + if (padding != null) { + return FilledButton.styleFrom(padding: padding); + } + return IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + foregroundColor: foregroundColor, + ); + } + return ButtonStyle( + padding: padding != null ? WidgetStatePropertyAll(padding) : null, + minimumSize: padding == null ? const WidgetStatePropertyAll(Size(48, 48)) : null, + maximumSize: padding == null ? const WidgetStatePropertyAll(Size(48, 48)) : null, + overlayColor: noOverlay, + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.focused)) return focusBg; + return tonalBg; + }), + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.focused)) return focusFg; + return foregroundColor ?? tonalFg; + }), + ); + } + + return Focus( + skipTraversal: true, + onKeyEvent: _handlePlayButtonKeyEvent, + child: Row( + children: [ + SizedBox( + height: 48, + child: FilledButton( + focusNode: _playButtonFocusNode, + autofocus: isKeyboardMode, + onPressed: onPlayPressed, + style: actionButtonStyle(padding: const EdgeInsets.symmetric(horizontal: 16)), + child: playButtonLabel.isNotEmpty + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + playButtonIcon, + const SizedBox(width: 8), + Text(playButtonLabel, style: const TextStyle(fontSize: 16)), + ], + ) + : playButtonIcon, + ), + ), + const SizedBox(width: 12), + // Trailer button (only if trailer is available) + if (primaryTrailer != null) ...[ + IconButton.filledTonal( + onPressed: () async { + await navigateToVideoPlayer(context, metadata: primaryTrailer); + }, + icon: const AppIcon(Symbols.theaters_rounded, fill: 1), + tooltip: t.tooltips.playTrailer, + iconSize: 20, + style: actionButtonStyle(), + ), + const SizedBox(width: 12), + ], + // Shuffle button (only for shows and seasons) + if (metadata.isShow || metadata.isSeason) ...[ + IconButton.filledTonal( + onPressed: () async { + await _handleShufflePlayWithQueue(context, metadata); + }, + icon: const AppIcon(Symbols.shuffle_rounded, fill: 1), + tooltip: t.tooltips.shufflePlay, + iconSize: 20, + style: actionButtonStyle(), + ), + const SizedBox(width: 12), + ], + // Download button (hide in offline mode - already downloaded, + // and on Apple TV where there's no user file storage). + if (!widget.isOffline && !PlatformDetector.isAppleTV()) _buildDownloadButton(metadata, actionButtonStyle), + const SizedBox(width: 12), + // Mark as watched/unwatched toggle (works offline too) + _buildWatchedToggleButton(metadata, actionButtonStyle), + // Three-dots menu button (hidden in offline mode) + if (!widget.isOffline) ...[const SizedBox(width: 12), _buildMoreActionsButton(metadata, actionButtonStyle)], + ], + ), + ); + } + + Widget _buildWatchedToggleButton( + MediaItem metadata, + ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle, + ) { + return IconButton.filledTonal( + onPressed: () async { + try { + final isWatched = metadata.isWatched; + if (widget.isOffline) { + // Offline mode: queue action for later sync + final offlineWatch = context.read(); + if (isWatched) { + await offlineWatch.markAsUnwatched(serverId: metadata.serverId!, itemId: metadata.id); + } else { + await offlineWatch.markAsWatched(serverId: metadata.serverId!, itemId: metadata.id); + } + if (mounted) { + showAppSnackBar( + context, + isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline, + ); + unawaited(_updateWatchStateOffline()); + unawaited(_loadOfflineOnDeckEpisode()); + } + } else { + // Online mode: dispatch via the right backend's neutral method so + // Jellyfin items hit /UserPlayedItems and Plex items hit /:/scrobble. + final serverId = metadata.serverId; + if (serverId == null) return; + final client = context.tryGetMediaClientForServer(serverId); + if (client == null) return; + + if (isWatched) { + await client.markUnwatched(metadata); + } else { + await client.markWatched(metadata); + } + if (mounted) { + _watchStateChanged = true; + showSuccessSnackBar(context, isWatched ? t.messages.markedAsUnwatched : t.messages.markedAsWatched); + } + } + } catch (e) { + if (mounted) { + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + } + } + }, + icon: AppIcon(metadata.isWatched ? Symbols.remove_done_rounded : Symbols.check_rounded, fill: 1), + tooltip: metadata.isWatched ? t.tooltips.markAsUnwatched : t.tooltips.markAsWatched, + iconSize: 20, + style: actionButtonStyle(), + ); + } + + Widget _buildMoreActionsButton( + MediaItem metadata, + ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle, + ) { + return MediaContextMenu( + key: _contextMenuKey, + item: metadata, + onRefresh: (_) => _loadFullMetadata(), + child: Builder( + builder: (buttonContext) => IconButton.filledTonal( + onPressed: () { + final renderBox = buttonContext.findRenderObject() as RenderBox?; + if (renderBox != null) { + final position = renderBox.localToGlobal(renderBox.size.center(Offset.zero)); + _contextMenuKey.currentState?.showContextMenu(buttonContext, position: position); + } + }, + icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), + iconSize: 20, + style: actionButtonStyle(), + ), + ), + ); + } + + Widget _buildDownloadButton( + MediaItem metadata, + ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle, + ) { + return Consumer( + builder: (context, downloadProvider, _) { + final globalKey = metadata.globalKey; + final ruleKey = _syncRuleKeyForMetadata(context, downloadProvider, metadata); + final progress = downloadProvider.getProgress(globalKey); + final isQueueing = downloadProvider.isQueueing(globalKey); + + // Debug logging + if (progress != null) { + appLogger.d('UI rebuilding for $globalKey: status=${progress.status}, progress=${progress.progress}%'); + } + + // State 1: Queueing (building download queue) + if (isQueueing) { + return IconButton.filledTonal( + onPressed: null, + icon: const LoadingIndicatorBox(size: 20), + iconSize: 20, + style: actionButtonStyle(), + ); + } + + // State 2: Queued (waiting to download) + if (progress?.status == DownloadStatus.queued) { + final currentFile = progress?.currentFile; + final tooltip = currentFile != null && currentFile.contains('episodes') + ? t.downloads.queuedFilesTooltip(files: currentFile) + : t.downloads.queuedTooltip; + + return IconButton.filledTonal( + onPressed: null, + tooltip: tooltip, + icon: const AppIcon(Symbols.schedule_rounded, fill: 1), + iconSize: 20, + style: actionButtonStyle(), + ); + } + + // State 3: Downloading (active download) + if (progress?.status == DownloadStatus.downloading) { + // Show episode count in tooltip for shows/seasons + final currentFile = progress?.currentFile; + final tooltip = currentFile != null && currentFile.contains('episodes') + ? t.downloads.downloadingFilesTooltip(files: currentFile) + : t.downloads.downloadingTooltip; + + return IconButton.filledTonal( + onPressed: null, + tooltip: tooltip, + icon: _buildRadialProgress(progress?.progressPercent), + iconSize: 20, + style: actionButtonStyle(), + ); + } + + // State 4: Paused (can resume) + if (progress?.status == DownloadStatus.paused) { + return IconButton.filledTonal( + onPressed: () async { + final client = _getMediaClientForMetadata(context); + if (client == null) return; + await downloadProvider.resumeDownload(globalKey, client); + if (context.mounted) { + showAppSnackBar(context, 'Download resumed'); + } + }, + icon: const AppIcon(Symbols.pause_circle_outline_rounded, fill: 1), + tooltip: 'Resume download', + iconSize: 20, + style: actionButtonStyle(foregroundColor: Colors.amber), + ); + } + + // State 5: Failed (can retry) + if (progress?.status == DownloadStatus.failed) { + return IconButton.filledTonal( + onPressed: () async { + final client = _getMediaClientForMetadata(context); + if (client == null) return; + + final versionConfig = await _resolveDownloadVersion(context, metadata, client); + if (versionConfig == null || !context.mounted) return; + + await downloadProvider.deleteDownload(globalKey); + try { + await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig); + + if (context.mounted) { + showSuccessSnackBar(context, t.downloads.downloadQueued); + } + } on CellularDownloadBlockedException { + if (context.mounted) { + showErrorSnackBar(context, t.settings.cellularDownloadBlocked); + } + } + }, + icon: const AppIcon(Symbols.error_outline_rounded, fill: 1), + tooltip: 'Retry download', + iconSize: 20, + style: actionButtonStyle(foregroundColor: Colors.red), + ); + } + + // State 6: Cancelled (can delete or retry) + if (progress?.status == DownloadStatus.cancelled) { + return IconButton.filledTonal( + onPressed: () async { + // Show options: Delete or Retry + final retry = await showConfirmDialog( + context, + title: 'Cancelled Download', + message: 'This download was cancelled. What would you like to do?', + cancelText: t.common.delete, + confirmText: 'Retry', + ); + + if (!retry && context.mounted) { + await downloadProvider.deleteDownload(globalKey); + if (context.mounted) { + showSuccessSnackBar(context, t.downloads.downloadDeleted); + } + } else if (retry && context.mounted) { + final client = _getMediaClientForMetadata(context); + if (client == null) return; + + final versionConfig = await _resolveDownloadVersion(context, metadata, client); + if (versionConfig == null || !context.mounted) return; + + await downloadProvider.deleteDownload(globalKey); + try { + await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig); + if (context.mounted) { + showSuccessSnackBar(context, t.downloads.downloadQueued); + } + } on CellularDownloadBlockedException { + if (context.mounted) { + showErrorSnackBar(context, t.settings.cellularDownloadBlocked); + } + } + } + }, + icon: const AppIcon(Symbols.cancel_rounded, fill: 1), + tooltip: 'Cancelled download', + iconSize: 20, + style: actionButtonStyle(foregroundColor: Colors.grey), + ); + } + + // State 7: Partial Download (some episodes downloaded, not all) + if (progress?.status == DownloadStatus.partial) { + final hasSyncRule = downloadProvider.hasSyncRule(ruleKey); + final currentFile = progress?.currentFile; + + if (hasSyncRule) { + // Synced partial — this is the normal state for sync rules + final syncRule = downloadProvider.getSyncRule(ruleKey); + final isEnabled = syncRule?.enabled ?? true; + final tooltip = currentFile != null + ? '$currentFile (syncing ${t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?')})' + : t.downloads.keepSynced; + + return IconButton.filledTonal( + onPressed: () => _showSyncRuleActions( + context, + downloadProvider, + metadata, + ruleKey: ruleKey, + downloadGlobalKey: globalKey, + ), + tooltip: tooltip, + icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1), + iconSize: 20, + style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey), + ); + } + + final tooltip = currentFile != null + ? 'Downloaded $currentFile - Click to complete' + : 'Partially downloaded - Click to complete'; + + return IconButton.filledTonal( + onPressed: () async { + final client = _getMediaClientForMetadata(context); + if (client == null) return; + + final versionConfig = await _resolveDownloadVersion(context, metadata, client); + if (versionConfig == null || !context.mounted) return; + + final count = await downloadProvider.queueMissingEpisodes(metadata, client, versionConfig: versionConfig); + + if (context.mounted) { + final message = count > 0 + ? t.downloads.episodesQueued(count: count) + : 'All episodes already downloaded'; + showAppSnackBar(context, message); + } + }, + tooltip: tooltip, + icon: const AppIcon(Symbols.downloading_rounded, fill: 1), + iconSize: 20, + style: actionButtonStyle(foregroundColor: Colors.orange), + ); + } + + // State 8: Downloaded/Completed (can delete) + if (downloadProvider.isDownloaded(globalKey)) { + final hasSyncRule = downloadProvider.hasSyncRule(ruleKey); + + if (hasSyncRule) { + // Synced + complete — show sync icon + final syncRule = downloadProvider.getSyncRule(ruleKey); + final isEnabled = syncRule?.enabled ?? true; + return IconButton.filledTonal( + onPressed: () => _showSyncRuleActions( + context, + downloadProvider, + metadata, + ruleKey: ruleKey, + downloadGlobalKey: globalKey, + ), + icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1), + tooltip: t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?'), + iconSize: 20, + style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey), + ); + } + + return IconButton.filledTonal( + onPressed: () async { + // Show delete download confirmation + final confirmed = await showDeleteConfirmation( + context, + title: t.downloads.deleteDownload, + message: t.downloads.deleteConfirm(title: metadata.displayTitle), + ); + + if (confirmed && context.mounted) { + await downloadProvider.deleteDownload(globalKey); + if (context.mounted) { + showSuccessSnackBar(context, t.downloads.downloadDeleted); + } + } + }, + icon: const AppIcon(Symbols.file_download_done_rounded, fill: 1), + tooltip: t.downloads.deleteDownload, + iconSize: 20, + style: actionButtonStyle(foregroundColor: Colors.green), + ); + } + + // State 9: Not downloaded (default - can download) + return IconButton.filledTonal( + onPressed: () async { + final client = _getMediaClientForMetadata(context); + if (client == null) return; + + try { + final result = await showDownloadOptionsAndQueue( + context, + metadata: metadata, + client: client, + downloadProvider: downloadProvider, + ); + if (result == null || !context.mounted) return; + + showSuccessSnackBar(context, result.toSnackBarMessage()); + } on CellularDownloadBlockedException { + if (context.mounted) { + showErrorSnackBar(context, t.settings.cellularDownloadBlocked); + } + } + }, + icon: const AppIcon(Symbols.download_rounded, fill: 1), + tooltip: t.downloads.downloadNow, + iconSize: 20, + style: actionButtonStyle(), + ); + }, + ); + } +} diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index b5b4188e..ccf0ff74 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -70,6 +70,8 @@ import '../widgets/focusable_tab_chip.dart'; import '../widgets/hub_section.dart'; import '../widgets/loading_indicator_box.dart'; +part 'media_detail/action_buttons.dart'; + enum _SyncRuleAction { edit, remove, delete } class MediaDetailScreen extends StatefulWidget { @@ -475,513 +477,6 @@ class _MediaDetailScreenState extends State } /// Build action buttons row (play, shuffle, download, mark watched) - Widget _buildActionButtons(MediaItem metadata) { - final playButtonLabel = _getPlayButtonLabel(metadata); - final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: 20); - - Future onPlayPressed() async { - // For TV shows, play the OnDeck episode if available - // Otherwise, play the first episode of the first season - if (metadata.isShow) { - if (_onDeckEpisode != null) { - appLogger.d('Playing on deck episode: ${_onDeckEpisode!.title}'); - await navigateToVideoPlayerWithRefresh( - context, - metadata: _onDeckEpisode!, - isOffline: widget.isOffline, - onRefresh: _loadFullMetadata, - ); - } else { - // No on deck episode, fetch first episode of first season - await _playFirstEpisode(); - } - } else if (metadata.isSeason) { - // For seasons, play the first episode - if (_episodes.isNotEmpty) { - await navigateToVideoPlayerWithRefresh( - context, - metadata: _episodes.first, - isOffline: widget.isOffline, - onRefresh: _loadFullMetadata, - ); - } else { - await _playFirstEpisode(); - } - } else { - appLogger.d('Playing: ${metadata.title}'); - // For movies or episodes, play directly - await navigateToVideoPlayerWithRefresh( - context, - metadata: metadata, - isOffline: widget.isOffline, - onRefresh: _loadFullMetadata, - ); - } - } - - final primaryTrailer = _getPrimaryTrailer(); - - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - final colorScheme = Theme.of(context).colorScheme; - - // In keyboard/d-pad mode, focused buttons get a prominent style. - // overlayColor is set to transparent to prevent the Material focus - // overlay from dimming the background color we set. - final focusBg = colorScheme.inverseSurface; - final focusFg = colorScheme.onInverseSurface; - final tonalBg = colorScheme.secondaryContainer; - final tonalFg = colorScheme.onSecondaryContainer; - final noOverlay = WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.focused)) return Colors.transparent; - return null; // default for other states - }); - - ButtonStyle actionButtonStyle({Color? foregroundColor, EdgeInsetsGeometry? padding}) { - if (!isKeyboardMode) { - if (padding != null) { - return FilledButton.styleFrom(padding: padding); - } - return IconButton.styleFrom( - minimumSize: const Size(48, 48), - maximumSize: const Size(48, 48), - foregroundColor: foregroundColor, - ); - } - return ButtonStyle( - padding: padding != null ? WidgetStatePropertyAll(padding) : null, - minimumSize: padding == null ? const WidgetStatePropertyAll(Size(48, 48)) : null, - maximumSize: padding == null ? const WidgetStatePropertyAll(Size(48, 48)) : null, - overlayColor: noOverlay, - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.focused)) return focusBg; - return tonalBg; - }), - foregroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.focused)) return focusFg; - return foregroundColor ?? tonalFg; - }), - ); - } - - return Focus( - skipTraversal: true, - onKeyEvent: _handlePlayButtonKeyEvent, - child: Row( - children: [ - SizedBox( - height: 48, - child: FilledButton( - focusNode: _playButtonFocusNode, - autofocus: isKeyboardMode, - onPressed: onPlayPressed, - style: actionButtonStyle(padding: const EdgeInsets.symmetric(horizontal: 16)), - child: playButtonLabel.isNotEmpty - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - playButtonIcon, - const SizedBox(width: 8), - Text(playButtonLabel, style: const TextStyle(fontSize: 16)), - ], - ) - : playButtonIcon, - ), - ), - const SizedBox(width: 12), - // Trailer button (only if trailer is available) - if (primaryTrailer != null) ...[ - IconButton.filledTonal( - onPressed: () async { - await navigateToVideoPlayer(context, metadata: primaryTrailer); - }, - icon: const AppIcon(Symbols.theaters_rounded, fill: 1), - tooltip: t.tooltips.playTrailer, - iconSize: 20, - style: actionButtonStyle(), - ), - const SizedBox(width: 12), - ], - // Shuffle button (only for shows and seasons) - if (metadata.isShow || metadata.isSeason) ...[ - IconButton.filledTonal( - onPressed: () async { - await _handleShufflePlayWithQueue(context, metadata); - }, - icon: const AppIcon(Symbols.shuffle_rounded, fill: 1), - tooltip: t.tooltips.shufflePlay, - iconSize: 20, - style: actionButtonStyle(), - ), - const SizedBox(width: 12), - ], - // Download button (hide in offline mode - already downloaded, - // and on Apple TV where there's no user file storage). - if (!widget.isOffline && !PlatformDetector.isAppleTV()) - Consumer( - builder: (context, downloadProvider, _) { - final globalKey = metadata.globalKey; - final ruleKey = _syncRuleKeyForMetadata(context, downloadProvider, metadata); - final progress = downloadProvider.getProgress(globalKey); - final isQueueing = downloadProvider.isQueueing(globalKey); - - // Debug logging - if (progress != null) { - appLogger.d( - 'UI rebuilding for $globalKey: status=${progress.status}, progress=${progress.progress}%', - ); - } - - // State 1: Queueing (building download queue) - if (isQueueing) { - return IconButton.filledTonal( - onPressed: null, - icon: const LoadingIndicatorBox(size: 20), - iconSize: 20, - style: actionButtonStyle(), - ); - } - - // State 2: Queued (waiting to download) - if (progress?.status == DownloadStatus.queued) { - final currentFile = progress?.currentFile; - final tooltip = currentFile != null && currentFile.contains('episodes') - ? t.downloads.queuedFilesTooltip(files: currentFile) - : t.downloads.queuedTooltip; - - return IconButton.filledTonal( - onPressed: null, - tooltip: tooltip, - icon: const AppIcon(Symbols.schedule_rounded, fill: 1), - iconSize: 20, - style: actionButtonStyle(), - ); - } - - // State 3: Downloading (active download) - if (progress?.status == DownloadStatus.downloading) { - // Show episode count in tooltip for shows/seasons - final currentFile = progress?.currentFile; - final tooltip = currentFile != null && currentFile.contains('episodes') - ? t.downloads.downloadingFilesTooltip(files: currentFile) - : t.downloads.downloadingTooltip; - - return IconButton.filledTonal( - onPressed: null, - tooltip: tooltip, - icon: _buildRadialProgress(progress?.progressPercent), - iconSize: 20, - style: actionButtonStyle(), - ); - } - - // State 4: Paused (can resume) - if (progress?.status == DownloadStatus.paused) { - return IconButton.filledTonal( - onPressed: () async { - final client = _getMediaClientForMetadata(context); - if (client == null) return; - await downloadProvider.resumeDownload(globalKey, client); - if (context.mounted) { - showAppSnackBar(context, 'Download resumed'); - } - }, - icon: const AppIcon(Symbols.pause_circle_outline_rounded, fill: 1), - tooltip: 'Resume download', - iconSize: 20, - style: actionButtonStyle(foregroundColor: Colors.amber), - ); - } - - // State 5: Failed (can retry) - if (progress?.status == DownloadStatus.failed) { - return IconButton.filledTonal( - onPressed: () async { - final client = _getMediaClientForMetadata(context); - if (client == null) return; - - final versionConfig = await _resolveDownloadVersion(context, metadata, client); - if (versionConfig == null || !context.mounted) return; - - await downloadProvider.deleteDownload(globalKey); - try { - await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig); - - if (context.mounted) { - showSuccessSnackBar(context, t.downloads.downloadQueued); - } - } on CellularDownloadBlockedException { - if (context.mounted) { - showErrorSnackBar(context, t.settings.cellularDownloadBlocked); - } - } - }, - icon: const AppIcon(Symbols.error_outline_rounded, fill: 1), - tooltip: 'Retry download', - iconSize: 20, - style: actionButtonStyle(foregroundColor: Colors.red), - ); - } - - // State 6: Cancelled (can delete or retry) - if (progress?.status == DownloadStatus.cancelled) { - return IconButton.filledTonal( - onPressed: () async { - // Show options: Delete or Retry - final retry = await showConfirmDialog( - context, - title: 'Cancelled Download', - message: 'This download was cancelled. What would you like to do?', - cancelText: t.common.delete, - confirmText: 'Retry', - ); - - if (!retry && context.mounted) { - await downloadProvider.deleteDownload(globalKey); - if (context.mounted) { - showSuccessSnackBar(context, t.downloads.downloadDeleted); - } - } else if (retry && context.mounted) { - final client = _getMediaClientForMetadata(context); - if (client == null) return; - - final versionConfig = await _resolveDownloadVersion(context, metadata, client); - if (versionConfig == null || !context.mounted) return; - - await downloadProvider.deleteDownload(globalKey); - try { - await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig); - if (context.mounted) { - showSuccessSnackBar(context, t.downloads.downloadQueued); - } - } on CellularDownloadBlockedException { - if (context.mounted) { - showErrorSnackBar(context, t.settings.cellularDownloadBlocked); - } - } - } - }, - icon: const AppIcon(Symbols.cancel_rounded, fill: 1), - tooltip: 'Cancelled download', - iconSize: 20, - style: actionButtonStyle(foregroundColor: Colors.grey), - ); - } - - // State 7: Partial Download (some episodes downloaded, not all) - if (progress?.status == DownloadStatus.partial) { - final hasSyncRule = downloadProvider.hasSyncRule(ruleKey); - final currentFile = progress?.currentFile; - - if (hasSyncRule) { - // Synced partial — this is the normal state for sync rules - final syncRule = downloadProvider.getSyncRule(ruleKey); - final isEnabled = syncRule?.enabled ?? true; - final tooltip = currentFile != null - ? '$currentFile (syncing ${t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?')})' - : t.downloads.keepSynced; - - return IconButton.filledTonal( - onPressed: () => _showSyncRuleActions( - context, - downloadProvider, - metadata, - ruleKey: ruleKey, - downloadGlobalKey: globalKey, - ), - tooltip: tooltip, - icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1), - iconSize: 20, - style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey), - ); - } - - final tooltip = currentFile != null - ? 'Downloaded $currentFile - Click to complete' - : 'Partially downloaded - Click to complete'; - - return IconButton.filledTonal( - onPressed: () async { - final client = _getMediaClientForMetadata(context); - if (client == null) return; - - final versionConfig = await _resolveDownloadVersion(context, metadata, client); - if (versionConfig == null || !context.mounted) return; - - final count = await downloadProvider.queueMissingEpisodes( - metadata, - client, - versionConfig: versionConfig, - ); - - if (context.mounted) { - final message = count > 0 - ? t.downloads.episodesQueued(count: count) - : 'All episodes already downloaded'; - showAppSnackBar(context, message); - } - }, - tooltip: tooltip, - icon: const AppIcon(Symbols.downloading_rounded, fill: 1), - iconSize: 20, - style: actionButtonStyle(foregroundColor: Colors.orange), - ); - } - - // State 8: Downloaded/Completed (can delete) - if (downloadProvider.isDownloaded(globalKey)) { - final hasSyncRule = downloadProvider.hasSyncRule(ruleKey); - - if (hasSyncRule) { - // Synced + complete — show sync icon - final syncRule = downloadProvider.getSyncRule(ruleKey); - final isEnabled = syncRule?.enabled ?? true; - return IconButton.filledTonal( - onPressed: () => _showSyncRuleActions( - context, - downloadProvider, - metadata, - ruleKey: ruleKey, - downloadGlobalKey: globalKey, - ), - icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1), - tooltip: t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?'), - iconSize: 20, - style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey), - ); - } - - return IconButton.filledTonal( - onPressed: () async { - // Show delete download confirmation - final confirmed = await showDeleteConfirmation( - context, - title: t.downloads.deleteDownload, - message: t.downloads.deleteConfirm(title: metadata.displayTitle), - ); - - if (confirmed && context.mounted) { - await downloadProvider.deleteDownload(globalKey); - if (context.mounted) { - showSuccessSnackBar(context, t.downloads.downloadDeleted); - } - } - }, - icon: const AppIcon(Symbols.file_download_done_rounded, fill: 1), - tooltip: t.downloads.deleteDownload, - iconSize: 20, - style: actionButtonStyle(foregroundColor: Colors.green), - ); - } - - // State 9: Not downloaded (default - can download) - return IconButton.filledTonal( - onPressed: () async { - final client = _getMediaClientForMetadata(context); - if (client == null) return; - - try { - final result = await showDownloadOptionsAndQueue( - context, - metadata: metadata, - client: client, - downloadProvider: downloadProvider, - ); - if (result == null || !context.mounted) return; - - showSuccessSnackBar(context, result.toSnackBarMessage()); - } on CellularDownloadBlockedException { - if (context.mounted) { - showErrorSnackBar(context, t.settings.cellularDownloadBlocked); - } - } - }, - icon: const AppIcon(Symbols.download_rounded, fill: 1), - tooltip: t.downloads.downloadNow, - iconSize: 20, - style: actionButtonStyle(), - ); - }, - ), - const SizedBox(width: 12), - // Mark as watched/unwatched toggle (works offline too) - IconButton.filledTonal( - onPressed: () async { - try { - final isWatched = metadata.isWatched; - if (widget.isOffline) { - // Offline mode: queue action for later sync - final offlineWatch = context.read(); - if (isWatched) { - await offlineWatch.markAsUnwatched(serverId: metadata.serverId!, itemId: metadata.id); - } else { - await offlineWatch.markAsWatched(serverId: metadata.serverId!, itemId: metadata.id); - } - if (mounted) { - showAppSnackBar( - context, - isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline, - ); - unawaited(_updateWatchStateOffline()); - unawaited(_loadOfflineOnDeckEpisode()); - } - } else { - // Online mode: dispatch via the right backend's neutral - // method so Jellyfin items hit /UserPlayedItems and Plex - // items hit /:/scrobble. - final serverId = metadata.serverId; - if (serverId == null) return; - final client = context.tryGetMediaClientForServer(serverId); - if (client == null) return; - - if (isWatched) { - await client.markUnwatched(metadata); - } else { - await client.markWatched(metadata); - } - if (mounted) { - _watchStateChanged = true; - showSuccessSnackBar(context, isWatched ? t.messages.markedAsUnwatched : t.messages.markedAsWatched); - } - } - } catch (e) { - if (mounted) { - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - } - } - }, - icon: AppIcon(metadata.isWatched ? Symbols.remove_done_rounded : Symbols.check_rounded, fill: 1), - tooltip: metadata.isWatched ? t.tooltips.markAsUnwatched : t.tooltips.markAsWatched, - iconSize: 20, - style: actionButtonStyle(), - ), - // Three-dots menu button (hidden in offline mode) - if (!widget.isOffline) ...[ - const SizedBox(width: 12), - MediaContextMenu( - key: _contextMenuKey, - item: metadata, - onRefresh: (_) => _loadFullMetadata(), - child: Builder( - builder: (buttonContext) => IconButton.filledTonal( - onPressed: () { - final renderBox = buttonContext.findRenderObject() as RenderBox?; - if (renderBox != null) { - final position = renderBox.localToGlobal(renderBox.size.center(Offset.zero)); - _contextMenuKey.currentState?.showContextMenu(buttonContext, position: position); - } - }, - icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), - iconSize: 20, - style: actionButtonStyle(), - ), - ), - ), - ], - ], - ), - ); - } - /// Build a metadata chip with optional leading icon or widget Widget _buildMetadataChip(String text, {IconData? icon, Widget? leading}) { final colorScheme = Theme.of(context).colorScheme; @@ -2614,187 +2109,7 @@ class _MediaDetailScreenState extends State controller: _scrollController, slivers: [ // Hero header with background art - SliverToBoxAdapter( - child: Stack( - children: [ - // Background Art (fixed height, no parallax) - SizedBox( - height: headerHeight, - width: double.infinity, - child: Builder( - builder: (context) { - final containerAspect = size.width / headerHeight; - final heroArtPaths = metadata.heroArtCandidates(containerAspectRatio: containerAspect); - if (heroArtPaths.isEmpty) return const PlaceholderContainer(); - - final localArtwork = _buildOfflineArtworkIfAvailable( - context, - artworkPaths: heroArtPaths, - fit: BoxFit.cover, - imageType: ImageType.art, - errorWidget: (context, url, error) => const PlaceholderContainer(), - ); - if (localArtwork != null) return localArtwork; - - final client = _getArtworkMediaClient(context); - final mqSize = MediaQuery.sizeOf(context); - final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); - final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( - displayWidth: (mqSize.width * dpr).round(), - displayHeight: (mqSize.height * 0.6 * dpr).round(), - imageType: ImageType.art, - ); - - return blurArtwork( - _buildHeroNetworkArtwork( - context, - client: client, - artworkPaths: heroArtPaths, - mediaSize: mqSize, - dpr: dpr, - memCacheHeight: memHeight, - ), - ); - }, - ), - ), - - // Gradient overlay - Positioned( - top: 0, - left: 0, - right: 0, - bottom: -1, // Extend 1px past to prevent subpixel gap - child: Builder( - builder: (context) { - final bgColor = Theme.of(context).scaffoldBackgroundColor; - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], - stops: const [0.3, 0.8, 1.0], - ), - ), - ); - }, - ), - ), - - // Content at bottom - Positioned( - bottom: 16, - left: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Clear logo or title - if (metadata.clearLogoPath != null) - SizedBox( - height: 120, - width: 400, - child: Builder( - builder: (context) { - final localArtwork = _buildOfflineArtworkIfAvailable( - context, - artworkPaths: [metadata.clearLogoPath], - fit: BoxFit.contain, - alignment: Alignment.centerLeft, - imageType: ImageType.logo, - errorWidget: (context, url, error) => - _buildTitleText(context, metadata.displayTitle), - ); - if (localArtwork != null) return localArtwork; - - final client = _getArtworkMediaClient(context); - final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); - final logoUrl = MediaImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: metadata.clearLogoPath, - maxWidth: 400, - maxHeight: 120, - devicePixelRatio: dpr, - imageType: ImageType.logo, - ); - - return blurArtwork( - CachedNetworkImage( - imageUrl: logoUrl, - cacheManager: PlexImageCacheManager.instance, - filterQuality: FilterQuality.medium, - fit: BoxFit.contain, - alignment: Alignment.centerLeft, - memCacheWidth: (400 * dpr).clamp(200, 800).round(), - placeholder: (context, url) => Align( - alignment: Alignment.centerLeft, - child: Text( - metadata.displayTitle, - style: Theme.of(context).textTheme.displaySmall?.copyWith( - color: Colors.white.withValues(alpha: 0.3), - fontWeight: FontWeight.bold, - shadows: [ - Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - errorWidget: (context, url, error) { - return _buildTitleText(context, metadata.displayTitle); - }, - ), - sigma: 10, - clip: false, - ); - }, - ), - ) - else - Text( - metadata.displayTitle, - style: theme.textTheme.displaySmall?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 12), - - // Metadata chips - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (metadata.year != null) _buildMetadataChip('${metadata.year}'), - if (metadata case PlexMediaItem(:final editionTitle?)) - _buildMetadataChip(editionTitle), - if (metadata.contentRating != null) - _buildMetadataChip(formatContentRating(metadata.contentRating!)), - if (metadata.durationMs != null) - _buildMetadataChip(formatDurationTextual(metadata.durationMs!)), - ..._buildRatingChips(metadata), - ], - ), - const SizedBox(height: 16), - // Action buttons - _buildActionButtons(metadata), - ], - ), - ), - ), - ), - ], - ), - ), + SliverToBoxAdapter(child: _buildHeroHeader(context, metadata, size, headerHeight, theme)), // Main content SliverToBoxAdapter( @@ -3048,6 +2363,181 @@ class _MediaDetailScreenState extends State ); } + Widget _buildHeroHeader(BuildContext context, MediaItem metadata, Size size, double headerHeight, ThemeData theme) { + return Stack( + children: [ + // Background Art (fixed height, no parallax) + SizedBox( + height: headerHeight, + width: double.infinity, + child: Builder( + builder: (context) { + final containerAspect = size.width / headerHeight; + final heroArtPaths = metadata.heroArtCandidates(containerAspectRatio: containerAspect); + if (heroArtPaths.isEmpty) return const PlaceholderContainer(); + + final localArtwork = _buildOfflineArtworkIfAvailable( + context, + artworkPaths: heroArtPaths, + fit: BoxFit.cover, + imageType: ImageType.art, + errorWidget: (context, url, error) => const PlaceholderContainer(), + ); + if (localArtwork != null) return localArtwork; + + final client = _getArtworkMediaClient(context); + final mqSize = MediaQuery.sizeOf(context); + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( + displayWidth: (mqSize.width * dpr).round(), + displayHeight: (mqSize.height * 0.6 * dpr).round(), + imageType: ImageType.art, + ); + + return blurArtwork( + _buildHeroNetworkArtwork( + context, + client: client, + artworkPaths: heroArtPaths, + mediaSize: mqSize, + dpr: dpr, + memCacheHeight: memHeight, + ), + ); + }, + ), + ), + + // Gradient overlay + Positioned( + top: 0, + left: 0, + right: 0, + bottom: -1, // Extend 1px past to prevent subpixel gap + child: Builder( + builder: (context) { + final bgColor = Theme.of(context).scaffoldBackgroundColor; + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], + stops: const [0.3, 0.8, 1.0], + ), + ), + ); + }, + ), + ), + + // Content at bottom + Positioned( + bottom: 16, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Clear logo or title + if (metadata.clearLogoPath != null) + SizedBox( + height: 120, + width: 400, + child: Builder( + builder: (context) { + final localArtwork = _buildOfflineArtworkIfAvailable( + context, + artworkPaths: [metadata.clearLogoPath], + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + imageType: ImageType.logo, + errorWidget: (context, url, error) => _buildTitleText(context, metadata.displayTitle), + ); + if (localArtwork != null) return localArtwork; + + final client = _getArtworkMediaClient(context); + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final logoUrl = MediaImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: metadata.clearLogoPath, + maxWidth: 400, + maxHeight: 120, + devicePixelRatio: dpr, + imageType: ImageType.logo, + ); + + return blurArtwork( + CachedNetworkImage( + imageUrl: logoUrl, + cacheManager: PlexImageCacheManager.instance, + filterQuality: FilterQuality.medium, + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + memCacheWidth: (400 * dpr).clamp(200, 800).round(), + placeholder: (context, url) => Align( + alignment: Alignment.centerLeft, + child: Text( + metadata.displayTitle, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + color: Colors.white.withValues(alpha: 0.3), + fontWeight: FontWeight.bold, + shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + errorWidget: (context, url, error) => _buildTitleText(context, metadata.displayTitle), + ), + sigma: 10, + clip: false, + ); + }, + ), + ) + else + Text( + metadata.displayTitle, + style: theme.textTheme.displaySmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + + // Metadata chips + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (metadata.year != null) _buildMetadataChip('${metadata.year}'), + if (metadata case PlexMediaItem(:final editionTitle?)) _buildMetadataChip(editionTitle), + if (metadata.contentRating != null) + _buildMetadataChip(formatContentRating(metadata.contentRating!)), + if (metadata.durationMs != null) _buildMetadataChip(formatDurationTextual(metadata.durationMs!)), + ..._buildRatingChips(metadata), + ], + ), + const SizedBox(height: 16), + // Action buttons + _buildActionButtons(metadata), + ], + ), + ), + ), + ), + ], + ); + } + /// Get the primary trailer from the extras list MediaItem? _getPrimaryTrailer() { if (_extras == null || _extras!.isEmpty) return null; diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart new file mode 100644 index 00000000..658a1dd3 --- /dev/null +++ b/lib/screens/video_player/parts/build.dart @@ -0,0 +1,257 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerBuildMethods on VideoPlayerScreenState { + Widget _buildLoadingSpinner() { + return const Scaffold( + backgroundColor: Colors.black, + body: Center(child: CircularProgressIndicator(color: Colors.white)), + ); + } + + Widget _buildInitializationError(String message) { + return Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppIcon(Symbols.error_rounded, color: Colors.white70, size: 44, fill: 1), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton( + autofocus: true, + onPressed: () { + final playerToDispose = player; + player = null; + if (playerToDispose != null) unawaited(playerToDispose.dispose()); + _setPlayerState(() { + _playerInitializationError = null; + _isPlayerInitialized = false; + }); + unawaited(_initializePlayer()); + }, + child: Text(t.common.retry), + ), + const SizedBox(width: 12), + OutlinedButton(onPressed: () => unawaited(_handleBackButton()), child: Text(t.common.back)), + ], + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildVideoPlayer(BuildContext context) { + // Cache platform detection to avoid multiple calls + final isMobile = PlatformDetector.isMobile(context); + + return PopScope( + canPop: false, // Disable swipe-back gesture to prevent interference with timeline scrubbing + onPopInvokedWithResult: (didPop, result) { + if (!didPop) { + // If an overlay sheet is open, delegate back to it instead of + // exiting the player. This prevents the double-pop on Android TV + // where the system back gesture would otherwise reach both the + // sheet and the player's PopScope. + final sheetController = OverlaySheetController.maybeOf(context); + if (sheetController != null && sheetController.isOpen) { + sheetController.pop(); + return; + } + if (BackKeyCoordinator.consumeIfHandled()) return; + BackKeyCoordinator.markHandled(); + _handleBackButton(); + } + }, + child: Scaffold( + // Use transparent background on macOS when native video layer is active + backgroundColor: Colors.transparent, + body: GestureDetector( + behavior: HitTestBehavior.translucent, // Allow taps to pass through to controls + onScaleStart: (details) { + // Initialize pinch gesture tracking (mobile only) + if (!isMobile) return; + if (_videoFilterManager != null) { + _videoFilterManager!.isPinching = false; + } + }, + onScaleUpdate: (details) { + // Track if this is a pinch gesture (2+ fingers) on mobile + if (!isMobile) return; + if (details.pointerCount >= 2 && _videoFilterManager != null) { + _videoFilterManager!.isPinching = true; + } + }, + onScaleEnd: (details) { + // Only toggle if we detected a pinch gesture on mobile + if (!isMobile) return; + if (_videoFilterManager != null && _videoFilterManager!.isPinching) { + _toggleContainCover(); + _videoFilterManager!.isPinching = false; + } + }, + 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) const VideoPlayerMacPipPlaceholder(), + // Video player + Center( + child: LayoutBuilder( + builder: (context, constraints) { + // Update player size when layout changes + final newSize = Size(constraints.maxWidth, constraints.maxHeight); + + // Update player size in video filter manager, PiP manager, and native layer + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && player != null) { + _videoFilterManager?.updatePlayerSize(newSize); + _videoPIPManager?.updatePlayerSize(newSize); + // Update ambient lighting shader if active (output aspect changed) + _updateAmbientLightingOnResize(newSize); + // Update Metal layer frame on iOS/macOS for rotation + player!.updateFrame(); + } + }); + + // Compute canControl from Watch Together provider (reactive) + bool canControl = true; + try { + canControl = context.select( + (wt) => wt.isInSession ? wt.canControl() : true, + ); + } catch (e) { + // Watch Together not available, default to can control + } + + VoidCallback? onNext; + if (widget.isLive) { + onNext = _hasNextChannel ? () => _switchLiveChannel(1) : null; + } else { + onNext = (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null; + } + + VoidCallback? onPrevious; + if (widget.isLive) { + onPrevious = _hasPreviousChannel ? () => _switchLiveChannel(-1) : null; + } else { + onPrevious = (_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null; + } + + return Video( + player: player!, + controls: (context) => plexVideoControlsBuilder( + player!, + _currentMetadata, + onNext: onNext, + onPrevious: onPrevious, + availableVersions: _availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, + selectedQualityPreset: _selectedQualityPreset, + serverSupportsTranscoding: _serverSupportsTranscoding, + isTranscoding: _isTranscoding, + isOfflinePlayback: _isOfflinePlayback, + sourceAudioTracks: _currentMediaInfo?.audioTracks ?? const [], + selectedAudioStreamId: _selectedAudioStreamId, + onTogglePIPMode: _togglePIPMode, + boxFitMode: _videoFilterManager?.boxFitMode ?? 0, + onCycleBoxFitMode: _cycleBoxFitMode, + onCycleAudioTrack: _cycleAudioTrack, + onCycleSubtitleTrack: _cycleSubtitleTrack, + onAudioTrackChanged: _onAudioTrackChanged, + onSubtitleTrackChanged: _onSubtitleTrackChanged, + onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged, + onSeekCompleted: (position) { + // Notify Watch Together of seek for sync + // Note: canControl() check is done in sync manager, not here + // This matches play/pause behavior and avoids timing issues + try { + final watchTogether = this.context.read(); + if (watchTogether.isInSession) { + watchTogether.onLocalSeek(position); + } + } catch (e) { + // Watch Together not available, ignore + } + }, + onBack: _handleBackButton, + onReachedEnd: ({skipAutoPlayCountdown = false}) => + _onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown), + canControl: canControl, + hasFirstFrame: _hasFirstFrame, + playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null, + controlsVisible: _controlsVisible, + shaderService: _shaderService, + // ignore: no-empty-block - state update triggers rebuild to reflect shader change + onShaderChanged: () => _setPlayerState(() {}), + thumbnailDataBuilder: _scrubPreviewSource?.isAvailable == true ? _getThumbnailData : null, + isLive: widget.isLive, + liveChannelName: _liveChannelName, + captureBuffer: _captureBuffer, + isAtLiveEdge: _isAtLiveEdge, + streamStartEpoch: _streamStartEpoch, + currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null, + onLiveSeek: _captureBuffer != null ? _seekLivePosition : null, + onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null, + isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false, + onToggleAmbientLighting: _toggleAmbientLighting, + toastController: _toastController, + ), + ); + }, + ), + ), + // Netflix-style auto-play overlay (hidden in PiP mode) + VideoPlayerPlayNextOverlay( + visible: _showPlayNextDialog, + nextEpisode: _nextEpisode, + autoPlayCountdown: _autoPlayCountdown, + cancelFocusNode: _playNextCancelFocusNode, + confirmFocusNode: _playNextConfirmFocusNode, + controlsVisible: _controlsVisible, + onCancel: _cancelAutoPlay, + onPlayNext: _playNext, + ), + // "Still watching?" overlay (hidden in PiP mode) + VideoPlayerStillWatchingOverlay( + visible: _showStillWatchingPrompt, + countdown: _stillWatchingCountdown, + pauseFocusNode: _stillWatchingPauseFocusNode, + continueFocusNode: _stillWatchingContinueFocusNode, + controlsVisible: _controlsVisible, + onPause: _onStillWatchingPause, + onContinue: _onStillWatchingContinue, + ), + // Buffering indicator (also shows during initial load, but not when exiting) + // Hidden in PiP mode + VideoPlayerBufferingOverlay( + isBuffering: _isBuffering, + hasFirstFrame: _hasFirstFrame, + isExiting: _isExiting, + ), + // Watch Together overlays (isolated from video surface repaints) + const VideoPlayerWatchTogetherOverlays(), + // Black overlay during exit (no spinner - just covers transparency) + VideoPlayerExitOverlay(isExiting: _isExiting), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/video_player/parts/companion_remote.dart b/lib/screens/video_player/parts/companion_remote.dart new file mode 100644 index 00000000..13ca25ec --- /dev/null +++ b/lib/screens/video_player/parts/companion_remote.dart @@ -0,0 +1,108 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState { + void _setupCompanionRemoteCallbacks() { + final receiver = CompanionRemoteReceiver.instance; + receiver.onStop = () { + if (mounted) _handleBackButton(); + }; + receiver.onNextTrack = () { + if (mounted && _nextEpisode != null) _playNext(); + }; + receiver.onPreviousTrack = () { + if (mounted && _previousEpisode != null) _playPrevious(); + }; + receiver.onSeekForward = () async { + if (player == null) return; + final settings = await SettingsService.getInstance(); + final seekSeconds = settings.read(SettingsService.seekTimeSmall); + if (widget.isLive && _captureBuffer != null) { + await _seekLivePosition(_currentPositionEpoch + seekSeconds); + return; + } + final target = clampSeekPosition(player!, player!.state.position + Duration(seconds: seekSeconds)); + await player!.seek(target); + }; + receiver.onSeekBackward = () async { + if (player == null) return; + final settings = await SettingsService.getInstance(); + final seekSeconds = settings.read(SettingsService.seekTimeSmall); + if (widget.isLive && _captureBuffer != null) { + await _seekLivePosition(_currentPositionEpoch - seekSeconds); + return; + } + final target = clampSeekPosition(player!, player!.state.position - Duration(seconds: seekSeconds)); + await player!.seek(target); + }; + receiver.onVolumeUp = () async { + if (player == null) return; + final settings = await SettingsService.getInstance(); + final maxVol = settings.read(SettingsService.maxVolume).toDouble(); + final newVolume = (player!.state.volume + 10).clamp(0.0, maxVol); + unawaited(player!.setVolume(newVolume)); + unawaited(settings.write(SettingsService.volume, newVolume)); + }; + receiver.onVolumeDown = () async { + if (player == null) return; + final settings = await SettingsService.getInstance(); + final maxVol = settings.read(SettingsService.maxVolume).toDouble(); + final newVolume = (player!.state.volume - 10).clamp(0.0, maxVol); + unawaited(player!.setVolume(newVolume)); + unawaited(settings.write(SettingsService.volume, newVolume)); + }; + receiver.onVolumeMute = () async { + if (player == null) return; + final settings = await SettingsService.getInstance(); + final newVolume = player!.state.volume > 0 ? 0.0 : 100.0; + unawaited(player!.setVolume(newVolume)); + unawaited(settings.write(SettingsService.volume, newVolume)); + }; + receiver.onSubtitles = _cycleSubtitleTrack; + receiver.onAudioTracks = _cycleAudioTrack; + receiver.onFullscreen = _toggleFullscreen; + + // Override home to exit the player first (main screen handler runs after pop) + _savedOnHome = receiver.onHome; + receiver.onHome = () { + if (mounted) _handleBackButton(); + }; + + // Store provider reference for use in dispose and notify remote + try { + _companionRemoteProvider = context.read(); + _companionRemoteProvider!.sendCommand(RemoteCommandType.syncState, data: {'playerActive': true}); + } catch (e) { + appLogger.d('CompanionRemote provider unavailable', error: e); + } + } + + void _cleanupCompanionRemoteCallbacks() { + final receiver = CompanionRemoteReceiver.instance; + receiver.onStop = null; + receiver.onNextTrack = null; + receiver.onPreviousTrack = null; + receiver.onSeekForward = null; + receiver.onSeekBackward = null; + receiver.onVolumeUp = null; + receiver.onVolumeDown = null; + receiver.onVolumeMute = null; + receiver.onSubtitles = null; + receiver.onAudioTracks = null; + receiver.onFullscreen = null; + receiver.onHome = _savedOnHome; + _savedOnHome = null; + + // Notify remote that player is no longer active + _companionRemoteProvider?.sendCommand(RemoteCommandType.syncState, data: {'playerActive': false}); + _companionRemoteProvider = null; + } + + void _cycleSubtitleTrack() => _trackManager?.cycleSubtitleTrack(); + + void _cycleAudioTrack() => _trackManager?.cycleAudioTrack(); + + Future _toggleFullscreen() async { + if (PlatformDetector.isMobile(context)) return; + await FullscreenStateManager().toggleFullscreen(); + } +} diff --git a/lib/screens/video_player/parts/display_matching.dart b/lib/screens/video_player/parts/display_matching.dart new file mode 100644 index 00000000..df81c341 --- /dev/null +++ b/lib/screens/video_player/parts/display_matching.dart @@ -0,0 +1,143 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState { + Future _applyFrameRateMatching() async { + if (player == null || !Platform.isAndroid) return; + if (_frameRateMatchingApplied) return; + + try { + final fpsStr = await player!.getProperty('container-fps'); + final fps = double.tryParse(fpsStr ?? ''); + if (fps == null || fps <= 0) { + // ExoPlayer detects FPS from frame timestamps after ~8 rendered frames. + // STATE_READY fires before frames render, so retry until detection completes. + if (player is PlayerAndroid && _frameRateRetries < 10) { + _frameRateRetries++; + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted && player != null) _applyFrameRateMatching(); + }); + return; + } + appLogger.d('Frame rate matching: No valid fps available ($fpsStr)'); + return; + } + + _frameRateRetries = 0; + _frameRateMatchingApplied = true; + final durationMs = player!.state.duration.inMilliseconds; + final settingsService = await SettingsService.getInstance(); + final delaySec = settingsService.read(SettingsService.displaySwitchDelay); + + // Suppress spurious PauseEvent from MediaSession during HDMI renegotiation. + // Fire Stick (and similar Android TV devices) send onPause() through the + // MediaSession callback when the display mode changes for frame rate matching. + _suppressMediaPauseDuringFrameRateSwitch = true; + Future.delayed(Duration(seconds: 2 + delaySec + 1), () { + _suppressMediaPauseDuringFrameRateSwitch = false; + }); + + // Pause so the playback clock doesn't advance while the TV renegotiates + // HDMI. The native setVideoFrameRate call below awaits the real display + // change event (+ settle + user delay) before returning, and then we + // resume — same shape as the primary pre-playback path, just later. + try { + await player!.pause(); + } catch (e) { + appLogger.w('Failed to pause before frame rate switch', error: e); + } + + final didSwitch = await player!.setVideoFrameRate(fps, durationMs, extraDelayMs: delaySec * 1000); + + // Set MPV video-sync mode for smoother playback when display is synced + try { + await player!.setProperty('video-sync', 'display-tempo'); + } catch (e) { + appLogger.d('video-sync property unsupported', error: e); + } + + if (mounted && player != null) { + await player!.play(); + } + + unawaited( + Sentry.addBreadcrumb( + Breadcrumb( + message: 'Frame rate matching: ${fps}fps, switched=$didSwitch, delay=${delaySec}s', + category: 'player', + ), + ), + ); + appLogger.d('Frame rate matching: Set display to ${fps}fps (duration: ${durationMs}ms, switched=$didSwitch)'); + } catch (e) { + appLogger.w('Failed to apply frame rate matching', error: e); + } + } + + /// Clear frame rate matching and restore default display mode + Future _clearFrameRateMatching() async { + if (player == null || !Platform.isAndroid) return; + + try { + await player!.clearVideoFrameRate(); + await player!.setProperty('video-sync', 'audio'); + unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Frame rate matching cleared', category: 'player'))); + appLogger.d('Frame rate matching: Cleared, restored default display mode'); + } catch (e) { + appLogger.d('Failed to clear frame rate matching', error: e); + } + } + + /// Apply Windows display mode matching (refresh rate, HDR). + Future _applyWindowsDisplayMatching() async { + if (player == null || _displayModeService == null) return; + + try { + final fpsStr = await player!.getProperty('container-fps'); + final fps = double.tryParse(fpsStr ?? ''); + + final sigPeakStr = await player!.getProperty('video-params/sig-peak'); + final sigPeak = double.tryParse(sigPeakStr ?? ''); + + final delay = await _displayModeService!.applyDisplayMatching(fps: fps, sigPeak: sigPeak); + + if (delay > Duration.zero) { + await Future.delayed(delay); + } + } catch (e) { + appLogger.w('Failed to apply display mode matching', error: e); + } + } + + /// Called when fullscreen state changes — apply or restore Windows display + /// matching. On Windows the player opens windowed by default, so the initial + /// attempt during `playbackRestart` is skipped by DisplayModeService's + /// fullscreen gate. Catching the enter-fullscreen transition here lets the + /// switch happen at the natural moment the user starts watching. + void _onFullscreenChanged() { + if (_displayModeService == null) return; + if (FullscreenStateManager().isFullscreen) { + if (_hasFirstFrame.value && !_displayModeService!.anyChangeApplied) { + _applyWindowsDisplayMatching(); + } + } else if (_displayModeService!.anyChangeApplied) { + _restoreWindowsDisplayMode(); + } + } + + /// Restore Windows display mode to original state. + Future _restoreWindowsDisplayMode() async { + if (_displayModeService == null || !_displayModeService!.anyChangeApplied) return; + + try { + // If HDR was toggled, release mpv's HDR swapchain first. + if (_displayModeService!.hdrStateChanged && player != null) { + await player!.setProperty('target-colorspace-hint', 'no'); + await Future.delayed(const Duration(milliseconds: 200)); + } + + await _displayModeService!.restoreAll(); + } catch (e) { + appLogger.w('Failed to restore display mode', error: e); + } + } +} diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart new file mode 100644 index 00000000..0e321bbc --- /dev/null +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -0,0 +1,280 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { + Future _playNext() async { + if (_nextEpisode == null || _isLoadingNext) return; + + // Cancel auto-play timer if running + _autoPlayTimer?.cancel(); + _dismissStillWatching(); + + // Notify Watch Together of episode change before navigating + _notifyWatchTogetherMediaChange(metadata: _nextEpisode); + + _setPlayerState(() { + _isLoadingNext = true; + _showPlayNextDialog = false; + }); + + await _navigateToEpisode(_nextEpisode!); + } + + Future _playPrevious() async { + if (_previousEpisode == null || _isLoadingPrevious) return; + + _notifyWatchTogetherMediaChange(metadata: _previousEpisode); + + _setPlayerState(() { + _isLoadingPrevious = true; + }); + + await _navigateToEpisode(_previousEpisode!); + } + + /// Navigates to a new episode, preserving playback state and track selections. + /// When PiP is active, swaps the media source in-place to keep the PiP window alive. + Future _navigateToEpisode(MediaItem episodeMetadata) async { + // PiP active: swap media in-place to keep the PiP window alive. The + // swap path threads the neutral [MediaServerClient] through + // [PlaybackInitializationService] and the lifecycle services, so it + // works for both Plex and Jellyfin sessions. + if (PipService().isPipActive.value && player != null) { + await _swapEpisodeInPip(episodeMetadata); + return; + } + + // Set flag to skip orientation restoration in dispose() + _isReplacingWithVideo = true; + + // Clear Discord Rich Presence + Trakt scrobble before switching episodes + unawaited(DiscordRPCService.instance.stopPlayback()); + unawaited(TraktScrobbleService.instance.stopPlayback()); + unawaited(TrackerCoordinator.instance.stopPlayback()); + + // If player isn't available, navigate without preserving settings + if (player == null) { + if (mounted) { + unawaited( + navigateToVideoPlayer( + context, + metadata: episodeMetadata, + usePushReplacement: true, + isOffline: _isOfflinePlayback, + ), + ); + } + return; + } + + // Capture current state atomically to avoid race conditions + final currentPlayer = player; + if (currentPlayer == null) { + // Player already disposed, navigate without preserving settings + if (mounted) { + unawaited( + navigateToVideoPlayer( + context, + metadata: episodeMetadata, + usePushReplacement: true, + isOffline: _isOfflinePlayback, + ), + ); + } + return; + } + + final currentAudioTrack = currentPlayer.state.track.audio; + final currentSubtitleTrack = currentPlayer.state.track.subtitle; + final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle; + + // Pause and stop current playback + unawaited(currentPlayer.pause()); + await _progressTracker?.sendProgress('stopped'); + _progressTracker?.stopTracking(); + + // Ensure the native player is fully disposed before creating the next one + await disposePlayerForNavigation(); + + // Navigate to the episode using pushReplacement to destroy current player + if (mounted) { + unawaited( + navigateToVideoPlayer( + context, + metadata: episodeMetadata, + preferredAudioTrack: currentAudioTrack, + preferredSubtitleTrack: currentSubtitleTrack, + preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, + usePushReplacement: true, + isOffline: _isOfflinePlayback, + ), + ); + } + } + + /// Swap to a new episode while keeping the player alive for PiP continuity. + /// Reuses the existing mpv instance (and its Metal layer in PiP) and only + /// reloads the media source + resets Dart-side services. + Future _swapEpisodeInPip(MediaItem episodeMetadata) async { + _isSwappingEpisode = true; + final currentPlayer = player!; + final previousMetadata = _currentMetadata; + + final currentAudioTrack = currentPlayer.state.track.audio; + final currentSubtitleTrack = currentPlayer.state.track.subtitle; + final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle; + + // Capture context-dependent values before async gaps. The neutral + // [PlaybackInitializationService] consumes [mediaClient] regardless of + // backend. We still narrow to [plexClient] for [TrackManager]'s + // server-side track persistence, which is Plex-only — Jellyfin + // sessions get a null `getPlexClient` and skip that path. + final mediaClient = _isOfflinePlayback ? null : _getMediaServerClient(context); + final plexClient = mediaClient is PlexClient ? mediaClient : null; + final streamHeaders = mediaClient?.streamHeaders ?? const {}; + final offlineWatchService = context.read(); + final userProfileProvider = context.read(); + final playbackState = context.read(); + final database = context.read(); + + await _progressTracker?.sendProgress('stopped'); + _progressTracker?.stopTracking(); + _progressTracker?.dispose(); + _progressTracker = null; + unawaited(DiscordRPCService.instance.stopPlayback()); + unawaited(TraktScrobbleService.instance.stopPlayback()); + unawaited(TrackerCoordinator.instance.stopPlayback()); + + _currentMetadata = episodeMetadata; + VideoPlayerScreenState._activeId = episodeMetadata.id; + _showPlayNextDialog = false; + _autoPlayTimer?.cancel(); + _hasFirstFrame.value = false; + + try { + // Same service shape works for both online (mediaClient non-null, + // bundled video URL + media info) and pure-offline (mediaClient null, + // local file + cached media info if available). + final playbackService = PlaybackInitializationService(client: mediaClient, database: database); + final result = await playbackService.getPlaybackData( + metadata: episodeMetadata, + selectedMediaIndex: widget.selectedMediaIndex, + preferOffline: _isOfflinePlayback || _selectedQualityPreset.isOriginal, + qualityPreset: _selectedQualityPreset, + selectedAudioStreamId: _selectedAudioStreamId, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ); + + if (result.videoUrl == null) { + throw PlaybackException('No video URL available'); + } + + Duration? resumePosition; + _isTranscoding = result.isTranscoding; + _effectiveIsOffline = result.isOffline; + _playbackPlaySessionId = result.playSessionId; + _playbackPlayMethod = result.playMethod; + if (result.activeAudioStreamId != null) { + _selectedAudioStreamId = result.activeAudioStreamId; + } + if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) { + if (mounted) { + showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback); + } + _selectedQualityPreset = TranscodeQualityPreset.original; + } + + if (_isOfflinePlayback) { + final localOffset = await offlineWatchService.getLocalViewOffset(episodeMetadata.globalKey); + if (localOffset != null && localOffset > 0) { + resumePosition = Duration(milliseconds: localOffset); + } + } + resumePosition ??= episodeMetadata.viewOffsetMs != null + ? Duration(milliseconds: episodeMetadata.viewOffsetMs!) + : null; + + final hasExternalSubs = result.externalSubtitles.isNotEmpty; + final isExoPlayer = player is PlayerAndroid; + await currentPlayer.open( + Media(result.videoUrl!, start: resumePosition, headers: streamHeaders), + play: isExoPlayer || !hasExternalSubs, + externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, + ); + + _completionTriggered = false; + _isSwappingEpisode = false; + + if (!mounted) return; + + _scrubPreviewSource?.dispose(); + _setPlayerState(() { + _availableVersions = result.availableVersions; + _currentMediaInfo = result.mediaInfo; + _scrubPreviewSource = null; + _isLoadingNext = false; + }); + + _trackManager?.dispose(); + _trackManager = TrackManager( + player: currentPlayer, + isActive: () => mounted && player != null, + // Plex writes track changes immediately. Jellyfin persists selected + // indexes through playback progress reports. + persistTrackPreference: plexClient != null ? _plexTrackPersister(() => plexClient) : null, + getProfileSettings: () => userProfileProvider.profileSettings, + waitForProfileSettings: _waitForProfileSettingsIfNeeded, + metadata: episodeMetadata, + mediaInfo: _currentMediaInfo, + preferredAudioTrack: currentAudioTrack, + preferredSubtitleTrack: currentSubtitleTrack, + preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, + showMessage: (message, {duration}) { + if (mounted) showAppSnackBar(context, message, duration: duration); + }, + ); + _trackManager!.cacheExternalSubtitles(result.externalSubtitles); + + if (player is! PlayerAndroid && hasExternalSubs) { + _trackManager!.waitingForExternalSubsTrackSelection = true; + try { + await _trackManager!.addExternalSubtitles(result.externalSubtitles); + } finally { + await _trackManager!.resumeAfterSubtitleLoad(); + } + } else { + _trackManager!.applyTrackSelectionWhenReady(); + } + + // Wire progress tracker, media-controls metadata, and the + // Discord/Trakt/Tracker scrobblers — same helper as the initial + // start flow, so any future change lands in both paths together. + _wirePerItemPlaybackServices( + metadata: episodeMetadata, + mediaClient: mediaClient, + offlineWatchService: offlineWatchService, + playSessionId: _playbackPlaySessionId, + playMethod: _playbackPlayMethod, + mediaInfo: _currentMediaInfo, + ); + + try { + playbackState.setCurrentItem(episodeMetadata); + } catch (e) { + appLogger.d('playbackState.setCurrentItem failed', error: e); + } + + await _loadAdjacentEpisodes(); + + if (_autoPipEnabled) { + unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing)); + } + } catch (e) { + _isSwappingEpisode = false; + _completionTriggered = false; + _currentMetadata = previousMetadata; + VideoPlayerScreenState._activeId = previousMetadata.id; + appLogger.e('Failed to swap episode in PiP', error: e); + } + } +} diff --git a/lib/screens/video_player/parts/episode_queue.dart b/lib/screens/video_player/parts/episode_queue.dart new file mode 100644 index 00000000..4b49658c --- /dev/null +++ b/lib/screens/video_player/parts/episode_queue.dart @@ -0,0 +1,148 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState { + /// Ensure a play queue exists for sequential episode playback + Future _ensurePlayQueue() async { + if (!mounted) return; + + // Skip play queue in offline mode (requires server connection) + if (_isOfflinePlayback) return; + + // Skip play queue for live TV (would interfere with tuner session) + if (widget.isLive) return; + + // Only create play queues for episodes + if (!_currentMetadata.isEpisode) { + return; + } + + // Plex-only — Jellyfin's local queue is published by + // EpisodeNavigationService._ensureLocalEpisodeQueue from + // _loadAdjacentEpisodes, so this method is a no-op for it. + if (_currentMetadata.backend != MediaBackend.plex) return; + + try { + final client = context.getPlexClientForServer(_currentMetadata.serverId!); + + final playbackState = context.read(); + + // Determine the show's rating key + // For episodes, grandparentId points to the show + final showRatingKey = _currentMetadata.grandparentId; + if (showRatingKey == null) { + appLogger.d('Episode missing grandparentId, skipping play queue creation'); + return; + } + + // Check if there's already an active queue for THIS show. + // A leftover queue from a different show or — more importantly — + // from a different backend (Jellyfin's local queue is published + // here too) would otherwise mask the new show's navigation. + final existingContextKey = playbackState.shuffleContextKey; + final isQueueActive = playbackState.isQueueActive; + + if (isQueueActive && existingContextKey == showRatingKey) { + playbackState.setCurrentItem(_currentMetadata); + appLogger.d('Using existing play queue (context: $existingContextKey)'); + return; + } + if (isQueueActive) { + appLogger.d('Resetting stale play queue (was: $existingContextKey, now: $showRatingKey)'); + playbackState.clearShuffle(); + } + + // Create a new sequential play queue for the show + appLogger.d('Creating sequential play queue for show $showRatingKey'); + final playQueue = await client.createShowPlayQueue( + showRatingKey: showRatingKey, + shuffle: 0, // Sequential order + startingEpisodeKey: _currentMetadata.id, + ); + + if (playQueue != null && playQueue.items != null && playQueue.items!.isNotEmpty) { + // Initialize playback state with the play queue + await playbackState.setPlaybackFromPlayQueue(playQueue, showRatingKey); + + // Set the client for loading more items + playbackState.setPlayQueueWindowFetcher(client.getPlayQueue); + + appLogger.d('Sequential play queue created with ${playQueue.items!.length} items'); + } + } catch (e) { + // Non-critical: Sequential playback will fall back to non-queue navigation + appLogger.d('Could not create play queue for sequential playback', error: e); + } + } + + Future _loadAdjacentEpisodes() async { + if (!mounted || widget.isLive) return; + + if (_isOfflinePlayback) { + // Offline mode: find next/previous from downloaded episodes + _loadAdjacentEpisodesOffline(); + return; + } + + try { + // Load adjacent episodes using the service + final adjacentEpisodes = await _episodeNavigation.loadAdjacentEpisodes( + context: context, + metadata: _currentMetadata, + ); + + if (mounted) { + _setPlayerState(() { + _nextEpisode = adjacentEpisodes.next; + _previousEpisode = adjacentEpisodes.previous; + }); + } + } catch (e) { + // Non-critical: Failed to load next/previous episode metadata + appLogger.d('Could not load adjacent episodes', error: e); + } + } + + /// Load next/previous episodes from locally downloaded content + void _loadAdjacentEpisodesOffline() { + if (!_currentMetadata.isEpisode) return; + + final showKey = _currentMetadata.grandparentId; + if (showKey == null) return; + + try { + final downloadProvider = context.read(); + final episodes = downloadProvider.getDownloadedEpisodesForShow(showKey); + + if (episodes.isEmpty) return; + + // Sort by aired date, falling back to season/episode number + final sorted = List.from(episodes) + ..sort((a, b) { + final aDate = a.originallyAvailableAt ?? ''; + final bDate = b.originallyAvailableAt ?? ''; + if (aDate.isEmpty && bDate.isEmpty) { + final seasonCmp = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0); + if (seasonCmp != 0) return seasonCmp; + return (a.index ?? 0).compareTo(b.index ?? 0); + } + if (aDate.isEmpty) return 1; + if (bDate.isEmpty) return -1; + return aDate.compareTo(bDate); + }); + + // Find current episode in the sorted list + final currentIdx = sorted.indexWhere((ep) => ep.id == _currentMetadata.id); + + if (currentIdx == -1) return; + + if (mounted) { + _setPlayerState(() { + _previousEpisode = currentIdx > 0 ? sorted[currentIdx - 1] : null; + _nextEpisode = currentIdx < sorted.length - 1 ? sorted[currentIdx + 1] : null; + }); + } + } catch (e) { + appLogger.d('Could not load offline adjacent episodes', error: e); + } + } +} diff --git a/lib/screens/video_player/parts/errors.dart b/lib/screens/video_player/parts/errors.dart new file mode 100644 index 00000000..fcbc6a2b --- /dev/null +++ b/lib/screens/video_player/parts/errors.dart @@ -0,0 +1,68 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerErrorMethods on VideoPlayerScreenState { + String _safePlaybackErrorMessage(Object error) { + final raw = error.toString(); + final redacted = LogRedactionManager.redact(raw); + if (raw.contains('No client registered')) { + return t.messages.errorLoading(error: 'Server is unavailable for the active profile'); + } + return t.messages.errorLoading(error: redacted); + } + + void _onPlayerError(PlayerError err) { + appLogger.e('[Player ERROR] ${err.message}'); + if (!mounted || _isExiting.value) return; + + // Fatal, unrecoverable until server-side fix — show modal instead of a snackbar. + if (err.cause == PlayerError.serverHttp500 || _sawServer500) { + _showServerLimitDialog(); + return; + } + + // Live TV: retry with progressively degraded stream settings + // (mirrors Plex web client fallback chain). + if (widget.isLive && _liveStreamFallbackLevel < 2 && !_isRetryingLiveStream) { + _liveStreamFallbackLevel++; + _isRetryingLiveStream = true; + appLogger.w('Live stream failed, retrying with fallback level $_liveStreamFallbackLevel'); + _retryLiveStream().whenComplete(() => _isRetryingLiveStream = false); + return; + } + + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? err.message)); + _handleBackButton(); + } + + void _onPlayerLog(PlayerLog log) { + if (!_sawServer500 && VideoPlayerScreenState._server500Pattern.hasMatch(log.text)) { + _sawServer500 = true; + } + if (log.level == PlayerLogLevel.error || log.level == PlayerLogLevel.fatal) { + appLogger.e('[Player LOG ERROR] [${log.prefix}] ${log.text}'); + _lastLogError = _redactPlayerError(log.text.trim()); + } + } + + String _redactPlayerError(String message) => LogRedactionManager.redact(message); + + Future _showServerLimitDialog() async { + if (!mounted) return; + await showServerLimitDialog(context); + if (mounted) unawaited(_handleBackButton()); + } + + /// Handle notification when native player switched from ExoPlayer to MPV + Future _onBackendSwitched() async { + _playerBackendLabel = 'mpv'; + _recordLifecycleState('backend_switched', action: 'mpv_fallback'); + + _toastController.show( + Symbols.swap_horiz_rounded, + t.messages.switchingToCompatiblePlayer, + duration: const Duration(seconds: 2), + ); + + await _trackManager?.onBackendSwitched(); + } +} diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart new file mode 100644 index 00000000..ddfbf44e --- /dev/null +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -0,0 +1,147 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { + void _enqueueLifecycleTransition(String label, Future Function() transition) { + _lifecycleTransition = _lifecycleTransition + .catchError((Object error, StackTrace stackTrace) { + appLogger.w('Previous lifecycle transition failed', error: error, stackTrace: stackTrace); + }) + .then((_) async { + if (!mounted) return; + try { + await transition(); + } catch (e, stackTrace) { + appLogger.w('Lifecycle transition failed during $label', error: e, stackTrace: stackTrace); + } + }); + } + + void _recordLifecycleState(String state, {String? action}) { + final isTv = PlatformDetector.isTV(); + final pipActive = PipService().isPipActive.value; + final breadcrumbData = { + 'state': state, + 'isTv': isTv, + 'autoPipEnabled': _autoPipEnabled, + 'pipActive': pipActive, + 'pipTransitionInFlight': _androidAutoPipTransitionInFlight, + 'hiddenForBackground': _hiddenForBackground, + 'backend': _playerBackendLabel, + }; + if (action != null) { + breadcrumbData['action'] = action; + } + + Sentry.addBreadcrumb( + Breadcrumb(message: 'Player lifecycle $state', category: 'player.lifecycle', data: breadcrumbData), + ); + + appLogger.d( + 'Player lifecycle: state=$state' + '${action != null ? ' action=$action' : ''}' + ' isTv=$isTv' + ' autoPipEnabled=$_autoPipEnabled' + ' pipActive=$pipActive' + ' pipTransitionInFlight=$_androidAutoPipTransitionInFlight' + ' hiddenForBackground=$_hiddenForBackground' + ' backend=$_playerBackendLabel', + ); + } + + void _setAndroidAutoPipTransitionInFlight(bool value, {required String reason}) { + if (!Platform.isAndroid || _androidAutoPipTransitionInFlight == value) return; + _androidAutoPipTransitionInFlight = value; + _recordLifecycleState('pip_transition', action: '${value ? 'started' : 'cleared'}:$reason'); + } + + void _suspendLiveTimelineForBackground() { + _resumeLiveTimelineOnResume = _liveTimelineTimer != null; + _stopLiveTimelineUpdates(); + } + + void _resumeLiveTimelineAfterBackgroundIfNeeded() { + final shouldResume = _resumeLiveTimelineOnResume; + _resumeLiveTimelineOnResume = false; + if (shouldResume && _liveSessionIdentifier != null) { + _startLiveTimelineUpdates(); + } + } + + Future _handleAppHidden() async { + if (_shouldSkipForPip) { + _recordLifecycleState('hidden', action: 'skipped_for_pip'); + return; + } + + // Suppress Watch Together heartbeats while backgrounded so App Nap + // doesn't cause stale position broadcasts that make guests loop. + _watchTogetherProvider?.setBackgrounded(true); + + final currentPlayer = player; + if (currentPlayer == null || !_isPlayerInitialized) { + _recordLifecycleState('hidden', action: 'skipped_no_player'); + return; + } + + final isTv = PlatformDetector.isTV(); + final shouldPauseForBackground = PlatformDetector.isHandheld(context) || isTv; + + // Pause first so Android MPV does not keep decoding against a transient + // background surface while the app is locking or hiding. + if (shouldPauseForBackground) { + _wasPlayingBeforeInactive = currentPlayer.state.isActive; + if (_wasPlayingBeforeInactive) { + try { + await currentPlayer.pause(); + appLogger.d('Video paused due to app being hidden (${isTv ? 'tv' : 'handheld'})'); + } catch (e) { + appLogger.w('Failed to pause video before background transition', error: e); + } + } + } + + if (!mounted || currentPlayer != player) return; + + _suspendLiveTimelineForBackground(); + + if (isTv) { + _recordLifecycleState('hidden', action: 'tv_background_pause_only'); + return; + } + + _hiddenForBackground = true; + await currentPlayer.setVisible(false); + _recordLifecycleState('hidden', action: 'render_hidden'); + } + + Future _handleAppResumed() async { + _recordLifecycleState('resumed', action: 'begin'); + _watchTogetherProvider?.setBackgrounded(false); + + if (Platform.isAndroid && _androidAutoPipTransitionInFlight && !PipService().isPipActive.value) { + _setAndroidAutoPipTransitionInFlight(false, reason: 'resume_without_pip'); + } + + final currentPlayer = player; + + // Restore render layer if it was hidden for background, then force a + // video-output refresh before any auto-resume logic runs. + if (_hiddenForBackground && currentPlayer != null && _isPlayerInitialized) { + await currentPlayer.setVisible(true); + await currentPlayer.updateFrame(); + + if (!mounted || currentPlayer != player) return; + + _hiddenForBackground = false; + _recordLifecycleState('resumed', action: 'render_restored'); + } + + // Restore media controls and wakelock when app is resumed. + if (_isPlayerInitialized && mounted) { + await _restoreMediaControlsAfterResume(); + } + + _resumeLiveTimelineAfterBackgroundIfNeeded(); + _recordLifecycleState('resumed', action: 'complete'); + } +} diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart new file mode 100644 index 00000000..59efa8b3 --- /dev/null +++ b/lib/screens/video_player/parts/live_tv.dart @@ -0,0 +1,346 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { + /// Start periodic timeline heartbeats for live TV transcode session. + void _startLiveTimelineUpdates() { + final generation = ++_liveTimelineGeneration; + _liveTimelineTimer?.cancel(); + _liveTimelineTimer = Timer.periodic(const Duration(seconds: 10), (_) { + if (generation != _liveTimelineGeneration) return; + final state = player?.state.playing == true ? 'playing' : 'paused'; + _sendLiveTimeline(state); + }); + // Delay initial heartbeat to let the transcode session stabilize. + // Sending time=0 immediately after player.open() causes the server + // to spawn a duplicate transcode job with offset=-1 that 404s. + Future.delayed(const Duration(seconds: 3), () { + if (_liveTimelineTimer != null && generation == _liveTimelineGeneration) { + final state = player?.state.playing == true ? 'playing' : 'paused'; + _sendLiveTimeline(state); + } + }); + } + + void _stopLiveTimelineUpdates() { + _liveTimelineGeneration++; + _liveTimelineTimer?.cancel(); + _liveTimelineTimer = null; + } + + Future _sendLiveTimeline(String state) async { + final client = _liveClient; + final playbackTime = _livePlaybackStartTime != null + ? DateTime.now().difference(_livePlaybackStartTime!).inMilliseconds + : 0; + + if (client is PlexClient) { + final sessionId = _liveSessionIdentifier; + final sessionPath = _liveSessionPath; + if (sessionId == null || sessionPath == null) return; + try { + // Use the program ratingKey from tune metadata, not the channel key + final ratingKey = _liveProgramId ?? _liveItemId ?? widget.metadata.id; + // For live TV, player position/duration are unreliable (often 0). + // Use playbackTime as time, and program duration from tune metadata. + // Plex rejects timeline pings where time > duration; grow duration to + // match — otherwise Tunarr-style short synthetic programs 400 mid-stream. + final time = playbackTime; + final duration = max(_liveDurationMs ?? 0, time); + final updatedBuffer = await client.updateLiveTimeline( + ratingKey: ratingKey, + sessionPath: sessionPath, + sessionIdentifier: sessionId, + state: state, + time: time, + duration: duration, + playbackTime: playbackTime, + ); + if (updatedBuffer != null && mounted) { + _setPlayerState(() { + _captureBuffer = updatedBuffer; + _isAtLiveEdge = + (_currentPositionEpoch >= + updatedBuffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds); + }); + } + } catch (e) { + appLogger.d('Plex live timeline update failed', error: e); + } + return; + } + + if (client is JellyfinClient) { + await _jellyfinLiveSession.report( + client: client, + itemId: _liveItemId ?? widget.metadata.id, + state: state, + position: Duration(milliseconds: playbackTime), + duration: Duration(milliseconds: _liveDurationMs ?? 0), + ); + return; + } + } + + /// Retry the live stream with degraded direct-stream settings. + /// + /// Plex re-tunes the channel for a fresh capture session (the previous one + /// expires while MPV exhausts its reconnect attempts). Jellyfin streams the + /// channel directly with a session-less URL, so retry is just re-opening + /// that URL — degradation knobs apply only to the Plex transcoder branch. + Future _retryLiveStream() async { + final client = _liveClient; + final ds = _liveStreamFallbackLevel < 1; + final dsa = _liveStreamFallbackLevel < 2; + + if (client is PlexClient) { + final channels = widget.liveChannels; + final channelIndex = _liveChannelIndex; + final dvrKey = _liveDvrKey; + if (channels == null || channelIndex < 0 || channelIndex >= channels.length || dvrKey == null) { + appLogger.w('Cannot retry live stream — missing session info'); + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? 'Live stream failed')); + unawaited(_handleBackButton()); + return; + } + final channel = channels[channelIndex]; + appLogger.i('Retrying live stream (re-tune ${channel.key}): directStream=$ds directStreamAudio=$dsa'); + + // Re-tune to get a fresh capture session — the previous one is dead. + final tuneResult = await client.tuneChannel(dvrKey, channel.key); + if (tuneResult == null || !mounted) { + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? 'Live stream failed')); + unawaited(_handleBackButton()); + return; + } + + _liveSessionIdentifier = tuneResult.sessionIdentifier; + _liveSessionPath = tuneResult.sessionPath; + _transcodeSessionId = generateSessionIdentifier(); + + final streamPath = await client.buildLiveStreamPath( + sessionPath: tuneResult.sessionPath, + sessionIdentifier: tuneResult.sessionIdentifier, + transcodeSessionId: _transcodeSessionId!, + directStream: ds, + directStreamAudio: dsa, + ); + if (streamPath == null || !mounted) { + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? 'Live stream failed')); + unawaited(_handleBackButton()); + return; + } + + final streamUrl = client.buildLiveStreamUrl(streamPath); + _liveStreamUrl = streamUrl; + _livePlaybackStartTime = DateTime.now(); + _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; + _isAtLiveEdge = true; + + await _setLiveStreamOptions(); + await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + return; + } + + final liveStreamUrl = _liveStreamUrl; + if (client is JellyfinClient && liveStreamUrl != null) { + appLogger.i('Retrying Jellyfin live stream by re-opening URL'); + _livePlaybackStartTime = DateTime.now(); + _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; + _isAtLiveEdge = true; + await _setLiveStreamOptions(); + await player!.open(Media(liveStreamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + return; + } + + appLogger.w('Cannot retry live stream — no compatible client/URL available'); + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? 'Live stream failed')); + unawaited(_handleBackButton()); + } + + /// Configure MPV options for live streaming. + /// The official Plex Media Player does not set client-side reconnect options — + /// reconnection is handled by the server's transcoder on the input side. + Future _setLiveStreamOptions() async { + await player!.setProperty('force-seekable', 'no'); + } + + /// The current playback position as an absolute epoch second (for live TV time-shift). + int get _currentPositionEpoch => (_streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round(); + + /// Show "Watch from Start" / "Watch Live" dialog. + /// Returns true if user chose "Watch from start", false for "Watch Live", null if dismissed. + Future _showWatchFromStartDialog(int effectiveStartEpoch, int nowEpoch) { + final minutesAgo = ((nowEpoch - effectiveStartEpoch) / 60).round(); + return showOptionPickerDialog( + context, + title: t.liveTv.joinSession, + options: [ + (icon: Symbols.replay_rounded, label: t.liveTv.watchFromStart(minutes: minutesAgo), value: true), + (icon: Symbols.live_tv_rounded, label: t.liveTv.watchLive, value: false), + ], + ); + } + + /// Seek the live TV stream to an absolute epoch second. + /// Creates a new transcode session at the target offset. + Future _seekLivePosition(int targetEpochSeconds) async { + if (_captureBuffer == null || + _liveSessionPath == null || + _liveSessionIdentifier == null || + _transcodeSessionId == null) { + return; + } + + final clamped = targetEpochSeconds.clamp(_captureBuffer!.seekableStartEpoch, _captureBuffer!.seekableEndEpoch); + + final offsetSeconds = clamped - _captureBuffer!.startedAt.round(); + + // Live seek requires a transcode session — Plex-only by protocol. The + // Plex path populates _captureBuffer; the Jellyfin path never does, so + // the early-return above already covers Jellyfin in practice. This + // explicit guard keeps the contract obvious. + final client = _liveClient; + if (client is! PlexClient) return; + + final streamPath = await client.buildLiveStreamPath( + sessionPath: _liveSessionPath!, + sessionIdentifier: _liveSessionIdentifier!, + transcodeSessionId: _transcodeSessionId!, + offsetSeconds: offsetSeconds, + ); + if (streamPath == null || !mounted) return; + + final streamUrl = client.buildLiveStreamUrl(streamPath); + + _streamStartEpoch = _captureBuffer!.startedAt + offsetSeconds; + _isAtLiveEdge = (clamped >= _captureBuffer!.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds); + _livePlaybackStartTime = DateTime.now(); + + await _setLiveStreamOptions(); + await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + if (mounted) _setPlayerState(() {}); + } + + /// Jump to the live edge of the capture buffer. + Future _jumpToLiveEdge() async { + if (_captureBuffer == null) return; + await _seekLivePosition(_captureBuffer!.seekableEndEpoch); + } + + Future _switchLiveChannel(int delta) async { + final channels = widget.liveChannels; + if (channels == null || channels.isEmpty) return; + if (_isSwitchingChannel) return; // debounce concurrent switches + + final newIndex = _liveChannelIndex + delta; + if (newIndex < 0 || newIndex >= channels.length) return; + + _isSwitchingChannel = true; + + // Stop old session heartbeats and notify server + _stopLiveTimelineUpdates(); + await _sendLiveTimeline('stopped'); + + final channel = channels[newIndex]; + appLogger.d('Switching to channel: ${channel.displayName} (${channel.key})'); + + if (!mounted) return; + _setPlayerState(() => _hasFirstFrame.value = false); + + try { + // Look up the correct client/DVR for this channel's server + final multiServer = context.read(); + final serverInfo = liveTvServerInfoForChannel(multiServer, channel); + + if (serverInfo == null) return; + + final genericClient = multiServer.getClientForServer(serverInfo.serverId); + final resolution = await genericClient?.liveTv.resolveStreamUrl(channel.key, dvrKey: serverInfo.dvrKey); + if (resolution != null) { + // Jellyfin: pre-resolved negotiated URL. + await _setLiveStreamOptions(); + await player!.open(Media(resolution.url, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + _liveClient = genericClient; + _liveDvrKey = serverInfo.dvrKey; + _liveStreamUrl = resolution.url; + _liveItemId = channel.key; + _liveSessionIdentifier = resolution.playSessionId; + _jellyfinLiveSession = JellyfinLiveSessionTracker(playSessionId: resolution.playSessionId); + _livePlaybackStartTime = DateTime.now(); + _captureBuffer = null; + _programBeginsAt = null; + _liveProgramId = null; + _liveDurationMs = null; + _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; + _isAtLiveEdge = true; + if (!mounted) return; + _setPlayerState(() { + _liveChannelIndex = newIndex; + _liveChannelName = channel.displayName; + }); + _startLiveTimelineUpdates(); + return; + } + + // Plex-only: DVR tune flow (Jellyfin Live TV uses pre-resolved URLs). + final client = multiServer.getPlexClientForServer(serverInfo.serverId); + if (client == null) return; + + final tuneResult = await client.tuneChannel(serverInfo.dvrKey, channel.key); + if (tuneResult == null || !mounted) return; + + _transcodeSessionId = generateSessionIdentifier(); + _liveStreamFallbackLevel = 0; + + final streamPath = await client.buildLiveStreamPath( + sessionPath: tuneResult.sessionPath, + sessionIdentifier: tuneResult.sessionIdentifier, + transcodeSessionId: _transcodeSessionId!, + ); + if (streamPath == null || !mounted) return; + + final streamUrl = client.buildLiveStreamUrl(streamPath); + + await _setLiveStreamOptions(); + await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + + _liveClient = client; + _liveDvrKey = serverInfo.dvrKey; + _liveStreamUrl = streamUrl; + _liveItemId = channel.key; + _livePlaybackStartTime = DateTime.now(); + _liveProgramId = tuneResult.metadata.ratingKey; + _liveDurationMs = tuneResult.metadata.duration; + + // Reset time-shift state for new channel + _captureBuffer = tuneResult.captureBuffer; + _programBeginsAt = tuneResult.beginsAt; + _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; + _isAtLiveEdge = true; + + if (!mounted) return; + _setPlayerState(() { + _liveChannelIndex = newIndex; + _liveChannelName = channel.displayName; + _liveSessionIdentifier = tuneResult.sessionIdentifier; + _liveSessionPath = tuneResult.sessionPath; + }); + + // Restart timeline heartbeats for the new session + _startLiveTimelineUpdates(); + } catch (e) { + appLogger.e('Failed to switch channel', error: e); + if (mounted) showErrorSnackBar(context, e.toString()); + } finally { + _isSwitchingChannel = false; + } + } + + bool get _hasNextChannel => + widget.isLive && + widget.liveChannels != null && + _liveChannelIndex >= 0 && + _liveChannelIndex < (widget.liveChannels!.length - 1); + + bool get _hasPreviousChannel => widget.isLive && widget.liveChannels != null && _liveChannelIndex > 0; +} diff --git a/lib/screens/video_player/parts/media_controls.dart b/lib/screens/video_player/parts/media_controls.dart new file mode 100644 index 00000000..fd87337c --- /dev/null +++ b/lib/screens/video_player/parts/media_controls.dart @@ -0,0 +1,74 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { + Future _syncMediaControlsAvailability() async { + final manager = _mediaControlsManager; + final currentPlayer = player; + if (!mounted || manager == null || currentPlayer == null) return; + + final playbackState = context.read(); + final canNavigateEpisodes = _currentMetadata.isEpisode || playbackState.isPlaylistActive; + final canSeek = !widget.isLive && currentPlayer.state.seekable; + + if (!mounted || currentPlayer != player || manager != _mediaControlsManager) return; + + await manager.setControlsEnabled( + canGoNext: canNavigateEpisodes, + canGoPrevious: canNavigateEpisodes, + canSeek: canSeek, + ); + } + + Future _seekBackForRewind(Player p) async { + if (_rewindOnResume <= 0) return; + final target = p.state.position - Duration(seconds: _rewindOnResume); + await p.seek(clampSeekPosition(p, target)); + } + + Future _restoreMediaControlsAfterResume() async { + if (!_isPlayerInitialized || !mounted) return; + + unawaited(_setWakelock(player?.state.isActive ?? false)); + + final manager = _mediaControlsManager; + final currentPlayer = player; + if (manager != null && currentPlayer != null) { + final client = _isOfflinePlayback ? null : _getMediaServerClient(context); + await manager.updateMetadata( + metadata: _currentMetadata, + client: client, + duration: _currentMetadata.durationMs != null ? Duration(milliseconds: _currentMetadata.durationMs!) : null, + ); + await _syncMediaControlsAvailability(); + } + + if (!mounted || currentPlayer != player || currentPlayer == null) return; + + if (_wasPlayingBeforeInactive) { + try { + await _seekBackForRewind(currentPlayer); + await currentPlayer.play(); + appLogger.d('Video resumed after returning from inactive state'); + } catch (e) { + appLogger.w('Failed to resume playback after returning from inactive state', error: e); + } finally { + _wasPlayingBeforeInactive = false; + } + } + + _updateMediaControlsPlaybackState(); + appLogger.d('Media controls restored on app resume'); + } + + /// Wrapper method to update media controls playback state + void _updateMediaControlsPlaybackState() { + if (player == null) return; + + _mediaControlsManager?.updatePlaybackState( + isPlaying: player!.state.isActive, + position: player!.state.position, + speed: player!.state.rate, + force: true, // Force update since this is an explicit state change + ); + } +} diff --git a/lib/screens/video_player/parts/pip_shader.dart b/lib/screens/video_player/parts/pip_shader.dart new file mode 100644 index 00000000..7e1be461 --- /dev/null +++ b/lib/screens/video_player/parts/pip_shader.dart @@ -0,0 +1,175 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerPipShaderMethods on VideoPlayerScreenState { + /// Initialize VideoFilterManager and VideoPIPManager if not already set up. + /// Called from both live TV and VOD playback paths. + Future _initVideoFilterAndPip() async { + if (player == null || _videoFilterManager != null) return; + final settings = await SettingsService.getInstance(); + _videoFilterManager = VideoFilterManager( + player: player!, + availableVersions: _availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, + initialBoxFitMode: settings.read(SettingsService.defaultBoxFitMode), + onBoxFitModeChanged: (mode) => settings.write(SettingsService.defaultBoxFitMode, mode), + ); + _videoFilterManager!.updateVideoFilter(); + + _videoPIPManager = VideoPIPManager(player: player!); + _videoPIPManager!.onBeforeEnterPip = () { + _videoFilterManager?.enterPipMode(); + }; + _videoPIPManager!.isPipActive.addListener(_onPipStateChanged); + } + + Future _togglePIPMode() async { + final result = await _videoPIPManager?.togglePIP(); + if (result != null && !result.$1 && mounted) { + showErrorSnackBar(context, result.$2 ?? t.videoControls.pipFailed); + } + } + + /// Handle PiP state changes to restore video scaling when exiting PiP + void _onPipStateChanged() { + final isInPip = _videoPIPManager?.isPipActive.value ?? PipService().isPipActive.value; + _setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed'); + _recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited'); + + if (_videoPIPManager == null || _videoFilterManager == null) return; + + // Only handle exit - entry is handled by onBeforeEnterPip callback + if (!isInPip) { + final restoreAmbient = _videoFilterManager!.hadAmbientLightingBeforePip; + _videoFilterManager!.exitPipMode(); + // Restore ambient lighting if it was active before PiP + if (restoreAmbient) { + _videoFilterManager!.clearPipAmbientLightingFlag(); + _restoreAmbientLighting(); + } + } + } + + /// Apply the saved shader preset on playback start. + /// Reads directly from SettingsService (synchronous SharedPreferences) to + /// avoid a race with ShaderProvider's async initialization. + Future _applySavedShaderPreset() async { + if (_shaderService == null || !_shaderService!.isSupported) return; + + try { + final shaderProvider = context.read(); + final settings = await SettingsService.getInstance(); + final presetId = settings.read(SettingsService.globalShaderPreset); + final preset = + (shaderProvider.initialized ? shaderProvider.findPresetById(presetId) : ShaderPreset.fromId(presetId)) ?? + ShaderPreset.none; + await _shaderService!.applyPreset(preset); + if (!mounted) return; + shaderProvider.setCurrentPreset(preset); + } catch (e) { + appLogger.d('Could not apply shader preset', error: e); + } + } + + /// Restore ambient lighting from persisted setting + Future _restoreAmbientLighting() async { + final shaderProvider = context.read(); + final settings = await SettingsService.getInstance(); + if (!settings.read(SettingsService.ambientLighting)) return; + + final ambientLighting = _ambientLightingService; + if (ambientLighting == null || !ambientLighting.isSupported) return; + + // Same enable logic as _toggleAmbientLighting + final dwidth = await player?.getProperty('dwidth'); + final dheight = await player?.getProperty('dheight'); + if (dwidth == null || dheight == null) return; + final w = double.tryParse(dwidth); + final h = double.tryParse(dheight); + if (w == null || h == null || h == 0) return; + final videoAspect = w / h; + + final playerSize = _videoFilterManager?.playerSize; + if (playerSize == null || playerSize.height == 0) return; + final outputAspect = playerSize.width / playerSize.height; + + // Clear shaders — ambient lighting and shaders are mutually exclusive + if (shaderProvider.isShaderEnabled) { + await _shaderService!.applyPreset(ShaderPreset.none); + shaderProvider.setCurrentPreset(ShaderPreset.none); + } + + _videoFilterManager?.resetToContain(); + await ambientLighting.enable(videoAspect, outputAspect); + if (mounted) _setPlayerState(() {}); + } + + /// Cycle through BoxFit modes: contain → cover → fill → contain (for button) + void _cycleBoxFitMode() { + // Disable ambient lighting when switching boxfit modes + // (cover/fill change the video rect, making the baked-in shader incorrect) + _ambientLightingService?.disable(); + _setPlayerState(() { + _videoFilterManager?.cycleBoxFitMode(); + }); + } + + /// Update video-aspect-override when player size changes. + /// The shader adapts automatically via built-in target_size uniform. + void _updateAmbientLightingOnResize(Size newSize) { + final ambientLighting = _ambientLightingService; + if (ambientLighting == null || !ambientLighting.isEnabled) return; + if (newSize.height == 0) return; + + ambientLighting.updateOutputAspect(newSize.width / newSize.height); + } + + /// Toggle ambient lighting effect on/off + Future _toggleAmbientLighting() async { + final ambientLighting = _ambientLightingService; + if (ambientLighting == null || !ambientLighting.isSupported) return; + final shaderProvider = context.read(); + + if (ambientLighting.isEnabled) { + await ambientLighting.disable(); + _videoFilterManager?.updateVideoFilter(); + } else { + // Get video display aspect ratio + final dwidth = await player?.getProperty('dwidth'); + final dheight = await player?.getProperty('dheight'); + if (dwidth == null || dheight == null) return; + final w = double.tryParse(dwidth); + final h = double.tryParse(dheight); + if (w == null || h == null || h == 0) return; + final videoAspect = w / h; + + // Get player widget aspect ratio + final playerSize = _videoFilterManager?.playerSize; + if (playerSize == null || playerSize.height == 0) return; + final outputAspect = playerSize.width / playerSize.height; + + // Clear shaders — ambient lighting and shaders are mutually exclusive + if (shaderProvider.isShaderEnabled) { + await _shaderService!.applyPreset(ShaderPreset.none); + shaderProvider.setCurrentPreset(ShaderPreset.none); + } + + // Force contain mode when enabling ambient lighting + _videoFilterManager?.resetToContain(); + + await ambientLighting.enable(videoAspect, outputAspect); + } + + // Persist ambient lighting state + final settings = await SettingsService.getInstance(); + unawaited(settings.write(SettingsService.ambientLighting, ambientLighting.isEnabled)); + + if (mounted) _setPlayerState(() {}); + } + + /// Toggle between contain and cover modes only (for pinch gesture) + void _toggleContainCover() { + _setPlayerState(() { + _videoFilterManager?.toggleContainCover(); + }); + } +} diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart new file mode 100644 index 00000000..ecccb00e --- /dev/null +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -0,0 +1,156 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { + void _onVideoCompleted(bool completed, {bool skipAutoPlayCountdown = false}) async { + // Live TV streams are continuous — ignore spurious EOF events caused by + // inter-segment gaps in the chunked MKV transcode stream. + if (widget.isLive) return; + if (!completed) return; + // Ignore spurious EOF from the old file during in-place episode swap + if (_isSwappingEpisode) return; + + // mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged + // never fires false. Normalize all playback-dependent state. + unawaited(_setWakelock(false)); + unawaited(_progressTracker?.sendProgress('paused')); + _updateMediaControlsPlaybackState(); + unawaited(DiscordRPCService.instance.pausePlayback()); + unawaited(TraktScrobbleService.instance.pausePlayback()); + if (_autoPipEnabled) { + unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: false)); + } + + if (_nextEpisode != null && !_showPlayNextDialog && !_showStillWatchingPrompt && !_completionTriggered) { + _completionTriggered = true; + + // PiP: skip dialog (user can't interact), auto-play immediately + if (PipService().isPipActive.value) { + unawaited(_playNext()); + return; + } + + // Capture keyboard mode before async gap + final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context); + + final settings = await SettingsService.getInstance(); + final autoPlayEnabled = settings.read(SettingsService.autoPlayNextEpisode); + + if (skipAutoPlayCountdown && autoPlayEnabled) { + unawaited(_playNext()); + return; + } + + if (!mounted) return; + _setPlayerState(() { + _showPlayNextDialog = true; + _autoPlayCountdown = autoPlayEnabled ? 5 : -1; + }); + + // Auto-focus Play Next button on TV when dialog appears (only in keyboard/TV mode) + if (isKeyboardMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _playNextConfirmFocusNode.requestFocus(); + } + }); + } + + if (autoPlayEnabled) { + _startAutoPlayTimer(); + } + } else if (_nextEpisode == null && !_completionTriggered) { + _completionTriggered = true; + unawaited(_handleBackButton()); + } + } + + void _startAutoPlayTimer() { + _autoPlayTimer?.cancel(); + _autoPlayTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + _setPlayerState(() { + _autoPlayCountdown--; + }); + if (_autoPlayCountdown <= 0) { + timer.cancel(); + _playNext(); + } + }); + } + + void _cancelAutoPlay() { + _autoPlayTimer?.cancel(); + _completionTriggered = false; // Reset so it can trigger again if user seeks near end + _setPlayerState(() { + _showPlayNextDialog = false; + }); + } + + void _showStillWatchingDialog() { + // Don't show if auto-play dialog is already visible + if (_showPlayNextDialog) return; + + final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context); + + _setPlayerState(() { + _showStillWatchingPrompt = true; + _stillWatchingCountdown = 30; + }); + + if (isKeyboardMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _stillWatchingContinueFocusNode.requestFocus(); + }); + } + + _stillWatchingTimer?.cancel(); + _stillWatchingTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + _setPlayerState(() { + _stillWatchingCountdown--; + }); + if (_stillWatchingCountdown <= 0) { + timer.cancel(); + _onStillWatchingTimeout(); + } + }); + } + + void _onStillWatchingTimeout() { + player?.pause(); + _setPlayerState(() { + _showStillWatchingPrompt = false; + }); + } + + void _onStillWatchingContinue() { + _stillWatchingTimer?.cancel(); + SleepTimerService().restartTimer(); + _setPlayerState(() { + _showStillWatchingPrompt = false; + }); + } + + void _onStillWatchingPause() { + _stillWatchingTimer?.cancel(); + player?.pause(); + _setPlayerState(() { + _showStillWatchingPrompt = false; + }); + } + + void _dismissStillWatching() { + _stillWatchingTimer?.cancel(); + if (_showStillWatchingPrompt) { + _setPlayerState(() { + _showStillWatchingPrompt = false; + }); + } + } +} diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart new file mode 100644 index 00000000..ff9c21b8 --- /dev/null +++ b/lib/screens/video_player/parts/playback_services.dart @@ -0,0 +1,222 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { + /// Wire the per-item playback services that need to (re)bind whenever + /// the active media item changes: [PlaybackProgressTracker], + /// [MediaControlsManager.updateMetadata], and the + /// Discord/Trakt/Tracker scrobblers. Both [_initializeServices] and + /// [_swapEpisodeInPip] call this so the two flows can't drift. + /// + /// The caller is responsible for ensuring `player != null` and (if the + /// media-controls metadata refresh should run) for having created + /// [_mediaControlsManager] before the first call. + void _wirePerItemPlaybackServices({ + required MediaItem metadata, + required MediaServerClient? mediaClient, + required OfflineWatchSyncService? offlineWatchService, + String? playSessionId, + String? playMethod, + MediaSourceInfo? mediaInfo, + }) { + if (player == null) return; + + // Progress tracker — offline mode queues for later sync; online mode + // dispatches to the right backend through the neutral client. + if (_isOfflinePlayback) { + _progressTracker = PlaybackProgressTracker( + client: null, + metadata: metadata, + player: player!, + isOffline: true, + offlineWatchService: offlineWatchService, + ); + _progressTracker!.startTracking(); + } else if (mediaClient != null) { + _progressTracker = PlaybackProgressTracker( + client: mediaClient, + metadata: metadata, + player: player!, + playMethod: playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay'), + playSessionId: playSessionId, + mediaInfo: mediaInfo, + ); + _progressTracker!.startTracking(); + } + + // Media controls metadata. Fire-and-forget — the OS plugin downloads + // the poster synchronously inside `setMetadata` (~270 ms); the + // controls populate a beat after first frame which is fine. + if (_mediaControlsManager != null) { + unawaited( + _mediaControlsManager!.updateMetadata( + metadata: metadata, + client: mediaClient, + duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null, + ), + ); + } + + // Scrobblers — Discord RPC, Trakt, unified tracker. All accept the + // neutral [MediaServerClient]; null short-circuits cleanly. + if (mediaClient != null) { + unawaited(DiscordRPCService.instance.startPlayback(metadata, mediaClient)); + unawaited(TraktScrobbleService.instance.startPlayback(metadata, mediaClient, isLive: widget.isLive)); + unawaited(TrackerCoordinator.instance.startPlayback(metadata, mediaClient, isLive: widget.isLive)); + } + } + + /// Initialize the service layer + Future _initializeServices() async { + if (!mounted || player == null) return; + + // Live TV: send timeline heartbeats to keep transcode session alive + if (widget.isLive) { + _startLiveTimelineUpdates(); + return; + } + + // Get client (null in offline mode). Backend-neutral lookup so Jellyfin + // items also wire a [PlaybackProgressTracker]; the tracker dispatches + // to the right backend's reporting endpoints internally. + final mediaClient = _isOfflinePlayback ? null : _getMediaServerClient(context); + final offlineWatchService = context.read(); + + // Initialize media controls manager (must exist before the per-item + // helper wires its metadata update). + _mediaControlsManager = MediaControlsManager(); + + // Set up media control event handling + _mediaControlSubscription = _mediaControlsManager!.controlEvents.listen((event) { + final currentPlayer = player; + if (currentPlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return; + + if (event is PlayEvent) { + appLogger.d('Media control: Play event received'); + _seekBackForRewind(currentPlayer!); + currentPlayer.play(); + _wasPlayingBeforeInactive = false; + _updateMediaControlsPlaybackState(); + } else if (event is PauseEvent) { + if (_suppressMediaPauseDuringFrameRateSwitch) { + appLogger.d('Media control: Pause event suppressed (frame rate switch in progress)'); + return; + } + appLogger.d('Media control: Pause event received'); + currentPlayer!.pause(); + _updateMediaControlsPlaybackState(); + } else if (event is TogglePlayPauseEvent) { + appLogger.d('Media control: Toggle play/pause event received'); + if (currentPlayer!.state.isActive) { + currentPlayer.pause(); + } else { + _seekBackForRewind(currentPlayer); + currentPlayer.play(); + _wasPlayingBeforeInactive = false; + } + _updateMediaControlsPlaybackState(); + } else if (event is SeekEvent) { + appLogger.d('Media control: Seek event received to ${event.position}'); + unawaited(currentPlayer!.seek(clampSeekPosition(currentPlayer, event.position))); + } else if (event is NextTrackEvent) { + appLogger.d('Media control: Next track event received'); + if (_nextEpisode != null) _playNext(); + } else if (event is PreviousTrackEvent) { + appLogger.d('Media control: Previous track event received'); + if (_previousEpisode != null) _playPrevious(); + } + }); + + // Wire progress tracker, media-controls metadata, and the + // Discord/Trakt/Tracker scrobblers. Shared with [_swapEpisodeInPip] + // so the two flows can't drift. + _wirePerItemPlaybackServices( + metadata: _currentMetadata, + mediaClient: mediaClient, + offlineWatchService: offlineWatchService, + playSessionId: _playbackPlaySessionId, + playMethod: _playbackPlayMethod, + mediaInfo: _currentMediaInfo, + ); + + if (!mounted) return; + + await _syncMediaControlsAvailability(); + + // Listen to playing state and update media controls + _mediaControlsPlayingSubscription = player!.streams.playing.listen((isPlaying) { + _updateMediaControlsPlaybackState(); + }); + + // Listen to position updates for media controls and Discord + _mediaControlsPositionSubscription = player!.streams.position.listen((position) { + _mediaControlsManager?.updatePlaybackState( + isPlaying: player!.state.isActive, + position: position, + speed: player!.state.rate, + ); + DiscordRPCService.instance.updatePosition(position); + TraktScrobbleService.instance.updatePosition(position); + TrackerCoordinator.instance.updatePosition(position); + // Keep Trakt's known duration current — mpv only emits on the duration + // stream once per load, but this is cheap and avoids an extra listener. + TraktScrobbleService.instance.updateDuration(player!.state.duration); + TrackerCoordinator.instance.updateDuration(player!.state.duration); + }); + + // Listen to playback rate changes for Discord Rich Presence + _mediaControlsRateSubscription = player!.streams.rate.listen((rate) { + DiscordRPCService.instance.updatePlaybackSpeed(rate); + }); + + _mediaControlsSeekableSubscription = player!.streams.seekable.listen((_) { + unawaited(_syncMediaControlsAvailability()); + }); + } + + void _onPlayingStateChanged(bool isPlaying) { + _setWakelock(isPlaying); + + if (isPlaying) { + // Force a texture refresh on resume to unstick stale frames + // (Linux/macOS texture registrars can miss frame-available + // notifications after extended pause periods) + player?.updateFrame(); + } + + // Send timeline update when playback state changes + _progressTracker?.sendProgress(isPlaying ? 'playing' : 'paused'); + + // Update OS media controls playback state + _updateMediaControlsPlaybackState(); + + // Update Discord Rich Presence + Trakt scrobble + if (isPlaying) { + DiscordRPCService.instance.resumePlayback(); + TraktScrobbleService.instance.resumePlayback(); + } else { + DiscordRPCService.instance.pausePlayback(); + TraktScrobbleService.instance.pausePlayback(); + } + + // Update auto-PiP readiness + if (_autoPipEnabled) { + _videoPIPManager?.updateAutoPipState(isPlaying: isPlaying); + } + } + + /// Force mpv to reconnect its HTTP stream by seeking to the current position. + /// This bypasses ffmpeg's exponential reconnect backoff when the app detects + /// that network connectivity has been restored. + void _forceStreamReconnect() { + final p = player; + if (p == null || !_isPlayerInitialized) return; + final pos = p.state.position; + appLogger.i('Network restored while buffering, forcing stream reconnect at ${pos.inSeconds}s'); + // Clear any stale completion latch caused by a spurious EOF during the drop, + // so the real end-of-file can trigger Play Next after we recover. + if (_completionTriggered && !_showPlayNextDialog && _autoPlayTimer?.isActive != true) { + _completionTriggered = false; + } + p.seek(pos); + } +} diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart new file mode 100644 index 00000000..e5ddb0f1 --- /dev/null +++ b/lib/screens/video_player/parts/playback_start.dart @@ -0,0 +1,424 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { + Future _startPlayback() async { + if (!mounted) return; + + // Live TV mode: bypass standard playback initialization + if (widget.isLive) { + try { + _hasFirstFrame.value = false; + await player!.requestAudioFocus(); + await _setLiveStreamOptions(); + + String streamUrl; + if (_liveStreamUrl != null) { + streamUrl = _liveStreamUrl!; + _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; + _isAtLiveEdge = true; + } else { + // Tune channel inside the player (shows loading spinner while tuning) + final channels = widget.liveChannels; + final channelIndex = _liveChannelIndex; + if (channels == null || channelIndex < 0 || channelIndex >= channels.length) { + throw Exception('No channel to tune'); + } + final channel = channels[channelIndex]; + appLogger.d('Tune: dvrKey=$_liveDvrKey channelKey=${channel.key}'); + final client = _liveClient; + if (client is! PlexClient) { + throw StateError( + 'In-player live tuning is Plex-only; got ${client?.runtimeType ?? 'null'}. ' + 'Jellyfin live TV must pass a pre-resolved liveStreamUrl via LiveTvSupport.resolveStreamUrl.', + ); + } + final dvrKey = _liveDvrKey; + if (dvrKey == null) throw Exception('No DVR to tune'); + final tuneResult = await client.tuneChannel(dvrKey, channel.key); + if (tuneResult == null) throw Exception('Failed to tune channel'); + + _liveSessionIdentifier = tuneResult.sessionIdentifier; + _liveSessionPath = tuneResult.sessionPath; + _liveProgramId = tuneResult.metadata.ratingKey; + _liveDurationMs = tuneResult.metadata.duration; + _captureBuffer = tuneResult.captureBuffer; + _programBeginsAt = tuneResult.beginsAt; + _transcodeSessionId = generateSessionIdentifier(); + + // Show "Watch from Start" dialog when an existing capture session has >60s of history. + // On a fresh tune (no active recording), the buffer is empty so this won't trigger. + int? offsetSeconds; + if (_captureBuffer != null && _programBeginsAt != null) { + final nowEpoch = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final offsetProgramStart = _programBeginsAt! - _captureBuffer!.startedAt.round(); + // If a session recording started after current program start, offset of program start at will be negative. + // If a session recording started before current program start, offset of program start will be positive. + // If guide data is not available, program start will be equal to current time. + final useProgramStart = offsetProgramStart > 0 && nowEpoch - _programBeginsAt! > 60; + final effectiveStart = useProgramStart ? _programBeginsAt! : _captureBuffer!.seekableStartEpoch; + final elapsed = nowEpoch - effectiveStart; + appLogger.d( + 'Time-shift: buffer=${_captureBuffer!.seekableDurationSeconds}s, ' + 'beginsAt=$_programBeginsAt, elapsed=${elapsed}s (need >60 for dialog)', + ); + if (elapsed > 60) { + final watchFromStart = await _showWatchFromStartDialog(effectiveStart, nowEpoch); + if (!mounted) return; + if (watchFromStart == true) { + offsetSeconds = useProgramStart ? offsetProgramStart : _captureBuffer!.seekStartSeconds.round(); + } + } + } + + // Build the stream URL (with optional offset for time-shift) + final streamPath = await client.buildLiveStreamPath( + sessionPath: tuneResult.sessionPath, + sessionIdentifier: tuneResult.sessionIdentifier, + transcodeSessionId: _transcodeSessionId!, + offsetSeconds: offsetSeconds, + ); + if (streamPath == null || !mounted) throw Exception('Failed to build stream path'); + + streamUrl = client.buildLiveStreamUrl(streamPath); + _liveStreamUrl = streamUrl; + + // Track stream start epoch for position calculations + if (offsetSeconds != null) { + _streamStartEpoch = _captureBuffer!.startedAt + offsetSeconds; + _isAtLiveEdge = false; + } else { + _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; + _isAtLiveEdge = true; + } + } + + _livePlaybackStartTime = DateTime.now(); + await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + + _trackManager?.cacheExternalSubtitles(const []); + + await _initVideoFilterAndPip(); + + if (mounted) { + _setPlayerState(() { + _availableVersions = []; + _currentMediaInfo = null; + _isPlayerInitialized = true; + }); + _trackManager?.mediaInfo = null; + } + } catch (e) { + appLogger.e('Failed to start live TV playback', error: e); + unawaited(_sendLiveTimeline('stopped')); + if (mounted) { + showErrorSnackBar(context, e.toString()); + unawaited(_handleBackButton()); + } + } + return; + } + + // Capture providers before async gaps + final offlineWatchService = context.read(); + + try { + PlaybackInitializationResult result; + Map? streamHeaders; + + if (widget.isOffline) { + // Offline mode: route through PlaybackInitializationService with a + // (possibly null) cached client. The service reads cached media + // info via the client when available, falls back to local file + + // sidecar subtitles otherwise. + final cachedSourceClient = _getMediaServerClient(context); + final offlineService = PlaybackInitializationService( + client: cachedSourceClient, + database: context.read(), + ); + result = await offlineService.getPlaybackData( + metadata: _currentMetadata, + selectedMediaIndex: widget.selectedMediaIndex, + preferOffline: true, + ); + if (result.videoUrl == null) { + throw PlaybackException(t.messages.fileInfoNotAvailable); + } + } else { + // Online path: `_playbackDataFuture` was kicked off in `_initializePlayer` + // in parallel with MPV setup. Quality preset + server capabilities + + // headers were resolved there too. Just await the result. + streamHeaders = _streamHeaders; + result = await _playbackDataFuture!; + + _isTranscoding = result.isTranscoding; + _effectiveIsOffline = result.isOffline; + _playbackPlaySessionId = result.playSessionId; + _playbackPlayMethod = result.playMethod; + if (result.activeAudioStreamId != null) { + _selectedAudioStreamId = result.activeAudioStreamId; + } + + if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) { + if (mounted) { + showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback); + } + // Reset the preset so the UI reflects what's actually playing. + _selectedQualityPreset = TranscodeQualityPreset.original; + } + } + + // Primary refresh-rate path: when Plex metadata provides an fps and the + // user has frame-rate matching on, open the player paused so the HDMI + // refresh-rate switch can complete before any frame renders. + final settingsService = await SettingsService.getInstance(); + final preKnownFps = result.mediaInfo?.frameRate; + final willAutoSwitch = + Platform.isAndroid && + settingsService.read(SettingsService.matchContentFrameRate) && + preKnownFps != null && + preKnownFps > 0; + + // Open video through Player + if (result.videoUrl != null) { + // Reset first frame flag and frame rate retry counter for new video + _hasFirstFrame.value = false; + _frameRateRetries = 0; + _frameRateMatchingApplied = false; + + // Request audio focus before starting playback (Android) + // This causes other media apps (Spotify, podcasts, etc.) to pause. + // Fired in parallel with MPV setup in `_initializePlayer`; we await + // the in-flight future here (usually already resolved). + if (_audioFocusFuture != null) { + await _audioFocusFuture; + _audioFocusFuture = null; + } else { + await player!.requestAudioFocus(); + } + + // Pass resume position if available. + // In offline mode, prefer locally tracked progress over the cached server value + // since the user may have watched further since downloading. + Duration? resumePosition; + if (_isOfflinePlayback) { + final globalKey = _currentMetadata.globalKey; + final localOffset = await offlineWatchService.getLocalViewOffset(globalKey); + if (localOffset != null && localOffset > 0) { + resumePosition = Duration(milliseconds: localOffset); + appLogger.d('Resuming offline playback from local progress: ${localOffset}ms'); + } + } + resumePosition ??= _currentMetadata.viewOffsetMs != null + ? Duration(milliseconds: _currentMetadata.viewOffsetMs!) + : null; + + // Enable FFmpeg auto-reconnect for VOD streams (covers network drops + // up to 10 min). Forwarded to the Kotlin layer on Android so MPV + // inherits it on the ExoPlayer→MPV fallback path (see + // _onBackendSwitched), so keep it unconditional. + if (!_isOfflinePlayback && !widget.isLive) { + await player!.setProperty( + 'stream-lavf-o', + 'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600', + ); + } + + final hasExternalSubs = result.externalSubtitles.isNotEmpty; + final isExoPlayer = player is PlayerAndroid; + + // ExoPlayer: attach external subs at open time so it discovers + // them in a single prepare() — no media reload needed for selection. + // MPV (all platforms including Android): external subs added after open via sub-add. + await player!.open( + Media(result.videoUrl!, start: resumePosition, headers: streamHeaders), + play: !willAutoSwitch && (isExoPlayer || !hasExternalSubs), + externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, + ); + + // Apply subtitle styling to ExoPlayer native layer (CaptionStyleCompat + libass font scale) + // Must be called after open() since that's when ExoPlayer initializes + if (player is PlayerAndroid) { + await (player as PlayerAndroid).setSubtitleStyle( + fontSize: settingsService.read(SettingsService.subtitleFontSize).toDouble(), + textColor: settingsService.read(SettingsService.subtitleTextColor), + borderSize: settingsService.read(SettingsService.subtitleBorderSize).toDouble(), + borderColor: settingsService.read(SettingsService.subtitleBorderColor), + bgColor: settingsService.read(SettingsService.subtitleBackgroundColor), + bgOpacity: settingsService.read(SettingsService.subtitleBackgroundOpacity), + subtitlePosition: settingsService.read(SettingsService.subtitlePosition), + bold: settingsService.read(SettingsService.subtitleBold), + italic: settingsService.read(SettingsService.subtitleItalic), + ); + } + + // Attach player to Watch Together session for sync (if in session) + if (mounted && !_isOfflinePlayback) { + _attachToWatchTogetherSession(); + _notifyWatchTogetherMediaChange(); + } + } + + // Update available versions from the playback data + if (mounted) { + _setPlayerState(() { + _availableVersions = result.availableVersions; + _currentMediaInfo = result.mediaInfo; + _scrubPreviewSource?.dispose(); + _scrubPreviewSource = null; + }); + + // Backend-neutral scrub-thumbnail load. The factory dispatches to + // BIF (Plex) or trickplay sprite sheets (Jellyfin) and returns null + // when the inputs aren't sufficient. Guard against media-change + // races during the async load. + final mediaClient = context.tryGetMediaClientForServer(_currentMetadata.serverId); + final mediaInfoAtStart = _currentMediaInfo; + if (mediaInfoAtStart != null && !_isOfflinePlayback && mediaClient != null) { + unawaited( + mediaClient + .createScrubPreviewSource(item: _currentMetadata, mediaSource: mediaInfoAtStart) + .then((service) { + if (service == null) return; + if (mounted && identical(_currentMediaInfo, mediaInfoAtStart)) { + _setPlayerState(() => _scrubPreviewSource = service); + } else { + service.dispose(); + } + }) + .catchError((e, st) { + appLogger.w('Scrub preview load failed', error: e, stackTrace: st); + }), + ); + } + + await _initVideoFilterAndPip(); + + if (player != null) { + // Auto-PiP: set up callback for API 26-30 path and initial state + if (_autoPipEnabled) { + PipService.onAutoPipEntering = () { + _setAndroidAutoPipTransitionInFlight(true, reason: 'native_auto_pip_entering'); + _videoFilterManager?.enterPipMode(); + }; + if (player!.state.playing) { + unawaited(_videoPIPManager!.updateAutoPipState(isPlaying: true)); + } + } + + // Shader Service (MPV only) + _shaderService = ShaderService(player!); + if (_shaderService!.isSupported) { + // Ambient Lighting Service + _ambientLightingService = AmbientLightingService(player!); + _shaderService!.ambientLightingService = _ambientLightingService; + _videoFilterManager?.ambientLightingService = _ambientLightingService; + + await _applySavedShaderPreset(); + await _restoreAmbientLighting(); + } + } + + // Track manager: owns track selection, external subtitle loading, and Plex + // immediate stream writes. Jellyfin persists selected stream indexes through + // playback progress reports instead. + final plexTrackClient = mediaClient is PlexClient ? mediaClient : null; + _trackManager = TrackManager( + player: player!, + isActive: () => mounted && player != null, + persistTrackPreference: plexTrackClient != null ? _plexTrackPersister(() => plexTrackClient) : null, + getProfileSettings: () => context.read().profileSettings, + waitForProfileSettings: _waitForProfileSettingsIfNeeded, + metadata: _currentMetadata, + mediaInfo: _currentMediaInfo, + preferredAudioTrack: widget.preferredAudioTrack, + preferredSubtitleTrack: widget.preferredSubtitleTrack, + preferredSecondarySubtitleTrack: widget.preferredSecondarySubtitleTrack, + showMessage: (message, {duration}) { + if (mounted) showAppSnackBar(context, message, duration: duration); + }, + ); + + // Store external subtitles for re-use after backend fallback + _trackManager!.cacheExternalSubtitles(result.externalSubtitles); + + // MPV with external subs: add after open via sub-add, + // opened paused to avoid race condition (issue #226) + if (player is! PlayerAndroid && result.externalSubtitles.isNotEmpty) { + _hasFirstFrame.value = false; + _trackManager!.waitingForExternalSubsTrackSelection = true; + + try { + await _trackManager!.addExternalSubtitles(result.externalSubtitles); + } finally { + // When willAutoSwitch the pre-playback refresh-rate block below + // owns the resume, so skip this one to avoid a double-play. + if (!willAutoSwitch) { + await _trackManager!.resumeAfterSubtitleLoad(); + } + } + } else { + // Android (subs attached at open time) or no external subs: + // apply once tracks are available + _trackManager!.applyTrackSelectionWhenReady(); + } + + // Initiate the HDMI refresh-rate switch BEFORE any frame renders. + // The player was opened paused; setVideoFrameRate awaits the real + // display-change event (+ settle + user delay) before returning, and + // then we start playback — so the first frame the user sees is after + // the switch has settled. + if (willAutoSwitch && mounted && player != null) { + _frameRateMatchingApplied = true; + final delaySec = settingsService.read(SettingsService.displaySwitchDelay); + final durationMs = _currentMetadata.durationMs ?? player!.state.duration.inMilliseconds; + _suppressMediaPauseDuringFrameRateSwitch = true; + Future.delayed(Duration(seconds: 2 + delaySec + 1), () { + _suppressMediaPauseDuringFrameRateSwitch = false; + }); + bool didSwitch = false; + try { + didSwitch = await player!.setVideoFrameRate(preKnownFps, durationMs, extraDelayMs: delaySec * 1000); + // MPV video-sync tuning (no-op on ExoPlayer). + try { + await player!.setProperty('video-sync', 'display-tempo'); + } catch (e) { + appLogger.d('video-sync property unsupported on this player', error: e); + } + } catch (e) { + appLogger.w('Failed to apply pre-playback frame rate matching', error: e); + } + + // Always resume — either the switch completed and we want to play, + // or no switch was needed and we need to start playback now that the + // preparation gate has been cleared. + if (mounted && player != null) { + if (player is! PlayerAndroid && result.externalSubtitles.isNotEmpty) { + await _trackManager!.resumeAfterSubtitleLoad(); + } else { + await player!.play(); + } + } + + unawaited( + Sentry.addBreadcrumb( + Breadcrumb( + message: 'Pre-playback frame rate: ${preKnownFps}fps, switched=$didSwitch, delay=${delaySec}s', + category: 'player', + ), + ), + ); + } + } + } on PlaybackException catch (e) { + if (mounted) { + _hasFirstFrame.value = true; // Hide spinner on error + showErrorSnackBar(context, e.message); + } + } catch (e) { + if (mounted) { + _hasFirstFrame.value = true; // Hide spinner on error + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + } + } + } +} diff --git a/lib/screens/video_player/parts/watch_together.dart b/lib/screens/video_player/parts/watch_together.dart new file mode 100644 index 00000000..408f17a4 --- /dev/null +++ b/lib/screens/video_player/parts/watch_together.dart @@ -0,0 +1,101 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState { + /// Attach player to Watch Together session for playback sync + void _attachToWatchTogetherSession() { + try { + final watchTogether = context.read(); + _watchTogetherProvider = watchTogether; // Store reference for use in dispose + if (watchTogether.isInSession && player != null) { + watchTogether.attachPlayer(player!); + appLogger.d('WatchTogether: Player attached for sync'); + + // If guest, handle mediaSwitch internally for proper navigation context + if (!watchTogether.isHost) { + watchTogether.onPlayerMediaSwitched = _handlePlayerMediaSwitch; + } + } + } catch (e) { + // Watch together provider not available or not in session - non-critical + appLogger.d('Could not attach player to watch together', error: e); + } + } + + /// Detach player from Watch Together session + void _detachFromWatchTogetherSession() { + try { + final watchTogether = _watchTogetherProvider ?? context.read(); + if (watchTogether.isInSession) { + watchTogether.detachPlayer(); + appLogger.d('WatchTogether: Player detached'); + } + watchTogether.onPlayerMediaSwitched = null; // Always clear player callback + } catch (e) { + // Non-critical + appLogger.d('Could not detach player from watch together', error: e); + } + } + + /// Check if episode navigation controls should be enabled + /// Returns true if not in Watch Together session, or if user is the host + bool _canNavigateEpisodes() { + if (_watchTogetherProvider == null) return true; + if (!_watchTogetherProvider!.isInSession) return true; + return _watchTogetherProvider!.isHost; + } + + /// Notify watch together session of current media change (host only) + /// If [metadata] is provided, uses that instead of _currentMetadata (for episode navigation) + void _notifyWatchTogetherMediaChange({MediaItem? metadata}) { + final targetMetadata = metadata ?? _currentMetadata; + try { + final watchTogether = context.read(); + if (watchTogether.isHost && watchTogether.isInSession) { + watchTogether.setCurrentMedia( + ratingKey: targetMetadata.id, + serverId: targetMetadata.serverId!, + mediaTitle: targetMetadata.displayTitle, + ); + } + } catch (e) { + // Watch together provider not available or not in session - non-critical + appLogger.d('Could not notify watch together of media change', error: e); + } + } + + /// Handle media switch from host (guest only) + /// Uses VideoPlayerScreen's context for proper navigation (pushReplacement) + Future _handlePlayerMediaSwitch(String ratingKey, String serverId, String title) async { + if (!mounted) return; + + appLogger.d('WatchTogether: Guest handling media switch to $title'); + + // Fetch metadata for the new episode. WatchTogether's sync transport is + // backend-neutral (sync_message.dart carries `ratingKey` + `serverId` + // over WebRTC); resolving the item is just a `fetchItem` on whichever + // backend the guest has registered for [serverId]. + final multiServer = context.read(); + final client = multiServer.getClientForServer(serverId); + if (client == null) { + appLogger.w('WatchTogether: Server $serverId not found for media switch'); + if (mounted) showAppSnackBar(context, t.watchTogether.guestSwitchUnavailable); + return; + } + + final metadata = await client.fetchItem(ratingKey); + if (!mounted) return; + if (metadata == null) { + appLogger.w('WatchTogether: Could not fetch metadata for $ratingKey'); + showAppSnackBar(context, t.watchTogether.guestSwitchFailed); + return; + } + + // Detach and dispose current player before switching to avoid sync calls on a disposed instance + _isReplacingWithVideo = true; + await disposePlayerForNavigation(); + if (!mounted) return; + + // Use same navigation as local episode change (pushReplacement from player context) + unawaited(navigateToVideoPlayer(context, metadata: metadata, usePushReplacement: true)); + } +} diff --git a/lib/screens/video_player/widgets/player_prompt_overlays.dart b/lib/screens/video_player/widgets/player_prompt_overlays.dart new file mode 100644 index 00000000..beef5463 --- /dev/null +++ b/lib/screens/video_player/widgets/player_prompt_overlays.dart @@ -0,0 +1,459 @@ +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../../focus/focusable_button.dart'; +import '../../../i18n/strings.g.dart'; +import '../../../media/media_item.dart'; +import '../../../providers/playback_state_provider.dart'; +import '../../../services/pip_service.dart'; +import '../../../utils/platform_detector.dart'; +import '../../../watch_together/providers/watch_together_provider.dart'; +import '../../../watch_together/widgets/watch_together_overlay.dart'; +import '../../../widgets/app_icon.dart'; + +class VideoPlayerMacPipPlaceholder extends StatelessWidget { + const VideoPlayerMacPipPlaceholder({super.key}); + + @override + Widget build(BuildContext context) { + return 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), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class VideoPlayerBufferingOverlay extends StatelessWidget { + final ValueListenable isBuffering; + final ValueListenable hasFirstFrame; + final ValueListenable isExiting; + + const VideoPlayerBufferingOverlay({ + super.key, + required this.isBuffering, + required this.hasFirstFrame, + required this.isExiting, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: PipService().isPipActive, + builder: (context, isInPip, child) { + if (isInPip) return const SizedBox.shrink(); + return ValueListenableBuilder( + valueListenable: isBuffering, + builder: (context, buffering, child) { + return ValueListenableBuilder( + valueListenable: hasFirstFrame, + builder: (context, hasFrame, child) { + if ((!buffering && hasFrame) || isExiting.value) return const SizedBox.shrink(); + return Positioned.fill( + child: IgnorePointer( + child: Center( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle), + child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3), + ), + ), + ), + ); + }, + ); + }, + ); + }, + ); + } +} + +class VideoPlayerWatchTogetherOverlays extends StatelessWidget { + const VideoPlayerWatchTogetherOverlays({super.key}); + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: Stack( + fit: StackFit.expand, + children: [ + Selector( + selector: (_, provider) => provider.isWaitingForHostReconnect, + builder: (context, isWaiting, child) { + if (!isWaiting) return const SizedBox.shrink(); + return Positioned( + bottom: 120, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: const BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (PlatformDetector.isTV()) + const Icon(Symbols.sync_rounded, size: 14, color: Colors.white) + else + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + const SizedBox(width: 8), + Text( + t.watchTogether.reconnectingToHost, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ], + ), + ), + ), + ); + }, + ), + const ParticipantNotificationOverlay(), + const WaitingForParticipantsIndicator(), + const SyncingIndicator(), + ], + ), + ); + } +} + +class VideoPlayerExitOverlay extends StatelessWidget { + final ValueListenable isExiting; + + const VideoPlayerExitOverlay({super.key, required this.isExiting}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: isExiting, + builder: (context, exiting, child) { + if (!exiting) return const SizedBox.shrink(); + return const Positioned.fill(child: ColoredBox(color: Colors.black)); + }, + ); + } +} + +class VideoPlayerPlayNextOverlay extends StatelessWidget { + final bool visible; + final MediaItem? nextEpisode; + final int autoPlayCountdown; + final FocusNode cancelFocusNode; + final FocusNode confirmFocusNode; + final ValueListenable controlsVisible; + final VoidCallback onCancel; + final VoidCallback onPlayNext; + + const VideoPlayerPlayNextOverlay({ + super.key, + required this.visible, + required this.nextEpisode, + required this.autoPlayCountdown, + required this.cancelFocusNode, + required this.confirmFocusNode, + required this.controlsVisible, + required this.onCancel, + required this.onPlayNext, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: PipService().isPipActive, + builder: (context, isInPip, child) { + final episode = nextEpisode; + if (isInPip || !visible || episode == null) { + return const SizedBox.shrink(); + } + return ValueListenableBuilder( + valueListenable: controlsVisible, + builder: (context, controlsShown, child) { + return AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + right: 24, + bottom: controlsShown ? 100 : 24, + child: Container( + width: 320, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.9), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PlayNextEpisodeHeader(episode: episode), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FocusableButton( + focusNode: cancelFocusNode, + onPressed: onCancel, + autoScroll: false, + onNavigateRight: () => confirmFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + child: OutlinedButton( + onPressed: onCancel, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text(t.common.cancel), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FocusableButton( + focusNode: confirmFocusNode, + onPressed: onPlayNext, + autoScroll: false, + onNavigateLeft: () => cancelFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + child: FilledButton( + onPressed: onPlayNext, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (autoPlayCountdown > 0) ...[ + Text('$autoPlayCountdown'), + const SizedBox(width: 4), + const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), + ] else + Text(t.videoControls.playNext), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }, + ); + } +} + +class _PlayNextEpisodeHeader extends StatelessWidget { + final MediaItem episode; + + const _PlayNextEpisodeHeader({required this.episode}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Consumer( + builder: (context, playbackState, child) { + final isShuffleActive = playbackState.isShuffleActive; + return Row( + children: [ + Text( + 'Next Episode', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + if (isShuffleActive) ...[ + const SizedBox(width: 4), + AppIcon(Symbols.shuffle_rounded, fill: 1, size: 12, color: Colors.white.withValues(alpha: 0.7)), + ], + ], + ); + }, + ), + const SizedBox(height: 4), + if (episode.parentIndex != null && episode.index != null) + Text( + 'S${episode.parentIndex} E${episode.index} · ${episode.title}', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + else + Text( + episode.title!, + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } +} + +class VideoPlayerStillWatchingOverlay extends StatelessWidget { + final bool visible; + final int countdown; + final FocusNode pauseFocusNode; + final FocusNode continueFocusNode; + final ValueListenable controlsVisible; + final VoidCallback onPause; + final VoidCallback onContinue; + + const VideoPlayerStillWatchingOverlay({ + super.key, + required this.visible, + required this.countdown, + required this.pauseFocusNode, + required this.continueFocusNode, + required this.controlsVisible, + required this.onPause, + required this.onContinue, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: PipService().isPipActive, + builder: (context, isInPip, child) { + if (isInPip || !visible) { + return const SizedBox.shrink(); + } + return ValueListenableBuilder( + valueListenable: controlsVisible, + builder: (context, controlsShown, child) { + return AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + right: 24, + bottom: controlsShown ? 100 : 24, + child: Container( + width: 320, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.9), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.videoControls.stillWatching, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + t.videoControls.pausingIn(seconds: '$countdown'), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FocusableButton( + focusNode: pauseFocusNode, + onPressed: onPause, + autoScroll: false, + onNavigateRight: () => continueFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + child: OutlinedButton( + onPressed: onPause, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text(t.videoControls.pauseButton), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FocusableButton( + focusNode: continueFocusNode, + onPressed: onContinue, + autoScroll: false, + onNavigateLeft: () => pauseFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + child: FilledButton( + onPressed: onContinue, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('$countdown'), + const SizedBox(width: 4), + Text(t.videoControls.continueWatching), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index f8214f1e..5c5c3536 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -67,16 +67,30 @@ import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.dart'; +import 'video_player/widgets/player_prompt_overlays.dart'; import '../widgets/overlay_sheet.dart'; import '../widgets/video_controls/video_controls.dart'; import '../widgets/video_controls/widgets/player_toast_indicator.dart'; -import '../focus/focusable_button.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/dpad_navigator.dart'; import '../focus/key_event_utils.dart'; import '../i18n/strings.g.dart'; import '../watch_together/providers/watch_together_provider.dart'; -import '../watch_together/widgets/watch_together_overlay.dart'; + +part 'video_player/parts/companion_remote.dart'; +part 'video_player/parts/display_matching.dart'; +part 'video_player/parts/episode_navigation.dart'; +part 'video_player/parts/episode_queue.dart'; +part 'video_player/parts/errors.dart'; +part 'video_player/parts/lifecycle.dart'; +part 'video_player/parts/live_tv.dart'; +part 'video_player/parts/media_controls.dart'; +part 'video_player/parts/pip_shader.dart'; +part 'video_player/parts/playback_prompts.dart'; +part 'video_player/parts/playback_services.dart'; +part 'video_player/parts/playback_start.dart'; +part 'video_player/parts/build.dart'; +part 'video_player/parts/watch_together.dart'; bool? _wakelockEnabled; @@ -512,150 +526,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - void _enqueueLifecycleTransition(String label, Future Function() transition) { - _lifecycleTransition = _lifecycleTransition - .catchError((Object error, StackTrace stackTrace) { - appLogger.w('Previous lifecycle transition failed', error: error, stackTrace: stackTrace); - }) - .then((_) async { - if (!mounted) return; - try { - await transition(); - } catch (e, stackTrace) { - appLogger.w('Lifecycle transition failed during $label', error: e, stackTrace: stackTrace); - } - }); - } - - void _recordLifecycleState(String state, {String? action}) { - final isTv = PlatformDetector.isTV(); - final pipActive = PipService().isPipActive.value; - final breadcrumbData = { - 'state': state, - 'isTv': isTv, - 'autoPipEnabled': _autoPipEnabled, - 'pipActive': pipActive, - 'pipTransitionInFlight': _androidAutoPipTransitionInFlight, - 'hiddenForBackground': _hiddenForBackground, - 'backend': _playerBackendLabel, - }; - if (action != null) { - breadcrumbData['action'] = action; - } - - Sentry.addBreadcrumb( - Breadcrumb(message: 'Player lifecycle $state', category: 'player.lifecycle', data: breadcrumbData), - ); - - appLogger.d( - 'Player lifecycle: state=$state' - '${action != null ? ' action=$action' : ''}' - ' isTv=$isTv' - ' autoPipEnabled=$_autoPipEnabled' - ' pipActive=$pipActive' - ' pipTransitionInFlight=$_androidAutoPipTransitionInFlight' - ' hiddenForBackground=$_hiddenForBackground' - ' backend=$_playerBackendLabel', - ); - } - - void _setAndroidAutoPipTransitionInFlight(bool value, {required String reason}) { - if (!Platform.isAndroid || _androidAutoPipTransitionInFlight == value) return; - _androidAutoPipTransitionInFlight = value; - _recordLifecycleState('pip_transition', action: '${value ? 'started' : 'cleared'}:$reason'); - } - - void _suspendLiveTimelineForBackground() { - _resumeLiveTimelineOnResume = _liveTimelineTimer != null; - _stopLiveTimelineUpdates(); - } - - void _resumeLiveTimelineAfterBackgroundIfNeeded() { - final shouldResume = _resumeLiveTimelineOnResume; - _resumeLiveTimelineOnResume = false; - if (shouldResume && _liveSessionIdentifier != null) { - _startLiveTimelineUpdates(); - } - } - - Future _handleAppHidden() async { - if (_shouldSkipForPip) { - _recordLifecycleState('hidden', action: 'skipped_for_pip'); - return; - } - - // Suppress Watch Together heartbeats while backgrounded so App Nap - // doesn't cause stale position broadcasts that make guests loop. - _watchTogetherProvider?.setBackgrounded(true); - - final currentPlayer = player; - if (currentPlayer == null || !_isPlayerInitialized) { - _recordLifecycleState('hidden', action: 'skipped_no_player'); - return; - } - - final isTv = PlatformDetector.isTV(); - final shouldPauseForBackground = PlatformDetector.isHandheld(context) || isTv; - - // Pause first so Android MPV does not keep decoding against a transient - // background surface while the app is locking or hiding. - if (shouldPauseForBackground) { - _wasPlayingBeforeInactive = currentPlayer.state.isActive; - if (_wasPlayingBeforeInactive) { - try { - await currentPlayer.pause(); - appLogger.d('Video paused due to app being hidden (${isTv ? 'tv' : 'handheld'})'); - } catch (e) { - appLogger.w('Failed to pause video before background transition', error: e); - } - } - } - - if (!mounted || currentPlayer != player) return; - - _suspendLiveTimelineForBackground(); - - if (isTv) { - _recordLifecycleState('hidden', action: 'tv_background_pause_only'); - return; - } - - _hiddenForBackground = true; - await currentPlayer.setVisible(false); - _recordLifecycleState('hidden', action: 'render_hidden'); - } - - Future _handleAppResumed() async { - _recordLifecycleState('resumed', action: 'begin'); - _watchTogetherProvider?.setBackgrounded(false); - - if (Platform.isAndroid && _androidAutoPipTransitionInFlight && !PipService().isPipActive.value) { - _setAndroidAutoPipTransitionInFlight(false, reason: 'resume_without_pip'); - } - - final currentPlayer = player; - - // Restore render layer if it was hidden for background, then force a - // video-output refresh before any auto-resume logic runs. - if (_hiddenForBackground && currentPlayer != null && _isPlayerInitialized) { - await currentPlayer.setVisible(true); - await currentPlayer.updateFrame(); - - if (!mounted || currentPlayer != player) return; - - _hiddenForBackground = false; - _recordLifecycleState('resumed', action: 'render_restored'); - } - - // Restore media controls and wakelock when app is resumed. - if (_isPlayerInitialized && mounted) { - await _restoreMediaControlsAfterResume(); - } - - _resumeLiveTimelineAfterBackgroundIfNeeded(); - _recordLifecycleState('resumed', action: 'complete'); - } - Future _initializePlayer() async { try { if (mounted) { @@ -1027,15 +897,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - String _safePlaybackErrorMessage(Object error) { - final raw = error.toString(); - final redacted = LogRedactionManager.redact(raw); - if (raw.contains('No client registered')) { - return t.messages.errorLoading(error: 'Server is unavailable for the active profile'); - } - return t.messages.errorLoading(error: redacted); - } - /// Windows display mode matching service. DisplayModeService? _displayModeService; @@ -1047,1258 +908,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin // session — either via the pre-playback primary path (Plex metadata fps) or // via the post-`playbackRestart` fallback. Prevents double-switching. bool _frameRateMatchingApplied = false; - Future _applyFrameRateMatching() async { - if (player == null || !Platform.isAndroid) return; - if (_frameRateMatchingApplied) return; - - try { - final fpsStr = await player!.getProperty('container-fps'); - final fps = double.tryParse(fpsStr ?? ''); - if (fps == null || fps <= 0) { - // ExoPlayer detects FPS from frame timestamps after ~8 rendered frames. - // STATE_READY fires before frames render, so retry until detection completes. - if (player is PlayerAndroid && _frameRateRetries < 10) { - _frameRateRetries++; - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted && player != null) _applyFrameRateMatching(); - }); - return; - } - appLogger.d('Frame rate matching: No valid fps available ($fpsStr)'); - return; - } - - _frameRateRetries = 0; - _frameRateMatchingApplied = true; - final durationMs = player!.state.duration.inMilliseconds; - final settingsService = await SettingsService.getInstance(); - final delaySec = settingsService.read(SettingsService.displaySwitchDelay); - - // Suppress spurious PauseEvent from MediaSession during HDMI renegotiation. - // Fire Stick (and similar Android TV devices) send onPause() through the - // MediaSession callback when the display mode changes for frame rate matching. - _suppressMediaPauseDuringFrameRateSwitch = true; - Future.delayed(Duration(seconds: 2 + delaySec + 1), () { - _suppressMediaPauseDuringFrameRateSwitch = false; - }); - - // Pause so the playback clock doesn't advance while the TV renegotiates - // HDMI. The native setVideoFrameRate call below awaits the real display - // change event (+ settle + user delay) before returning, and then we - // resume — same shape as the primary pre-playback path, just later. - try { - await player!.pause(); - } catch (e) { - appLogger.w('Failed to pause before frame rate switch', error: e); - } - - final didSwitch = await player!.setVideoFrameRate(fps, durationMs, extraDelayMs: delaySec * 1000); - - // Set MPV video-sync mode for smoother playback when display is synced - try { - await player!.setProperty('video-sync', 'display-tempo'); - } catch (e) { - appLogger.d('video-sync property unsupported', error: e); - } - - if (mounted && player != null) { - await player!.play(); - } - - unawaited( - Sentry.addBreadcrumb( - Breadcrumb( - message: 'Frame rate matching: ${fps}fps, switched=$didSwitch, delay=${delaySec}s', - category: 'player', - ), - ), - ); - appLogger.d('Frame rate matching: Set display to ${fps}fps (duration: ${durationMs}ms, switched=$didSwitch)'); - } catch (e) { - appLogger.w('Failed to apply frame rate matching', error: e); - } - } - - /// Clear frame rate matching and restore default display mode - Future _clearFrameRateMatching() async { - if (player == null || !Platform.isAndroid) return; - - try { - await player!.clearVideoFrameRate(); - await player!.setProperty('video-sync', 'audio'); - unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Frame rate matching cleared', category: 'player'))); - appLogger.d('Frame rate matching: Cleared, restored default display mode'); - } catch (e) { - appLogger.d('Failed to clear frame rate matching', error: e); - } - } - - /// Apply Windows display mode matching (refresh rate, HDR). - Future _applyWindowsDisplayMatching() async { - if (player == null || _displayModeService == null) return; - - try { - final fpsStr = await player!.getProperty('container-fps'); - final fps = double.tryParse(fpsStr ?? ''); - - final sigPeakStr = await player!.getProperty('video-params/sig-peak'); - final sigPeak = double.tryParse(sigPeakStr ?? ''); - - final delay = await _displayModeService!.applyDisplayMatching(fps: fps, sigPeak: sigPeak); - - if (delay > Duration.zero) { - await Future.delayed(delay); - } - } catch (e) { - appLogger.w('Failed to apply display mode matching', error: e); - } - } - - /// Called when fullscreen state changes — apply or restore Windows display - /// matching. On Windows the player opens windowed by default, so the initial - /// attempt during `playbackRestart` is skipped by DisplayModeService's - /// fullscreen gate. Catching the enter-fullscreen transition here lets the - /// switch happen at the natural moment the user starts watching. - void _onFullscreenChanged() { - if (_displayModeService == null) return; - if (FullscreenStateManager().isFullscreen) { - if (_hasFirstFrame.value && !_displayModeService!.anyChangeApplied) { - _applyWindowsDisplayMatching(); - } - } else if (_displayModeService!.anyChangeApplied) { - _restoreWindowsDisplayMode(); - } - } - - /// Restore Windows display mode to original state. - Future _restoreWindowsDisplayMode() async { - if (_displayModeService == null || !_displayModeService!.anyChangeApplied) return; - - try { - // If HDR was toggled, release mpv's HDR swapchain first. - if (_displayModeService!.hdrStateChanged && player != null) { - await player!.setProperty('target-colorspace-hint', 'no'); - await Future.delayed(const Duration(milliseconds: 200)); - } - - await _displayModeService!.restoreAll(); - } catch (e) { - appLogger.w('Failed to restore display mode', error: e); - } - } - - /// Wire the per-item playback services that need to (re)bind whenever - /// the active media item changes: [PlaybackProgressTracker], - /// [MediaControlsManager.updateMetadata], and the - /// Discord/Trakt/Tracker scrobblers. Both [_initializeServices] and - /// [_swapEpisodeInPip] call this so the two flows can't drift. - /// - /// The caller is responsible for ensuring `player != null` and (if the - /// media-controls metadata refresh should run) for having created - /// [_mediaControlsManager] before the first call. - void _wirePerItemPlaybackServices({ - required MediaItem metadata, - required MediaServerClient? mediaClient, - required OfflineWatchSyncService? offlineWatchService, - String? playSessionId, - String? playMethod, - MediaSourceInfo? mediaInfo, - }) { - if (player == null) return; - - // Progress tracker — offline mode queues for later sync; online mode - // dispatches to the right backend through the neutral client. - if (_isOfflinePlayback) { - _progressTracker = PlaybackProgressTracker( - client: null, - metadata: metadata, - player: player!, - isOffline: true, - offlineWatchService: offlineWatchService, - ); - _progressTracker!.startTracking(); - } else if (mediaClient != null) { - _progressTracker = PlaybackProgressTracker( - client: mediaClient, - metadata: metadata, - player: player!, - playMethod: playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay'), - playSessionId: playSessionId, - mediaInfo: mediaInfo, - ); - _progressTracker!.startTracking(); - } - - // Media controls metadata. Fire-and-forget — the OS plugin downloads - // the poster synchronously inside `setMetadata` (~270 ms); the - // controls populate a beat after first frame which is fine. - if (_mediaControlsManager != null) { - unawaited( - _mediaControlsManager!.updateMetadata( - metadata: metadata, - client: mediaClient, - duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null, - ), - ); - } - - // Scrobblers — Discord RPC, Trakt, unified tracker. All accept the - // neutral [MediaServerClient]; null short-circuits cleanly. - if (mediaClient != null) { - unawaited(DiscordRPCService.instance.startPlayback(metadata, mediaClient)); - unawaited(TraktScrobbleService.instance.startPlayback(metadata, mediaClient, isLive: widget.isLive)); - unawaited(TrackerCoordinator.instance.startPlayback(metadata, mediaClient, isLive: widget.isLive)); - } - } - - /// Initialize the service layer - Future _initializeServices() async { - if (!mounted || player == null) return; - - // Live TV: send timeline heartbeats to keep transcode session alive - if (widget.isLive) { - _startLiveTimelineUpdates(); - return; - } - - // Get client (null in offline mode). Backend-neutral lookup so Jellyfin - // items also wire a [PlaybackProgressTracker]; the tracker dispatches - // to the right backend's reporting endpoints internally. - final mediaClient = _isOfflinePlayback ? null : _getMediaServerClient(context); - final offlineWatchService = context.read(); - - // Initialize media controls manager (must exist before the per-item - // helper wires its metadata update). - _mediaControlsManager = MediaControlsManager(); - - // Set up media control event handling - _mediaControlSubscription = _mediaControlsManager!.controlEvents.listen((event) { - final currentPlayer = player; - if (currentPlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return; - - if (event is PlayEvent) { - appLogger.d('Media control: Play event received'); - _seekBackForRewind(currentPlayer!); - currentPlayer.play(); - _wasPlayingBeforeInactive = false; - _updateMediaControlsPlaybackState(); - } else if (event is PauseEvent) { - if (_suppressMediaPauseDuringFrameRateSwitch) { - appLogger.d('Media control: Pause event suppressed (frame rate switch in progress)'); - return; - } - appLogger.d('Media control: Pause event received'); - currentPlayer!.pause(); - _updateMediaControlsPlaybackState(); - } else if (event is TogglePlayPauseEvent) { - appLogger.d('Media control: Toggle play/pause event received'); - if (currentPlayer!.state.isActive) { - currentPlayer.pause(); - } else { - _seekBackForRewind(currentPlayer); - currentPlayer.play(); - _wasPlayingBeforeInactive = false; - } - _updateMediaControlsPlaybackState(); - } else if (event is SeekEvent) { - appLogger.d('Media control: Seek event received to ${event.position}'); - unawaited(currentPlayer!.seek(clampSeekPosition(currentPlayer, event.position))); - } else if (event is NextTrackEvent) { - appLogger.d('Media control: Next track event received'); - if (_nextEpisode != null) _playNext(); - } else if (event is PreviousTrackEvent) { - appLogger.d('Media control: Previous track event received'); - if (_previousEpisode != null) _playPrevious(); - } - }); - - // Wire progress tracker, media-controls metadata, and the - // Discord/Trakt/Tracker scrobblers. Shared with [_swapEpisodeInPip] - // so the two flows can't drift. - _wirePerItemPlaybackServices( - metadata: _currentMetadata, - mediaClient: mediaClient, - offlineWatchService: offlineWatchService, - playSessionId: _playbackPlaySessionId, - playMethod: _playbackPlayMethod, - mediaInfo: _currentMediaInfo, - ); - - if (!mounted) return; - - await _syncMediaControlsAvailability(); - - // Listen to playing state and update media controls - _mediaControlsPlayingSubscription = player!.streams.playing.listen((isPlaying) { - _updateMediaControlsPlaybackState(); - }); - - // Listen to position updates for media controls and Discord - _mediaControlsPositionSubscription = player!.streams.position.listen((position) { - _mediaControlsManager?.updatePlaybackState( - isPlaying: player!.state.isActive, - position: position, - speed: player!.state.rate, - ); - DiscordRPCService.instance.updatePosition(position); - TraktScrobbleService.instance.updatePosition(position); - TrackerCoordinator.instance.updatePosition(position); - // Keep Trakt's known duration current — mpv only emits on the duration - // stream once per load, but this is cheap and avoids an extra listener. - TraktScrobbleService.instance.updateDuration(player!.state.duration); - TrackerCoordinator.instance.updateDuration(player!.state.duration); - }); - - // Listen to playback rate changes for Discord Rich Presence - _mediaControlsRateSubscription = player!.streams.rate.listen((rate) { - DiscordRPCService.instance.updatePlaybackSpeed(rate); - }); - - _mediaControlsSeekableSubscription = player!.streams.seekable.listen((_) { - unawaited(_syncMediaControlsAvailability()); - }); - } - - /// Ensure a play queue exists for sequential episode playback - Future _ensurePlayQueue() async { - if (!mounted) return; - - // Skip play queue in offline mode (requires server connection) - if (_isOfflinePlayback) return; - - // Skip play queue for live TV (would interfere with tuner session) - if (widget.isLive) return; - - // Only create play queues for episodes - if (!_currentMetadata.isEpisode) { - return; - } - - // Plex-only — Jellyfin's local queue is published by - // EpisodeNavigationService._ensureLocalEpisodeQueue from - // _loadAdjacentEpisodes, so this method is a no-op for it. - if (_currentMetadata.backend != MediaBackend.plex) return; - - try { - final client = context.getPlexClientForServer(_currentMetadata.serverId!); - - final playbackState = context.read(); - - // Determine the show's rating key - // For episodes, grandparentId points to the show - final showRatingKey = _currentMetadata.grandparentId; - if (showRatingKey == null) { - appLogger.d('Episode missing grandparentId, skipping play queue creation'); - return; - } - - // Check if there's already an active queue for THIS show. - // A leftover queue from a different show or — more importantly — - // from a different backend (Jellyfin's local queue is published - // here too) would otherwise mask the new show's navigation. - final existingContextKey = playbackState.shuffleContextKey; - final isQueueActive = playbackState.isQueueActive; - - if (isQueueActive && existingContextKey == showRatingKey) { - playbackState.setCurrentItem(_currentMetadata); - appLogger.d('Using existing play queue (context: $existingContextKey)'); - return; - } - if (isQueueActive) { - appLogger.d('Resetting stale play queue (was: $existingContextKey, now: $showRatingKey)'); - playbackState.clearShuffle(); - } - - // Create a new sequential play queue for the show - appLogger.d('Creating sequential play queue for show $showRatingKey'); - final playQueue = await client.createShowPlayQueue( - showRatingKey: showRatingKey, - shuffle: 0, // Sequential order - startingEpisodeKey: _currentMetadata.id, - ); - - if (playQueue != null && playQueue.items != null && playQueue.items!.isNotEmpty) { - // Initialize playback state with the play queue - await playbackState.setPlaybackFromPlayQueue(playQueue, showRatingKey); - - // Set the client for loading more items - playbackState.setPlayQueueWindowFetcher(client.getPlayQueue); - - appLogger.d('Sequential play queue created with ${playQueue.items!.length} items'); - } - } catch (e) { - // Non-critical: Sequential playback will fall back to non-queue navigation - appLogger.d('Could not create play queue for sequential playback', error: e); - } - } - - Future _loadAdjacentEpisodes() async { - if (!mounted || widget.isLive) return; - - if (_isOfflinePlayback) { - // Offline mode: find next/previous from downloaded episodes - _loadAdjacentEpisodesOffline(); - return; - } - - try { - // Load adjacent episodes using the service - final adjacentEpisodes = await _episodeNavigation.loadAdjacentEpisodes( - context: context, - metadata: _currentMetadata, - ); - - if (mounted) { - setState(() { - _nextEpisode = adjacentEpisodes.next; - _previousEpisode = adjacentEpisodes.previous; - }); - } - } catch (e) { - // Non-critical: Failed to load next/previous episode metadata - appLogger.d('Could not load adjacent episodes', error: e); - } - } - - /// Load next/previous episodes from locally downloaded content - void _loadAdjacentEpisodesOffline() { - if (!_currentMetadata.isEpisode) return; - - final showKey = _currentMetadata.grandparentId; - if (showKey == null) return; - - try { - final downloadProvider = context.read(); - final episodes = downloadProvider.getDownloadedEpisodesForShow(showKey); - - if (episodes.isEmpty) return; - - // Sort by aired date, falling back to season/episode number - final sorted = List.from(episodes) - ..sort((a, b) { - final aDate = a.originallyAvailableAt ?? ''; - final bDate = b.originallyAvailableAt ?? ''; - if (aDate.isEmpty && bDate.isEmpty) { - final seasonCmp = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0); - if (seasonCmp != 0) return seasonCmp; - return (a.index ?? 0).compareTo(b.index ?? 0); - } - if (aDate.isEmpty) return 1; - if (bDate.isEmpty) return -1; - return aDate.compareTo(bDate); - }); - - // Find current episode in the sorted list - final currentIdx = sorted.indexWhere((ep) => ep.id == _currentMetadata.id); - - if (currentIdx == -1) return; - - if (mounted) { - setState(() { - _previousEpisode = currentIdx > 0 ? sorted[currentIdx - 1] : null; - _nextEpisode = currentIdx < sorted.length - 1 ? sorted[currentIdx + 1] : null; - }); - } - } catch (e) { - appLogger.d('Could not load offline adjacent episodes', error: e); - } - } - - Future _startPlayback() async { - if (!mounted) return; - - // Live TV mode: bypass standard playback initialization - if (widget.isLive) { - try { - _hasFirstFrame.value = false; - await player!.requestAudioFocus(); - await _setLiveStreamOptions(); - - String streamUrl; - if (_liveStreamUrl != null) { - streamUrl = _liveStreamUrl!; - _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; - _isAtLiveEdge = true; - } else { - // Tune channel inside the player (shows loading spinner while tuning) - final channels = widget.liveChannels; - final channelIndex = _liveChannelIndex; - if (channels == null || channelIndex < 0 || channelIndex >= channels.length) { - throw Exception('No channel to tune'); - } - final channel = channels[channelIndex]; - appLogger.d('Tune: dvrKey=$_liveDvrKey channelKey=${channel.key}'); - final client = _liveClient; - if (client is! PlexClient) { - throw StateError( - 'In-player live tuning is Plex-only; got ${client?.runtimeType ?? 'null'}. ' - 'Jellyfin live TV must pass a pre-resolved liveStreamUrl via LiveTvSupport.resolveStreamUrl.', - ); - } - final dvrKey = _liveDvrKey; - if (dvrKey == null) throw Exception('No DVR to tune'); - final tuneResult = await client.tuneChannel(dvrKey, channel.key); - if (tuneResult == null) throw Exception('Failed to tune channel'); - - _liveSessionIdentifier = tuneResult.sessionIdentifier; - _liveSessionPath = tuneResult.sessionPath; - _liveProgramId = tuneResult.metadata.ratingKey; - _liveDurationMs = tuneResult.metadata.duration; - _captureBuffer = tuneResult.captureBuffer; - _programBeginsAt = tuneResult.beginsAt; - _transcodeSessionId = generateSessionIdentifier(); - - // Show "Watch from Start" dialog when an existing capture session has >60s of history. - // On a fresh tune (no active recording), the buffer is empty so this won't trigger. - int? offsetSeconds; - if (_captureBuffer != null && _programBeginsAt != null) { - final nowEpoch = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final offsetProgramStart = _programBeginsAt! - _captureBuffer!.startedAt.round(); - // If a session recording started after current program start, offset of program start at will be negative. - // If a session recording started before current program start, offset of program start will be positive. - // If guide data is not available, program start will be equal to current time. - final useProgramStart = offsetProgramStart > 0 && nowEpoch - _programBeginsAt! > 60; - final effectiveStart = useProgramStart ? _programBeginsAt! : _captureBuffer!.seekableStartEpoch; - final elapsed = nowEpoch - effectiveStart; - appLogger.d( - 'Time-shift: buffer=${_captureBuffer!.seekableDurationSeconds}s, ' - 'beginsAt=$_programBeginsAt, elapsed=${elapsed}s (need >60 for dialog)', - ); - if (elapsed > 60) { - final watchFromStart = await _showWatchFromStartDialog(effectiveStart, nowEpoch); - if (!mounted) return; - if (watchFromStart == true) { - offsetSeconds = useProgramStart ? offsetProgramStart : _captureBuffer!.seekStartSeconds.round(); - } - } - } - - // Build the stream URL (with optional offset for time-shift) - final streamPath = await client.buildLiveStreamPath( - sessionPath: tuneResult.sessionPath, - sessionIdentifier: tuneResult.sessionIdentifier, - transcodeSessionId: _transcodeSessionId!, - offsetSeconds: offsetSeconds, - ); - if (streamPath == null || !mounted) throw Exception('Failed to build stream path'); - - streamUrl = client.buildLiveStreamUrl(streamPath); - _liveStreamUrl = streamUrl; - - // Track stream start epoch for position calculations - if (offsetSeconds != null) { - _streamStartEpoch = _captureBuffer!.startedAt + offsetSeconds; - _isAtLiveEdge = false; - } else { - _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; - _isAtLiveEdge = true; - } - } - - _livePlaybackStartTime = DateTime.now(); - await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); - - _trackManager?.cacheExternalSubtitles(const []); - - await _initVideoFilterAndPip(); - - if (mounted) { - setState(() { - _availableVersions = []; - _currentMediaInfo = null; - _isPlayerInitialized = true; - }); - _trackManager?.mediaInfo = null; - } - } catch (e) { - appLogger.e('Failed to start live TV playback', error: e); - unawaited(_sendLiveTimeline('stopped')); - if (mounted) { - showErrorSnackBar(context, e.toString()); - unawaited(_handleBackButton()); - } - } - return; - } - - // Capture providers before async gaps - final offlineWatchService = context.read(); - - try { - PlaybackInitializationResult result; - Map? streamHeaders; - - if (widget.isOffline) { - // Offline mode: route through PlaybackInitializationService with a - // (possibly null) cached client. The service reads cached media - // info via the client when available, falls back to local file + - // sidecar subtitles otherwise. - final cachedSourceClient = _getMediaServerClient(context); - final offlineService = PlaybackInitializationService( - client: cachedSourceClient, - database: context.read(), - ); - result = await offlineService.getPlaybackData( - metadata: _currentMetadata, - selectedMediaIndex: widget.selectedMediaIndex, - preferOffline: true, - ); - if (result.videoUrl == null) { - throw PlaybackException(t.messages.fileInfoNotAvailable); - } - } else { - // Online path: `_playbackDataFuture` was kicked off in `_initializePlayer` - // in parallel with MPV setup. Quality preset + server capabilities + - // headers were resolved there too. Just await the result. - streamHeaders = _streamHeaders; - result = await _playbackDataFuture!; - - _isTranscoding = result.isTranscoding; - _effectiveIsOffline = result.isOffline; - _playbackPlaySessionId = result.playSessionId; - _playbackPlayMethod = result.playMethod; - if (result.activeAudioStreamId != null) { - _selectedAudioStreamId = result.activeAudioStreamId; - } - - if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) { - if (mounted) { - showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback); - } - // Reset the preset so the UI reflects what's actually playing. - _selectedQualityPreset = TranscodeQualityPreset.original; - } - } - - // Primary refresh-rate path: when Plex metadata provides an fps and the - // user has frame-rate matching on, open the player paused so the HDMI - // refresh-rate switch can complete before any frame renders. - final settingsService = await SettingsService.getInstance(); - final preKnownFps = result.mediaInfo?.frameRate; - final willAutoSwitch = - Platform.isAndroid && - settingsService.read(SettingsService.matchContentFrameRate) && - preKnownFps != null && - preKnownFps > 0; - - // Open video through Player - if (result.videoUrl != null) { - // Reset first frame flag and frame rate retry counter for new video - _hasFirstFrame.value = false; - _frameRateRetries = 0; - _frameRateMatchingApplied = false; - - // Request audio focus before starting playback (Android) - // This causes other media apps (Spotify, podcasts, etc.) to pause. - // Fired in parallel with MPV setup in `_initializePlayer`; we await - // the in-flight future here (usually already resolved). - if (_audioFocusFuture != null) { - await _audioFocusFuture; - _audioFocusFuture = null; - } else { - await player!.requestAudioFocus(); - } - - // Pass resume position if available. - // In offline mode, prefer locally tracked progress over the cached server value - // since the user may have watched further since downloading. - Duration? resumePosition; - if (_isOfflinePlayback) { - final globalKey = _currentMetadata.globalKey; - final localOffset = await offlineWatchService.getLocalViewOffset(globalKey); - if (localOffset != null && localOffset > 0) { - resumePosition = Duration(milliseconds: localOffset); - appLogger.d('Resuming offline playback from local progress: ${localOffset}ms'); - } - } - resumePosition ??= _currentMetadata.viewOffsetMs != null - ? Duration(milliseconds: _currentMetadata.viewOffsetMs!) - : null; - - // Enable FFmpeg auto-reconnect for VOD streams (covers network drops - // up to 10 min). Forwarded to the Kotlin layer on Android so MPV - // inherits it on the ExoPlayer→MPV fallback path (see - // _onBackendSwitched), so keep it unconditional. - if (!_isOfflinePlayback && !widget.isLive) { - await player!.setProperty( - 'stream-lavf-o', - 'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600', - ); - } - - final hasExternalSubs = result.externalSubtitles.isNotEmpty; - final isExoPlayer = player is PlayerAndroid; - - // ExoPlayer: attach external subs at open time so it discovers - // them in a single prepare() — no media reload needed for selection. - // MPV (all platforms including Android): external subs added after open via sub-add. - await player!.open( - Media(result.videoUrl!, start: resumePosition, headers: streamHeaders), - play: !willAutoSwitch && (isExoPlayer || !hasExternalSubs), - externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, - ); - - // Apply subtitle styling to ExoPlayer native layer (CaptionStyleCompat + libass font scale) - // Must be called after open() since that's when ExoPlayer initializes - if (player is PlayerAndroid) { - await (player as PlayerAndroid).setSubtitleStyle( - fontSize: settingsService.read(SettingsService.subtitleFontSize).toDouble(), - textColor: settingsService.read(SettingsService.subtitleTextColor), - borderSize: settingsService.read(SettingsService.subtitleBorderSize).toDouble(), - borderColor: settingsService.read(SettingsService.subtitleBorderColor), - bgColor: settingsService.read(SettingsService.subtitleBackgroundColor), - bgOpacity: settingsService.read(SettingsService.subtitleBackgroundOpacity), - subtitlePosition: settingsService.read(SettingsService.subtitlePosition), - bold: settingsService.read(SettingsService.subtitleBold), - italic: settingsService.read(SettingsService.subtitleItalic), - ); - } - - // Attach player to Watch Together session for sync (if in session) - if (mounted && !_isOfflinePlayback) { - _attachToWatchTogetherSession(); - _notifyWatchTogetherMediaChange(); - } - } - - // Update available versions from the playback data - if (mounted) { - setState(() { - _availableVersions = result.availableVersions; - _currentMediaInfo = result.mediaInfo; - _scrubPreviewSource?.dispose(); - _scrubPreviewSource = null; - }); - - // Backend-neutral scrub-thumbnail load. The factory dispatches to - // BIF (Plex) or trickplay sprite sheets (Jellyfin) and returns null - // when the inputs aren't sufficient. Guard against media-change - // races during the async load. - final mediaClient = context.tryGetMediaClientForServer(_currentMetadata.serverId); - final mediaInfoAtStart = _currentMediaInfo; - if (mediaInfoAtStart != null && !_isOfflinePlayback && mediaClient != null) { - unawaited( - mediaClient - .createScrubPreviewSource(item: _currentMetadata, mediaSource: mediaInfoAtStart) - .then((service) { - if (service == null) return; - if (mounted && identical(_currentMediaInfo, mediaInfoAtStart)) { - setState(() => _scrubPreviewSource = service); - } else { - service.dispose(); - } - }) - .catchError((e, st) { - appLogger.w('Scrub preview load failed', error: e, stackTrace: st); - }), - ); - } - - await _initVideoFilterAndPip(); - - if (player != null) { - // Auto-PiP: set up callback for API 26-30 path and initial state - if (_autoPipEnabled) { - PipService.onAutoPipEntering = () { - _setAndroidAutoPipTransitionInFlight(true, reason: 'native_auto_pip_entering'); - _videoFilterManager?.enterPipMode(); - }; - if (player!.state.playing) { - unawaited(_videoPIPManager!.updateAutoPipState(isPlaying: true)); - } - } - - // Shader Service (MPV only) - _shaderService = ShaderService(player!); - if (_shaderService!.isSupported) { - // Ambient Lighting Service - _ambientLightingService = AmbientLightingService(player!); - _shaderService!.ambientLightingService = _ambientLightingService; - _videoFilterManager?.ambientLightingService = _ambientLightingService; - - await _applySavedShaderPreset(); - await _restoreAmbientLighting(); - } - } - - // Track manager: owns track selection, external subtitle loading, and Plex - // immediate stream writes. Jellyfin persists selected stream indexes through - // playback progress reports instead. - final plexTrackClient = mediaClient is PlexClient ? mediaClient : null; - _trackManager = TrackManager( - player: player!, - isActive: () => mounted && player != null, - persistTrackPreference: plexTrackClient != null ? _plexTrackPersister(() => plexTrackClient) : null, - getProfileSettings: () => context.read().profileSettings, - waitForProfileSettings: _waitForProfileSettingsIfNeeded, - metadata: _currentMetadata, - mediaInfo: _currentMediaInfo, - preferredAudioTrack: widget.preferredAudioTrack, - preferredSubtitleTrack: widget.preferredSubtitleTrack, - preferredSecondarySubtitleTrack: widget.preferredSecondarySubtitleTrack, - showMessage: (message, {duration}) { - if (mounted) showAppSnackBar(context, message, duration: duration); - }, - ); - - // Store external subtitles for re-use after backend fallback - _trackManager!.cacheExternalSubtitles(result.externalSubtitles); - - // MPV with external subs: add after open via sub-add, - // opened paused to avoid race condition (issue #226) - if (player is! PlayerAndroid && result.externalSubtitles.isNotEmpty) { - _hasFirstFrame.value = false; - _trackManager!.waitingForExternalSubsTrackSelection = true; - - try { - await _trackManager!.addExternalSubtitles(result.externalSubtitles); - } finally { - // When willAutoSwitch the pre-playback refresh-rate block below - // owns the resume, so skip this one to avoid a double-play. - if (!willAutoSwitch) { - await _trackManager!.resumeAfterSubtitleLoad(); - } - } - } else { - // Android (subs attached at open time) or no external subs: - // apply once tracks are available - _trackManager!.applyTrackSelectionWhenReady(); - } - - // Initiate the HDMI refresh-rate switch BEFORE any frame renders. - // The player was opened paused; setVideoFrameRate awaits the real - // display-change event (+ settle + user delay) before returning, and - // then we start playback — so the first frame the user sees is after - // the switch has settled. - if (willAutoSwitch && mounted && player != null) { - _frameRateMatchingApplied = true; - final delaySec = settingsService.read(SettingsService.displaySwitchDelay); - final durationMs = _currentMetadata.durationMs ?? player!.state.duration.inMilliseconds; - _suppressMediaPauseDuringFrameRateSwitch = true; - Future.delayed(Duration(seconds: 2 + delaySec + 1), () { - _suppressMediaPauseDuringFrameRateSwitch = false; - }); - bool didSwitch = false; - try { - didSwitch = await player!.setVideoFrameRate(preKnownFps, durationMs, extraDelayMs: delaySec * 1000); - // MPV video-sync tuning (no-op on ExoPlayer). - try { - await player!.setProperty('video-sync', 'display-tempo'); - } catch (e) { - appLogger.d('video-sync property unsupported on this player', error: e); - } - } catch (e) { - appLogger.w('Failed to apply pre-playback frame rate matching', error: e); - } - - // Always resume — either the switch completed and we want to play, - // or no switch was needed and we need to start playback now that the - // preparation gate has been cleared. - if (mounted && player != null) { - if (player is! PlayerAndroid && result.externalSubtitles.isNotEmpty) { - await _trackManager!.resumeAfterSubtitleLoad(); - } else { - await player!.play(); - } - } - - unawaited( - Sentry.addBreadcrumb( - Breadcrumb( - message: 'Pre-playback frame rate: ${preKnownFps}fps, switched=$didSwitch, delay=${delaySec}s', - category: 'player', - ), - ), - ); - } - } - } on PlaybackException catch (e) { - if (mounted) { - _hasFirstFrame.value = true; // Hide spinner on error - showErrorSnackBar(context, e.message); - } - } catch (e) { - if (mounted) { - _hasFirstFrame.value = true; // Hide spinner on error - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - } - } - } - - /// Initialize VideoFilterManager and VideoPIPManager if not already set up. - /// Called from both live TV and VOD playback paths. - Future _initVideoFilterAndPip() async { - if (player == null || _videoFilterManager != null) return; - final settings = await SettingsService.getInstance(); - _videoFilterManager = VideoFilterManager( - player: player!, - availableVersions: _availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - initialBoxFitMode: settings.read(SettingsService.defaultBoxFitMode), - onBoxFitModeChanged: (mode) => settings.write(SettingsService.defaultBoxFitMode, mode), - ); - _videoFilterManager!.updateVideoFilter(); - - _videoPIPManager = VideoPIPManager(player: player!); - _videoPIPManager!.onBeforeEnterPip = () { - _videoFilterManager?.enterPipMode(); - }; - _videoPIPManager!.isPipActive.addListener(_onPipStateChanged); - } - - Future _togglePIPMode() async { - final result = await _videoPIPManager?.togglePIP(); - if (result != null && !result.$1 && mounted) { - showErrorSnackBar(context, result.$2 ?? t.videoControls.pipFailed); - } - } - - /// Handle PiP state changes to restore video scaling when exiting PiP - void _onPipStateChanged() { - final isInPip = _videoPIPManager?.isPipActive.value ?? PipService().isPipActive.value; - _setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed'); - _recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited'); - - if (_videoPIPManager == null || _videoFilterManager == null) return; - - // Only handle exit - entry is handled by onBeforeEnterPip callback - if (!isInPip) { - final restoreAmbient = _videoFilterManager!.hadAmbientLightingBeforePip; - _videoFilterManager!.exitPipMode(); - // Restore ambient lighting if it was active before PiP - if (restoreAmbient) { - _videoFilterManager!.clearPipAmbientLightingFlag(); - _restoreAmbientLighting(); - } - } - } - - /// Apply the saved shader preset on playback start. - /// Reads directly from SettingsService (synchronous SharedPreferences) to - /// avoid a race with ShaderProvider's async initialization. - Future _applySavedShaderPreset() async { - if (_shaderService == null || !_shaderService!.isSupported) return; - - try { - final shaderProvider = context.read(); - final settings = await SettingsService.getInstance(); - final presetId = settings.read(SettingsService.globalShaderPreset); - final preset = - (shaderProvider.initialized ? shaderProvider.findPresetById(presetId) : ShaderPreset.fromId(presetId)) ?? - ShaderPreset.none; - await _shaderService!.applyPreset(preset); - if (!mounted) return; - shaderProvider.setCurrentPreset(preset); - } catch (e) { - appLogger.d('Could not apply shader preset', error: e); - } - } - - /// Restore ambient lighting from persisted setting - Future _restoreAmbientLighting() async { - final shaderProvider = context.read(); - final settings = await SettingsService.getInstance(); - if (!settings.read(SettingsService.ambientLighting)) return; - - final ambientLighting = _ambientLightingService; - if (ambientLighting == null || !ambientLighting.isSupported) return; - - // Same enable logic as _toggleAmbientLighting - final dwidth = await player?.getProperty('dwidth'); - final dheight = await player?.getProperty('dheight'); - if (dwidth == null || dheight == null) return; - final w = double.tryParse(dwidth); - final h = double.tryParse(dheight); - if (w == null || h == null || h == 0) return; - final videoAspect = w / h; - - final playerSize = _videoFilterManager?.playerSize; - if (playerSize == null || playerSize.height == 0) return; - final outputAspect = playerSize.width / playerSize.height; - - // Clear shaders — ambient lighting and shaders are mutually exclusive - if (shaderProvider.isShaderEnabled) { - await _shaderService!.applyPreset(ShaderPreset.none); - shaderProvider.setCurrentPreset(ShaderPreset.none); - } - - _videoFilterManager?.resetToContain(); - await ambientLighting.enable(videoAspect, outputAspect); - if (mounted) setState(() {}); - } - - /// Cycle through BoxFit modes: contain → cover → fill → contain (for button) - void _cycleBoxFitMode() { - // Disable ambient lighting when switching boxfit modes - // (cover/fill change the video rect, making the baked-in shader incorrect) - _ambientLightingService?.disable(); - setState(() { - _videoFilterManager?.cycleBoxFitMode(); - }); - } - - /// Update video-aspect-override when player size changes. - /// The shader adapts automatically via built-in target_size uniform. - void _updateAmbientLightingOnResize(Size newSize) { - final ambientLighting = _ambientLightingService; - if (ambientLighting == null || !ambientLighting.isEnabled) return; - if (newSize.height == 0) return; - - ambientLighting.updateOutputAspect(newSize.width / newSize.height); - } - - /// Toggle ambient lighting effect on/off - Future _toggleAmbientLighting() async { - final ambientLighting = _ambientLightingService; - if (ambientLighting == null || !ambientLighting.isSupported) return; - final shaderProvider = context.read(); - - if (ambientLighting.isEnabled) { - await ambientLighting.disable(); - _videoFilterManager?.updateVideoFilter(); - } else { - // Get video display aspect ratio - final dwidth = await player?.getProperty('dwidth'); - final dheight = await player?.getProperty('dheight'); - if (dwidth == null || dheight == null) return; - final w = double.tryParse(dwidth); - final h = double.tryParse(dheight); - if (w == null || h == null || h == 0) return; - final videoAspect = w / h; - - // Get player widget aspect ratio - final playerSize = _videoFilterManager?.playerSize; - if (playerSize == null || playerSize.height == 0) return; - final outputAspect = playerSize.width / playerSize.height; - - // Clear shaders — ambient lighting and shaders are mutually exclusive - if (shaderProvider.isShaderEnabled) { - await _shaderService!.applyPreset(ShaderPreset.none); - shaderProvider.setCurrentPreset(ShaderPreset.none); - } - - // Force contain mode when enabling ambient lighting - _videoFilterManager?.resetToContain(); - - await ambientLighting.enable(videoAspect, outputAspect); - } - - // Persist ambient lighting state - final settings = await SettingsService.getInstance(); - unawaited(settings.write(SettingsService.ambientLighting, ambientLighting.isEnabled)); - - if (mounted) setState(() {}); - } - - /// Toggle between contain and cover modes only (for pinch gesture) - void _toggleContainCover() { - setState(() { - _videoFilterManager?.toggleContainCover(); - }); - } - - /// Attach player to Watch Together session for playback sync - void _attachToWatchTogetherSession() { - try { - final watchTogether = context.read(); - _watchTogetherProvider = watchTogether; // Store reference for use in dispose - if (watchTogether.isInSession && player != null) { - watchTogether.attachPlayer(player!); - appLogger.d('WatchTogether: Player attached for sync'); - - // If guest, handle mediaSwitch internally for proper navigation context - if (!watchTogether.isHost) { - watchTogether.onPlayerMediaSwitched = _handlePlayerMediaSwitch; - } - } - } catch (e) { - // Watch together provider not available or not in session - non-critical - appLogger.d('Could not attach player to watch together', error: e); - } - } - - /// Detach player from Watch Together session - void _detachFromWatchTogetherSession() { - try { - final watchTogether = _watchTogetherProvider ?? context.read(); - if (watchTogether.isInSession) { - watchTogether.detachPlayer(); - appLogger.d('WatchTogether: Player detached'); - } - watchTogether.onPlayerMediaSwitched = null; // Always clear player callback - } catch (e) { - // Non-critical - appLogger.d('Could not detach player from watch together', error: e); - } - } - - /// Check if episode navigation controls should be enabled - /// Returns true if not in Watch Together session, or if user is the host - bool _canNavigateEpisodes() { - if (_watchTogetherProvider == null) return true; - if (!_watchTogetherProvider!.isInSession) return true; - return _watchTogetherProvider!.isHost; - } - - /// Notify watch together session of current media change (host only) - /// If [metadata] is provided, uses that instead of _currentMetadata (for episode navigation) - void _notifyWatchTogetherMediaChange({MediaItem? metadata}) { - final targetMetadata = metadata ?? _currentMetadata; - try { - final watchTogether = context.read(); - if (watchTogether.isHost && watchTogether.isInSession) { - watchTogether.setCurrentMedia( - ratingKey: targetMetadata.id, - serverId: targetMetadata.serverId!, - mediaTitle: targetMetadata.displayTitle, - ); - } - } catch (e) { - // Watch together provider not available or not in session - non-critical - appLogger.d('Could not notify watch together of media change', error: e); - } - } - - /// Handle media switch from host (guest only) - /// Uses VideoPlayerScreen's context for proper navigation (pushReplacement) - Future _handlePlayerMediaSwitch(String ratingKey, String serverId, String title) async { - if (!mounted) return; - - appLogger.d('WatchTogether: Guest handling media switch to $title'); - - // Fetch metadata for the new episode. WatchTogether's sync transport is - // backend-neutral (sync_message.dart carries `ratingKey` + `serverId` - // over WebRTC); resolving the item is just a `fetchItem` on whichever - // backend the guest has registered for [serverId]. - final multiServer = context.read(); - final client = multiServer.getClientForServer(serverId); - if (client == null) { - appLogger.w('WatchTogether: Server $serverId not found for media switch'); - if (mounted) showAppSnackBar(context, t.watchTogether.guestSwitchUnavailable); - return; - } - - final metadata = await client.fetchItem(ratingKey); - if (!mounted) return; - if (metadata == null) { - appLogger.w('WatchTogether: Could not fetch metadata for $ratingKey'); - showAppSnackBar(context, t.watchTogether.guestSwitchFailed); - return; - } - - // Detach and dispose current player before switching to avoid sync calls on a disposed instance - _isReplacingWithVideo = true; - await disposePlayerForNavigation(); - if (!mounted) return; - - // Use same navigation as local episode change (pushReplacement from player context) - unawaited(navigateToVideoPlayer(context, metadata: metadata, usePushReplacement: true)); - } - - void _setupCompanionRemoteCallbacks() { - final receiver = CompanionRemoteReceiver.instance; - receiver.onStop = () { - if (mounted) _handleBackButton(); - }; - receiver.onNextTrack = () { - if (mounted && _nextEpisode != null) _playNext(); - }; - receiver.onPreviousTrack = () { - if (mounted && _previousEpisode != null) _playPrevious(); - }; - receiver.onSeekForward = () async { - if (player == null) return; - final settings = await SettingsService.getInstance(); - final seekSeconds = settings.read(SettingsService.seekTimeSmall); - if (widget.isLive && _captureBuffer != null) { - await _seekLivePosition(_currentPositionEpoch + seekSeconds); - return; - } - final target = clampSeekPosition(player!, player!.state.position + Duration(seconds: seekSeconds)); - await player!.seek(target); - }; - receiver.onSeekBackward = () async { - if (player == null) return; - final settings = await SettingsService.getInstance(); - final seekSeconds = settings.read(SettingsService.seekTimeSmall); - if (widget.isLive && _captureBuffer != null) { - await _seekLivePosition(_currentPositionEpoch - seekSeconds); - return; - } - final target = clampSeekPosition(player!, player!.state.position - Duration(seconds: seekSeconds)); - await player!.seek(target); - }; - receiver.onVolumeUp = () async { - if (player == null) return; - final settings = await SettingsService.getInstance(); - final maxVol = settings.read(SettingsService.maxVolume).toDouble(); - final newVolume = (player!.state.volume + 10).clamp(0.0, maxVol); - unawaited(player!.setVolume(newVolume)); - unawaited(settings.write(SettingsService.volume, newVolume)); - }; - receiver.onVolumeDown = () async { - if (player == null) return; - final settings = await SettingsService.getInstance(); - final maxVol = settings.read(SettingsService.maxVolume).toDouble(); - final newVolume = (player!.state.volume - 10).clamp(0.0, maxVol); - unawaited(player!.setVolume(newVolume)); - unawaited(settings.write(SettingsService.volume, newVolume)); - }; - receiver.onVolumeMute = () async { - if (player == null) return; - final settings = await SettingsService.getInstance(); - final newVolume = player!.state.volume > 0 ? 0.0 : 100.0; - unawaited(player!.setVolume(newVolume)); - unawaited(settings.write(SettingsService.volume, newVolume)); - }; - receiver.onSubtitles = _cycleSubtitleTrack; - receiver.onAudioTracks = _cycleAudioTrack; - receiver.onFullscreen = _toggleFullscreen; - - // Override home to exit the player first (main screen handler runs after pop) - _savedOnHome = receiver.onHome; - receiver.onHome = () { - if (mounted) _handleBackButton(); - }; - - // Store provider reference for use in dispose and notify remote - try { - _companionRemoteProvider = context.read(); - _companionRemoteProvider!.sendCommand(RemoteCommandType.syncState, data: {'playerActive': true}); - } catch (e) { - appLogger.d('CompanionRemote provider unavailable', error: e); - } - } - - void _cleanupCompanionRemoteCallbacks() { - final receiver = CompanionRemoteReceiver.instance; - receiver.onStop = null; - receiver.onNextTrack = null; - receiver.onPreviousTrack = null; - receiver.onSeekForward = null; - receiver.onSeekBackward = null; - receiver.onVolumeUp = null; - receiver.onVolumeDown = null; - receiver.onVolumeMute = null; - receiver.onSubtitles = null; - receiver.onAudioTracks = null; - receiver.onFullscreen = null; - receiver.onHome = _savedOnHome; - _savedOnHome = null; - - // Notify remote that player is no longer active - _companionRemoteProvider?.sendCommand(RemoteCommandType.syncState, data: {'playerActive': false}); - _companionRemoteProvider = null; - } - - void _cycleSubtitleTrack() => _trackManager?.cycleSubtitleTrack(); - - void _cycleAudioTrack() => _trackManager?.cycleAudioTrack(); - - Future _toggleFullscreen() async { - if (PlatformDetector.isMobile(context)) return; - await FullscreenStateManager().toggleFullscreen(); - } /// Handle back button press /// For non-host participants in Watch Together, shows leave session confirmation @@ -2497,719 +1106,23 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - void _onPlayingStateChanged(bool isPlaying) { - _setWakelock(isPlaying); - - if (isPlaying) { - // Force a texture refresh on resume to unstick stale frames - // (Linux/macOS texture registrars can miss frame-available - // notifications after extended pause periods) - player?.updateFrame(); - } - - // Send timeline update when playback state changes - _progressTracker?.sendProgress(isPlaying ? 'playing' : 'paused'); - - // Update OS media controls playback state - _updateMediaControlsPlaybackState(); - - // Update Discord Rich Presence + Trakt scrobble - if (isPlaying) { - DiscordRPCService.instance.resumePlayback(); - TraktScrobbleService.instance.resumePlayback(); - } else { - DiscordRPCService.instance.pausePlayback(); - TraktScrobbleService.instance.pausePlayback(); - } - - // Update auto-PiP readiness - if (_autoPipEnabled) { - _videoPIPManager?.updateAutoPipState(isPlaying: isPlaying); - } - } - - void _onVideoCompleted(bool completed, {bool skipAutoPlayCountdown = false}) async { - // Live TV streams are continuous — ignore spurious EOF events caused by - // inter-segment gaps in the chunked MKV transcode stream. - if (widget.isLive) return; - if (!completed) return; - // Ignore spurious EOF from the old file during in-place episode swap - if (_isSwappingEpisode) return; - - // mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged - // never fires false. Normalize all playback-dependent state. - unawaited(_setWakelock(false)); - unawaited(_progressTracker?.sendProgress('paused')); - _updateMediaControlsPlaybackState(); - unawaited(DiscordRPCService.instance.pausePlayback()); - unawaited(TraktScrobbleService.instance.pausePlayback()); - if (_autoPipEnabled) { - unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: false)); - } - - if (_nextEpisode != null && !_showPlayNextDialog && !_showStillWatchingPrompt && !_completionTriggered) { - _completionTriggered = true; - - // PiP: skip dialog (user can't interact), auto-play immediately - if (PipService().isPipActive.value) { - unawaited(_playNext()); - return; - } - - // Capture keyboard mode before async gap - final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context); - - final settings = await SettingsService.getInstance(); - final autoPlayEnabled = settings.read(SettingsService.autoPlayNextEpisode); - - if (skipAutoPlayCountdown && autoPlayEnabled) { - unawaited(_playNext()); - return; - } - - if (!mounted) return; - setState(() { - _showPlayNextDialog = true; - _autoPlayCountdown = autoPlayEnabled ? 5 : -1; - }); - - // Auto-focus Play Next button on TV when dialog appears (only in keyboard/TV mode) - if (isKeyboardMode) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _playNextConfirmFocusNode.requestFocus(); - } - }); - } - - if (autoPlayEnabled) { - _startAutoPlayTimer(); - } - } else if (_nextEpisode == null && !_completionTriggered) { - _completionTriggered = true; - unawaited(_handleBackButton()); - } - } - - void _onPlayerError(PlayerError err) { - appLogger.e('[Player ERROR] ${err.message}'); - if (!mounted || _isExiting.value) return; - - // Fatal, unrecoverable until server-side fix — show modal instead of a snackbar. - if (err.cause == PlayerError.serverHttp500 || _sawServer500) { - _showServerLimitDialog(); - return; - } - - // Live TV: retry with progressively degraded stream settings - // (mirrors Plex web client fallback chain). - if (widget.isLive && _liveStreamFallbackLevel < 2 && !_isRetryingLiveStream) { - _liveStreamFallbackLevel++; - _isRetryingLiveStream = true; - appLogger.w('Live stream failed, retrying with fallback level $_liveStreamFallbackLevel'); - _retryLiveStream().whenComplete(() => _isRetryingLiveStream = false); - return; - } - - showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? err.message)); - _handleBackButton(); - } - String? _lastLogError; bool _sawServer500 = false; static final RegExp _server500Pattern = RegExp(r'\b(?:HTTP error |Response code: )500\b'); - void _onPlayerLog(PlayerLog log) { - if (!_sawServer500 && _server500Pattern.hasMatch(log.text)) { - _sawServer500 = true; - } - if (log.level == PlayerLogLevel.error || log.level == PlayerLogLevel.fatal) { - appLogger.e('[Player LOG ERROR] [${log.prefix}] ${log.text}'); - _lastLogError = _redactPlayerError(log.text.trim()); - } - } - - String _redactPlayerError(String message) => LogRedactionManager.redact(message); - - Future _showServerLimitDialog() async { - if (!mounted) return; - await showServerLimitDialog(context); - if (mounted) unawaited(_handleBackButton()); - } - - /// Handle notification when native player switched from ExoPlayer to MPV - Future _onBackendSwitched() async { - _playerBackendLabel = 'mpv'; - _recordLifecycleState('backend_switched', action: 'mpv_fallback'); - - _toastController.show( - Symbols.swap_horiz_rounded, - t.messages.switchingToCompatiblePlayer, - duration: const Duration(seconds: 2), - ); - - await _trackManager?.onBackendSwitched(); - } - // OS Media Controls Integration - Future _syncMediaControlsAvailability() async { - final manager = _mediaControlsManager; - final currentPlayer = player; - if (!mounted || manager == null || currentPlayer == null) return; - - final playbackState = context.read(); - final canNavigateEpisodes = _currentMetadata.isEpisode || playbackState.isPlaylistActive; - final canSeek = !widget.isLive && currentPlayer.state.seekable; - - if (!mounted || currentPlayer != player || manager != _mediaControlsManager) return; - - await manager.setControlsEnabled( - canGoNext: canNavigateEpisodes, - canGoPrevious: canNavigateEpisodes, - canSeek: canSeek, - ); - } - - Future _seekBackForRewind(Player p) async { - if (_rewindOnResume <= 0) return; - final target = p.state.position - Duration(seconds: _rewindOnResume); - await p.seek(clampSeekPosition(p, target)); - } - - Future _restoreMediaControlsAfterResume() async { - if (!_isPlayerInitialized || !mounted) return; - - unawaited(_setWakelock(player?.state.isActive ?? false)); - - final manager = _mediaControlsManager; - final currentPlayer = player; - if (manager != null && currentPlayer != null) { - final client = _isOfflinePlayback ? null : _getMediaServerClient(context); - await manager.updateMetadata( - metadata: _currentMetadata, - client: client, - duration: _currentMetadata.durationMs != null ? Duration(milliseconds: _currentMetadata.durationMs!) : null, - ); - await _syncMediaControlsAvailability(); - } - - if (!mounted || currentPlayer != player || currentPlayer == null) return; - - if (_wasPlayingBeforeInactive) { - try { - await _seekBackForRewind(currentPlayer); - await currentPlayer.play(); - appLogger.d('Video resumed after returning from inactive state'); - } catch (e) { - appLogger.w('Failed to resume playback after returning from inactive state', error: e); - } finally { - _wasPlayingBeforeInactive = false; - } - } - - _updateMediaControlsPlaybackState(); - appLogger.d('Media controls restored on app resume'); - } - - /// Wrapper method to update media controls playback state - void _updateMediaControlsPlaybackState() { - if (player == null) return; - - _mediaControlsManager?.updatePlaybackState( - isPlaying: player!.state.isActive, - position: player!.state.position, - speed: player!.state.rate, - force: true, // Force update since this is an explicit state change - ); - } - - Future _playNext() async { - if (_nextEpisode == null || _isLoadingNext) return; - - // Cancel auto-play timer if running - _autoPlayTimer?.cancel(); - _dismissStillWatching(); - - // Notify Watch Together of episode change before navigating - _notifyWatchTogetherMediaChange(metadata: _nextEpisode); - - setState(() { - _isLoadingNext = true; - _showPlayNextDialog = false; - }); - - await _navigateToEpisode(_nextEpisode!); - } - - Future _playPrevious() async { - if (_previousEpisode == null || _isLoadingPrevious) return; - - _notifyWatchTogetherMediaChange(metadata: _previousEpisode); - - setState(() { - _isLoadingPrevious = true; - }); - - await _navigateToEpisode(_previousEpisode!); - } - /// Navigate to a specific queue item (called from QueueSheet) Future navigateToQueueItem(MediaItem metadata) async { _notifyWatchTogetherMediaChange(metadata: metadata); await _navigateToEpisode(metadata); } + void _setPlayerState(VoidCallback fn) => setState(fn); + bool _isSwitchingChannel = false; - /// Start periodic timeline heartbeats for live TV transcode session. - void _startLiveTimelineUpdates() { - final generation = ++_liveTimelineGeneration; - _liveTimelineTimer?.cancel(); - _liveTimelineTimer = Timer.periodic(const Duration(seconds: 10), (_) { - if (generation != _liveTimelineGeneration) return; - final state = player?.state.playing == true ? 'playing' : 'paused'; - _sendLiveTimeline(state); - }); - // Delay initial heartbeat to let the transcode session stabilize. - // Sending time=0 immediately after player.open() causes the server - // to spawn a duplicate transcode job with offset=-1 that 404s. - Future.delayed(const Duration(seconds: 3), () { - if (_liveTimelineTimer != null && generation == _liveTimelineGeneration) { - final state = player?.state.playing == true ? 'playing' : 'paused'; - _sendLiveTimeline(state); - } - }); - } - - void _stopLiveTimelineUpdates() { - _liveTimelineGeneration++; - _liveTimelineTimer?.cancel(); - _liveTimelineTimer = null; - } - - Future _sendLiveTimeline(String state) async { - final client = _liveClient; - final playbackTime = _livePlaybackStartTime != null - ? DateTime.now().difference(_livePlaybackStartTime!).inMilliseconds - : 0; - - if (client is PlexClient) { - final sessionId = _liveSessionIdentifier; - final sessionPath = _liveSessionPath; - if (sessionId == null || sessionPath == null) return; - try { - // Use the program ratingKey from tune metadata, not the channel key - final ratingKey = _liveProgramId ?? _liveItemId ?? widget.metadata.id; - // For live TV, player position/duration are unreliable (often 0). - // Use playbackTime as time, and program duration from tune metadata. - // Plex rejects timeline pings where time > duration; grow duration to - // match — otherwise Tunarr-style short synthetic programs 400 mid-stream. - final time = playbackTime; - final duration = max(_liveDurationMs ?? 0, time); - final updatedBuffer = await client.updateLiveTimeline( - ratingKey: ratingKey, - sessionPath: sessionPath, - sessionIdentifier: sessionId, - state: state, - time: time, - duration: duration, - playbackTime: playbackTime, - ); - if (updatedBuffer != null && mounted) { - setState(() { - _captureBuffer = updatedBuffer; - _isAtLiveEdge = (_currentPositionEpoch >= updatedBuffer.seekableEndEpoch - _liveEdgeThresholdSeconds); - }); - } - } catch (e) { - appLogger.d('Plex live timeline update failed', error: e); - } - return; - } - - if (client is JellyfinClient) { - await _jellyfinLiveSession.report( - client: client, - itemId: _liveItemId ?? widget.metadata.id, - state: state, - position: Duration(milliseconds: playbackTime), - duration: Duration(milliseconds: _liveDurationMs ?? 0), - ); - return; - } - } - - /// Retry the live stream with degraded direct-stream settings. - /// - /// Plex re-tunes the channel for a fresh capture session (the previous one - /// expires while MPV exhausts its reconnect attempts). Jellyfin streams the - /// channel directly with a session-less URL, so retry is just re-opening - /// that URL — degradation knobs apply only to the Plex transcoder branch. - Future _retryLiveStream() async { - final client = _liveClient; - final ds = _liveStreamFallbackLevel < 1; - final dsa = _liveStreamFallbackLevel < 2; - - if (client is PlexClient) { - final channels = widget.liveChannels; - final channelIndex = _liveChannelIndex; - final dvrKey = _liveDvrKey; - if (channels == null || channelIndex < 0 || channelIndex >= channels.length || dvrKey == null) { - appLogger.w('Cannot retry live stream — missing session info'); - showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? 'Live stream failed')); - unawaited(_handleBackButton()); - return; - } - final channel = channels[channelIndex]; - appLogger.i('Retrying live stream (re-tune ${channel.key}): directStream=$ds directStreamAudio=$dsa'); - - // Re-tune to get a fresh capture session — the previous one is dead. - final tuneResult = await client.tuneChannel(dvrKey, channel.key); - if (tuneResult == null || !mounted) { - showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? 'Live stream failed')); - unawaited(_handleBackButton()); - return; - } - - _liveSessionIdentifier = tuneResult.sessionIdentifier; - _liveSessionPath = tuneResult.sessionPath; - _transcodeSessionId = generateSessionIdentifier(); - - final streamPath = await client.buildLiveStreamPath( - sessionPath: tuneResult.sessionPath, - sessionIdentifier: tuneResult.sessionIdentifier, - transcodeSessionId: _transcodeSessionId!, - directStream: ds, - directStreamAudio: dsa, - ); - if (streamPath == null || !mounted) { - showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? 'Live stream failed')); - unawaited(_handleBackButton()); - return; - } - - final streamUrl = client.buildLiveStreamUrl(streamPath); - _liveStreamUrl = streamUrl; - _livePlaybackStartTime = DateTime.now(); - _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; - _isAtLiveEdge = true; - - await _setLiveStreamOptions(); - await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); - return; - } - - final liveStreamUrl = _liveStreamUrl; - if (client is JellyfinClient && liveStreamUrl != null) { - appLogger.i('Retrying Jellyfin live stream by re-opening URL'); - _livePlaybackStartTime = DateTime.now(); - _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; - _isAtLiveEdge = true; - await _setLiveStreamOptions(); - await player!.open(Media(liveStreamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); - return; - } - - appLogger.w('Cannot retry live stream — no compatible client/URL available'); - showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? 'Live stream failed')); - unawaited(_handleBackButton()); - } - - /// Force mpv to reconnect its HTTP stream by seeking to the current position. - /// This bypasses ffmpeg's exponential reconnect backoff when the app detects - /// that network connectivity has been restored. - void _forceStreamReconnect() { - final p = player; - if (p == null || !_isPlayerInitialized) return; - final pos = p.state.position; - appLogger.i('Network restored while buffering, forcing stream reconnect at ${pos.inSeconds}s'); - // Clear any stale completion latch caused by a spurious EOF during the drop, - // so the real end-of-file can trigger Play Next after we recover. - if (_completionTriggered && !_showPlayNextDialog && _autoPlayTimer?.isActive != true) { - _completionTriggered = false; - } - p.seek(pos); - } - - /// Configure MPV options for live streaming. - /// The official Plex Media Player does not set client-side reconnect options — - /// reconnection is handled by the server's transcoder on the input side. - Future _setLiveStreamOptions() async { - await player!.setProperty('force-seekable', 'no'); - } - - /// The current playback position as an absolute epoch second (for live TV time-shift). - int get _currentPositionEpoch => (_streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round(); - - /// Show "Watch from Start" / "Watch Live" dialog. - /// Returns true if user chose "Watch from start", false for "Watch Live", null if dismissed. - Future _showWatchFromStartDialog(int effectiveStartEpoch, int nowEpoch) { - final minutesAgo = ((nowEpoch - effectiveStartEpoch) / 60).round(); - return showOptionPickerDialog( - context, - title: t.liveTv.joinSession, - options: [ - (icon: Symbols.replay_rounded, label: t.liveTv.watchFromStart(minutes: minutesAgo), value: true), - (icon: Symbols.live_tv_rounded, label: t.liveTv.watchLive, value: false), - ], - ); - } - - /// Seek the live TV stream to an absolute epoch second. - /// Creates a new transcode session at the target offset. - Future _seekLivePosition(int targetEpochSeconds) async { - if (_captureBuffer == null || - _liveSessionPath == null || - _liveSessionIdentifier == null || - _transcodeSessionId == null) { - return; - } - - final clamped = targetEpochSeconds.clamp(_captureBuffer!.seekableStartEpoch, _captureBuffer!.seekableEndEpoch); - - final offsetSeconds = clamped - _captureBuffer!.startedAt.round(); - - // Live seek requires a transcode session — Plex-only by protocol. The - // Plex path populates _captureBuffer; the Jellyfin path never does, so - // the early-return above already covers Jellyfin in practice. This - // explicit guard keeps the contract obvious. - final client = _liveClient; - if (client is! PlexClient) return; - - final streamPath = await client.buildLiveStreamPath( - sessionPath: _liveSessionPath!, - sessionIdentifier: _liveSessionIdentifier!, - transcodeSessionId: _transcodeSessionId!, - offsetSeconds: offsetSeconds, - ); - if (streamPath == null || !mounted) return; - - final streamUrl = client.buildLiveStreamUrl(streamPath); - - _streamStartEpoch = _captureBuffer!.startedAt + offsetSeconds; - _isAtLiveEdge = (clamped >= _captureBuffer!.seekableEndEpoch - _liveEdgeThresholdSeconds); - _livePlaybackStartTime = DateTime.now(); - - await _setLiveStreamOptions(); - await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); - if (mounted) setState(() {}); - } - - /// Jump to the live edge of the capture buffer. - Future _jumpToLiveEdge() async { - if (_captureBuffer == null) return; - await _seekLivePosition(_captureBuffer!.seekableEndEpoch); - } - - Future _switchLiveChannel(int delta) async { - final channels = widget.liveChannels; - if (channels == null || channels.isEmpty) return; - if (_isSwitchingChannel) return; // debounce concurrent switches - - final newIndex = _liveChannelIndex + delta; - if (newIndex < 0 || newIndex >= channels.length) return; - - _isSwitchingChannel = true; - - // Stop old session heartbeats and notify server - _stopLiveTimelineUpdates(); - await _sendLiveTimeline('stopped'); - - final channel = channels[newIndex]; - appLogger.d('Switching to channel: ${channel.displayName} (${channel.key})'); - - if (!mounted) return; - setState(() => _hasFirstFrame.value = false); - - try { - // Look up the correct client/DVR for this channel's server - final multiServer = context.read(); - final serverInfo = liveTvServerInfoForChannel(multiServer, channel); - - if (serverInfo == null) return; - - final genericClient = multiServer.getClientForServer(serverInfo.serverId); - final resolution = await genericClient?.liveTv.resolveStreamUrl(channel.key, dvrKey: serverInfo.dvrKey); - if (resolution != null) { - // Jellyfin: pre-resolved negotiated URL. - await _setLiveStreamOptions(); - await player!.open(Media(resolution.url, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); - _liveClient = genericClient; - _liveDvrKey = serverInfo.dvrKey; - _liveStreamUrl = resolution.url; - _liveItemId = channel.key; - _liveSessionIdentifier = resolution.playSessionId; - _jellyfinLiveSession = JellyfinLiveSessionTracker(playSessionId: resolution.playSessionId); - _livePlaybackStartTime = DateTime.now(); - _captureBuffer = null; - _programBeginsAt = null; - _liveProgramId = null; - _liveDurationMs = null; - _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; - _isAtLiveEdge = true; - if (!mounted) return; - setState(() { - _liveChannelIndex = newIndex; - _liveChannelName = channel.displayName; - }); - _startLiveTimelineUpdates(); - return; - } - - // Plex-only: DVR tune flow (Jellyfin Live TV uses pre-resolved URLs). - final client = multiServer.getPlexClientForServer(serverInfo.serverId); - if (client == null) return; - - final tuneResult = await client.tuneChannel(serverInfo.dvrKey, channel.key); - if (tuneResult == null || !mounted) return; - - _transcodeSessionId = generateSessionIdentifier(); - _liveStreamFallbackLevel = 0; - - final streamPath = await client.buildLiveStreamPath( - sessionPath: tuneResult.sessionPath, - sessionIdentifier: tuneResult.sessionIdentifier, - transcodeSessionId: _transcodeSessionId!, - ); - if (streamPath == null || !mounted) return; - - final streamUrl = client.buildLiveStreamUrl(streamPath); - - await _setLiveStreamOptions(); - await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); - - _liveClient = client; - _liveDvrKey = serverInfo.dvrKey; - _liveStreamUrl = streamUrl; - _liveItemId = channel.key; - _livePlaybackStartTime = DateTime.now(); - _liveProgramId = tuneResult.metadata.ratingKey; - _liveDurationMs = tuneResult.metadata.duration; - - // Reset time-shift state for new channel - _captureBuffer = tuneResult.captureBuffer; - _programBeginsAt = tuneResult.beginsAt; - _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; - _isAtLiveEdge = true; - - if (!mounted) return; - setState(() { - _liveChannelIndex = newIndex; - _liveChannelName = channel.displayName; - _liveSessionIdentifier = tuneResult.sessionIdentifier; - _liveSessionPath = tuneResult.sessionPath; - }); - - // Restart timeline heartbeats for the new session - _startLiveTimelineUpdates(); - } catch (e) { - appLogger.e('Failed to switch channel', error: e); - if (mounted) showErrorSnackBar(context, e.toString()); - } finally { - _isSwitchingChannel = false; - } - } - - bool get _hasNextChannel => - widget.isLive && - widget.liveChannels != null && - _liveChannelIndex >= 0 && - _liveChannelIndex < (widget.liveChannels!.length - 1); - - bool get _hasPreviousChannel => widget.isLive && widget.liveChannels != null && _liveChannelIndex > 0; - - void _startAutoPlayTimer() { - _autoPlayTimer?.cancel(); - _autoPlayTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (!mounted) { - timer.cancel(); - return; - } - setState(() { - _autoPlayCountdown--; - }); - if (_autoPlayCountdown <= 0) { - timer.cancel(); - _playNext(); - } - }); - } - - void _cancelAutoPlay() { - _autoPlayTimer?.cancel(); - _completionTriggered = false; // Reset so it can trigger again if user seeks near end - setState(() { - _showPlayNextDialog = false; - }); - } - - void _showStillWatchingDialog() { - // Don't show if auto-play dialog is already visible - if (_showPlayNextDialog) return; - - final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context); - - setState(() { - _showStillWatchingPrompt = true; - _stillWatchingCountdown = 30; - }); - - if (isKeyboardMode) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _stillWatchingContinueFocusNode.requestFocus(); - }); - } - - _stillWatchingTimer?.cancel(); - _stillWatchingTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (!mounted) { - timer.cancel(); - return; - } - setState(() { - _stillWatchingCountdown--; - }); - if (_stillWatchingCountdown <= 0) { - timer.cancel(); - _onStillWatchingTimeout(); - } - }); - } - - void _onStillWatchingTimeout() { - player?.pause(); - setState(() { - _showStillWatchingPrompt = false; - }); - } - - void _onStillWatchingContinue() { - _stillWatchingTimer?.cancel(); - SleepTimerService().restartTimer(); - setState(() { - _showStillWatchingPrompt = false; - }); - } - - void _onStillWatchingPause() { - _stillWatchingTimer?.cancel(); - player?.pause(); - setState(() { - _showStillWatchingPrompt = false; - }); - } - - void _dismissStillWatching() { - _stillWatchingTimer?.cancel(); - if (_showStillWatchingPrompt) { - setState(() { - _showStillWatchingPrompt = false; - }); - } - } - /// Wait briefly for profile settings to load in offline mode. /// This prevents default-track fallback when playback starts before /// UserProfileProvider finishes initialization. @@ -3252,253 +1165,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin String get playbackSessionIdentifier => _playbackSessionIdentifier; String get playbackTranscodeSessionId => _playbackTranscodeSessionId; - /// Navigates to a new episode, preserving playback state and track selections. - /// When PiP is active, swaps the media source in-place to keep the PiP window alive. - Future _navigateToEpisode(MediaItem episodeMetadata) async { - // PiP active: swap media in-place to keep the PiP window alive. The - // swap path threads the neutral [MediaServerClient] through - // [PlaybackInitializationService] and the lifecycle services, so it - // works for both Plex and Jellyfin sessions. - if (PipService().isPipActive.value && player != null) { - await _swapEpisodeInPip(episodeMetadata); - return; - } - - // Set flag to skip orientation restoration in dispose() - _isReplacingWithVideo = true; - - // Clear Discord Rich Presence + Trakt scrobble before switching episodes - unawaited(DiscordRPCService.instance.stopPlayback()); - unawaited(TraktScrobbleService.instance.stopPlayback()); - unawaited(TrackerCoordinator.instance.stopPlayback()); - - // If player isn't available, navigate without preserving settings - if (player == null) { - if (mounted) { - unawaited( - navigateToVideoPlayer( - context, - metadata: episodeMetadata, - usePushReplacement: true, - isOffline: _isOfflinePlayback, - ), - ); - } - return; - } - - // Capture current state atomically to avoid race conditions - final currentPlayer = player; - if (currentPlayer == null) { - // Player already disposed, navigate without preserving settings - if (mounted) { - unawaited( - navigateToVideoPlayer( - context, - metadata: episodeMetadata, - usePushReplacement: true, - isOffline: _isOfflinePlayback, - ), - ); - } - return; - } - - final currentAudioTrack = currentPlayer.state.track.audio; - final currentSubtitleTrack = currentPlayer.state.track.subtitle; - final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle; - - // Pause and stop current playback - unawaited(currentPlayer.pause()); - await _progressTracker?.sendProgress('stopped'); - _progressTracker?.stopTracking(); - - // Ensure the native player is fully disposed before creating the next one - await disposePlayerForNavigation(); - - // Navigate to the episode using pushReplacement to destroy current player - if (mounted) { - unawaited( - navigateToVideoPlayer( - context, - metadata: episodeMetadata, - preferredAudioTrack: currentAudioTrack, - preferredSubtitleTrack: currentSubtitleTrack, - preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, - usePushReplacement: true, - isOffline: _isOfflinePlayback, - ), - ); - } - } - - /// Swap to a new episode while keeping the player alive for PiP continuity. - /// Reuses the existing mpv instance (and its Metal layer in PiP) and only - /// reloads the media source + resets Dart-side services. - Future _swapEpisodeInPip(MediaItem episodeMetadata) async { - _isSwappingEpisode = true; - final currentPlayer = player!; - final previousMetadata = _currentMetadata; - - final currentAudioTrack = currentPlayer.state.track.audio; - final currentSubtitleTrack = currentPlayer.state.track.subtitle; - final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle; - - // Capture context-dependent values before async gaps. The neutral - // [PlaybackInitializationService] consumes [mediaClient] regardless of - // backend. We still narrow to [plexClient] for [TrackManager]'s - // server-side track persistence, which is Plex-only — Jellyfin - // sessions get a null `getPlexClient` and skip that path. - final mediaClient = _isOfflinePlayback ? null : _getMediaServerClient(context); - final plexClient = mediaClient is PlexClient ? mediaClient : null; - final streamHeaders = mediaClient?.streamHeaders ?? const {}; - final offlineWatchService = context.read(); - final userProfileProvider = context.read(); - final playbackState = context.read(); - final database = context.read(); - - await _progressTracker?.sendProgress('stopped'); - _progressTracker?.stopTracking(); - _progressTracker?.dispose(); - _progressTracker = null; - unawaited(DiscordRPCService.instance.stopPlayback()); - unawaited(TraktScrobbleService.instance.stopPlayback()); - unawaited(TrackerCoordinator.instance.stopPlayback()); - - _currentMetadata = episodeMetadata; - _activeId = episodeMetadata.id; - _showPlayNextDialog = false; - _autoPlayTimer?.cancel(); - _hasFirstFrame.value = false; - - try { - // Same service shape works for both online (mediaClient non-null, - // bundled video URL + media info) and pure-offline (mediaClient null, - // local file + cached media info if available). - final playbackService = PlaybackInitializationService(client: mediaClient, database: database); - final result = await playbackService.getPlaybackData( - metadata: episodeMetadata, - selectedMediaIndex: widget.selectedMediaIndex, - preferOffline: _isOfflinePlayback || _selectedQualityPreset.isOriginal, - qualityPreset: _selectedQualityPreset, - selectedAudioStreamId: _selectedAudioStreamId, - sessionIdentifier: _playbackSessionIdentifier, - transcodeSessionId: _playbackTranscodeSessionId, - ); - - if (result.videoUrl == null) { - throw PlaybackException('No video URL available'); - } - - Duration? resumePosition; - _isTranscoding = result.isTranscoding; - _effectiveIsOffline = result.isOffline; - _playbackPlaySessionId = result.playSessionId; - _playbackPlayMethod = result.playMethod; - if (result.activeAudioStreamId != null) { - _selectedAudioStreamId = result.activeAudioStreamId; - } - if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) { - if (mounted) { - showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback); - } - _selectedQualityPreset = TranscodeQualityPreset.original; - } - - if (_isOfflinePlayback) { - final localOffset = await offlineWatchService.getLocalViewOffset(episodeMetadata.globalKey); - if (localOffset != null && localOffset > 0) { - resumePosition = Duration(milliseconds: localOffset); - } - } - resumePosition ??= episodeMetadata.viewOffsetMs != null - ? Duration(milliseconds: episodeMetadata.viewOffsetMs!) - : null; - - final hasExternalSubs = result.externalSubtitles.isNotEmpty; - final isExoPlayer = player is PlayerAndroid; - await currentPlayer.open( - Media(result.videoUrl!, start: resumePosition, headers: streamHeaders), - play: isExoPlayer || !hasExternalSubs, - externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, - ); - - _completionTriggered = false; - _isSwappingEpisode = false; - - if (!mounted) return; - - _scrubPreviewSource?.dispose(); - setState(() { - _availableVersions = result.availableVersions; - _currentMediaInfo = result.mediaInfo; - _scrubPreviewSource = null; - _isLoadingNext = false; - }); - - _trackManager?.dispose(); - _trackManager = TrackManager( - player: currentPlayer, - isActive: () => mounted && player != null, - // Plex writes track changes immediately. Jellyfin persists selected - // indexes through playback progress reports. - persistTrackPreference: plexClient != null ? _plexTrackPersister(() => plexClient) : null, - getProfileSettings: () => userProfileProvider.profileSettings, - waitForProfileSettings: _waitForProfileSettingsIfNeeded, - metadata: episodeMetadata, - mediaInfo: _currentMediaInfo, - preferredAudioTrack: currentAudioTrack, - preferredSubtitleTrack: currentSubtitleTrack, - preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, - showMessage: (message, {duration}) { - if (mounted) showAppSnackBar(context, message, duration: duration); - }, - ); - _trackManager!.cacheExternalSubtitles(result.externalSubtitles); - - if (player is! PlayerAndroid && hasExternalSubs) { - _trackManager!.waitingForExternalSubsTrackSelection = true; - try { - await _trackManager!.addExternalSubtitles(result.externalSubtitles); - } finally { - await _trackManager!.resumeAfterSubtitleLoad(); - } - } else { - _trackManager!.applyTrackSelectionWhenReady(); - } - - // Wire progress tracker, media-controls metadata, and the - // Discord/Trakt/Tracker scrobblers — same helper as the initial - // start flow, so any future change lands in both paths together. - _wirePerItemPlaybackServices( - metadata: episodeMetadata, - mediaClient: mediaClient, - offlineWatchService: offlineWatchService, - playSessionId: _playbackPlaySessionId, - playMethod: _playbackPlayMethod, - mediaInfo: _currentMediaInfo, - ); - - try { - playbackState.setCurrentItem(episodeMetadata); - } catch (e) { - appLogger.d('playbackState.setCurrentItem failed', error: e); - } - - await _loadAdjacentEpisodes(); - - if (_autoPipEnabled) { - unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing)); - } - } catch (e) { - _isSwappingEpisode = false; - _completionTriggered = false; - _currentMetadata = previousMetadata; - _activeId = previousMetadata.id; - appLogger.e('Failed to swap episode in PiP', error: e); - } - } - /// Dispose the player before replacing the video to avoid race conditions Future disposePlayerForNavigation() async { if (_isDisposingForNavigation) return; @@ -3524,61 +1190,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - Widget _buildLoadingSpinner() { - return const Scaffold( - backgroundColor: Colors.black, - body: Center(child: CircularProgressIndicator(color: Colors.white)), - ); - } - - Widget _buildInitializationError(String message) { - return Scaffold( - backgroundColor: Colors.black, - body: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AppIcon(Symbols.error_rounded, color: Colors.white70, size: 44, fill: 1), - const SizedBox(height: 16), - Text( - message, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white, fontSize: 16), - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FilledButton( - autofocus: true, - onPressed: () { - final playerToDispose = player; - player = null; - if (playerToDispose != null) unawaited(playerToDispose.dispose()); - setState(() { - _playerInitializationError = null; - _isPlayerInitialized = false; - }); - unawaited(_initializePlayer()); - }, - child: Text(t.common.retry), - ), - const SizedBox(width: 12), - OutlinedButton(onPressed: () => unawaited(_handleBackButton()), child: Text(t.common.back)), - ], - ), - ], - ), - ), - ), - ), - ); - } - @override Widget build(BuildContext context) { final isCurrentRoute = ModalRoute.of(context)?.isCurrent ?? true; @@ -3629,542 +1240,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin ), ); } - - Widget _buildVideoPlayer(BuildContext context) { - // Cache platform detection to avoid multiple calls - final isMobile = PlatformDetector.isMobile(context); - - return PopScope( - canPop: false, // Disable swipe-back gesture to prevent interference with timeline scrubbing - onPopInvokedWithResult: (didPop, result) { - if (!didPop) { - // If an overlay sheet is open, delegate back to it instead of - // exiting the player. This prevents the double-pop on Android TV - // where the system back gesture would otherwise reach both the - // sheet and the player's PopScope. - final sheetController = OverlaySheetController.maybeOf(context); - if (sheetController != null && sheetController.isOpen) { - sheetController.pop(); - return; - } - if (BackKeyCoordinator.consumeIfHandled()) return; - BackKeyCoordinator.markHandled(); - _handleBackButton(); - } - }, - child: Scaffold( - // Use transparent background on macOS when native video layer is active - backgroundColor: Colors.transparent, - body: GestureDetector( - behavior: HitTestBehavior.translucent, // Allow taps to pass through to controls - onScaleStart: (details) { - // Initialize pinch gesture tracking (mobile only) - if (!isMobile) return; - if (_videoFilterManager != null) { - _videoFilterManager!.isPinching = false; - } - }, - onScaleUpdate: (details) { - // Track if this is a pinch gesture (2+ fingers) on mobile - if (!isMobile) return; - if (details.pointerCount >= 2 && _videoFilterManager != null) { - _videoFilterManager!.isPinching = true; - } - }, - onScaleEnd: (details) { - // Only toggle if we detected a pinch gesture on mobile - if (!isMobile) return; - if (_videoFilterManager != null && _videoFilterManager!.isPinching) { - _toggleContainCover(); - _videoFilterManager!.isPinching = false; - } - }, - 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( - builder: (context, constraints) { - // Update player size when layout changes - final newSize = Size(constraints.maxWidth, constraints.maxHeight); - - // Update player size in video filter manager, PiP manager, and native layer - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && player != null) { - _videoFilterManager?.updatePlayerSize(newSize); - _videoPIPManager?.updatePlayerSize(newSize); - // Update ambient lighting shader if active (output aspect changed) - _updateAmbientLightingOnResize(newSize); - // Update Metal layer frame on iOS/macOS for rotation - player!.updateFrame(); - } - }); - - // Compute canControl from Watch Together provider (reactive) - bool canControl = true; - try { - canControl = context.select( - (wt) => wt.isInSession ? wt.canControl() : true, - ); - } catch (e) { - // Watch Together not available, default to can control - } - - VoidCallback? onNext; - if (widget.isLive) { - onNext = _hasNextChannel ? () => _switchLiveChannel(1) : null; - } else { - onNext = (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null; - } - - VoidCallback? onPrevious; - if (widget.isLive) { - onPrevious = _hasPreviousChannel ? () => _switchLiveChannel(-1) : null; - } else { - onPrevious = (_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null; - } - - return Video( - player: player!, - controls: (context) => plexVideoControlsBuilder( - player!, - _currentMetadata, - onNext: onNext, - onPrevious: onPrevious, - availableVersions: _availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - selectedQualityPreset: _selectedQualityPreset, - serverSupportsTranscoding: _serverSupportsTranscoding, - isTranscoding: _isTranscoding, - isOfflinePlayback: _isOfflinePlayback, - sourceAudioTracks: _currentMediaInfo?.audioTracks ?? const [], - selectedAudioStreamId: _selectedAudioStreamId, - onTogglePIPMode: _togglePIPMode, - boxFitMode: _videoFilterManager?.boxFitMode ?? 0, - onCycleBoxFitMode: _cycleBoxFitMode, - onCycleAudioTrack: _cycleAudioTrack, - onCycleSubtitleTrack: _cycleSubtitleTrack, - onAudioTrackChanged: _onAudioTrackChanged, - onSubtitleTrackChanged: _onSubtitleTrackChanged, - onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged, - onSeekCompleted: (position) { - // Notify Watch Together of seek for sync - // Note: canControl() check is done in sync manager, not here - // This matches play/pause behavior and avoids timing issues - try { - final watchTogether = this.context.read(); - if (watchTogether.isInSession) { - watchTogether.onLocalSeek(position); - } - } catch (e) { - // Watch Together not available, ignore - } - }, - onBack: _handleBackButton, - onReachedEnd: ({skipAutoPlayCountdown = false}) => - _onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown), - canControl: canControl, - hasFirstFrame: _hasFirstFrame, - playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null, - controlsVisible: _controlsVisible, - shaderService: _shaderService, - // ignore: no-empty-block - setState triggers rebuild to reflect shader change - onShaderChanged: () => setState(() {}), - thumbnailDataBuilder: _scrubPreviewSource?.isAvailable == true ? _getThumbnailData : null, - isLive: widget.isLive, - liveChannelName: _liveChannelName, - captureBuffer: _captureBuffer, - isAtLiveEdge: _isAtLiveEdge, - streamStartEpoch: _streamStartEpoch, - currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null, - onLiveSeek: _captureBuffer != null ? _seekLivePosition : null, - onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null, - isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false, - onToggleAmbientLighting: _toggleAmbientLighting, - toastController: _toastController, - ), - ); - }, - ), - ), - // Netflix-style auto-play overlay (hidden in PiP mode) - ValueListenableBuilder( - valueListenable: PipService().isPipActive, - builder: (context, isInPip, child) { - if (isInPip || !_showPlayNextDialog || _nextEpisode == null) { - return const SizedBox.shrink(); - } - return ValueListenableBuilder( - valueListenable: _controlsVisible, - builder: (context, controlsShown, child) { - return AnimatedPositioned( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - right: 24, - bottom: controlsShown ? 100 : 24, - child: Container( - width: 320, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.9), - borderRadius: const BorderRadius.all(Radius.circular(12)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Consumer( - builder: (context, playbackState, child) { - final isShuffleActive = playbackState.isShuffleActive; - return Row( - children: [ - Text( - 'Next Episode', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - if (isShuffleActive) ...[ - const SizedBox(width: 4), - AppIcon( - Symbols.shuffle_rounded, - fill: 1, - size: 12, - color: Colors.white.withValues(alpha: 0.7), - ), - ], - ], - ); - }, - ), - const SizedBox(height: 4), - if (_nextEpisode!.parentIndex != null && _nextEpisode!.index != null) - Text( - 'S${_nextEpisode!.parentIndex} E${_nextEpisode!.index} · ${_nextEpisode!.title}', - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ) - else - Text( - _nextEpisode!.title!, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FocusableButton( - focusNode: _playNextCancelFocusNode, - onPressed: _cancelAutoPlay, - autoScroll: false, - onNavigateRight: () => _playNextConfirmFocusNode.requestFocus(), - onNavigateUp: () {}, // Trap focus - onNavigateDown: () {}, // Trap focus - child: OutlinedButton( - onPressed: _cancelAutoPlay, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Text(t.common.cancel), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FocusableButton( - focusNode: _playNextConfirmFocusNode, - onPressed: _playNext, - autoScroll: false, - onNavigateLeft: () => _playNextCancelFocusNode.requestFocus(), - onNavigateUp: () {}, // Trap focus - onNavigateDown: () {}, // Trap focus - child: FilledButton( - onPressed: _playNext, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_autoPlayCountdown > 0) ...[ - Text('$_autoPlayCountdown'), - const SizedBox(width: 4), - const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), - ] else - Text(t.videoControls.playNext), - ], - ), - ), - ), - ), - ], - ), - ], - ), - ), - ); - }, - ); - }, - ), - // "Still watching?" overlay (hidden in PiP mode) - ValueListenableBuilder( - valueListenable: PipService().isPipActive, - builder: (context, isInPip, child) { - if (isInPip || !_showStillWatchingPrompt) { - return const SizedBox.shrink(); - } - return ValueListenableBuilder( - valueListenable: _controlsVisible, - builder: (context, controlsShown, child) { - return AnimatedPositioned( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - right: 24, - bottom: controlsShown ? 100 : 24, - child: Container( - width: 320, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.9), - borderRadius: const BorderRadius.all(Radius.circular(12)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.videoControls.stillWatching, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - t.videoControls.pausingIn(seconds: '$_stillWatchingCountdown'), - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FocusableButton( - focusNode: _stillWatchingPauseFocusNode, - onPressed: _onStillWatchingPause, - autoScroll: false, - onNavigateRight: () => _stillWatchingContinueFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - child: OutlinedButton( - onPressed: _onStillWatchingPause, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Text(t.videoControls.pauseButton), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FocusableButton( - focusNode: _stillWatchingContinueFocusNode, - onPressed: _onStillWatchingContinue, - autoScroll: false, - onNavigateLeft: () => _stillWatchingPauseFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - child: FilledButton( - onPressed: _onStillWatchingContinue, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('$_stillWatchingCountdown'), - const SizedBox(width: 4), - Text(t.videoControls.continueWatching), - ], - ), - ), - ), - ), - ], - ), - ], - ), - ), - ); - }, - ); - }, - ), - // Buffering indicator (also shows during initial load, but not when exiting) - // Hidden in PiP mode - ValueListenableBuilder( - valueListenable: PipService().isPipActive, - builder: (context, isInPip, child) { - if (isInPip) return const SizedBox.shrink(); - return ValueListenableBuilder( - valueListenable: _isBuffering, - builder: (context, isBuffering, child) { - return ValueListenableBuilder( - valueListenable: _hasFirstFrame, - builder: (context, hasFrame, child) { - if ((!isBuffering && hasFrame) || _isExiting.value) return const SizedBox.shrink(); - // Show spinner only - controls overlay provides its own black background during loading - return Positioned.fill( - child: IgnorePointer( - child: Center( - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.5), - shape: BoxShape.circle, - ), - child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3), - ), - ), - ), - ); - }, - ); - }, - ); - }, - ), - // Watch Together overlays (isolated from video surface repaints) - RepaintBoundary( - child: Stack( - fit: StackFit.expand, - children: [ - // Watch Together: reconnecting to host overlay - Selector( - selector: (_, provider) => provider.isWaitingForHostReconnect, - builder: (context, isWaiting, child) { - if (!isWaiting) return const SizedBox.shrink(); - return Positioned( - bottom: 120, - left: 0, - right: 0, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: const BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.all(Radius.circular(20)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (PlatformDetector.isTV()) - const Icon(Symbols.sync_rounded, size: 14, color: Colors.white) - else - const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ), - const SizedBox(width: 8), - Text( - t.watchTogether.reconnectingToHost, - style: const TextStyle(color: Colors.white, fontSize: 12), - ), - ], - ), - ), - ), - ); - }, - ), - // Watch Together: participant join/leave/buffering notifications - const ParticipantNotificationOverlay(), - // Watch Together: waiting for participants to load - const WaitingForParticipantsIndicator(), - // Watch Together: syncing indicator during drift correction - const SyncingIndicator(), - ], - ), - ), - // Black overlay during exit (no spinner - just covers transparency) - ValueListenableBuilder( - valueListenable: _isExiting, - builder: (context, isExiting, child) { - if (!isExiting) return const SizedBox.shrink(); - return const Positioned.fill(child: ColoredBox(color: Colors.black)); - }, - ), - ], - ), - ), - ), - ); - } } /// Returns the appropriate hwdec value based on platform and user preference. diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 1f8c6eec..6b125d5b 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -45,11 +45,14 @@ import 'jellyfin_api_cache.dart'; import 'jellyfin_mappers.dart'; import 'jellyfin_media_info_mapper.dart'; import 'jellyfin_playback_bundle.dart'; +import 'jellyfin_playback_urls.dart'; import 'jellyfin_trickplay_service.dart'; import 'playback_initialization_types.dart'; import 'scrub_preview_source.dart'; import '../mpv/mpv.dart'; +part 'jellyfin_client/live_tv_support.dart'; + /// [MediaServerClient] over a Jellyfin server. /// /// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the @@ -1560,16 +1563,14 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc /// can be omitted; for items with multiple versions Jellyfin uses the /// param to pick which file to serve. String buildDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) { - final params = { - 'Static': 'true', - 'api_key': connection.accessToken, - 'DeviceId': connection.deviceId, - 'Container': ?container, - 'MediaSourceId': ?mediaSourceId, - }; - final query = params.entries.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}').join('&'); - final encodedItem = Uri.encodeComponent(itemId); - return '${connection.baseUrl}/Videos/$encodedItem/stream?$query'; + return buildJellyfinDirectStreamUrl( + baseUrl: connection.baseUrl, + accessToken: connection.accessToken, + deviceId: connection.deviceId, + itemId: itemId, + container: container, + mediaSourceId: mediaSourceId, + ); } /// Trickplay sprite-sheet URL. [width] picks one of the resolutions @@ -1578,14 +1579,15 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc /// Pass [mediaSourceId] when the item has more than one source so the /// server returns the matching version's tiles. String buildTrickplayTileUrl(String itemId, int width, int sheetIndex, {String? mediaSourceId}) { - final params = { - 'api_key': connection.accessToken, - 'DeviceId': connection.deviceId, - 'MediaSourceId': ?mediaSourceId, - }; - final query = params.entries.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}').join('&'); - final encodedItem = Uri.encodeComponent(itemId); - return '${connection.baseUrl}/Videos/$encodedItem/Trickplay/$width/$sheetIndex.jpg?$query'; + return buildJellyfinTrickplayTileUrl( + baseUrl: connection.baseUrl, + accessToken: connection.accessToken, + deviceId: connection.deviceId, + itemId: itemId, + width: width, + sheetIndex: sheetIndex, + mediaSourceId: mediaSourceId, + ); } /// HLS master playlist URL for transcoded playback. Use when the file @@ -1601,18 +1603,17 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc int? subtitleStreamIndex, String? playSessionId, }) { - final params = { - 'DeviceId': connection.deviceId, - 'MediaSourceId': mediaSourceId, - 'api_key': connection.accessToken, - 'VideoBitrate': ?videoBitrate?.toString(), - 'AudioStreamIndex': ?audioStreamIndex?.toString(), - 'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(), - 'PlaySessionId': ?playSessionId, - }; - final query = params.entries.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}').join('&'); - final encodedItem = Uri.encodeComponent(itemId); - return '${connection.baseUrl}/Videos/$encodedItem/master.m3u8?$query'; + return buildJellyfinHlsStreamUrl( + baseUrl: connection.baseUrl, + accessToken: connection.accessToken, + deviceId: connection.deviceId, + itemId: itemId, + mediaSourceId: mediaSourceId, + videoBitrate: videoBitrate, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + playSessionId: playSessionId, + ); } /// Negotiate playback: returns the parsed `MediaSources[]` array and the @@ -2158,100 +2159,3 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc return buildArtworkSpecs(item, (path) => path); } } - -/// Jellyfin implementation of [LiveTvSupport]. Wraps the existing -/// `fetchLiveTvChannels` / `fetchLiveTvPrograms` / `buildDirectStreamUrl`. -class _JellyfinLiveTvSupport implements LiveTvSupport { - final JellyfinClient _client; - _JellyfinLiveTvSupport(this._client); - - @override - Future isAvailable() => _client.hasLiveTv(); - - @override - Future> fetchDvrs() async => const []; - - @override - Future> fetchChannels({String? lineup}) => _client.fetchLiveTvChannels(); - - @override - Future> fetchSchedule({DateTime? from, DateTime? to}) { - int? toEpoch(DateTime? dt) => dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000; - return _client.fetchLiveTvPrograms(beginsAt: toEpoch(from), endsAt: toEpoch(to)); - } - - @override - Future resolveStreamUrl(String channelKey, {String? dvrKey}) async { - final info = await _client.getPlaybackInfo(channelKey); - final sources = info?['MediaSources']; - final source = sources is List && sources.isNotEmpty && sources.first is Map - ? sources.first as Map - : null; - if (source == null) return null; - - final rawUrl = source['TranscodingUrl'] ?? source['DirectStreamUrl']; - final url = rawUrl is String && rawUrl.isNotEmpty - ? _client._withApiKey(rawUrl) - : _client.buildDirectStreamUrl(channelKey); - var playSessionId = info?['PlaySessionId'] as String?; - playSessionId ??= Uri.tryParse(url)?.queryParameters['PlaySessionId']; - return LiveTvStreamResolution(url: url, playSessionId: playSessionId); - } - - /// SharedPreferences key for the locally-persisted favorite-channel list. - /// Keyed by the compound connection id (`{machineId}/{userId}`) so two - /// Jellyfin users on the same server don't share favorites. - String get _favoritesPrefsKey => 'jellyfin_fav_channels:${_client.connection.id}'; - - /// Legacy bare-machineId key, kept for one-shot migration. - String get _legacyFavoritesPrefsKey => 'jellyfin_fav_channels:${_client.serverId}'; - - @override - Future buildFavoriteChannelSource({String? lineup}) async => 'server://${_client.serverId}/jellyfin'; - - @override - String get favoriteStoreKey => 'jellyfin:${_client.connection.id}'; - - @override - FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.serverSlice; - - /// Local list is the source of truth (preserves order + display fields). - /// Server-side `IsFavorite` is mirrored on writes via [setFavoriteChannels]. - @override - Future> fetchFavoriteChannels() async { - try { - return await _client._favoritesRepository.read(key: _favoritesPrefsKey, legacyKey: _legacyFavoritesPrefsKey); - } catch (e) { - appLogger.e('Failed to read Jellyfin favorite channels', error: e); - return const []; - } - } - - @override - Future setFavoriteChannels(List channels) async { - try { - final previous = await fetchFavoriteChannels(); - final previousIds = previous.map((c) => c.id).toSet(); - final newIds = channels.map((c) => c.id).toSet(); - - for (final id in newIds.difference(previousIds)) { - try { - await _client._setItemFavorite(id, true); - } catch (e) { - appLogger.w('Failed to mark Jellyfin channel $id favorite: $e'); - } - } - for (final id in previousIds.difference(newIds)) { - try { - await _client._setItemFavorite(id, false); - } catch (e) { - appLogger.w('Failed to unmark Jellyfin channel $id favorite: $e'); - } - } - - await _client._favoritesRepository.write(_favoritesPrefsKey, channels); - } catch (e) { - appLogger.e('Failed to save Jellyfin favorite channels', error: e); - } - } -} diff --git a/lib/services/jellyfin_client/live_tv_support.dart b/lib/services/jellyfin_client/live_tv_support.dart new file mode 100644 index 00000000..be94f801 --- /dev/null +++ b/lib/services/jellyfin_client/live_tv_support.dart @@ -0,0 +1,98 @@ +part of '../jellyfin_client.dart'; + +/// Jellyfin implementation of [LiveTvSupport]. Wraps the existing +/// `fetchLiveTvChannels` / `fetchLiveTvPrograms` / `buildDirectStreamUrl`. +class _JellyfinLiveTvSupport implements LiveTvSupport { + final JellyfinClient _client; + _JellyfinLiveTvSupport(this._client); + + @override + Future isAvailable() => _client.hasLiveTv(); + + @override + Future> fetchDvrs() async => const []; + + @override + Future> fetchChannels({String? lineup}) => _client.fetchLiveTvChannels(); + + @override + Future> fetchSchedule({DateTime? from, DateTime? to}) { + int? toEpoch(DateTime? dt) => dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000; + return _client.fetchLiveTvPrograms(beginsAt: toEpoch(from), endsAt: toEpoch(to)); + } + + @override + Future resolveStreamUrl(String channelKey, {String? dvrKey}) async { + final info = await _client.getPlaybackInfo(channelKey); + final sources = info?['MediaSources']; + final source = sources is List && sources.isNotEmpty && sources.first is Map + ? sources.first as Map + : null; + if (source == null) return null; + + final rawUrl = source['TranscodingUrl'] ?? source['DirectStreamUrl']; + final url = rawUrl is String && rawUrl.isNotEmpty + ? _client._withApiKey(rawUrl) + : _client.buildDirectStreamUrl(channelKey); + var playSessionId = info?['PlaySessionId'] as String?; + playSessionId ??= Uri.tryParse(url)?.queryParameters['PlaySessionId']; + return LiveTvStreamResolution(url: url, playSessionId: playSessionId); + } + + /// SharedPreferences key for the locally-persisted favorite-channel list. + /// Keyed by the compound connection id (`{machineId}/{userId}`) so two + /// Jellyfin users on the same server don't share favorites. + String get _favoritesPrefsKey => 'jellyfin_fav_channels:${_client.connection.id}'; + + /// Legacy bare-machineId key, kept for one-shot migration. + String get _legacyFavoritesPrefsKey => 'jellyfin_fav_channels:${_client.serverId}'; + + @override + Future buildFavoriteChannelSource({String? lineup}) async => 'server://${_client.serverId}/jellyfin'; + + @override + String get favoriteStoreKey => 'jellyfin:${_client.connection.id}'; + + @override + FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.serverSlice; + + /// Local list is the source of truth (preserves order + display fields). + /// Server-side `IsFavorite` is mirrored on writes via [setFavoriteChannels]. + @override + Future> fetchFavoriteChannels() async { + try { + return await _client._favoritesRepository.read(key: _favoritesPrefsKey, legacyKey: _legacyFavoritesPrefsKey); + } catch (e) { + appLogger.e('Failed to read Jellyfin favorite channels', error: e); + return const []; + } + } + + @override + Future setFavoriteChannels(List channels) async { + try { + final previous = await fetchFavoriteChannels(); + final previousIds = previous.map((c) => c.id).toSet(); + final newIds = channels.map((c) => c.id).toSet(); + + for (final id in newIds.difference(previousIds)) { + try { + await _client._setItemFavorite(id, true); + } catch (e) { + appLogger.w('Failed to mark Jellyfin channel $id favorite: $e'); + } + } + for (final id in previousIds.difference(newIds)) { + try { + await _client._setItemFavorite(id, false); + } catch (e) { + appLogger.w('Failed to unmark Jellyfin channel $id favorite: $e'); + } + } + + await _client._favoritesRepository.write(_favoritesPrefsKey, channels); + } catch (e) { + appLogger.e('Failed to save Jellyfin favorite channels', error: e); + } + } +} diff --git a/lib/services/jellyfin_playback_urls.dart b/lib/services/jellyfin_playback_urls.dart new file mode 100644 index 00000000..0f72f737 --- /dev/null +++ b/lib/services/jellyfin_playback_urls.dart @@ -0,0 +1,59 @@ +String buildJellyfinDirectStreamUrl({ + required String baseUrl, + required String accessToken, + required String deviceId, + required String itemId, + String? container, + String? mediaSourceId, +}) { + final params = { + 'Static': 'true', + 'api_key': accessToken, + 'DeviceId': deviceId, + 'Container': ?container, + 'MediaSourceId': ?mediaSourceId, + }; + final encodedItem = Uri.encodeComponent(itemId); + return '$baseUrl/Videos/$encodedItem/stream?${_encodeQuery(params)}'; +} + +String buildJellyfinTrickplayTileUrl({ + required String baseUrl, + required String accessToken, + required String deviceId, + required String itemId, + required int width, + required int sheetIndex, + String? mediaSourceId, +}) { + final params = {'api_key': accessToken, 'DeviceId': deviceId, 'MediaSourceId': ?mediaSourceId}; + final encodedItem = Uri.encodeComponent(itemId); + return '$baseUrl/Videos/$encodedItem/Trickplay/$width/$sheetIndex.jpg?${_encodeQuery(params)}'; +} + +String buildJellyfinHlsStreamUrl({ + required String baseUrl, + required String accessToken, + required String deviceId, + required String itemId, + required String mediaSourceId, + int? videoBitrate, + int? audioStreamIndex, + int? subtitleStreamIndex, + String? playSessionId, +}) { + final params = { + 'DeviceId': deviceId, + 'MediaSourceId': mediaSourceId, + 'api_key': accessToken, + 'VideoBitrate': ?videoBitrate?.toString(), + 'AudioStreamIndex': ?audioStreamIndex?.toString(), + 'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(), + 'PlaySessionId': ?playSessionId, + }; + final encodedItem = Uri.encodeComponent(itemId); + return '$baseUrl/Videos/$encodedItem/master.m3u8?${_encodeQuery(params)}'; +} + +String _encodeQuery(Map params) => + params.entries.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}').join('&'); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index a37a6bfd..435d05ba 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -16,12 +16,10 @@ import '../media/media_kind.dart'; import '../media/media_library.dart'; import '../media/media_playlist.dart'; import '../media/media_server_client.dart'; -import '../media/media_version.dart'; import '../media/server_capabilities.dart'; import '../utils/external_ids.dart'; import 'bif_thumbnail_service.dart'; import 'download_artwork_helpers.dart'; -import 'file_info_parser.dart'; import 'library_query_translator.dart'; import 'scrub_preview_source.dart'; import '../utils/media_server_http_client.dart'; @@ -57,8 +55,11 @@ import '../mpv/mpv.dart'; import 'api_cache.dart'; import 'plex_api_cache.dart'; import 'plex_mappers.dart'; +import 'plex_playback_mapper.dart'; import 'playback_initialization_types.dart'; +part 'plex_client/parts/live_tv.dart'; + /// Result of a paginated library content fetch class _LibraryContentResult { final List items; @@ -125,8 +126,11 @@ class ConnectionTestResult { ConnectionTestResult({required this.success, required this.latencyMs, this.error, this.transcoderVideo}); } -class PlexClient with MediaServerCacheMixin implements MediaServerClient { +class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements MediaServerClient { + @override PlexConfig config; + + @override late final MediaServerHttpClient _http; final EndpointFailoverManager? _endpointManager; final Future Function(String newBaseUrl)? _onEndpointChanged; @@ -164,6 +168,7 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { List _providerLibraries = const []; /// EPG providers parsed from /media/providers + @override List<({String identifier, String gridEndpoint})> _providerEpg = const []; /// Server-level preferences fetched from /:/prefs @@ -275,6 +280,7 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { /// Execute a GET request with endpoint failover retry. On timeout/connection /// errors the next endpoint is tried (once). Non-GET methods are not retried. + @override Future _getWithFailover( String path, { Map? queryParameters, @@ -341,18 +347,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { return sc != null && sc >= 500 && sc <= 599; } - /// POST the tune endpoint with one retry on transient HTTP failure. - Future _postTuneWithRetry(String path, String sessionIdentifier) async { - final query = {'X-Plex-Session-Identifier': sessionIdentifier}; - try { - return await _http.post(path, queryParameters: query, timeout: MediaServerTimeouts.tune); - } on MediaServerHttpException catch (e) { - if (!e.isTransient) rethrow; - appLogger.w('Tune channel: transient failure, retrying once', error: e); - return await _http.post(path, queryParameters: query, timeout: MediaServerTimeouts.tune); - } - } - /// Fetch /media/providers and parse libraries + EPG providers from the response. /// This discovers individually shared items that don't appear in /library/sections. Future _initMediaProviders() async { @@ -553,6 +547,7 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { return ConnectionTestResult(success: true, latencyMs: avgLatency); } + @override Map? _getMediaContainer(MediaServerResponse response) { if (response.data is Map && response.data.containsKey('MediaContainer')) { return response.data['MediaContainer']; @@ -563,8 +558,10 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { PlexMetadataDto _tagMetadata(PlexMetadataDto metadata) => metadata.copyWith(serverId: serverId, serverName: serverName); + @override PlexMetadataDto _createTaggedMetadata(Map json) => _tagMetadata(PlexMetadataDto.fromJson(json)); + @override List _extractMetadataList(MediaServerResponse response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { @@ -867,6 +864,7 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { } /// Wraps an API call that returns a list, returning empty list on error + @override Future> _wrapListApiCall( Future Function() apiCall, List Function(MediaServerResponse response) parseResponse, @@ -908,14 +906,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { return all; } - static const _streamReader = PlexFileInfoStreamReader(); - - /// Parse chapters from metadata JSON - List _parseChapters(Map? metadataJson) => plexChaptersFromCacheJson(metadataJson); - - /// Parse markers from metadata JSON - List _parseMarkers(Map? metadataJson) => plexMarkersFromCacheJson(metadataJson); - /// Set per-media language preferences (audio and subtitle) /// For TV shows, use grandparentRatingKey to set preference for the entire series /// For movies, use the movie's ratingKey @@ -1226,60 +1216,14 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { /// Used by [getVideoPlaybackData] to avoid redundant fetches when the /// response is already available. PlexVideoPlaybackData parseVideoPlaybackDataFromJson(Map? metadataJson, {int mediaIndex = 0}) { - String? videoUrl; - MediaSourceInfo? mediaInfo; - List availableVersions = []; - final markers = _parseMarkers(metadataJson); - - if (metadataJson != null) { - if (metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { - final mediaList = metadataJson['Media'] as List; - - availableVersions = mediaList - .map((media) => PlexMappers.mediaVersionFromJson(media as Map)) - .toList(); - - if (mediaIndex < 0 || mediaIndex >= mediaList.length) { - mediaIndex = 0; - } - - if (!availableVersions[mediaIndex].isPlayable) { - final fallback = availableVersions.indexWhere((v) => v.isPlayable); - if (fallback >= 0) { - appLogger.w('Version $mediaIndex inaccessible/missing — falling back to version $fallback'); - mediaIndex = fallback; - } - } - - final media = mediaList[mediaIndex]; - if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { - final part = media['Part'][0]; - final partKey = part['key'] as String?; - - if (partKey != null) { - videoUrl = '${config.baseUrl}$partKey'.withPlexToken(config.token); - - final streams = walkStreams(part['Stream'] as List?, _streamReader); - final chapters = _parseChapters(metadataJson); - - mediaInfo = MediaSourceInfo( - videoUrl: videoUrl, - audioTracks: streams.audioTracks, - subtitleTracks: streams.subtitleTracks, - chapters: chapters, - partId: part['id'] as int?, - frameRate: streams.frameRate, - ); - } - } - } - } - - return PlexVideoPlaybackData( - videoUrl: videoUrl, - mediaInfo: mediaInfo, - availableVersions: availableVersions, - markers: markers, + return parsePlexVideoPlaybackDataFromJson( + metadataJson, + baseUrl: config.baseUrl, + token: config.token, + mediaIndex: mediaIndex, + onVersionFallback: (requested, fallback) { + appLogger.w('Version $requested inaccessible/missing — falling back to version $fallback'); + }, ); } @@ -1325,54 +1269,7 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { ); final metadataJson = _getFirstMetadataJsonFromData(data); - if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { - final media = metadataJson['Media'][0]; - final part = media['Part'] != null && (media['Part'] as List).isNotEmpty ? media['Part'][0] : null; - - // One pass over the streams array, capturing both the raw video / - // audio map pointers (for fields the parsed track classes don't - // carry — colorSpace, bitDepth, …) and the parsed track lists. - final parsedTracks = walkStreams(part?['Stream'] as List?, _streamReader); - final videoStream = parsedTracks.videoStream; - final audioStream = parsedTracks.audioStream; - - return MediaFileInfo( - // Media level properties - container: media['container'] as String?, - videoCodec: media['videoCodec'] as String?, - videoResolution: media['videoResolution'] as String?, - videoFrameRate: media['videoFrameRate'] as String?, - videoProfile: media['videoProfile'] as String?, - width: media['width'] as int?, - height: media['height'] as int?, - aspectRatio: (media['aspectRatio'] as num?)?.toDouble(), - bitrate: media['bitrate'] as int?, - duration: media['duration'] as int?, - audioCodec: media['audioCodec'] as String?, - audioProfile: media['audioProfile'] as String?, - audioChannels: media['audioChannels'] as int?, - optimizedForStreaming: flexibleBool(media['optimizedForStreaming']), - has64bitOffsets: flexibleBool(media['has64bitOffsets']), - // Part level properties (file) - filePath: part?['file'] as String?, - fileSize: part?['size'] as int?, - // Video stream details - colorSpace: videoStream?['colorSpace'] as String?, - colorRange: videoStream?['colorRange'] as String?, - colorPrimaries: videoStream?['colorPrimaries'] as String?, - chromaSubsampling: videoStream?['chromaSubsampling'] as String?, - frameRate: (videoStream?['frameRate'] as num?)?.toDouble(), - bitDepth: videoStream?['bitDepth'] as int?, - videoBitrate: videoStream?['bitrate'] as int?, - // Audio stream details - audioChannelLayout: audioStream?['audioChannelLayout'] as String?, - // All audio and subtitle tracks - audioTracks: parsedTracks.audioTracks, - subtitleTracks: parsedTracks.subtitleTracks, - ); - } - - return null; + return parsePlexFileInfoFromJson(metadataJson); } catch (e) { appLogger.e('Failed to get file info: $e'); return null; @@ -1451,69 +1348,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { throwIfHttpError(response); } - /// Send a live TV timeline heartbeat to keep the transcode session alive. - /// - /// Returns an updated [CaptureBuffer] if the response contains a - /// `TranscodeSession` with seek-range data (used to expand the seekable - /// window over time). - Future updateLiveTimeline({ - required String ratingKey, - required String sessionPath, - required String sessionIdentifier, - required String state, - required int time, - required int duration, - required int playbackTime, - }) async { - final response = await _getWithFailover( - '/:/timeline', - queryParameters: { - 'ratingKey': ratingKey, - 'key': sessionPath, - 'state': state, - 'hasMDE': '1', - 'time': time, - 'duration': duration, - 'playbackTime': playbackTime, - 'X-Plex-Session-Identifier': sessionIdentifier, - }, - ); - if (response.statusCode != 200) { - appLogger.e('Live timeline returned ${response.statusCode}: ${response.data}'); - return null; - } - - // Parse updated capture buffer from TranscodeSession in the response - try { - final data = response.data; - if (data is! Map) return null; - final container = data['MediaContainer'] as Map? ?? data; - - // Try CaptureBuffer wrapper first, then TranscodeSession directly - final captureBufferWrapper = container['CaptureBuffer']; - if (captureBufferWrapper != null) { - final cbMap = captureBufferWrapper is List - ? captureBufferWrapper.firstOrNull as Map? - : captureBufferWrapper as Map?; - if (cbMap != null) { - final ts = cbMap['TranscodeSession']; - final tsMap = ts is List ? ts.firstOrNull as Map? : ts as Map?; - if (tsMap != null) return CaptureBuffer.fromTranscodeSession(tsMap); - } - } - - final transcodeSessions = container['TranscodeSession']; - if (transcodeSessions is List && transcodeSessions.isNotEmpty) { - return CaptureBuffer.fromTranscodeSession(transcodeSessions.first as Map); - } else if (transcodeSessions is Map) { - return CaptureBuffer.fromTranscodeSession(transcodeSessions); - } - } catch (e) { - // Parsing failure is non-fatal — just no updated seek range - } - return null; - } - /// Remove item from Continue Watching (On Deck) without affecting watch status or progress /// This uses the same endpoint Plex Web uses to hide items from Continue Watching Future removeFromOnDeck(String ratingKey) async { @@ -2477,271 +2311,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { await _getWithFailover('/library/sections/$sectionId/analyze'); } - /// Get all DVR devices configured on this server - Future> getDvrs() async { - return _wrapListApiCall(() => _http.get('/livetv/dvrs'), (response) { - final container = _getMediaContainer(response); - if (container != null && container['Dvr'] != null) { - return (container['Dvr'] as List).map((json) => LiveTvDvr.fromJson(json as Map)).toList(); - } - return []; - }, 'Failed to get DVRs'); - } - - /// Check if this server has at least one DVR configured - Future hasDvr() async { - final dvrs = await getDvrs(); - return dvrs.isNotEmpty; - } - - /// Get EPG channels using provider lineup endpoints (matches official Plex web client) - Future> getEpgChannels({String? lineup}) async { - List parseChannels(MediaServerResponse response) { - final container = _getMediaContainer(response); - if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) { - appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}'); - } - if (container != null && container['Channel'] != null) { - return (container['Channel'] as List) - .map( - (json) => LiveTvChannel.fromJson( - json as Map, - ).copyWith(serverId: serverId, serverName: serverName), - ) - .where((ch) => ch.key.isNotEmpty) - .toList(); - } - if (container != null && container['Metadata'] != null) { - return (container['Metadata'] as List) - .map( - (json) => LiveTvChannel.fromJson( - json as Map, - ).copyWith(serverId: serverId, serverName: serverName), - ) - .where((ch) => ch.key.isNotEmpty) - .toList(); - } - appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}'); - return []; - } - - final allChannels = []; - for (final provider in _epgProvidersForLineup(lineup)) { - final isCloudGuide = provider.identifier.startsWith('tv.plex.providers.epg'); - final legacyEndpoint = '/${provider.identifier}/lineups/dvr/channels'; - - if (isCloudGuide) { - try { - final response = await _getWithFailover('/lineups/plex/channels'); - final parsed = parseChannels(response); - if (parsed.isNotEmpty) { - allChannels.addAll(parsed); - continue; - } - } catch (e) { - appLogger.d( - 'Cloud channel endpoint /lineups/plex/channels unavailable, falling back to $legacyEndpoint', - error: e, - ); - } - } - - try { - final response = await _getWithFailover(legacyEndpoint); - allChannels.addAll(parseChannels(response)); - } catch (e) { - appLogger.e('Failed to get EPG channels from ${provider.identifier} via $legacyEndpoint', error: e); - } - } - return allChannels; - } - - /// Return EPG providers (already parsed from /media/providers during initialization) - Future> _discoverEpgProviders() async { - return _providerEpg; - } - - List<({String identifier, String gridEndpoint})> _epgProvidersForLineup(String? lineup) { - if (lineup == null || lineup.isEmpty) return _providerEpg; - final matching = _providerEpg.where((p) => p.identifier == lineup || p.gridEndpoint.contains(lineup)).toList(); - return matching.isNotEmpty ? matching : _providerEpg; - } - - /// Parse a list of JSON items into [LiveTvProgram] objects, skipping any that fail. - /// A single Metadata entry may carry multiple Media entries representing back-to-back - /// airings of the same program on the same channel; emit one program per airing. - List _parseLiveTvPrograms(List items) { - final programs = []; - for (final item in items) { - try { - final map = item as Map; - final mediaList = (map['Media'] as List?)?.whereType>().toList(); - if (mediaList != null && mediaList.length > 1) { - for (final media in mediaList) { - programs.add(LiveTvProgram.fromJson(map, mediaOverride: media)); - } - } else { - programs.add(LiveTvProgram.fromJson(map)); - } - } catch (e, st) { - appLogger.w('LiveTvProgram parse failed', error: e, stackTrace: st); - } - } - return programs; - } - - /// Get guide/program data for channels (EPG grid data) - /// Discovers grid endpoints from /media/providers on first call and queries all providers - Future> getEpgGrid({int? beginsAt, int? endsAt}) async { - final providers = await _discoverEpgProviders(); - if (providers.isEmpty) return []; - - final queryParams = {}; - if (beginsAt != null) queryParams['endsAt>'] = beginsAt; - if (endsAt != null) queryParams['beginsAt<'] = endsAt; - - final allPrograms = []; - - for (final provider in providers) { - try { - final programs = await _wrapListApiCall( - () => _http.get(provider.gridEndpoint, queryParameters: queryParams), - (response) => _parseEpgGridResponse(response, provider.identifier), - 'Failed to get EPG grid from ${provider.identifier}', - ); - appLogger.d('EPG grid from ${provider.identifier}: ${programs.length} programs'); - allPrograms.addAll(programs); - } catch (e) { - appLogger.e('Failed to get EPG grid from provider ${provider.identifier}', error: e); - } - } - - return allPrograms; - } - - /// Parse an EPG grid response into a list of [LiveTvProgram] objects. - List _parseEpgGridResponse(MediaServerResponse response, String providerIdentifier) { - final container = _getMediaContainer(response); - if (container != null && container['Metadata'] is List && (container['Metadata'] as List).isNotEmpty) { - appLogger.d('EPG grid sample from $providerIdentifier: ${(container['Metadata'] as List).first}'); - } - final programs = []; - if (container != null && container['Metadata'] != null) { - programs.addAll(_parseLiveTvPrograms(container['Metadata'] as List)); - } - // Some responses nest programs inside Hub entries - if (container != null && container['Hub'] != null) { - for (final hub in container['Hub'] as List) { - if (hub is Map && hub['Metadata'] != null) { - programs.addAll(_parseLiveTvPrograms(hub['Metadata'] as List)); - } - } - } - return programs; - } - - /// Get live TV hubs (What's On Now, etc.) from all EPG providers' discover endpoints. - /// Returns hubs with both display metadata and EPG timing/channel data per item. - Future> getLiveTvHubs({int count = 12}) async { - final providers = await _discoverEpgProviders(); - if (providers.isEmpty) return []; - - final allHubs = []; - - for (final provider in providers) { - try { - final response = await _getWithFailover( - '/${provider.identifier}/hubs/discover', - queryParameters: { - 'count': count, - 'includeStations': 1, - 'includeRecentChannels': 1, - 'includeMeta': 1, - 'includeExternalMetadata': 1, - }, - ); - - final container = _getMediaContainer(response); - if (container == null || container['Hub'] == null) continue; - - for (final hubJson in container['Hub'] as List) { - final hub = _parseLiveTvHub(hubJson); - if (hub != null) allHubs.add(hub); - } - } catch (e) { - appLogger.e('Failed to get live TV hubs from provider ${provider.identifier}', error: e); - } - } - - return allHubs; - } - - /// Parse a single hub JSON object into a [LiveTvHubResult], or null if parsing fails. - LiveTvHubResult? _parseLiveTvHub(dynamic hubJson) { - try { - final metadataList = hubJson['Metadata'] as List?; - if (metadataList == null || metadataList.isEmpty) return null; - - final entries = []; - for (final itemJson in metadataList) { - if (itemJson is! Map) continue; - _extractLiveTvImages(itemJson); - final entry = _parseLiveTvHubEntry(itemJson); - if (entry != null) entries.add(entry); - } - - if (entries.isEmpty) return null; - return LiveTvHubResult( - title: hubJson['title'] as String? ?? 'Unknown', - hubKey: hubJson['key'] as String? ?? '', - entries: entries, - ); - } catch (e) { - appLogger.w('Failed to parse live TV hub', error: e); - return null; - } - } - - /// Parse a single metadata item into a [LiveTvHubEntry], or null if parsing fails. - LiveTvHubEntry? _parseLiveTvHubEntry(Map itemJson) { - try { - final dto = PlexMetadataDto.fromJson(itemJson).copyWith(serverId: serverId, serverName: serverName); - final metadata = PlexMappers.mediaItem(dto); - final program = LiveTvProgram.fromJson(itemJson); - return LiveTvHubEntry(metadata: metadata, program: program); - } catch (_) { - return null; - } - } - - /// Extract poster/art URLs from the Image array in EPG metadata items. - /// EPG items often have images only in the Image array (coverPoster, coverArt, etc.) - /// rather than in the standard thumb/art fields. - void _extractLiveTvImages(Map item) { - final images = item['Image'] as List?; - if (images == null) return; - - for (final img in images) { - if (img is! Map) continue; - final type = img['type'] as String?; - final url = img['url'] as String?; - if (url == null) continue; - - switch (type) { - case 'coverPoster': - // Always prefer coverPoster as thumb for poster display - item['thumb'] = url; - break; - case 'coverArt': - item['art'] ??= url; - break; - case 'background': - item['art'] ??= url; - break; - } - } - } - /// Generate 24-char random alphanumeric string. Backend-neutral helper — /// prefer importing `utils/session_identifier.dart` directly. This thin /// forwarder stays for callers that already had a `PlexClient.` reference; @@ -2779,267 +2348,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { } } - /// Tune to a live TV channel. - /// - /// POSTs to the tune endpoint and extracts metadata, session info, and - /// capture buffer data from the response. Call [buildLiveStreamPath] after - /// to build the actual stream URL (with optional offset for time-shift). - Future< - ({ - PlexMetadataDto metadata, - String sessionPath, - String sessionIdentifier, - CaptureBuffer? captureBuffer, - int? beginsAt, - })? - > - tuneChannel(String dvrKey, String channelIdentifier) async { - try { - final sessionIdentifier = generateSessionIdentifier(); - - final response = await _postTuneWithRetry( - '/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune', - sessionIdentifier, - ); - - if (response.statusCode >= 400) { - appLogger.w('Tune channel returned status ${response.statusCode}'); - return null; - } - - final container = _getMediaContainer(response); - if (container == null) return null; - - final containerStatus = container['status']; - final statusInt = containerStatus is num - ? containerStatus.toInt() - : containerStatus is String - ? int.tryParse(containerStatus) - : null; - if (statusInt != null && statusInt != 0 && statusInt != 200) { - final msg = container['message'] ?? 'Unknown error'; - appLogger.w('Tune channel error: $msg (status: $containerStatus)'); - throw Exception(msg); - } - - // Metadata is nested: MediaSubscription[0].MediaGrabOperation[0].Metadata - // Both may be a List or single Map depending on the response format. - Map? metadataJson; - int? beginsAt; - final subscriptions = container['MediaSubscription']; - final subList = subscriptions is List - ? subscriptions - : subscriptions is Map - ? [subscriptions] - : null; - if (subList != null && subList.isNotEmpty) { - final sub = subList.first as Map; - - final timeline = sub['Timeline']; - - // Safely extract the first element if it's a list, or the map itself - final op = timeline is List - ? (timeline.isNotEmpty ? timeline.first : null) - : (timeline is Map ? timeline : null); - - if (op is Map) { - if (op['Metadata'] case [final Map firstMetadata, ...]) { - if (firstMetadata['Media'] case [final Map firstMedia, ...]) { - final rawBeginsAt = firstMedia['beginsAt']; - - beginsAt = switch (rawBeginsAt) { - final num n => n.toInt(), - final String s => int.tryParse(s), - _ => null, - }; - - appLogger.d('beginsAt=$beginsAt'); - } - } - } - - final ops = sub['MediaGrabOperation']; - final opList = ops is List - ? ops - : ops is Map - ? [ops] - : null; - if (opList != null && opList.isNotEmpty) { - final op = opList.first as Map; - final nested = op['Metadata']; - if (nested is Map) { - metadataJson = nested; - } else if (nested is List && nested.isNotEmpty) { - metadataJson = nested.first as Map; - } - } - } - if (metadataJson == null) { - final fallback = container['Metadata']; - if (fallback is List && fallback.isNotEmpty) { - metadataJson = fallback.first as Map; - } else if (fallback is Map) { - metadataJson = fallback; - } - } - - if (metadataJson == null) { - appLogger.w( - 'Tune channel failed: ${container['message'] ?? 'no metadata'} (status: ${container['status']}, keys: ${container.keys.toList()})', - ); - return null; - } - - // Tune response may return XML-style string values where fromJson expects nums. - _coerceNumericFields(metadataJson); - - final metadata = _createTaggedMetadata(metadataJson); - - final sessionPath = metadataJson['key'] as String?; - if (sessionPath == null) { - appLogger.w('Tune channel: no session path in metadata key'); - return null; - } - - // Extract capture buffer from TranscodeSession. - // May be at the container level OR inside the Metadata object. - CaptureBuffer? captureBuffer; - final tsSource = container['TranscodeSession'] ?? metadataJson['TranscodeSession']; - if (tsSource is List && tsSource.isNotEmpty) { - captureBuffer = CaptureBuffer.fromTranscodeSession(tsSource.first as Map); - } else if (tsSource is Map) { - captureBuffer = CaptureBuffer.fromTranscodeSession(tsSource); - } - - // beginsAt may also be on the Media items (not just the GrabOperation) - // This value is the start of the requested stream, not the current program. So it will effectively be the current time - if (beginsAt == null) { - final media = metadataJson['Media']; - if (media is List && media.isNotEmpty) { - final firstMedia = media.first; - if (firstMedia is Map) { - final rawBeginsAt = firstMedia['beginsAt']; - beginsAt = switch (rawBeginsAt) { - final num n => n.toInt(), - final String s => int.tryParse(s), - _ => null, - }; - } - } - } - - return ( - metadata: metadata, - sessionPath: sessionPath, - sessionIdentifier: sessionIdentifier, - captureBuffer: captureBuffer, - beginsAt: beginsAt, - ); - } catch (e, st) { - appLogger.e('Failed to tune channel', error: e, stackTrace: st); - return null; - } - } - - /// Build a live TV stream URL (decision + start path). - /// - /// [sessionPath] and [sessionIdentifier] come from [tuneChannel]. - /// [transcodeSessionId] should be reused across seeks within the same - /// viewing session so the server reuses its capture buffer. - /// [offsetSeconds] positions the stream at that many seconds from the - /// capture buffer origin (for time-shift / watch-from-start). - Future buildLiveStreamPath({ - required String sessionPath, - required String sessionIdentifier, - required String transcodeSessionId, - int? offsetSeconds, - bool directStream = true, - bool directStreamAudio = true, - }) async { - try { - final allParams = { - 'hasMDE': '1', - 'path': sessionPath, - 'mediaIndex': '0', - 'partIndex': '0', - 'protocol': 'http', - 'fastSeek': '1', - 'directPlay': '0', - 'directStream': directStream ? '1' : '0', - 'subtitleSize': '100', - 'audioBoost': '100', - 'location': 'lan', - 'addDebugOverlay': '0', - 'autoAdjustQuality': '0', - 'directStreamAudio': directStreamAudio ? '1' : '0', - 'advancedSubtitles': 'text', - 'mediaBufferSize': '157286', - 'session': transcodeSessionId, - 'subtitles': 'auto', - 'copyts': '0', - 'Accept-Language': 'en', - 'X-Plex-Session-Identifier': sessionIdentifier, - 'X-Plex-Chunked': '1', - 'X-Plex-Incomplete-Segments': '1', - 'X-Plex-Product': config.product, - 'X-Plex-Version': config.version, - 'X-Plex-Client-Identifier': config.clientIdentifier, - 'X-Plex-Platform': config.platform, - 'X-Plex-Client-Profile-Name': 'Plex Desktop', - if (offsetSeconds != null) 'offset': offsetSeconds.toString(), - if (config.token != null) 'X-Plex-Token': config.token!, - }; - - // Manual query encoding — use '%20' for spaces as Plex requires. - final queryString = allParams.entries - .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') - .join('&'); - - // Decision — separate client so no default X-Plex-* HTTP headers leak through. - final decisionClient = MediaServerHttpClient( - connectTimeout: MediaServerTimeouts.connect, - receiveTimeout: MediaServerTimeouts.receive, - defaultHeaders: {'Accept-Language': 'en'}, - ); - final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; - final decisionResponse = await decisionClient.get(decisionUrl); - - if (decisionResponse.statusCode != 200) { - appLogger.w('Decision returned ${decisionResponse.statusCode}'); - return null; - } - - // Log decision response for diagnostics (the web client parses this XML - // to extract generalDecisionCode, mdeDecisionCode, transcodeDecisionCode). - final decisionBody = decisionResponse.data?.toString() ?? ''; - if (decisionBody.isNotEmpty) { - appLogger.d( - 'Decision response: ${decisionBody.length > 500 ? '${decisionBody.substring(0, 500)}...' : decisionBody}', - ); - } - - // Token is added by the caller via .withPlexToken() - final startParams = Map.from(allParams)..remove('X-Plex-Token'); - final startQuery = startParams.entries - .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') - .join('&'); - - return '/video/:/transcode/universal/start?$startQuery'; - } catch (e, st) { - appLogger.e('Failed to build live stream path', error: e, stackTrace: st); - return null; - } - } - - /// Compose a fully-qualified live stream URL from a relative - /// [streamPath] (returned by [buildLiveStreamPath]) by prefixing the - /// configured base URL and appending the Plex token. Centralizes the - /// `'${config.baseUrl}$streamPath'.withPlexToken(config.token)` pattern - /// so token placement / base-URL handling lives in one place. - String buildLiveStreamUrl(String streamPath) { - return '${config.baseUrl}$streamPath'.withPlexToken(config.token); - } - /// Checks whether the server has video transcoding enabled. /// /// Reads `transcoderVideo` from the root MediaContainer. Result is cached @@ -3278,56 +2586,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { } } - /// Get active live TV sessions - Future> _getLiveTvSessions() { - return _wrapListApiCall( - () => _http.get('/livetv/sessions'), - _extractMetadataList, - 'Failed to get live TV sessions', - ); - } - - static const _favoriteChannelsUrl = 'https://epg.provider.plex.tv/settings/favoriteChannels'; - static const _providerVersionHeader = {'X-Plex-Provider-Version': '5.1'}; - - /// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}` - Future buildFavoriteChannelSource({String? lineup}) async { - final providers = _epgProvidersForLineup(lineup); - final providerIdentifier = providers.isNotEmpty ? providers.first.identifier : 'tv.plex.provider.epg'; - final machineId = config.machineIdentifier ?? serverId; - return 'server://$machineId/$providerIdentifier'; - } - - /// Get favorite channels from the Plex cloud. - Future> getFavoriteChannels() async { - try { - final response = await _http.get(_favoriteChannelsUrl, headers: _providerVersionHeader); - final container = _getMediaContainer(response); - if (container != null && container['FavoriteChannel'] != null) { - return (container['FavoriteChannel'] as List) - .map((json) => FavoriteChannel.fromJson(json as Map)) - .toList(); - } - return []; - } catch (e) { - appLogger.e('Failed to get favorite channels', error: e); - return []; - } - } - - /// Update favorite channels on the Plex cloud. - Future setFavoriteChannels(List channels) async { - try { - await _http.put( - _favoriteChannelsUrl, - body: channels.map((c) => c.toJson()).toList(), - headers: _providerVersionHeader, - ); - } catch (e) { - appLogger.e('Failed to update favorite channels', error: e); - } - } - Future _handleEndpointSwitch(String newBaseUrl, {bool persist = true}) async { if (config.baseUrl == newBaseUrl) { return; @@ -3874,12 +3132,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { return raw.map((m) => PlexMappers.mediaItem(m)).toList(); } - /// Plex-specific: live TV sessions (active recordings/playback). - Future> fetchLiveTvSessions() async { - final raw = await _getLiveTvSessions(); - return raw.map((m) => PlexMappers.mediaItem(m)).toList(); - } - /// Plex-specific: library-scoped playlists. Future> fetchLibraryPlaylists({String playlistType = 'video'}) async { final raw = await _getLibraryPlaylists(playlistType: playlistType); @@ -4005,9 +3257,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { String? mediaSourceId, }) => updateProgress(itemId, time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds); - @override - LiveTvSupport get liveTv => _PlexLiveTvSupport(this); - // ── Downloads ──────────────────────────────────────────────────── @override @@ -4047,46 +3296,3 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient { return buildArtworkSpecs(item, getThumbnailUrl); } } - -/// Plex implementation of [LiveTvSupport] — wraps the existing per-DVR -/// methods. The legacy `tuneChannel` / `buildLiveStreamPath` flow remains on -/// [PlexClient] itself because the player consumes those rich session -/// outputs directly; [resolveStreamUrl] returns `null` so callers route -/// through `client + dvrKey`. -class _PlexLiveTvSupport implements LiveTvSupport { - final PlexClient _client; - _PlexLiveTvSupport(this._client); - - @override - Future isAvailable() => _client.hasDvr(); - - @override - Future> fetchDvrs() => _client.getDvrs(); - - @override - Future> fetchChannels({String? lineup}) => _client.getEpgChannels(lineup: lineup); - - @override - Future> fetchSchedule({DateTime? from, DateTime? to}) { - int? toEpoch(DateTime? dt) => dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000; - return _client.getEpgGrid(beginsAt: toEpoch(from), endsAt: toEpoch(to)); - } - - @override - Future resolveStreamUrl(String channelKey, {String? dvrKey}) async => null; - - @override - Future buildFavoriteChannelSource({String? lineup}) => _client.buildFavoriteChannelSource(lineup: lineup); - - @override - String get favoriteStoreKey => 'plex:${_client.config.clientIdentifier}'; - - @override - FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.sharedFullList; - - @override - Future> fetchFavoriteChannels() => _client.getFavoriteChannels(); - - @override - Future setFavoriteChannels(List channels) => _client.setFavoriteChannels(channels); -} diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart new file mode 100644 index 00000000..be38a225 --- /dev/null +++ b/lib/services/plex_client/parts/live_tv.dart @@ -0,0 +1,729 @@ +part of '../../plex_client.dart'; + +const _favoriteChannelsUrl = 'https://epg.provider.plex.tv/settings/favoriteChannels'; +const _providerVersionHeader = {'X-Plex-Provider-Version': '5.1'}; + +mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { + PlexConfig get config; + MediaServerHttpClient get _http; + + @override + String get serverId; + + @override + String? get serverName; + + List<({String identifier, String gridEndpoint})> get _providerEpg; + + Future _getWithFailover(String path, {Map? queryParameters}); + + Map? _getMediaContainer(MediaServerResponse response); + PlexMetadataDto _createTaggedMetadata(Map json); + List _extractMetadataList(MediaServerResponse response); + + Future> _wrapListApiCall( + Future Function() apiCall, + List Function(MediaServerResponse response) parseResponse, + String errorMessage, + ); + + /// POST the tune endpoint with one retry on transient HTTP failure. + Future _postTuneWithRetry(String path, String sessionIdentifier) async { + final query = {'X-Plex-Session-Identifier': sessionIdentifier}; + try { + return await _http.post(path, queryParameters: query, timeout: MediaServerTimeouts.tune); + } on MediaServerHttpException catch (e) { + if (!e.isTransient) rethrow; + appLogger.w('Tune channel: transient failure, retrying once', error: e); + return await _http.post(path, queryParameters: query, timeout: MediaServerTimeouts.tune); + } + } + + /// Send a live TV timeline heartbeat to keep the transcode session alive. + /// + /// Returns an updated [CaptureBuffer] if the response contains a + /// `TranscodeSession` with seek-range data (used to expand the seekable + /// window over time). + Future updateLiveTimeline({ + required String ratingKey, + required String sessionPath, + required String sessionIdentifier, + required String state, + required int time, + required int duration, + required int playbackTime, + }) async { + final response = await _getWithFailover( + '/:/timeline', + queryParameters: { + 'ratingKey': ratingKey, + 'key': sessionPath, + 'state': state, + 'hasMDE': '1', + 'time': time, + 'duration': duration, + 'playbackTime': playbackTime, + 'X-Plex-Session-Identifier': sessionIdentifier, + }, + ); + if (response.statusCode != 200) { + appLogger.e('Live timeline returned ${response.statusCode}: ${response.data}'); + return null; + } + + // Parse updated capture buffer from TranscodeSession in the response + try { + final data = response.data; + if (data is! Map) return null; + final container = data['MediaContainer'] as Map? ?? data; + + // Try CaptureBuffer wrapper first, then TranscodeSession directly + final captureBufferWrapper = container['CaptureBuffer']; + if (captureBufferWrapper != null) { + final cbMap = captureBufferWrapper is List + ? captureBufferWrapper.firstOrNull as Map? + : captureBufferWrapper as Map?; + if (cbMap != null) { + final ts = cbMap['TranscodeSession']; + final tsMap = ts is List ? ts.firstOrNull as Map? : ts as Map?; + if (tsMap != null) return CaptureBuffer.fromTranscodeSession(tsMap); + } + } + + final transcodeSessions = container['TranscodeSession']; + if (transcodeSessions is List && transcodeSessions.isNotEmpty) { + return CaptureBuffer.fromTranscodeSession(transcodeSessions.first as Map); + } else if (transcodeSessions is Map) { + return CaptureBuffer.fromTranscodeSession(transcodeSessions); + } + } catch (e) { + // Parsing failure is non-fatal — just no updated seek range + } + return null; + } + + /// Get all DVR devices configured on this server + Future> getDvrs() async { + return _wrapListApiCall(() => _http.get('/livetv/dvrs'), (response) { + final container = _getMediaContainer(response); + if (container != null && container['Dvr'] != null) { + return (container['Dvr'] as List).map((json) => LiveTvDvr.fromJson(json as Map)).toList(); + } + return []; + }, 'Failed to get DVRs'); + } + + /// Check if this server has at least one DVR configured + Future hasDvr() async { + final dvrs = await getDvrs(); + return dvrs.isNotEmpty; + } + + /// Get EPG channels using provider lineup endpoints (matches official Plex web client) + Future> getEpgChannels({String? lineup}) async { + List parseChannels(MediaServerResponse response) { + final container = _getMediaContainer(response); + if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) { + appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}'); + } + if (container != null && container['Channel'] != null) { + return (container['Channel'] as List) + .map( + (json) => LiveTvChannel.fromJson( + json as Map, + ).copyWith(serverId: serverId, serverName: serverName), + ) + .where((ch) => ch.key.isNotEmpty) + .toList(); + } + if (container != null && container['Metadata'] != null) { + return (container['Metadata'] as List) + .map( + (json) => LiveTvChannel.fromJson( + json as Map, + ).copyWith(serverId: serverId, serverName: serverName), + ) + .where((ch) => ch.key.isNotEmpty) + .toList(); + } + appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}'); + return []; + } + + final allChannels = []; + for (final provider in _epgProvidersForLineup(lineup)) { + final isCloudGuide = provider.identifier.startsWith('tv.plex.providers.epg'); + final legacyEndpoint = '/${provider.identifier}/lineups/dvr/channels'; + + if (isCloudGuide) { + try { + final response = await _getWithFailover('/lineups/plex/channels'); + final parsed = parseChannels(response); + if (parsed.isNotEmpty) { + allChannels.addAll(parsed); + continue; + } + } catch (e) { + appLogger.d( + 'Cloud channel endpoint /lineups/plex/channels unavailable, falling back to $legacyEndpoint', + error: e, + ); + } + } + + try { + final response = await _getWithFailover(legacyEndpoint); + allChannels.addAll(parseChannels(response)); + } catch (e) { + appLogger.e('Failed to get EPG channels from ${provider.identifier} via $legacyEndpoint', error: e); + } + } + return allChannels; + } + + /// Return EPG providers (already parsed from /media/providers during initialization) + Future> _discoverEpgProviders() async { + return _providerEpg; + } + + List<({String identifier, String gridEndpoint})> _epgProvidersForLineup(String? lineup) { + if (lineup == null || lineup.isEmpty) return _providerEpg; + final matching = _providerEpg.where((p) => p.identifier == lineup || p.gridEndpoint.contains(lineup)).toList(); + return matching.isNotEmpty ? matching : _providerEpg; + } + + /// Parse a list of JSON items into [LiveTvProgram] objects, skipping any that fail. + /// A single Metadata entry may carry multiple Media entries representing back-to-back + /// airings of the same program on the same channel; emit one program per airing. + List _parseLiveTvPrograms(List items) { + final programs = []; + for (final item in items) { + try { + final map = item as Map; + final mediaList = (map['Media'] as List?)?.whereType>().toList(); + if (mediaList != null && mediaList.length > 1) { + for (final media in mediaList) { + programs.add(LiveTvProgram.fromJson(map, mediaOverride: media)); + } + } else { + programs.add(LiveTvProgram.fromJson(map)); + } + } catch (e, st) { + appLogger.w('LiveTvProgram parse failed', error: e, stackTrace: st); + } + } + return programs; + } + + /// Get guide/program data for channels (EPG grid data) + /// Discovers grid endpoints from /media/providers on first call and queries all providers + Future> getEpgGrid({int? beginsAt, int? endsAt}) async { + final providers = await _discoverEpgProviders(); + if (providers.isEmpty) return []; + + final queryParams = {}; + if (beginsAt != null) queryParams['endsAt>'] = beginsAt; + if (endsAt != null) queryParams['beginsAt<'] = endsAt; + + final allPrograms = []; + + for (final provider in providers) { + try { + final programs = await _wrapListApiCall( + () => _http.get(provider.gridEndpoint, queryParameters: queryParams), + (response) => _parseEpgGridResponse(response, provider.identifier), + 'Failed to get EPG grid from ${provider.identifier}', + ); + appLogger.d('EPG grid from ${provider.identifier}: ${programs.length} programs'); + allPrograms.addAll(programs); + } catch (e) { + appLogger.e('Failed to get EPG grid from provider ${provider.identifier}', error: e); + } + } + + return allPrograms; + } + + /// Parse an EPG grid response into a list of [LiveTvProgram] objects. + List _parseEpgGridResponse(MediaServerResponse response, String providerIdentifier) { + final container = _getMediaContainer(response); + if (container != null && container['Metadata'] is List && (container['Metadata'] as List).isNotEmpty) { + appLogger.d('EPG grid sample from $providerIdentifier: ${(container['Metadata'] as List).first}'); + } + final programs = []; + if (container != null && container['Metadata'] != null) { + programs.addAll(_parseLiveTvPrograms(container['Metadata'] as List)); + } + // Some responses nest programs inside Hub entries + if (container != null && container['Hub'] != null) { + for (final hub in container['Hub'] as List) { + if (hub is Map && hub['Metadata'] != null) { + programs.addAll(_parseLiveTvPrograms(hub['Metadata'] as List)); + } + } + } + return programs; + } + + /// Get live TV hubs (What's On Now, etc.) from all EPG providers' discover endpoints. + /// Returns hubs with both display metadata and EPG timing/channel data per item. + Future> getLiveTvHubs({int count = 12}) async { + final providers = await _discoverEpgProviders(); + if (providers.isEmpty) return []; + + final allHubs = []; + + for (final provider in providers) { + try { + final response = await _getWithFailover( + '/${provider.identifier}/hubs/discover', + queryParameters: { + 'count': count, + 'includeStations': 1, + 'includeRecentChannels': 1, + 'includeMeta': 1, + 'includeExternalMetadata': 1, + }, + ); + + final container = _getMediaContainer(response); + if (container == null || container['Hub'] == null) continue; + + for (final hubJson in container['Hub'] as List) { + final hub = _parseLiveTvHub(hubJson); + if (hub != null) allHubs.add(hub); + } + } catch (e) { + appLogger.e('Failed to get live TV hubs from provider ${provider.identifier}', error: e); + } + } + + return allHubs; + } + + /// Parse a single hub JSON object into a [LiveTvHubResult], or null if parsing fails. + LiveTvHubResult? _parseLiveTvHub(dynamic hubJson) { + try { + final metadataList = hubJson['Metadata'] as List?; + if (metadataList == null || metadataList.isEmpty) return null; + + final entries = []; + for (final itemJson in metadataList) { + if (itemJson is! Map) continue; + _extractLiveTvImages(itemJson); + final entry = _parseLiveTvHubEntry(itemJson); + if (entry != null) entries.add(entry); + } + + if (entries.isEmpty) return null; + return LiveTvHubResult( + title: hubJson['title'] as String? ?? 'Unknown', + hubKey: hubJson['key'] as String? ?? '', + entries: entries, + ); + } catch (e) { + appLogger.w('Failed to parse live TV hub', error: e); + return null; + } + } + + /// Parse a single metadata item into a [LiveTvHubEntry], or null if parsing fails. + LiveTvHubEntry? _parseLiveTvHubEntry(Map itemJson) { + try { + final dto = PlexMetadataDto.fromJson(itemJson).copyWith(serverId: serverId, serverName: serverName); + final metadata = PlexMappers.mediaItem(dto); + final program = LiveTvProgram.fromJson(itemJson); + return LiveTvHubEntry(metadata: metadata, program: program); + } catch (_) { + return null; + } + } + + /// Extract poster/art URLs from the Image array in EPG metadata items. + /// EPG items often have images only in the Image array (coverPoster, coverArt, etc.) + /// rather than in the standard thumb/art fields. + void _extractLiveTvImages(Map item) { + final images = item['Image'] as List?; + if (images == null) return; + + for (final img in images) { + if (img is! Map) continue; + final type = img['type'] as String?; + final url = img['url'] as String?; + if (url == null) continue; + + switch (type) { + case 'coverPoster': + // Always prefer coverPoster as thumb for poster display + item['thumb'] = url; + break; + case 'coverArt': + item['art'] ??= url; + break; + case 'background': + item['art'] ??= url; + break; + } + } + } + + /// Tune to a live TV channel. + /// + /// POSTs to the tune endpoint and extracts metadata, session info, and + /// capture buffer data from the response. Call [buildLiveStreamPath] after + /// to build the actual stream URL (with optional offset for time-shift). + Future< + ({ + PlexMetadataDto metadata, + String sessionPath, + String sessionIdentifier, + CaptureBuffer? captureBuffer, + int? beginsAt, + })? + > + tuneChannel(String dvrKey, String channelIdentifier) async { + try { + final sessionIdentifier = PlexClient.generateSessionIdentifier(); + + final response = await _postTuneWithRetry( + '/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune', + sessionIdentifier, + ); + + if (response.statusCode >= 400) { + appLogger.w('Tune channel returned status ${response.statusCode}'); + return null; + } + + final container = _getMediaContainer(response); + if (container == null) return null; + + final containerStatus = container['status']; + final statusInt = containerStatus is num + ? containerStatus.toInt() + : containerStatus is String + ? int.tryParse(containerStatus) + : null; + if (statusInt != null && statusInt != 0 && statusInt != 200) { + final msg = container['message'] ?? 'Unknown error'; + appLogger.w('Tune channel error: $msg (status: $containerStatus)'); + throw Exception(msg); + } + + // Metadata is nested: MediaSubscription[0].MediaGrabOperation[0].Metadata + // Both may be a List or single Map depending on the response format. + Map? metadataJson; + int? beginsAt; + final subscriptions = container['MediaSubscription']; + final subList = subscriptions is List + ? subscriptions + : subscriptions is Map + ? [subscriptions] + : null; + if (subList != null && subList.isNotEmpty) { + final sub = subList.first as Map; + + final timeline = sub['Timeline']; + + // Safely extract the first element if it's a list, or the map itself + final op = timeline is List + ? (timeline.isNotEmpty ? timeline.first : null) + : (timeline is Map ? timeline : null); + + if (op is Map) { + if (op['Metadata'] case [final Map firstMetadata, ...]) { + if (firstMetadata['Media'] case [final Map firstMedia, ...]) { + final rawBeginsAt = firstMedia['beginsAt']; + + beginsAt = switch (rawBeginsAt) { + final num n => n.toInt(), + final String s => int.tryParse(s), + _ => null, + }; + + appLogger.d('beginsAt=$beginsAt'); + } + } + } + + final ops = sub['MediaGrabOperation']; + final opList = ops is List + ? ops + : ops is Map + ? [ops] + : null; + if (opList != null && opList.isNotEmpty) { + final op = opList.first as Map; + final nested = op['Metadata']; + if (nested is Map) { + metadataJson = nested; + } else if (nested is List && nested.isNotEmpty) { + metadataJson = nested.first as Map; + } + } + } + if (metadataJson == null) { + final fallback = container['Metadata']; + if (fallback is List && fallback.isNotEmpty) { + metadataJson = fallback.first as Map; + } else if (fallback is Map) { + metadataJson = fallback; + } + } + + if (metadataJson == null) { + appLogger.w( + 'Tune channel failed: ${container['message'] ?? 'no metadata'} (status: ${container['status']}, keys: ${container.keys.toList()})', + ); + return null; + } + + // Tune response may return XML-style string values where fromJson expects nums. + PlexClient._coerceNumericFields(metadataJson); + + final metadata = _createTaggedMetadata(metadataJson); + + final sessionPath = metadataJson['key'] as String?; + if (sessionPath == null) { + appLogger.w('Tune channel: no session path in metadata key'); + return null; + } + + // Extract capture buffer from TranscodeSession. + // May be at the container level OR inside the Metadata object. + CaptureBuffer? captureBuffer; + final tsSource = container['TranscodeSession'] ?? metadataJson['TranscodeSession']; + if (tsSource is List && tsSource.isNotEmpty) { + captureBuffer = CaptureBuffer.fromTranscodeSession(tsSource.first as Map); + } else if (tsSource is Map) { + captureBuffer = CaptureBuffer.fromTranscodeSession(tsSource); + } + + // beginsAt may also be on the Media items (not just the GrabOperation) + // This value is the start of the requested stream, not the current program. So it will effectively be the current time + if (beginsAt == null) { + final media = metadataJson['Media']; + if (media is List && media.isNotEmpty) { + final firstMedia = media.first; + if (firstMedia is Map) { + final rawBeginsAt = firstMedia['beginsAt']; + beginsAt = switch (rawBeginsAt) { + final num n => n.toInt(), + final String s => int.tryParse(s), + _ => null, + }; + } + } + } + + return ( + metadata: metadata, + sessionPath: sessionPath, + sessionIdentifier: sessionIdentifier, + captureBuffer: captureBuffer, + beginsAt: beginsAt, + ); + } catch (e, st) { + appLogger.e('Failed to tune channel', error: e, stackTrace: st); + return null; + } + } + + /// Build a live TV stream URL (decision + start path). + /// + /// [sessionPath] and [sessionIdentifier] come from [tuneChannel]. + /// [transcodeSessionId] should be reused across seeks within the same + /// viewing session so the server reuses its capture buffer. + /// [offsetSeconds] positions the stream at that many seconds from the + /// capture buffer origin (for time-shift / watch-from-start). + Future buildLiveStreamPath({ + required String sessionPath, + required String sessionIdentifier, + required String transcodeSessionId, + int? offsetSeconds, + bool directStream = true, + bool directStreamAudio = true, + }) async { + try { + final allParams = { + 'hasMDE': '1', + 'path': sessionPath, + 'mediaIndex': '0', + 'partIndex': '0', + 'protocol': 'http', + 'fastSeek': '1', + 'directPlay': '0', + 'directStream': directStream ? '1' : '0', + 'subtitleSize': '100', + 'audioBoost': '100', + 'location': 'lan', + 'addDebugOverlay': '0', + 'autoAdjustQuality': '0', + 'directStreamAudio': directStreamAudio ? '1' : '0', + 'advancedSubtitles': 'text', + 'mediaBufferSize': '157286', + 'session': transcodeSessionId, + 'subtitles': 'auto', + 'copyts': '0', + 'Accept-Language': 'en', + 'X-Plex-Session-Identifier': sessionIdentifier, + 'X-Plex-Chunked': '1', + 'X-Plex-Incomplete-Segments': '1', + 'X-Plex-Product': config.product, + 'X-Plex-Version': config.version, + 'X-Plex-Client-Identifier': config.clientIdentifier, + 'X-Plex-Platform': config.platform, + 'X-Plex-Client-Profile-Name': 'Plex Desktop', + if (offsetSeconds != null) 'offset': offsetSeconds.toString(), + if (config.token != null) 'X-Plex-Token': config.token!, + }; + + // Manual query encoding — use '%20' for spaces as Plex requires. + final queryString = allParams.entries + .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + + // Decision — separate client so no default X-Plex-* HTTP headers leak through. + final decisionClient = MediaServerHttpClient( + connectTimeout: MediaServerTimeouts.connect, + receiveTimeout: MediaServerTimeouts.receive, + defaultHeaders: {'Accept-Language': 'en'}, + ); + final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; + final decisionResponse = await decisionClient.get(decisionUrl); + + if (decisionResponse.statusCode != 200) { + appLogger.w('Decision returned ${decisionResponse.statusCode}'); + return null; + } + + // Log decision response for diagnostics (the web client parses this XML + // to extract generalDecisionCode, mdeDecisionCode, transcodeDecisionCode). + final decisionBody = decisionResponse.data?.toString() ?? ''; + if (decisionBody.isNotEmpty) { + appLogger.d( + 'Decision response: ${decisionBody.length > 500 ? '${decisionBody.substring(0, 500)}...' : decisionBody}', + ); + } + + // Token is added by the caller via .withPlexToken() + final startParams = Map.from(allParams)..remove('X-Plex-Token'); + final startQuery = startParams.entries + .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + + return '/video/:/transcode/universal/start?$startQuery'; + } catch (e, st) { + appLogger.e('Failed to build live stream path', error: e, stackTrace: st); + return null; + } + } + + /// Compose a fully-qualified live stream URL from a relative + /// [streamPath] (returned by [buildLiveStreamPath]) by prefixing the + /// configured base URL and appending the Plex token. Centralizes the + /// `'${config.baseUrl}$streamPath'.withPlexToken(config.token)` pattern + /// so token placement / base-URL handling lives in one place. + String buildLiveStreamUrl(String streamPath) { + return '${config.baseUrl}$streamPath'.withPlexToken(config.token); + } + + /// Get active live TV sessions + Future> _getLiveTvSessions() { + return _wrapListApiCall( + () => _http.get('/livetv/sessions'), + _extractMetadataList, + 'Failed to get live TV sessions', + ); + } + + /// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}` + Future buildFavoriteChannelSource({String? lineup}) async { + final providers = _epgProvidersForLineup(lineup); + final providerIdentifier = providers.isNotEmpty ? providers.first.identifier : 'tv.plex.provider.epg'; + final machineId = config.machineIdentifier ?? serverId; + return 'server://$machineId/$providerIdentifier'; + } + + /// Get favorite channels from the Plex cloud. + Future> getFavoriteChannels() async { + try { + final response = await _http.get(_favoriteChannelsUrl, headers: _providerVersionHeader); + final container = _getMediaContainer(response); + if (container != null && container['FavoriteChannel'] != null) { + return (container['FavoriteChannel'] as List) + .map((json) => FavoriteChannel.fromJson(json as Map)) + .toList(); + } + return []; + } catch (e) { + appLogger.e('Failed to get favorite channels', error: e); + return []; + } + } + + /// Update favorite channels on the Plex cloud. + Future setFavoriteChannels(List channels) async { + try { + await _http.put( + _favoriteChannelsUrl, + body: channels.map((c) => c.toJson()).toList(), + headers: _providerVersionHeader, + ); + } catch (e) { + appLogger.e('Failed to update favorite channels', error: e); + } + } + + /// Plex-specific: live TV sessions (active recordings/playback). + Future> fetchLiveTvSessions() async { + final raw = await _getLiveTvSessions(); + return raw.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + @override + LiveTvSupport get liveTv => _PlexLiveTvSupport(this as PlexClient); +} + +/// Plex implementation of [LiveTvSupport] — wraps the existing per-DVR +/// methods. The legacy `tuneChannel` / `buildLiveStreamPath` flow remains on +/// [PlexClient] itself because the player consumes those rich session +/// outputs directly; [resolveStreamUrl] returns `null` so callers route +/// through `client + dvrKey`. +class _PlexLiveTvSupport implements LiveTvSupport { + final PlexClient _client; + _PlexLiveTvSupport(this._client); + + @override + Future isAvailable() => _client.hasDvr(); + + @override + Future> fetchDvrs() => _client.getDvrs(); + + @override + Future> fetchChannels({String? lineup}) => _client.getEpgChannels(lineup: lineup); + + @override + Future> fetchSchedule({DateTime? from, DateTime? to}) { + int? toEpoch(DateTime? dt) => dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000; + return _client.getEpgGrid(beginsAt: toEpoch(from), endsAt: toEpoch(to)); + } + + @override + Future resolveStreamUrl(String channelKey, {String? dvrKey}) async => null; + + @override + Future buildFavoriteChannelSource({String? lineup}) => _client.buildFavoriteChannelSource(lineup: lineup); + + @override + String get favoriteStoreKey => 'plex:${_client.config.clientIdentifier}'; + + @override + FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.sharedFullList; + + @override + Future> fetchFavoriteChannels() => _client.getFavoriteChannels(); + + @override + Future setFavoriteChannels(List channels) => _client.setFavoriteChannels(channels); +} diff --git a/lib/services/plex_playback_mapper.dart b/lib/services/plex_playback_mapper.dart new file mode 100644 index 00000000..ed0336ce --- /dev/null +++ b/lib/services/plex_playback_mapper.dart @@ -0,0 +1,125 @@ +import '../media/media_file_info.dart'; +import '../media/media_source_info.dart'; +import '../media/media_version.dart'; +import '../models/plex/plex_video_playback_data.dart'; +import '../utils/json_utils.dart'; +import '../utils/plex_url_helper.dart'; +import 'file_info_parser.dart'; +import 'plex_mappers.dart'; + +const _streamReader = PlexFileInfoStreamReader(); + +PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( + Map? metadataJson, { + required String baseUrl, + required String? token, + int mediaIndex = 0, + void Function(int requestedIndex, int fallbackIndex)? onVersionFallback, +}) { + String? videoUrl; + MediaSourceInfo? mediaInfo; + List availableVersions = []; + final markers = plexMarkersFromCacheJson(metadataJson); + + if (metadataJson != null) { + if (metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { + final mediaList = metadataJson['Media'] as List; + + availableVersions = mediaList + .map((media) => PlexMappers.mediaVersionFromJson(media as Map)) + .toList(); + + if (mediaIndex < 0 || mediaIndex >= mediaList.length) { + mediaIndex = 0; + } + + if (!availableVersions[mediaIndex].isPlayable) { + final fallback = availableVersions.indexWhere((v) => v.isPlayable); + if (fallback >= 0) { + onVersionFallback?.call(mediaIndex, fallback); + mediaIndex = fallback; + } + } + + final media = mediaList[mediaIndex]; + if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { + final part = media['Part'][0]; + final partKey = part['key'] as String?; + + if (partKey != null) { + videoUrl = '$baseUrl$partKey'.withPlexToken(token); + + final streams = walkStreams(part['Stream'] as List?, _streamReader); + final chapters = plexChaptersFromCacheJson(metadataJson); + + mediaInfo = MediaSourceInfo( + videoUrl: videoUrl, + audioTracks: streams.audioTracks, + subtitleTracks: streams.subtitleTracks, + chapters: chapters, + partId: part['id'] as int?, + frameRate: streams.frameRate, + ); + } + } + } + } + + return PlexVideoPlaybackData( + videoUrl: videoUrl, + mediaInfo: mediaInfo, + availableVersions: availableVersions, + markers: markers, + ); +} + +MediaFileInfo? parsePlexFileInfoFromJson(Map? metadataJson) { + if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { + final media = metadataJson['Media'][0]; + final part = media['Part'] != null && (media['Part'] as List).isNotEmpty ? media['Part'][0] : null; + + // One pass over the streams array, capturing both the raw video / audio + // map pointers (for fields the parsed track classes don't carry — + // colorSpace, bitDepth, …) and the parsed track lists. + final parsedTracks = walkStreams(part?['Stream'] as List?, _streamReader); + final videoStream = parsedTracks.videoStream; + final audioStream = parsedTracks.audioStream; + + return MediaFileInfo( + // Media level properties + container: media['container'] as String?, + videoCodec: media['videoCodec'] as String?, + videoResolution: media['videoResolution'] as String?, + videoFrameRate: media['videoFrameRate'] as String?, + videoProfile: media['videoProfile'] as String?, + width: media['width'] as int?, + height: media['height'] as int?, + aspectRatio: (media['aspectRatio'] as num?)?.toDouble(), + bitrate: media['bitrate'] as int?, + duration: media['duration'] as int?, + audioCodec: media['audioCodec'] as String?, + audioProfile: media['audioProfile'] as String?, + audioChannels: media['audioChannels'] as int?, + optimizedForStreaming: flexibleBool(media['optimizedForStreaming']), + has64bitOffsets: flexibleBool(media['has64bitOffsets']), + // Part level properties (file) + filePath: part?['file'] as String?, + fileSize: part?['size'] as int?, + // Video stream details + colorSpace: videoStream?['colorSpace'] as String?, + colorRange: videoStream?['colorRange'] as String?, + colorPrimaries: videoStream?['colorPrimaries'] as String?, + chromaSubsampling: videoStream?['chromaSubsampling'] as String?, + frameRate: (videoStream?['frameRate'] as num?)?.toDouble(), + bitDepth: videoStream?['bitDepth'] as int?, + videoBitrate: videoStream?['bitrate'] as int?, + // Audio stream details + audioChannelLayout: audioStream?['audioChannelLayout'] as String?, + // All audio and subtitle tracks + audioTracks: parsedTracks.audioTracks, + subtitleTracks: parsedTracks.subtitleTracks, + ); + } + + return null; +} diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart new file mode 100644 index 00000000..cf3d6a6a --- /dev/null +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -0,0 +1,314 @@ +part of '../video_controls.dart'; + +extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { + Future _initKeyboardService() async { + _keyboardService = await KeyboardShortcutsService.getInstance(); + } + + /// Check if a key is a directional key (arrow keys) + bool _isDirectionalKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.arrowUp || + key == LogicalKeyboardKey.arrowDown || + key == LogicalKeyboardKey.arrowLeft || + key == LogicalKeyboardKey.arrowRight; + } + + /// Check if a key is a select/enter key + bool _isSelectKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.numpadEnter || + key == LogicalKeyboardKey.gameButtonA; + } + + /// Determine if the key event should toggle play/pause based on configured hotkeys. + bool _isPlayPauseKey(KeyEvent event) { + final logicalKey = event.logicalKey; + final physicalKey = event.physicalKey; + + // Always accept hardware media play/pause keys (Android TV remotes) + if (logicalKey == LogicalKeyboardKey.mediaPlayPause || + logicalKey == LogicalKeyboardKey.mediaPlay || + logicalKey == LogicalKeyboardKey.mediaPause) { + return true; + } + + // When the shortcuts service is available, respect the configured play/pause hotkey + if (_keyboardService != null) { + final hotkey = _keyboardService!.hotkeys['play_pause']; + if (hotkey == null) return false; + return hotkey.key == physicalKey; + } + + // Fallback to defaults while the service is loading + return physicalKey == PhysicalKeyboardKey.space || physicalKey == PhysicalKeyboardKey.mediaPlayPause; + } + + /// Check if a key is a media seek key (Android TV remotes) + bool _isMediaSeekKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.mediaFastForward || + key == LogicalKeyboardKey.mediaRewind || + key == LogicalKeyboardKey.mediaSkipForward || + key == LogicalKeyboardKey.mediaSkipBackward; + } + + /// Check if a key is a media track key (Android TV remotes) + bool _isMediaTrackKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.mediaTrackNext || key == LogicalKeyboardKey.mediaTrackPrevious; + } + + bool _isPlayPauseActivation(KeyEvent event) { + return event is KeyDownEvent && _isPlayPauseKey(event); + } + + /// Global key event handler for focus-independent shortcuts (desktop only) + bool _handleGlobalKeyEvent(KeyEvent event) { + if (!mounted) return false; + + // When an overlay sheet is open (e.g. subtitle search with text fields), + // don't consume key events — let text input work normally. + if (OverlaySheetController.maybeOf(context)?.isOpen ?? false) { + return false; + } + + // Back key fallback when _focusNode lost focus (TV, or desktop with nav on). + // Focus.onKeyEvent won't fire if _focusNode lost focus, so handle ESC here. + if ((_videoPlayerNavigationEnabled || PlatformDetector.isTV()) && event.logicalKey.isBackKey) { + if (!_focusNode.hasFocus) { + // Skip if an overlay sheet is open — the sheet's FocusScope handles + // back keys via its own onKeyEvent. Without this check, this global + // handler would call Navigator.pop() alongside the sheet's handler. + final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; + if (sheetOpen) return false; + // On TV, mark coordinator early (KeyDown) so PopScope.onPopInvokedWithResult + // sees it before KeyUp — prevents the system back from racing ahead. + if (PlatformDetector.isTV() && event is KeyDownEvent) { + BackKeyCoordinator.markHandled(); + } + final backResult = handleBackKeyAction(event, () { + if (PlatformDetector.isTV()) { + if (_showControls) { + if (_isContentStripVisible) { + _desktopControlsKey.currentState?.dismissContentStrip(); + _setControlsState(() => _isContentStripVisible = false); + _restartHideTimerIfPlaying(); + return; + } + _hideControls(); + return; + } + (widget.onBack ?? () => Navigator.of(context).pop(true))(); + return; + } + if (!_showControls) { + _showControlsWithFocus(); + } else { + (widget.onBack ?? () => Navigator.of(context).pop(true))(); + } + }); + if (backResult != KeyEventResult.ignored) return true; + } + } + + // Only handle when video player navigation is disabled (desktop mode without D-pad nav) + if (_videoPlayerNavigationEnabled) return false; + + // Skip on mobile (unless TV) + final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV(); + if (isMobile) return false; + + // Handle play/pause globally - works regardless of focus + if (_isPlayPauseActivation(event)) { + _playOrPause(); + _showControlsWithFocus(requestFocus: false); + return true; // Event handled, stop propagation + } + + // Fallback: handle all other shortcuts when focus has drifted away + // (e.g. after controls auto-hide). The !hasFocus guard prevents + // double-handling when the Focus onKeyEvent already processes the event. + if (!_focusNode.hasFocus && _keyboardService != null) { + // On Windows/Linux with navigation off, ESC only exits fullscreen — + // never exits the player. Intercept before the keyboard shortcuts + // service which would call onBack and pop the route. + // Skip if an overlay sheet is open — let the sheet handle ESC. + if (!_videoPlayerNavigationEnabled && (Platform.isWindows || Platform.isLinux) && event.logicalKey.isBackKey) { + final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; + if (!sheetOpen) { + if (event is KeyUpEvent) { + _exitFullscreenIfNeeded(); + } + _focusNode.requestFocus(); + return true; + } + } + final result = _keyboardService!.handleVideoPlayerKeyEvent( + event, + widget.player, + _toggleFullscreen, + _toggleSubtitles, + _nextAudioTrack, + _nextSubtitleTrack, + _nextChapter, + _previousChapter, + onBack: widget.onBack ?? () => Navigator.of(context).pop(true), + onToggleShader: _toggleShader, + onNextEpisode: widget.onNext, + onPreviousEpisode: widget.onPrevious, + currentPositionEpoch: widget.currentPositionEpoch, + onLiveSeek: widget.onLiveSeek, + ); + if (result == KeyEventResult.handled) { + _focusNode.requestFocus(); // self-heal focus + return true; + } + } + + return true; // Consume all events while video player is active + } + + KeyEventResult _handleControlsKeyEvent(KeyEvent event, bool isMobile) { + // On Windows/Linux with navigation off, ESC only exits fullscreen — + // never exits the player. Consume all back key events and check + // actual window state asynchronously. + if (!_videoPlayerNavigationEnabled && (Platform.isWindows || Platform.isLinux) && event.logicalKey.isBackKey) { + if (event is KeyUpEvent) { + _exitFullscreenIfNeeded(); + } + return KeyEventResult.handled; + } + // On TV, mark coordinator early (KeyDown) so PopScope.onPopInvokedWithResult + // sees it before KeyUp — prevents the system back from racing ahead. + if (PlatformDetector.isTV() && event.logicalKey.isBackKey && event is KeyDownEvent) { + BackKeyCoordinator.markHandled(); + } + final backResult = handleBackKeyAction(event, () { + if (PlatformDetector.isTV()) { + if (_showControls) { + if (_isContentStripVisible) { + _desktopControlsKey.currentState?.dismissContentStrip(); + _setControlsState(() => _isContentStripVisible = false); + _restartHideTimerIfPlaying(); + return; + } + _hideControls(); + return; + } + (widget.onBack ?? () => Navigator.of(context).pop(true))(); + return; + } + if (!_showControls) { + _showControlsWithFocus(); + return; + } + // Controls visible - navigate back + (widget.onBack ?? () => Navigator.of(context).pop(true))(); + }); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + + // Only handle KeyDown and KeyRepeat events. + // Consume KeyUp events for navigation keys to prevent leaking to previous routes. + // Let non-navigation keys (volume, etc.) pass through to the OS. + if (!event.isActionable) { + if (!event.logicalKey.isNavigationKey) return KeyEventResult.ignored; + return KeyEventResult.handled; + } + + // Reset hide timer on any keyboard/controller input when controls are visible. + if (_showControls) { + _restartHideTimerIfPlaying(); + } + + final key = event.logicalKey; + final isPlayPauseKey = _isPlayPauseKey(event); + + // Always consume play/pause keys to prevent propagation to background routes. + // On TV/mobile, handle play/pause here; on desktop, the global handler does it. + if (isPlayPauseKey) { + if (_videoPlayerNavigationEnabled || isMobile) { + if (_isPlayPauseActivation(event)) { + _playOrPause(); + _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); + } + } + return KeyEventResult.handled; + } + + // Handle media seek keys (Android TV remotes). + // Uses chapter navigation if chapters are available, otherwise seeks by configured time. + if (event is KeyDownEvent && _isMediaSeekKey(key)) { + if (widget.canControl) { + final isForward = key == LogicalKeyboardKey.mediaFastForward || key == LogicalKeyboardKey.mediaSkipForward; + unawaited(_seekToChapter(forward: isForward)); + } + _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); + return KeyEventResult.handled; + } + + // Handle next/previous track keys (Android TV remotes). + // Uses same behavior as seek keys: chapter navigation or time-based seek. + if (event is KeyDownEvent && _isMediaTrackKey(key)) { + if (widget.canControl) { + unawaited(_seekToChapter(forward: key == LogicalKeyboardKey.mediaTrackNext)); + } + _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); + return KeyEventResult.handled; + } + + // Handle Select/Enter when controls are hidden: pause and show controls. + // Only intercept if this Focus node itself has primary focus (not a descendant). + if (_isSelectKey(key) && !_showControls && _focusNode.hasPrimaryFocus) { + _playOrPause(); + _showControlsWithFocus(); + return KeyEventResult.handled; + } + + // On desktop/TV, show controls on directional input. + // LEFT/RIGHT focuses timeline for seeking, UP/DOWN focuses play/pause. + if (!isMobile && _isDirectionalKey(key) && (_videoPlayerNavigationEnabled || PlatformDetector.isTV())) { + if (!_showControls) { + final isHorizontal = key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight; + if (isHorizontal) { + _showControlsWithTimelineFocus(); + if (widget.canControl) { + final forward = key == LogicalKeyboardKey.arrowRight; + unawaited(_seekByTime(forward: forward)); + } + } else { + _showControlsWithFocus(); + } + return KeyEventResult.handled; + } + // Children (DesktopVideoControls) handle navigation first via their own onKeyEvent. + // If we reach here, children already declined the event — consume it to prevent leaking. + return KeyEventResult.handled; + } + + // Pass other events to the keyboard shortcuts service. + if (_keyboardService == null) return KeyEventResult.handled; + + final result = _keyboardService!.handleVideoPlayerKeyEvent( + event, + widget.player, + _toggleFullscreen, + _toggleSubtitles, + _nextAudioTrack, + _nextSubtitleTrack, + _nextChapter, + _previousChapter, + onBack: widget.onBack ?? () => Navigator.of(context).pop(true), + onToggleShader: _toggleShader, + onSkipMarker: _performAutoSkip, + onNextEpisode: widget.onNext, + onPreviousEpisode: widget.onPrevious, + currentPositionEpoch: widget.currentPositionEpoch, + onLiveSeek: widget.onLiveSeek, + ); + // Let non-navigation keys (volume, etc.) pass through to the OS. + if (!event.logicalKey.isNavigationKey) return KeyEventResult.ignored; + // Never return .ignored for navigation keys — prevent leaking to previous routes. + return result == KeyEventResult.ignored ? KeyEventResult.handled : result; + } +} diff --git a/lib/widgets/video_controls/parts/markers.dart b/lib/widgets/video_controls/parts/markers.dart new file mode 100644 index 00000000..b27b947e --- /dev/null +++ b/lib/widgets/video_controls/parts/markers.dart @@ -0,0 +1,174 @@ +part of '../video_controls.dart'; + +extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState { + void _listenToPosition() { + _positionSubscription = widget.player.streams.position.listen((position) { + if (_markers.isEmpty || !_markersLoaded) { + return; + } + + MediaMarker? foundMarker; + for (final marker in _markers) { + if (marker.containsPosition(position)) { + foundMarker = marker; + break; + } + } + + if (foundMarker != _currentMarker && mounted) { + _updateCurrentMarker(foundMarker); + } + }); + } + + /// Updates the current marker and manages auto-skip/focus behavior. + void _updateCurrentMarker(MediaMarker? foundMarker) { + _setControlsState(() { + _currentMarker = foundMarker; + _skipButtonDismissed = false; + }); + + if (foundMarker == null) { + _cancelAutoSkipTimer(); + _cancelSkipButtonDismissTimer(); + return; + } + + _startAutoSkipTimer(foundMarker); + + // Auto-skip OFF: dismiss button after 7s if no interaction + // Auto-skip ON: button stays until controls hide + if (!_shouldAutoSkipForMarker(foundMarker)) { + _startSkipButtonDismissTimer(); + } + + // Auto-focus skip button on TV when marker appears (only in keyboard/TV mode) + if (PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _skipMarkerFocusNode.requestFocus(); + } + }); + } + } + + Future _skipMarker({bool skipAutoPlayCountdown = false}) async { + if (_currentMarker == null) return; + + final marker = _currentMarker!; + final endTime = marker.endTime; + final duration = widget.player.state.duration; + final isAtEnd = duration > Duration.zero && (duration - endTime).inMilliseconds <= 1000; + + if (marker.isCredits && isAtEnd) { + if (!skipAutoPlayCountdown && widget.onNext != null) { + widget.onNext!.call(); + } else { + // Seeking to EOF is unreliable due to position stream throttling, + // so pause and defer to the parent's completion flow. + await widget.player.pause(); + widget.onReachedEnd?.call(skipAutoPlayCountdown: skipAutoPlayCountdown); + } + } else { + await _seekToPosition(endTime); + } + + if (!mounted) return; + _setControlsState(() { + _currentMarker = null; + }); + _cancelAutoSkipTimer(); + _cancelSkipButtonDismissTimer(); + } + + void _startAutoSkipTimer(MediaMarker marker) { + _cancelAutoSkipTimer(); + + final shouldAutoSkip = (marker.isCredits && _autoSkipCredits) || (!marker.isCredits && _autoSkipIntro); + + if (!shouldAutoSkip || _autoSkipDelay <= 0) return; + + _autoSkipProgress = 0.0; + const tickDuration = Duration(milliseconds: 200); + final totalTicks = (_autoSkipDelay * 1000) / tickDuration.inMilliseconds; + + if (totalTicks <= 0) return; + + _autoSkipTimer = Timer.periodic(tickDuration, (timer) { + if (!mounted || _currentMarker != marker) { + timer.cancel(); + return; + } + + _setControlsState(() { + _autoSkipProgress = (timer.tick / totalTicks).clamp(0.0, 1.0); + }); + + if (timer.tick >= totalTicks) { + timer.cancel(); + _performAutoSkip(skipAutoPlayCountdown: true); + } + }); + } + + void _cancelAutoSkipTimer() { + _autoSkipTimer?.cancel(); + _autoSkipTimer = null; + if (mounted) { + _setControlsState(() { + _autoSkipProgress = 0.0; + }); + } + } + + /// Starts/restarts the skip button dismiss timer. When it fires, hides the + /// button and cancels any active auto-skip countdown. + void _startSkipButtonDismissTimer() { + _skipButtonDismissTimer?.cancel(); + _skipButtonDismissTimer = Timer(const Duration(seconds: 7), () { + if (!mounted || _currentMarker == null) return; + _setControlsState(() { + _skipButtonDismissed = true; + }); + _cancelAutoSkipTimer(); + }); + } + + void _cancelSkipButtonDismissTimer() { + _skipButtonDismissTimer?.cancel(); + _skipButtonDismissTimer = null; + } + + /// Perform the appropriate skip action based on marker type and next episode availability + void _performAutoSkip({bool skipAutoPlayCountdown = false}) { + if (_currentMarker == null) return; + unawaited(_skipMarker(skipAutoPlayCountdown: skipAutoPlayCountdown)); + } + + /// Check if auto-skip should be active for the current marker + bool _shouldAutoSkipForMarker(MediaMarker marker) { + return (marker.isCredits && _autoSkipCredits) || (!marker.isCredits && _autoSkipIntro); + } + + bool _shouldShowAutoSkip() { + if (_currentMarker == null) return false; + return _shouldAutoSkipForMarker(_currentMarker!); + } + + Widget _buildSkipMarkerButton() { + final isAutoSkipActive = _autoSkipTimer?.isActive ?? false; + return SkipMarkerButton( + marker: _currentMarker!, + playerDuration: widget.player.state.duration, + hasNextEpisode: widget.onNext != null, + isAutoSkipActive: isAutoSkipActive, + shouldShowAutoSkip: _shouldShowAutoSkip(), + autoSkipDelay: _autoSkipDelay, + autoSkipProgress: _autoSkipProgress, + focusNode: _skipMarkerFocusNode, + onCancelAutoSkip: _cancelAutoSkipTimer, + onPerformAutoSkip: _performAutoSkip, + onFocusDown: () => _desktopControlsKey.currentState?.requestPlayPauseFocus(), + ); + } +} diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart new file mode 100644 index 00000000..efa25d0f --- /dev/null +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -0,0 +1,214 @@ +part of '../video_controls.dart'; + +extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { + Widget _buildDesktopControlsListener() { + final playbackState = context.watch(); + final trackControlsState = _buildTrackControlsState( + playbackState: playbackState, + onToggleAlwaysOnTop: Platform.isMacOS ? null : _toggleAlwaysOnTop, + ); + final useDpad = _videoPlayerNavigationEnabled || PlatformDetector.isTV(); + + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => _restartHideTimerIfPlaying(), + child: DesktopVideoControls( + key: _desktopControlsKey, + player: widget.player, + metadata: widget.metadata, + onNext: widget.onNext, + onPrevious: widget.onPrevious, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + seekTimeSmall: _seekTimeSmall, + onSeekToPreviousChapter: _seekToPreviousChapter, + onSeekToNextChapter: _seekToNextChapter, + onSeekBackward: () => unawaited(_seekByTime(forward: false)), + onSeekForward: () => unawaited(_seekByTime(forward: true)), + onSeek: _throttledSeek, + onSeekEnd: _finalizeSeek, + getReplayIcon: getReplayIcon, + getForwardIcon: getForwardIcon, + onFocusActivity: _restartHideTimerIfPlaying, + onHideControls: _hideControlsFromKeyboard, + trackControlsState: trackControlsState, + onBack: widget.onBack, + hasFirstFrame: widget.hasFirstFrame, + thumbnailDataBuilder: widget.thumbnailDataBuilder, + liveChannelName: widget.liveChannelName, + captureBuffer: widget.captureBuffer, + isAtLiveEdge: widget.isAtLiveEdge, + streamStartEpoch: widget.streamStartEpoch, + currentPositionEpoch: widget.currentPositionEpoch, + onLiveSeek: widget.onLiveSeek, + onJumpToLive: widget.onJumpToLive, + useDpadNavigation: useDpad, + serverId: widget.metadata.serverId, + showQueueTab: playbackState.isQueueActive, + onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, + onCancelAutoHide: () => _hideTimer?.cancel(), + onStartAutoHide: _startHideTimer, + onSeekCompleted: widget.onSeekCompleted, + onContentStripVisibilityChanged: (visible) { + _setControlsState(() => _isContentStripVisible = visible); + if (visible) { + _hideTimer?.cancel(); + } else { + _restartHideTimerIfPlaying(); + } + }, + ), + ); + } + + /// Switch to a different media version + void _onQueueItemSelected(MediaItem item) { + final videoPlayerState = context.findAncestorStateOfType(); + videoPlayerState?.navigateToQueueItem(item); + } + + Future _onSubtitleDownloaded() async { + if (!mounted) return; + + // Plex-only: the OpenSubtitles polling flow uses [getVideoPlaybackData] + // and the Plex token. Jellyfin has no analogue and the entry point + // (`subtitleSearchSupported`) is already gated on backend, but guard + // here too in case a future caller wires the same handler elsewhere. + if (widget.metadata.backend != MediaBackend.plex) return; + final serverId = widget.metadata.serverId; + if (serverId == null) return; + + try { + final client = context.getPlexClientForServer(serverId); + final token = client.config.token; + if (token == null) return; + + // Plex's OpenSubtitles download is asynchronous: the PUT returns immediately + // but the new stream entry shows up in metadata seconds later. Poll until it + // appears. Up to 15s matches what Plex-web tolerates before giving up. + // Snapshot what's already attached so we can identify the new download. + final existingUris = widget.player.state.tracks.subtitle.where((t) => t.uri != null).map((t) => t.uri!).toSet(); + + final deadline = DateTime.now().add(const Duration(seconds: 15)); + MediaSubtitleTrack? newTrack; + String? newUrl; + MediaSourceInfo? latestInfo; + + while (mounted && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(seconds: 2)); + if (!mounted) return; + + try { + final data = await client.getVideoPlaybackData(widget.metadata.id); + if (!mounted) return; + if (data.mediaInfo == null) continue; + latestInfo = data.mediaInfo; + + for (final plexTrack in data.mediaInfo!.subtitleTracks) { + if (!plexTrack.isExternal) continue; + final url = client.buildExternalSubtitleUrl(plexTrack); + if (url == null) continue; + if (existingUris.any((uri) => uri.contains(plexTrack.key!))) continue; + + newTrack = plexTrack; + newUrl = url; + break; + } + if (newTrack != null) break; + } catch (e) { + appLogger.w('Subtitle download poll iteration failed', error: e); + } + } + + if (!mounted || newTrack == null || newUrl == null) return; + + await widget.player.addSubtitleTrack( + uri: newUrl, + title: newTrack.displayTitle ?? newTrack.language ?? 'Downloaded', + language: newTrack.languageCode, + select: true, + ); + + final partId = latestInfo?.partId; + if (partId != null) { + await client.selectStreams(partId, subtitleStreamID: newTrack.id); + } + } catch (e) { + appLogger.w('Failed to refresh subtitles after download', error: e); + } + } + + /// Switch version, quality preset, or audio stream ID. Any combination may + /// change in one invocation; unspecified values retain their current value. + /// Always routes through pushReplacement, preserving playback position and + /// the transcode session identifiers. + Future _switchVersionAndQuality({ + int? newMediaIndex, + TranscodeQualityPreset? newPreset, + int? newAudioStreamId, + }) async { + final effectiveMediaIndex = newMediaIndex ?? widget.selectedMediaIndex; + final effectivePreset = newPreset ?? widget.selectedQualityPreset; + final effectiveAudioStreamId = newAudioStreamId ?? widget.selectedAudioStreamId; + + final isVersionChange = effectiveMediaIndex != widget.selectedMediaIndex; + final isPresetChange = effectivePreset != widget.selectedQualityPreset; + final isAudioChange = effectiveAudioStreamId != widget.selectedAudioStreamId; + if (!isVersionChange && !isPresetChange && !isAudioChange) { + return; + } + + try { + // Save current playback position + final currentPosition = widget.player.state.position; + + // Get state reference before async operations + final videoPlayerState = context.findAncestorStateOfType(); + + if (isVersionChange) { + final settingsService = await SettingsService.getInstance(); + final seriesKey = widget.metadata.grandparentId ?? widget.metadata.id; + await settingsService.write(SettingsService.mediaVersionPreferences, { + ...settingsService.read(SettingsService.mediaVersionPreferences), + seriesKey: effectiveMediaIndex, + }); + } + + // Preserve session identifiers across the reload so Plex reuses the + // transcode session rather than spinning up a new one. + final sessionId = videoPlayerState?.playbackSessionIdentifier; + final transcodeSessionId = videoPlayerState?.playbackTranscodeSessionId; + + // Set flag on parent VideoPlayerScreen to skip orientation restoration + videoPlayerState?.setReplacingWithVideo(); + // Dispose the existing player before spinning up the replacement to avoid race conditions + await videoPlayerState?.disposePlayerForNavigation(); + + // Navigate to new player screen with the updated selection + // Use PageRouteBuilder with zero-duration transitions to prevent orientation reset + if (mounted) { + unawaited( + Navigator.pushReplacement( + context, + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( + metadata: widget.metadata.copyWith(viewOffsetMs: currentPosition.inMilliseconds), + selectedMediaIndex: effectiveMediaIndex, + selectedQualityPreset: effectivePreset, + selectedAudioStreamId: effectiveAudioStreamId, + reusedSessionIdentifier: sessionId, + reusedTranscodeSessionId: transcodeSessionId, + ), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + ), + ), + ); + } + } catch (e) { + if (mounted) { + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + } + } + } +} diff --git a/lib/widgets/video_controls/parts/playback_extras.dart b/lib/widgets/video_controls/parts/playback_extras.dart new file mode 100644 index 00000000..455c5c2e --- /dev/null +++ b/lib/widgets/video_controls/parts/playback_extras.dart @@ -0,0 +1,37 @@ +part of '../video_controls.dart'; + +extension _PlexVideoControlsPlaybackExtrasMethods on _PlexVideoControlsState { + Future _loadPlaybackExtras({bool forceRefresh = false}) async { + // Live TV metadata uses EPG rating keys, not library items + if (widget.isLive) return; + if (_isLoadingExtras) return; + _isLoadingExtras = true; + + final serverId = widget.metadata.serverId; + // Read providers before any await — `context` after an async gap is + // a lint trigger and can crash if the widget unmounts mid-load. + final client = serverId != null ? context.tryGetMediaClientForServer(serverId) : null; + final database = context.read(); + + try { + final extras = await VideoControlsPlaybackExtrasLoader( + metadata: widget.metadata, + database: database, + client: client, + ).load(forceRefresh: forceRefresh); + if (extras != null) _applyPlaybackExtras(extras); + } finally { + _isLoadingExtras = false; + } + } + + void _applyPlaybackExtras(PlaybackExtras extras) { + if (!mounted) return; + _setControlsState(() { + _chapters = extras.chapters; + _markers = extras.markers; + _chaptersLoaded = true; + _markersLoaded = true; + }); + } +} diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart new file mode 100644 index 00000000..82158301 --- /dev/null +++ b/lib/widgets/video_controls/parts/playback_input.dart @@ -0,0 +1,306 @@ +part of '../video_controls.dart'; + +extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { + void _onRateChanged(double newRate) { + if (!mounted) return; + if (_isLongPressing) return; + if (_suppressRateToastUntil != null && DateTime.now().isBefore(_suppressRateToastUntil!)) return; + final prev = _lastReportedRate; + if (prev != null && (prev - newRate).abs() < 0.005) return; + _lastReportedRate = newRate; + final icon = newRate >= 1.0 ? Symbols.fast_forward_rounded : Symbols.slow_motion_video_rounded; + widget.toastController.show(icon, formatPlaybackRate(newRate)); + } + + void _seekToPreviousChapter() => unawaited(_seekToChapter(forward: false)); + + void _seekToNextChapter() => unawaited(_seekToChapter(forward: true)); + + Future _seekByTime({required bool forward}) async { + final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall); + await _seekByOffset(delta); + } + + Future _seekToChapter({required bool forward}) async { + if (_chapters.isEmpty) { + // No chapters - seek by configured amount + final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall); + await _seekByOffset(delta); + return; + } + + final currentPositionMs = widget.player.state.position.inMilliseconds; + + if (forward) { + // Find next chapter + for (final chapter in _chapters) { + final chapterStart = chapter.startTimeOffset ?? 0; + if (chapterStart > currentPositionMs) { + await _seekToPosition(Duration(milliseconds: chapterStart)); + return; + } + } + } else { + // Find previous/current chapter + for (int i = _chapters.length - 1; i >= 0; i--) { + final chapterStart = _chapters[i].startTimeOffset ?? 0; + if (currentPositionMs > chapterStart + 3000) { + // If more than 3 seconds into chapter, go to start of current chapter + await _seekToPosition(Duration(milliseconds: chapterStart)); + return; + } + } + // If at start of first chapter, go to beginning + await _seekToPosition(Duration.zero); + } + } + + Future _seekToPosition(Duration position, {bool notifyCompletion = true}) async { + final clamped = clampSeekPosition(widget.player, position); + await widget.player.seek(clamped); + if (notifyCompletion && mounted) { + widget.onSeekCompleted?.call(clamped); + } + } + + Future _seekByOffset(Duration delta, {bool notifyCompletion = true}) async { + // Route through live seek callback for time-shifted live TV + if (widget.isLive && widget.onLiveSeek != null && widget.currentPositionEpoch != null) { + widget.onLiveSeek!(widget.currentPositionEpoch! + delta.inSeconds); + return; + } + final target = widget.player.state.position + delta; + final clamped = clampSeekPosition(widget.player, target); + await widget.player.seek(clamped); + if (notifyCompletion && mounted) { + widget.onSeekCompleted?.call(clamped); + } + } + + Future _playOrPause() async { + if (!widget.player.state.playing && _rewindOnResume > 0) { + final target = widget.player.state.position - Duration(seconds: _rewindOnResume); + final clamped = clampSeekPosition(widget.player, target); + await widget.player.seek(clamped); + } + await widget.player.playOrPause(); + } + + /// Throttled seek for timeline slider - executes immediately then throttles to 200ms + void _throttledSeek(Duration position) => _seekThrottle([position]); + + /// Finalizes the seek when user stops scrubbing the timeline + void _finalizeSeek(Duration position) { + _seekThrottle.cancel(); + unawaited(_seekToPosition(position)); + } + + /// Timing-based double-click detection: avoids `onDoubleTap`'s ~300 ms + /// tap-resolution delay and the arena competition it introduces. + void _handleOuterTap() { + if (widget.canControl && _clickVideoTogglesPlayback) { + _playOrPause(); + } else { + _toggleControls(); + } + + if (PlatformDetector.isMobile(context)) return; + + final now = DateTime.now(); + if (_lastSkipTapTime != null && now.difference(_lastSkipTapTime!).inMilliseconds < 250) { + _lastSkipTapTime = null; + _toggleFullscreen(); + return; + } + _lastSkipTapTime = now; + } + + /// Handle tap in skip zone with custom double-tap detection + void _handleTapInSkipZone({required bool isForward}) { + final now = DateTime.now(); + + // Cancel any pending single-tap action + _singleTapTimer?.cancel(); + _singleTapTimer = null; + + // Debounce: ignore taps within 200ms of last skip action + // This prevents double-taps from counting as two separate skips + if (_lastSkipActionTime != null && now.difference(_lastSkipActionTime!).inMilliseconds < 200) { + return; + } + + // Check if this qualifies as a double-tap (within 250ms of last tap, same side) + final isDoubleTap = + _lastSkipTapTime != null && + now.difference(_lastSkipTapTime!).inMilliseconds < 250 && + _lastSkipTapWasForward == isForward; + + // Skip ONLY on detected double-tap (no single-tap-to-add behavior) + if (isDoubleTap) { + _lastSkipTapTime = null; // Reset to prevent triple-tap chaining + + if (_showDoubleTapFeedback && _lastDoubleTapWasForward == isForward) { + // Stacking skip - add to accumulated + unawaited(_handleStackingSkip(isForward: isForward)); + } else { + // First double-tap - initiate skip + unawaited(_handleDoubleTapSkip(isForward: isForward)); + } + } else { + // First tap - record timestamp and start timer for single-tap action + _lastSkipTapTime = now; + _lastSkipTapWasForward = isForward; + + // If no second tap within 250ms, treat as single tap to toggle controls + _singleTapTimer = Timer(const Duration(milliseconds: 250), () { + if (mounted) { + _toggleControls(); + } + }); + } + } + + /// Handle stacking skip - add to accumulated skip when feedback is active + Future _handleStackingSkip({required bool isForward}) async { + if (!widget.canControl) return; + + // Add to accumulated skip + _accumulatedSkipSeconds += _seekTimeSmall; + + // Calculate and perform seek + final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall); + await _seekByOffset(delta); + + // Refresh feedback (extends timer, updates display) + _showSkipFeedback(isForward: isForward); + + // Record skip time for debounce + _lastSkipActionTime = DateTime.now(); + } + + /// Handle double-tap skip forward or backward + Future _handleDoubleTapSkip({required bool isForward}) async { + // Ignore if user cannot control playback + if (!widget.canControl) return; + + // Reset accumulated skip for new gesture + _accumulatedSkipSeconds = _seekTimeSmall; + + final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall); + await _seekByOffset(delta); + + // Show visual feedback + _showSkipFeedback(isForward: isForward); + + // Record skip time for debounce + _lastSkipActionTime = DateTime.now(); + } + + /// Show animated visual feedback for skip gesture + void _showSkipFeedback({required bool isForward}) { + _feedbackTimer?.cancel(); + + _setControlsState(() { + _lastDoubleTapWasForward = isForward; + _showDoubleTapFeedback = true; + _doubleTapFeedbackOpacity = 1.0; + }); + + // Capture duration before timer to avoid context access in callback + final slowDuration = tokens(context).slow; + + // Fade out after delay (1200ms gives time to see value and continue tapping) + _feedbackTimer = Timer(const Duration(milliseconds: 1200), () { + if (mounted) { + _setControlsState(() { + _doubleTapFeedbackOpacity = 0.0; + }); + + Timer(slowDuration, () { + if (mounted) { + _setControlsState(() { + _showDoubleTapFeedback = false; + _accumulatedSkipSeconds = 0; // Reset when feedback hides + }); + } + }); + } + }); + } + + /// Handle tap on controls overlay - route to skip zones or toggle controls + void _handleControlsOverlayTap(TapUpDetails details, BoxConstraints constraints) { + final isMobile = PlatformDetector.isMobile(context); + + if (!isMobile) { + final DateTime now = DateTime.now(); + + // Always perform the single-click behavior immediately + if (widget.canControl && _clickVideoTogglesPlayback) { + _playOrPause(); + } else { + _toggleControls(); + } + + // Detect double-click + final bool isDoubleClick = _lastSkipTapTime != null && now.difference(_lastSkipTapTime!).inMilliseconds < 250; + + if (isDoubleClick) { + _lastSkipTapTime = null; + + // Perform desktop double-click action: toggle fullscreen + _toggleFullscreen(); + + return; + } + + // Record this click as a candidate for double-click detection + _lastSkipTapTime = now; + return; + } + + final width = constraints.maxWidth; + final skipZone = mobileSkipZoneForTap(position: details.localPosition, size: Size(width, constraints.maxHeight)); + if (skipZone != null) { + _handleTapInSkipZone(isForward: skipZone); + return; + } + + // Not in skip zone, toggle controls + _toggleControls(); + } + + /// Handle long-press start - activate 2x speed + void _handleLongPressStart() { + if (!widget.canControl || widget.isLive) return; + + _setControlsState(() { + _isLongPressing = true; + _rateBeforeLongPress = widget.player.state.rate; + _showSpeedIndicator = true; + }); + widget.player.setRate(2.0); + } + + /// Handle long-press end - restore original speed + void _handleLongPressEnd() { + if (!_isLongPressing) return; + // Swallow the rate-restore emission so the stream-driven toast doesn't + // flash as the rate snaps back to the prior value. + _suppressRateToastUntil = DateTime.now().add(const Duration(milliseconds: 250)); + widget.player.setRate(_rateBeforeLongPress ?? 1.0); + _setControlsState(() { + _isLongPressing = false; + _rateBeforeLongPress = null; + _showSpeedIndicator = false; + }); + } + + /// Handle long-press cancel (same as end) + void _handleLongPressCancel() => _handleLongPressEnd(); + + /// Build the visual indicator for long-press 2x speed. + /// Manual (persistent for duration of press) — separate from the stream-driven + /// toast so it stays visible for the full long-press rather than auto-hiding. + Widget _buildSpeedIndicator() => const PlayerToastIndicator(icon: Symbols.fast_forward_rounded, text: '2x'); +} diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart new file mode 100644 index 00000000..befc9f5f --- /dev/null +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -0,0 +1,226 @@ +part of '../video_controls.dart'; + +extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { + Future _loadSeekTimes() async { + final settingsService = await SettingsService.getInstance(); + if (mounted) { + _setControlsState(() { + _seekTimeSmall = settingsService.read(SettingsService.seekTimeSmall); + _rewindOnResume = settingsService.read(SettingsService.rewindOnResume); + _audioSyncOffset = settingsService.read(SettingsService.audioSyncOffset); + _subtitleSyncOffset = settingsService.read(SettingsService.subtitleSyncOffset); + _isRotationLocked = settingsService.read(SettingsService.rotationLocked); + _autoSkipIntro = settingsService.read(SettingsService.autoSkipIntro); + _autoSkipCredits = settingsService.read(SettingsService.autoSkipCredits); + _autoSkipDelay = settingsService.read(SettingsService.autoSkipDelay); + _videoPlayerNavigationEnabled = settingsService.read(SettingsService.videoPlayerNavigationEnabled); + _showPerformanceOverlay = settingsService.read(SettingsService.showPerformanceOverlay); + _autoHidePerformanceOverlay = settingsService.read(SettingsService.autoHidePerformanceOverlay); + _clickVideoTogglesPlayback = settingsService.read(SettingsService.clickVideoTogglesPlayback); + }); + + // Focus play/pause if navigation is now enabled and controls are visible + // (handles case where initState focus attempt failed due to async settings load) + if (_videoPlayerNavigationEnabled && _showControls) { + _focusPlayPauseIfKeyboardMode(); + } + + // Apply rotation lock setting + if (_isRotationLocked) { + unawaited( + SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]), + ); + } else { + unawaited(SystemChrome.setPreferredOrientations(DeviceOrientation.values)); + } + } + } + + void _toggleSubtitles() { + final currentTrack = widget.player.state.track.subtitle; + // No-op if no subtitle track is selected + if (currentTrack == null || currentTrack.id == 'no') return; + + final newVisible = !_subtitlesVisible; + widget.player.setProperty('sub-visibility', newVisible ? 'yes' : 'no'); + _setControlsState(() { + _subtitlesVisible = newVisible; + }); + } + + void _onSubtitleTrackChanged(SubtitleTrack track) { + // Reset visibility when user explicitly picks a new subtitle track + if (track.id != 'no' && !_subtitlesVisible) { + widget.player.setProperty('sub-visibility', 'yes'); + _setControlsState(() { + _subtitlesVisible = true; + }); + } + widget.onSubtitleTrackChanged?.call(track); + } + + void _toggleShader() { + final shaderService = widget.shaderService; + if (shaderService == null || !shaderService.isSupported) return; + + if (shaderService.currentPreset.isEnabled) { + // Currently active - disable temporarily + unawaited( + shaderService + .applyPreset(ShaderPreset.none) + .then((_) { + // ignore: no-empty-block - setState triggers rebuild to reflect disabled shader + if (mounted) _setControlsState(() {}); + widget.onShaderChanged?.call(); + }) + .catchError((Object e, StackTrace st) { + appLogger.w('Failed to disable shader', error: e, stackTrace: st); + }), + ); + } else { + // Currently off - restore saved preset + final shaderProvider = context.read(); + final saved = shaderProvider.savedPreset; + final allPresets = shaderProvider.allPresets; + final targetPreset = saved.isEnabled + ? saved + : allPresets.firstWhere((p) => p.isEnabled, orElse: () => allPresets[1]); + unawaited( + shaderService + .applyPreset(targetPreset) + .then((_) { + shaderProvider.setCurrentPreset(targetPreset); + // ignore: no-empty-block - setState triggers rebuild to reflect restored shader + if (mounted) _setControlsState(() {}); + widget.onShaderChanged?.call(); + }) + .catchError((Object e, StackTrace st) { + appLogger.w('Failed to apply shader preset', error: e, stackTrace: st); + }), + ); + } + } + + void _nextAudioTrack() { + if (!widget.canControl) return; + widget.onCycleAudioTrack?.call(); + } + + void _nextSubtitleTrack() { + if (!widget.canControl) return; + widget.onCycleSubtitleTrack?.call(); + } + + void _nextChapter() => _seekToNextChapter(); + + void _previousChapter() => _seekToPreviousChapter(); + + TrackControlsState _buildTrackControlsState({ + required PlaybackStateProvider playbackState, + required VoidCallback? onToggleAlwaysOnTop, + }) { + final versionQuality = effectiveVersionQualityControls( + isOfflinePlayback: widget.isOfflinePlayback, + availableVersions: widget.availableVersions, + serverSupportsTranscoding: widget.serverSupportsTranscoding, + isTranscoding: widget.isTranscoding, + sourceAudioTracks: widget.sourceAudioTracks, + selectedAudioStreamId: widget.selectedAudioStreamId, + ); + return TrackControlsState( + availableVersions: versionQuality.availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, + selectedQualityPreset: widget.selectedQualityPreset, + serverSupportsTranscoding: versionQuality.serverSupportsTranscoding, + isTranscoding: versionQuality.isTranscoding, + sourceAudioTracks: versionQuality.sourceAudioTracks, + selectedAudioStreamId: versionQuality.selectedAudioStreamId, + sourceDurationMs: widget.metadata.durationMs, + boxFitMode: widget.boxFitMode, + audioSyncOffset: _audioSyncOffset, + subtitleSyncOffset: _subtitleSyncOffset, + isRotationLocked: _isRotationLocked, + isScreenLocked: _isScreenLocked, + isFullscreen: _isFullscreen, + isAlwaysOnTop: _isAlwaysOnTop, + onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null, + onCycleBoxFitMode: widget.onCycleBoxFitMode, + onToggleRotationLock: _toggleRotationLock, + onToggleScreenLock: _toggleScreenLock, + onToggleFullscreen: _toggleFullscreen, + onToggleAlwaysOnTop: onToggleAlwaysOnTop, + onSwitchVersion: versionQuality.canSwitch ? (i) => _switchVersionAndQuality(newMediaIndex: i) : null, + onSwitchQualityPreset: versionQuality.canSwitch ? (p) => _switchVersionAndQuality(newPreset: p) : null, + onSwitchAudioStreamId: versionQuality.canSwitch ? (id) => _switchVersionAndQuality(newAudioStreamId: id) : null, + onAudioTrackChanged: widget.onAudioTrackChanged, + onSubtitleTrackChanged: _onSubtitleTrackChanged, + onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, + onLoadSeekTimes: () async { + if (mounted) { + await _loadSeekTimes(); + } + }, + onCancelAutoHide: () => _hideTimer?.cancel(), + onStartAutoHide: _startHideTimer, + onSyncOffsetChanged: (propertyName, offset) { + _setControlsState(() { + if (propertyName == 'sub-delay') { + _subtitleSyncOffset = offset; + } else { + _audioSyncOffset = offset; + } + }); + }, + serverId: widget.metadata.serverId ?? '', + shaderService: widget.shaderService, + onShaderChanged: widget.onShaderChanged, + isAmbientLightingEnabled: widget.isAmbientLightingEnabled, + onToggleAmbientLighting: widget.player.playerType != 'exoplayer' ? widget.onToggleAmbientLighting : null, + canControl: widget.canControl, + isLive: widget.isLive, + subtitlesVisible: _subtitlesVisible, + showQueueButton: playbackState.isQueueActive, + onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, + ratingKey: widget.metadata.id, + mediaTitle: widget.metadata.title, + onSubtitleDownloaded: _onSubtitleDownloaded, + // Plex proxies OpenSubtitles via its server-side plugin; Jellyfin + // doesn't expose an equivalent so the Search Subtitles tile is hidden + // for Jellyfin items. The check uses the registered client type for + // this metadata's serverId. + subtitleSearchSupported: _isPlexBackedMetadata(), + ); + } + + /// True when the active server supports external subtitle search (Plex + /// today). Requires a server id because the download callback needs the + /// Plex client/token for that server. + bool _isPlexBackedMetadata() { + try { + final serverId = widget.metadata.serverId; + if (serverId == null) return false; + final manager = context.read().serverManager; + final c = manager.getClient(serverId); + return c?.capabilities.externalSubtitleSearch ?? false; + } catch (_) { + return false; + } + } + + Widget _buildTrackChapterControlsWidget({bool hideChaptersAndQueue = false}) { + final playbackState = context.watch(); + final trackControlsState = _buildTrackControlsState( + playbackState: playbackState, + onToggleAlwaysOnTop: _toggleAlwaysOnTop, + ); + + return TrackChapterControls( + player: widget.player, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + trackControlsState: trackControlsState, + onSeekCompleted: widget.onSeekCompleted, + hideChaptersAndQueue: hideChaptersAndQueue, + ); + } +} diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart new file mode 100644 index 00000000..ffefdfbd --- /dev/null +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -0,0 +1,399 @@ +part of '../video_controls.dart'; + +extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { + /// Called when hasFirstFrame changes - start auto-hide timer when first frame is ready + void _onFirstFrameReady() { + if (widget.hasFirstFrame?.value == true) { + _startHideTimer(); + // Retry with network-first if initial cache-first returned empty + if (_chapters.isEmpty && _markers.isEmpty) { + _loadPlaybackExtras(forceRefresh: true); + } + } + } + + /// Called when controlsVisible is set externally (e.g. screen-level focus recovery + /// after controls auto-hide ejects focus on Android TV). + void _onControlsVisibleExternal() { + if (widget.controlsVisible?.value == true && !_showControls && mounted) { + _showControlsWithFocus(); + } + } + + /// Focus play/pause button if we're in keyboard navigation mode (desktop/TV only) + void _focusPlayPauseIfKeyboardMode() { + if (!mounted) return; + if (!_videoPlayerNavigationEnabled) return; + final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV(); + if (!isMobile && InputModeTracker.isKeyboardMode(context)) { + _desktopControlsKey.currentState?.requestPlayPauseFocus(); + } + } + + /// Listen to playback state changes to manage auto-hide timer + void _listenToPlayingState() { + _playingSubscription = widget.player.streams.playing.listen((isPlaying) { + if (isPlaying && _showControls) { + _startHideTimer(); + } else if (!isPlaying && _showControls) { + _startPausedHideTimer(); + } + }); + } + + /// Listen to completed stream to show controls when video ends + void _listenToCompleted() { + _completedSubscription = widget.player.streams.completed.listen((completed) { + if (completed && mounted) { + // Cancel long-press 2x speed if active + if (_isLongPressing) { + _handleLongPressCancel(); + } + // Show controls when video completes (for play next dialog etc.) + _setControlsState(() { + _showControls = true; + }); + // Notify parent of visibility change (for popup positioning) + widget.controlsVisible?.value = true; + _hideTimer?.cancel(); + } + }); + } + + /// Controls hide delay: 5s on mobile/TV/keyboard-nav, 3s on desktop with mouse. + Duration get _hideDelay { + final isMobile = (Platform.isIOS || Platform.isAndroid) && !PlatformDetector.isTV(); + if (isMobile || PlatformDetector.isTV() || _videoPlayerNavigationEnabled) { + return const Duration(seconds: 5); + } + return const Duration(seconds: 3); + } + + /// Shared hide logic: hides controls, notifies parent, updates traffic lights, restores focus. + void _hideControls() { + if (!mounted || !_showControls || _forceShowControls) return; + _setControlsState(() { + _showControls = false; + _isContentStripVisible = false; + // Dismiss skip button with controls — after this it only re-appears with controls + if (_currentMarker != null) { + _skipButtonDismissed = true; + } + }); + _desktopControlsKey.currentState?.hideContentStrip(); + _cancelSkipButtonDismissTimer(); + widget.controlsVisible?.value = false; + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + // Reclaim focus so the global key handler stays active for TV dpad, + // but skip if an overlay sheet owns focus — stealing it would break + // sheet navigation (e.g. the compact sync bar). + final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; + if (!sheetOpen) { + // Always request primary focus on _focusNode — not just when hasFocus is + // false. hasFocus is true when a descendant (e.g. play/pause) has focus, + // but we need _focusNode itself to hold primary focus so its onKeyEvent + // fires for the next d-pad press (otherwise focus escapes to the screen- + // level self-heal handler which shows controls with play/pause focus). + _focusNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_focusNode.hasPrimaryFocus) { + _focusNode.requestFocus(); + } + }); + } + } + + void _startHideTimer() { + _hideTimer?.cancel(); + + // Don't auto-hide while loading first frame (user needs to see spinner and back button) + 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, () { + // Also check hasFirstFrame in callback (in case it changed) + final stillLoading = !(widget.hasFirstFrame?.value ?? true); + if (mounted && widget.player.state.playing && !stillLoading) { + _hideControls(); + } + }); + } + } + + /// Auto-hide controls after pause (does not check playing state in callback). + void _startPausedHideTimer() { + _hideTimer?.cancel(); + if (_forceShowControls) return; + _hideTimer = Timer(_hideDelay, () { + _hideControls(); + }); + } + + /// Restart the hide timer on user interaction (if video is playing) + void _restartHideTimerIfPlaying() { + if (widget.player.state.playing) { + _startHideTimer(); + } + } + + /// Hide controls immediately when the mouse leaves the player area (desktop only). + void _hideControlsFromPointerExit() { + final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV(); + if (isMobile) return; + + _hideTimer?.cancel(); + _hideControls(); + } + + void _handlePointerSignal(PointerSignalEvent event) { + if (event is PointerScrollEvent && _keyboardService != null) { + final delta = event.scrollDelta.dy; + final volume = widget.player.state.volume; + final maxVol = _keyboardService!.maxVolume.toDouble(); + final newVolume = (volume - delta / 20).clamp(0.0, maxVol); + widget.player.setVolume(newVolume); + unawaited(SettingsService.getInstance().then((s) => s.write(SettingsService.volume, newVolume))); + _showControlsFromPointerActivity(); + } + } + + /// Show controls in response to pointer activity (mouse/trackpad movement). + void _showControlsFromPointerActivity() { + if (!_showControls) { + _setControlsState(() { + _showControls = true; + }); + // Notify parent of visibility change (for popup positioning) + widget.controlsVisible?.value = true; + // On macOS, keep window controls in sync with the overlay + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + } + + // Keep the overlay visible while the user is moving the pointer + _restartHideTimerIfPlaying(); + + // Cancel auto-skip when user moves pointer over the player + _cancelAutoSkipTimer(); + } + + void _toggleControls() { + if (_showControls) { + _hideControls(); + } else { + _setControlsState(() { + _showControls = true; + }); + widget.controlsVisible?.value = true; + _startHideTimer(); + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + } + // Cancel auto-skip on any tap + _cancelAutoSkipTimer(); + } + + void _toggleRotationLock() async { + _setControlsState(() { + _isRotationLocked = !_isRotationLocked; + }); + + // Save to settings + final settingsService = await SettingsService.getInstance(); + await settingsService.write(SettingsService.rotationLocked, _isRotationLocked); + + if (_isRotationLocked) { + // Locked: Allow landscape orientations only + await SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); + } else { + // Unlocked: Allow all orientations including portrait + await SystemChrome.setPreferredOrientations(DeviceOrientation.values); + } + } + + void _toggleScreenLock() { + final locking = !_isScreenLocked; + _setControlsState(() { + _isScreenLocked = locking; + if (locking) { + _showLockIcon = true; + } + }); + if (locking) { + _hideControls(); + _startLockIconHideTimer(); + } + } + + void _startLockIconHideTimer() { + _lockIconTimer?.cancel(); + _lockIconTimer = Timer(const Duration(seconds: 3), () { + if (mounted) _setControlsState(() => _showLockIcon = false); + }); + } + + void _unlockScreen() { + _setControlsState(() { + _isScreenLocked = false; + _showLockIcon = false; + _showControls = true; + }); + _lockIconTimer?.cancel(); + widget.controlsVisible?.value = true; + _startHideTimer(); + } + + void _updateTrafficLightVisibility() async { + // When maximized or fullscreen, always keep traffic lights visible so the + // 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 || _forceShowControls ? true : _showControls; + await MacOSWindowService.setTrafficLightsVisible(visible); + } + + /// Check whether PiP is supported on this device + Future _checkPipSupport() async { + if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) { + return; + } + + try { + final supported = await PipService.isSupported(); + if (mounted) { + _setControlsState(() { + _isPipSupported = supported; + }); + } + } catch (e) { + return; + } + } + + /// macOS PiP changed — force controls visible while PiP is active + void _onMacPipChanged() { + if (!mounted) return; + final inPip = _pipService.isPipActive.value; + _setControlsState(() => _forceShowControls = inPip); + if (inPip) { + _hideTimer?.cancel(); + widget.controlsVisible?.value = true; + } else { + _startHideTimer(); + } + } + + Future _toggleFullscreen() async { + if (!PlatformDetector.isMobile(context)) { + await FullscreenStateManager().toggleFullscreen(); + } + } + + /// Exit fullscreen if the window is actually fullscreen (async check). + /// Used by ESC handler on Windows/Linux to avoid relying on _isFullscreen flag. + Future _exitFullscreenIfNeeded() async { + if (await windowManager.isFullScreen()) { + await FullscreenStateManager().exitFullscreen(); + } + } + + /// Initialize always-on-top state from window manager (desktop only) + Future _initAlwaysOnTopState() async { + final isOnTop = await windowManager.isAlwaysOnTop(); + if (mounted && isOnTop != _isAlwaysOnTop) { + _setControlsState(() { + _isAlwaysOnTop = isOnTop; + }); + } + } + + /// Toggle always-on-top window mode (desktop only) + Future _toggleAlwaysOnTop() async { + if (!PlatformDetector.isMobile(context)) { + final newValue = !_isAlwaysOnTop; + await windowManager.setAlwaysOnTop(newValue); + if (!mounted) return; + _setControlsState(() { + _isAlwaysOnTop = newValue; + }); + } + } + + /// Show controls and optionally focus play/pause on keyboard input (desktop only) + void _showControlsWithFocus({bool requestFocus = true}) { + if (!_showControls) { + _setControlsState(() { + _showControls = true; + }); + // Notify parent of visibility change (for popup positioning) + widget.controlsVisible?.value = true; + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + } + _startHideTimer(); + + // Request focus on play/pause button after controls are shown + if (requestFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _desktopControlsKey.currentState?.requestPlayPauseFocus(); + }); + } else { + // When not requesting focus on play/pause, ensure main focus node keeps focus + // This prevents focus from being lost when controls become visible + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_focusNode.hasFocus) { + _focusNode.requestFocus(); + } + }); + } + } + + /// Show controls and focus timeline on LEFT/RIGHT input (TV/desktop) + void _showControlsWithTimelineFocus() { + if (!_showControls) { + _setControlsState(() { + _showControls = true; + }); + // Notify parent of visibility change (for popup positioning) + widget.controlsVisible?.value = true; + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + } + _startHideTimer(); + + // Request focus on timeline after controls are shown + WidgetsBinding.instance.addPostFrameCallback((_) { + _desktopControlsKey.currentState?.requestTimelineFocus(); + }); + } + + /// Hide controls when navigating up from timeline (keyboard mode) + /// If skip marker button or Play Next dialog is visible, focus it instead of hiding controls + void _hideControlsFromKeyboard() { + // If skip marker button is visible, focus it instead of hiding controls + if (_currentMarker != null) { + _skipMarkerFocusNode.requestFocus(); + return; + } + + // If Play Next dialog is visible (focus node provided), focus it instead of hiding controls + if (widget.playNextFocusNode != null) { + widget.playNextFocusNode!.requestFocus(); + return; + } + + if (_showControls) { + _hideControls(); + } + } +} diff --git a/lib/widgets/video_controls/playback_extras_loader.dart b/lib/widgets/video_controls/playback_extras_loader.dart new file mode 100644 index 00000000..89ad7a81 --- /dev/null +++ b/lib/widgets/video_controls/playback_extras_loader.dart @@ -0,0 +1,89 @@ +import '../../database/app_database.dart'; +import '../../media/media_item.dart'; +import '../../media/media_server_client.dart'; +import '../../media/media_source_info.dart'; +import '../../services/cached_playback_metadata_service.dart'; +import '../../services/settings_service.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/global_key_utils.dart'; + +class VideoControlsPlaybackExtrasLoader { + final MediaItem metadata; + final MediaServerClient? client; + final AppDatabase database; + + const VideoControlsPlaybackExtrasLoader({required this.metadata, required this.database, required this.client}); + + Future load({bool forceRefresh = false}) async { + if (client == null) { + return _loadFromCacheOnly(cacheServerId: await _resolveCacheServerId()); + } + + try { + appLogger.d('_loadPlaybackExtras: starting for ${metadata.id} (forceRefresh=$forceRefresh)'); + final settings = await SettingsService.getInstance(); + final extras = await client!.fetchPlaybackExtras( + metadata.id, + introPattern: settings.read(SettingsService.introPattern), + creditsPattern: settings.read(SettingsService.creditsPattern), + forceChapterFallback: settings.read(SettingsService.forceSkipMarkerFallback), + forceRefresh: forceRefresh, + ); + appLogger.d('_loadPlaybackExtras: got ${extras.chapters.length} chapters'); + return extras; + } catch (e, stack) { + appLogger.d('_loadPlaybackExtras: network path failed, trying cache fallback'); + try { + final settings = await SettingsService.getInstance(); + final extras = await client!.fetchPlaybackExtrasFromCacheOnly( + metadata.id, + introPattern: settings.read(SettingsService.introPattern), + creditsPattern: settings.read(SettingsService.creditsPattern), + forceChapterFallback: settings.read(SettingsService.forceSkipMarkerFallback), + ); + if (extras != null) { + appLogger.d('_loadPlaybackExtras: loaded ${extras.chapters.length} chapters from cache'); + return extras; + } + } catch (cacheError) { + appLogger.d('_loadPlaybackExtras: cache fallback failed', error: cacheError); + } + appLogger.e('_loadPlaybackExtras failed', error: e, stackTrace: stack); + return null; + } + } + + Future _loadFromCacheOnly({required String? cacheServerId}) async { + if (cacheServerId == null) { + appLogger.w('_loadPlaybackExtras: no client or cache scope for server ${metadata.serverId}'); + return null; + } + try { + final settings = await SettingsService.getInstance(); + return CachedPlaybackMetadataService.fetchPlaybackExtras( + backend: metadata.backend, + cacheServerId: cacheServerId, + itemId: metadata.id, + introPattern: settings.read(SettingsService.introPattern), + creditsPattern: settings.read(SettingsService.creditsPattern), + forceChapterFallback: settings.read(SettingsService.forceSkipMarkerFallback), + ); + } catch (e) { + appLogger.d('_loadPlaybackExtras: cache-only path failed', error: e); + return null; + } + } + + Future _resolveCacheServerId() async { + final serverId = metadata.serverId; + if (serverId == null) return null; + try { + final row = await (database.select( + database.downloadedMedia, + )..where((tbl) => tbl.globalKey.equals(buildGlobalKey(serverId, metadata.id)))).getSingleOrNull(); + return row?.clientScopeId ?? serverId; + } catch (_) { + return serverId; + } + } +} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 59143837..ca3e77e4 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -24,7 +24,6 @@ import 'package:window_manager/window_manager.dart'; import '../../mpv/mpv.dart'; import '../overlay_sheet.dart'; import '../../focus/dpad_navigator.dart'; -import '../../focus/focusable_wrapper.dart'; import '../../database/app_database.dart'; import '../../media/media_backend.dart'; @@ -37,22 +36,25 @@ import '../../media/media_version.dart'; import '../../screens/video_player_screen.dart'; import '../../focus/key_event_utils.dart'; import '../../services/keyboard_shortcuts_service.dart'; -import '../../services/cached_playback_metadata_service.dart'; import '../../services/scrub_preview_source.dart'; import '../../services/settings_service.dart'; import '../../utils/formatters.dart'; -import '../../utils/global_key_utils.dart'; import '../../utils/platform_detector.dart'; import '../../utils/player_utils.dart'; import '../../theme/mono_tokens.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import 'icons.dart'; +import 'playback_extras_loader.dart'; import 'widgets/player_toast_indicator.dart'; import '../../utils/app_logger.dart'; import '../../i18n/strings.g.dart'; import '../../focus/input_mode_tracker.dart'; import 'models/track_controls_state.dart'; +import 'widgets/double_tap_feedback.dart'; +import 'widgets/linux_keep_alive.dart'; +import 'widgets/mobile_skip_zones.dart'; +import 'widgets/skip_marker_button.dart'; import 'widgets/track_chapter_controls.dart'; import 'widgets/performance_overlay/performance_overlay.dart'; import 'mobile_video_controls.dart'; @@ -64,6 +66,14 @@ import '../../providers/playback_state_provider.dart'; import '../../providers/shader_provider.dart'; import '../../services/shader_service.dart'; +part 'parts/key_events.dart'; +part 'parts/markers.dart'; +part 'parts/navigation.dart'; +part 'parts/playback_extras.dart'; +part 'parts/playback_input.dart'; +part 'parts/track_controls.dart'; +part 'parts/visibility.dart'; + /// Custom video controls builder for Plex with chapter, audio, and subtitle support Widget plexVideoControlsBuilder( Player player, @@ -457,347 +467,7 @@ class _PlexVideoControlsState extends State with WindowListen }); } - void _onRateChanged(double newRate) { - if (!mounted) return; - if (_isLongPressing) return; - if (_suppressRateToastUntil != null && DateTime.now().isBefore(_suppressRateToastUntil!)) return; - final prev = _lastReportedRate; - if (prev != null && (prev - newRate).abs() < 0.005) return; - _lastReportedRate = newRate; - final icon = newRate >= 1.0 ? Symbols.fast_forward_rounded : Symbols.slow_motion_video_rounded; - widget.toastController.show(icon, formatPlaybackRate(newRate)); - } - - /// Called when hasFirstFrame changes - start auto-hide timer when first frame is ready - void _onFirstFrameReady() { - if (widget.hasFirstFrame?.value == true) { - _startHideTimer(); - // Retry with network-first if initial cache-first returned empty - if (_chapters.isEmpty && _markers.isEmpty) { - _loadPlaybackExtras(forceRefresh: true); - } - } - } - - /// Called when controlsVisible is set externally (e.g. screen-level focus recovery - /// after controls auto-hide ejects focus on Android TV). - void _onControlsVisibleExternal() { - if (widget.controlsVisible?.value == true && !_showControls && mounted) { - _showControlsWithFocus(); - } - } - - /// Focus play/pause button if we're in keyboard navigation mode (desktop/TV only) - void _focusPlayPauseIfKeyboardMode() { - if (!mounted) return; - if (!_videoPlayerNavigationEnabled) return; - final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV(); - if (!isMobile && InputModeTracker.isKeyboardMode(context)) { - _desktopControlsKey.currentState?.requestPlayPauseFocus(); - } - } - - Future _initKeyboardService() async { - _keyboardService = await KeyboardShortcutsService.getInstance(); - } - - void _listenToPosition() { - _positionSubscription = widget.player.streams.position.listen((position) { - if (_markers.isEmpty || !_markersLoaded) { - return; - } - - MediaMarker? foundMarker; - for (final marker in _markers) { - if (marker.containsPosition(position)) { - foundMarker = marker; - break; - } - } - - if (foundMarker != _currentMarker && mounted) { - _updateCurrentMarker(foundMarker); - } - }); - } - - /// Updates the current marker and manages auto-skip/focus behavior. - void _updateCurrentMarker(MediaMarker? foundMarker) { - setState(() { - _currentMarker = foundMarker; - _skipButtonDismissed = false; - }); - - if (foundMarker == null) { - _cancelAutoSkipTimer(); - _cancelSkipButtonDismissTimer(); - return; - } - - _startAutoSkipTimer(foundMarker); - - // Auto-skip OFF: dismiss button after 7s if no interaction - // Auto-skip ON: button stays until controls hide - if (!_shouldAutoSkipForMarker(foundMarker)) { - _startSkipButtonDismissTimer(); - } - - // Auto-focus skip button on TV when marker appears (only in keyboard/TV mode) - if (PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context)) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _skipMarkerFocusNode.requestFocus(); - } - }); - } - } - - /// Listen to playback state changes to manage auto-hide timer - void _listenToPlayingState() { - _playingSubscription = widget.player.streams.playing.listen((isPlaying) { - if (isPlaying && _showControls) { - _startHideTimer(); - } else if (!isPlaying && _showControls) { - _startPausedHideTimer(); - } - }); - } - - /// Listen to completed stream to show controls when video ends - void _listenToCompleted() { - _completedSubscription = widget.player.streams.completed.listen((completed) { - if (completed && mounted) { - // Cancel long-press 2x speed if active - if (_isLongPressing) { - _handleLongPressCancel(); - } - // Show controls when video completes (for play next dialog etc.) - setState(() { - _showControls = true; - }); - // Notify parent of visibility change (for popup positioning) - widget.controlsVisible?.value = true; - _hideTimer?.cancel(); - } - }); - } - - Future _skipMarker({bool skipAutoPlayCountdown = false}) async { - if (_currentMarker == null) return; - - final marker = _currentMarker!; - final endTime = marker.endTime; - final duration = widget.player.state.duration; - final isAtEnd = duration > Duration.zero && (duration - endTime).inMilliseconds <= 1000; - - if (marker.isCredits && isAtEnd) { - if (!skipAutoPlayCountdown && widget.onNext != null) { - widget.onNext!.call(); - } else { - // Seeking to EOF is unreliable due to position stream throttling, - // so pause and defer to the parent's completion flow. - await widget.player.pause(); - widget.onReachedEnd?.call(skipAutoPlayCountdown: skipAutoPlayCountdown); - } - } else { - await _seekToPosition(endTime); - } - - if (!mounted) return; - setState(() { - _currentMarker = null; - }); - _cancelAutoSkipTimer(); - _cancelSkipButtonDismissTimer(); - } - - void _startAutoSkipTimer(MediaMarker marker) { - _cancelAutoSkipTimer(); - - final shouldAutoSkip = (marker.isCredits && _autoSkipCredits) || (!marker.isCredits && _autoSkipIntro); - - if (!shouldAutoSkip || _autoSkipDelay <= 0) return; - - _autoSkipProgress = 0.0; - const tickDuration = Duration(milliseconds: 200); - final totalTicks = (_autoSkipDelay * 1000) / tickDuration.inMilliseconds; - - if (totalTicks <= 0) return; - - _autoSkipTimer = Timer.periodic(tickDuration, (timer) { - if (!mounted || _currentMarker != marker) { - timer.cancel(); - return; - } - - setState(() { - _autoSkipProgress = (timer.tick / totalTicks).clamp(0.0, 1.0); - }); - - if (timer.tick >= totalTicks) { - timer.cancel(); - _performAutoSkip(skipAutoPlayCountdown: true); - } - }); - } - - void _cancelAutoSkipTimer() { - _autoSkipTimer?.cancel(); - _autoSkipTimer = null; - if (mounted) { - setState(() { - _autoSkipProgress = 0.0; - }); - } - } - - /// Starts/restarts the skip button dismiss timer. When it fires, hides the - /// button and cancels any active auto-skip countdown. - void _startSkipButtonDismissTimer() { - _skipButtonDismissTimer?.cancel(); - _skipButtonDismissTimer = Timer(const Duration(seconds: 7), () { - if (!mounted || _currentMarker == null) return; - setState(() { - _skipButtonDismissed = true; - }); - _cancelAutoSkipTimer(); - }); - } - - void _cancelSkipButtonDismissTimer() { - _skipButtonDismissTimer?.cancel(); - _skipButtonDismissTimer = null; - } - - /// Perform the appropriate skip action based on marker type and next episode availability - void _performAutoSkip({bool skipAutoPlayCountdown = false}) { - if (_currentMarker == null) return; - unawaited(_skipMarker(skipAutoPlayCountdown: skipAutoPlayCountdown)); - } - - /// Check if auto-skip should be active for the current marker - bool _shouldAutoSkipForMarker(MediaMarker marker) { - return (marker.isCredits && _autoSkipCredits) || (!marker.isCredits && _autoSkipIntro); - } - - bool _shouldShowAutoSkip() { - if (_currentMarker == null) return false; - return _shouldAutoSkipForMarker(_currentMarker!); - } - - Future _loadSeekTimes() async { - final settingsService = await SettingsService.getInstance(); - if (mounted) { - setState(() { - _seekTimeSmall = settingsService.read(SettingsService.seekTimeSmall); - _rewindOnResume = settingsService.read(SettingsService.rewindOnResume); - _audioSyncOffset = settingsService.read(SettingsService.audioSyncOffset); - _subtitleSyncOffset = settingsService.read(SettingsService.subtitleSyncOffset); - _isRotationLocked = settingsService.read(SettingsService.rotationLocked); - _autoSkipIntro = settingsService.read(SettingsService.autoSkipIntro); - _autoSkipCredits = settingsService.read(SettingsService.autoSkipCredits); - _autoSkipDelay = settingsService.read(SettingsService.autoSkipDelay); - _videoPlayerNavigationEnabled = settingsService.read(SettingsService.videoPlayerNavigationEnabled); - _showPerformanceOverlay = settingsService.read(SettingsService.showPerformanceOverlay); - _autoHidePerformanceOverlay = settingsService.read(SettingsService.autoHidePerformanceOverlay); - _clickVideoTogglesPlayback = settingsService.read(SettingsService.clickVideoTogglesPlayback); - }); - - // Focus play/pause if navigation is now enabled and controls are visible - // (handles case where initState focus attempt failed due to async settings load) - if (_videoPlayerNavigationEnabled && _showControls) { - _focusPlayPauseIfKeyboardMode(); - } - - // Apply rotation lock setting - if (_isRotationLocked) { - unawaited( - SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]), - ); - } else { - unawaited(SystemChrome.setPreferredOrientations(DeviceOrientation.values)); - } - } - } - - void _toggleSubtitles() { - final currentTrack = widget.player.state.track.subtitle; - // No-op if no subtitle track is selected - if (currentTrack == null || currentTrack.id == 'no') return; - - final newVisible = !_subtitlesVisible; - widget.player.setProperty('sub-visibility', newVisible ? 'yes' : 'no'); - setState(() { - _subtitlesVisible = newVisible; - }); - } - - void _onSubtitleTrackChanged(SubtitleTrack track) { - // Reset visibility when user explicitly picks a new subtitle track - if (track.id != 'no' && !_subtitlesVisible) { - widget.player.setProperty('sub-visibility', 'yes'); - setState(() { - _subtitlesVisible = true; - }); - } - widget.onSubtitleTrackChanged?.call(track); - } - - void _toggleShader() { - final shaderService = widget.shaderService; - if (shaderService == null || !shaderService.isSupported) return; - - if (shaderService.currentPreset.isEnabled) { - // Currently active - disable temporarily - unawaited( - shaderService - .applyPreset(ShaderPreset.none) - .then((_) { - // ignore: no-empty-block - setState triggers rebuild to reflect disabled shader - if (mounted) setState(() {}); - widget.onShaderChanged?.call(); - }) - .catchError((Object e, StackTrace st) { - appLogger.w('Failed to disable shader', error: e, stackTrace: st); - }), - ); - } else { - // Currently off - restore saved preset - final shaderProvider = context.read(); - final saved = shaderProvider.savedPreset; - final allPresets = shaderProvider.allPresets; - final targetPreset = saved.isEnabled - ? saved - : allPresets.firstWhere((p) => p.isEnabled, orElse: () => allPresets[1]); - unawaited( - shaderService - .applyPreset(targetPreset) - .then((_) { - shaderProvider.setCurrentPreset(targetPreset); - // ignore: no-empty-block - setState triggers rebuild to reflect restored shader - if (mounted) setState(() {}); - widget.onShaderChanged?.call(); - }) - .catchError((Object e, StackTrace st) { - appLogger.w('Failed to apply shader preset', error: e, stackTrace: st); - }), - ); - } - } - - void _nextAudioTrack() { - if (!widget.canControl) return; - widget.onCycleAudioTrack?.call(); - } - - void _nextSubtitleTrack() { - if (!widget.canControl) return; - widget.onCycleSubtitleTrack?.call(); - } - - void _nextChapter() => _seekToNextChapter(); - - void _previousChapter() => _seekToPreviousChapter(); + void _setControlsState(VoidCallback fn) => setState(fn); @override void dispose() { @@ -886,1057 +556,6 @@ class _PlexVideoControlsState extends State with WindowListen // ignore: no-empty-block - required by WindowListener interface void onWindowResize() {} - /// Controls hide delay: 5s on mobile/TV/keyboard-nav, 3s on desktop with mouse. - Duration get _hideDelay { - final isMobile = (Platform.isIOS || Platform.isAndroid) && !PlatformDetector.isTV(); - if (isMobile || PlatformDetector.isTV() || _videoPlayerNavigationEnabled) { - return const Duration(seconds: 5); - } - return const Duration(seconds: 3); - } - - /// Shared hide logic: hides controls, notifies parent, updates traffic lights, restores focus. - void _hideControls() { - if (!mounted || !_showControls || _forceShowControls) return; - setState(() { - _showControls = false; - _isContentStripVisible = false; - // Dismiss skip button with controls — after this it only re-appears with controls - if (_currentMarker != null) { - _skipButtonDismissed = true; - } - }); - _desktopControlsKey.currentState?.hideContentStrip(); - _cancelSkipButtonDismissTimer(); - widget.controlsVisible?.value = false; - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } - // Reclaim focus so the global key handler stays active for TV dpad, - // but skip if an overlay sheet owns focus — stealing it would break - // sheet navigation (e.g. the compact sync bar). - final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; - if (!sheetOpen) { - // Always request primary focus on _focusNode — not just when hasFocus is - // false. hasFocus is true when a descendant (e.g. play/pause) has focus, - // but we need _focusNode itself to hold primary focus so its onKeyEvent - // fires for the next d-pad press (otherwise focus escapes to the screen- - // level self-heal handler which shows controls with play/pause focus). - _focusNode.requestFocus(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_focusNode.hasPrimaryFocus) { - _focusNode.requestFocus(); - } - }); - } - } - - void _startHideTimer() { - _hideTimer?.cancel(); - - // Don't auto-hide while loading first frame (user needs to see spinner and back button) - 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, () { - // Also check hasFirstFrame in callback (in case it changed) - final stillLoading = !(widget.hasFirstFrame?.value ?? true); - if (mounted && widget.player.state.playing && !stillLoading) { - _hideControls(); - } - }); - } - } - - /// Auto-hide controls after pause (does not check playing state in callback). - void _startPausedHideTimer() { - _hideTimer?.cancel(); - if (_forceShowControls) return; - _hideTimer = Timer(_hideDelay, () { - _hideControls(); - }); - } - - /// Restart the hide timer on user interaction (if video is playing) - void _restartHideTimerIfPlaying() { - if (widget.player.state.playing) { - _startHideTimer(); - } - } - - /// Hide controls immediately when the mouse leaves the player area (desktop only). - void _hideControlsFromPointerExit() { - final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV(); - if (isMobile) return; - - _hideTimer?.cancel(); - _hideControls(); - } - - void _handlePointerSignal(PointerSignalEvent event) { - if (event is PointerScrollEvent && _keyboardService != null) { - final delta = event.scrollDelta.dy; - final volume = widget.player.state.volume; - final maxVol = _keyboardService!.maxVolume.toDouble(); - final newVolume = (volume - delta / 20).clamp(0.0, maxVol); - widget.player.setVolume(newVolume); - unawaited(SettingsService.getInstance().then((s) => s.write(SettingsService.volume, newVolume))); - _showControlsFromPointerActivity(); - } - } - - /// Show controls in response to pointer activity (mouse/trackpad movement). - void _showControlsFromPointerActivity() { - if (!_showControls) { - setState(() { - _showControls = true; - }); - // Notify parent of visibility change (for popup positioning) - widget.controlsVisible?.value = true; - // On macOS, keep window controls in sync with the overlay - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } - } - - // Keep the overlay visible while the user is moving the pointer - _restartHideTimerIfPlaying(); - - // Cancel auto-skip when user moves pointer over the player - _cancelAutoSkipTimer(); - } - - void _toggleControls() { - if (_showControls) { - _hideControls(); - } else { - setState(() { - _showControls = true; - }); - widget.controlsVisible?.value = true; - _startHideTimer(); - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } - } - // Cancel auto-skip on any tap - _cancelAutoSkipTimer(); - } - - void _toggleRotationLock() async { - setState(() { - _isRotationLocked = !_isRotationLocked; - }); - - // Save to settings - final settingsService = await SettingsService.getInstance(); - await settingsService.write(SettingsService.rotationLocked, _isRotationLocked); - - if (_isRotationLocked) { - // Locked: Allow landscape orientations only - await SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); - } else { - // Unlocked: Allow all orientations including portrait - await SystemChrome.setPreferredOrientations(DeviceOrientation.values); - } - } - - void _toggleScreenLock() { - final locking = !_isScreenLocked; - setState(() { - _isScreenLocked = locking; - if (locking) { - _showLockIcon = true; - } - }); - if (locking) { - _hideControls(); - _startLockIconHideTimer(); - } - } - - void _startLockIconHideTimer() { - _lockIconTimer?.cancel(); - _lockIconTimer = Timer(const Duration(seconds: 3), () { - if (mounted) setState(() => _showLockIcon = false); - }); - } - - void _unlockScreen() { - setState(() { - _isScreenLocked = false; - _showLockIcon = false; - _showControls = true; - }); - _lockIconTimer?.cancel(); - widget.controlsVisible?.value = true; - _startHideTimer(); - } - - void _updateTrafficLightVisibility() async { - // When maximized or fullscreen, always keep traffic lights visible so the - // 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 || _forceShowControls ? true : _showControls; - await MacOSWindowService.setTrafficLightsVisible(visible); - } - - /// Check whether PiP is supported on this device - Future _checkPipSupport() async { - if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) { - return; - } - - try { - final supported = await PipService.isSupported(); - if (mounted) { - setState(() { - _isPipSupported = supported; - }); - } - } catch (e) { - return; - } - } - - /// 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({bool forceRefresh = false}) async { - // Live TV metadata uses EPG rating keys, not library items - if (widget.isLive) return; - if (_isLoadingExtras) return; - _isLoadingExtras = true; - - final serverId = widget.metadata.serverId; - // Read providers before any await — `context` after an async gap is - // a lint trigger and can crash if the widget unmounts mid-load. - final client = serverId != null ? context.tryGetMediaClientForServer(serverId) : null; - final database = context.read(); - if (client == null) { - await _loadPlaybackExtrasFromCacheOnly(cacheServerId: await _resolveCacheServerId(database)); - _isLoadingExtras = false; - return; - } - - try { - appLogger.d('_loadPlaybackExtras: starting for ${widget.metadata.id} (forceRefresh=$forceRefresh)'); - final settings = await SettingsService.getInstance(); - final introPattern = settings.read(SettingsService.introPattern); - final creditsPattern = settings.read(SettingsService.creditsPattern); - final forceChapterFallback = settings.read(SettingsService.forceSkipMarkerFallback); - // Backend-aware: Plex hits /library/metadata?includeChapters=1; Jellyfin - // pulls Chapters from /Users/{userId}/Items/{id}. - final extras = await client.fetchPlaybackExtras( - widget.metadata.id, - introPattern: introPattern, - creditsPattern: creditsPattern, - forceChapterFallback: forceChapterFallback, - forceRefresh: forceRefresh, - ); - appLogger.d('_loadPlaybackExtras: got ${extras.chapters.length} chapters'); - - _applyPlaybackExtras(extras); - } catch (e, stack) { - // Fallback: serve extras from the per-backend cache (for offline - // playback after the network call threw). - appLogger.d('_loadPlaybackExtras: network path failed, trying cache fallback'); - try { - final settings = await SettingsService.getInstance(); - final extras = await client.fetchPlaybackExtrasFromCacheOnly( - widget.metadata.id, - introPattern: settings.read(SettingsService.introPattern), - creditsPattern: settings.read(SettingsService.creditsPattern), - forceChapterFallback: settings.read(SettingsService.forceSkipMarkerFallback), - ); - if (extras != null) { - appLogger.d('_loadPlaybackExtras: loaded ${extras.chapters.length} chapters from cache'); - _applyPlaybackExtras(extras); - return; - } - } catch (cacheError) { - appLogger.d('_loadPlaybackExtras: cache fallback failed', error: cacheError); - } - appLogger.e('_loadPlaybackExtras failed', error: e, stackTrace: stack); - } finally { - _isLoadingExtras = false; - } - } - - Future _loadPlaybackExtrasFromCacheOnly({required String? cacheServerId}) async { - if (cacheServerId == null) { - appLogger.w('_loadPlaybackExtras: no client or cache scope for server ${widget.metadata.serverId}'); - return; - } - try { - final settings = await SettingsService.getInstance(); - final extras = await CachedPlaybackMetadataService.fetchPlaybackExtras( - backend: widget.metadata.backend, - cacheServerId: cacheServerId, - itemId: widget.metadata.id, - introPattern: settings.read(SettingsService.introPattern), - creditsPattern: settings.read(SettingsService.creditsPattern), - forceChapterFallback: settings.read(SettingsService.forceSkipMarkerFallback), - ); - if (extras != null) _applyPlaybackExtras(extras); - } catch (e) { - appLogger.d('_loadPlaybackExtras: cache-only path failed', error: e); - } - } - - Future _resolveCacheServerId(AppDatabase database) async { - final serverId = widget.metadata.serverId; - if (serverId == null) return null; - try { - final row = await (database.select( - database.downloadedMedia, - )..where((tbl) => tbl.globalKey.equals(buildGlobalKey(serverId, widget.metadata.id)))).getSingleOrNull(); - return row?.clientScopeId ?? serverId; - } catch (_) { - return serverId; - } - } - - void _applyPlaybackExtras(PlaybackExtras extras) { - if (!mounted) return; - setState(() { - _chapters = extras.chapters; - _markers = extras.markers; - _chaptersLoaded = true; - _markersLoaded = true; - }); - } - - TrackControlsState _buildTrackControlsState({ - required PlaybackStateProvider playbackState, - required VoidCallback? onToggleAlwaysOnTop, - }) { - final versionQuality = effectiveVersionQualityControls( - isOfflinePlayback: widget.isOfflinePlayback, - availableVersions: widget.availableVersions, - serverSupportsTranscoding: widget.serverSupportsTranscoding, - isTranscoding: widget.isTranscoding, - sourceAudioTracks: widget.sourceAudioTracks, - selectedAudioStreamId: widget.selectedAudioStreamId, - ); - return TrackControlsState( - availableVersions: versionQuality.availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - selectedQualityPreset: widget.selectedQualityPreset, - serverSupportsTranscoding: versionQuality.serverSupportsTranscoding, - isTranscoding: versionQuality.isTranscoding, - sourceAudioTracks: versionQuality.sourceAudioTracks, - selectedAudioStreamId: versionQuality.selectedAudioStreamId, - sourceDurationMs: widget.metadata.durationMs, - boxFitMode: widget.boxFitMode, - audioSyncOffset: _audioSyncOffset, - subtitleSyncOffset: _subtitleSyncOffset, - isRotationLocked: _isRotationLocked, - isScreenLocked: _isScreenLocked, - isFullscreen: _isFullscreen, - isAlwaysOnTop: _isAlwaysOnTop, - onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null, - onCycleBoxFitMode: widget.onCycleBoxFitMode, - onToggleRotationLock: _toggleRotationLock, - onToggleScreenLock: _toggleScreenLock, - onToggleFullscreen: _toggleFullscreen, - onToggleAlwaysOnTop: onToggleAlwaysOnTop, - onSwitchVersion: versionQuality.canSwitch ? (i) => _switchVersionAndQuality(newMediaIndex: i) : null, - onSwitchQualityPreset: versionQuality.canSwitch ? (p) => _switchVersionAndQuality(newPreset: p) : null, - onSwitchAudioStreamId: versionQuality.canSwitch ? (id) => _switchVersionAndQuality(newAudioStreamId: id) : null, - onAudioTrackChanged: widget.onAudioTrackChanged, - onSubtitleTrackChanged: _onSubtitleTrackChanged, - onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, - onLoadSeekTimes: () async { - if (mounted) { - await _loadSeekTimes(); - } - }, - onCancelAutoHide: () => _hideTimer?.cancel(), - onStartAutoHide: _startHideTimer, - onSyncOffsetChanged: (propertyName, offset) { - setState(() { - if (propertyName == 'sub-delay') { - _subtitleSyncOffset = offset; - } else { - _audioSyncOffset = offset; - } - }); - }, - serverId: widget.metadata.serverId ?? '', - shaderService: widget.shaderService, - onShaderChanged: widget.onShaderChanged, - isAmbientLightingEnabled: widget.isAmbientLightingEnabled, - onToggleAmbientLighting: widget.player.playerType != 'exoplayer' ? widget.onToggleAmbientLighting : null, - canControl: widget.canControl, - isLive: widget.isLive, - subtitlesVisible: _subtitlesVisible, - showQueueButton: playbackState.isQueueActive, - onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, - ratingKey: widget.metadata.id, - mediaTitle: widget.metadata.title, - onSubtitleDownloaded: _onSubtitleDownloaded, - // Plex proxies OpenSubtitles via its server-side plugin; Jellyfin - // doesn't expose an equivalent so the Search Subtitles tile is hidden - // for Jellyfin items. The check uses the registered client type for - // this metadata's serverId. - subtitleSearchSupported: _isPlexBackedMetadata(), - ); - } - - /// True when the active server supports external subtitle search (Plex - /// today). Requires a server id because the download callback needs the - /// Plex client/token for that server. - bool _isPlexBackedMetadata() { - try { - final serverId = widget.metadata.serverId; - if (serverId == null) return false; - final manager = context.read().serverManager; - final c = manager.getClient(serverId); - return c?.capabilities.externalSubtitleSearch ?? false; - } catch (_) { - return false; - } - } - - Widget _buildTrackChapterControlsWidget({bool hideChaptersAndQueue = false}) { - final playbackState = context.watch(); - final trackControlsState = _buildTrackControlsState( - playbackState: playbackState, - onToggleAlwaysOnTop: _toggleAlwaysOnTop, - ); - - return TrackChapterControls( - player: widget.player, - chapters: _chapters, - chaptersLoaded: _chaptersLoaded, - trackControlsState: trackControlsState, - onSeekCompleted: widget.onSeekCompleted, - hideChaptersAndQueue: hideChaptersAndQueue, - ); - } - - void _seekToPreviousChapter() => unawaited(_seekToChapter(forward: false)); - - void _seekToNextChapter() => unawaited(_seekToChapter(forward: true)); - - Future _seekByTime({required bool forward}) async { - final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall); - await _seekByOffset(delta); - } - - Future _seekToChapter({required bool forward}) async { - if (_chapters.isEmpty) { - // No chapters - seek by configured amount - final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall); - await _seekByOffset(delta); - return; - } - - final currentPositionMs = widget.player.state.position.inMilliseconds; - - if (forward) { - // Find next chapter - for (final chapter in _chapters) { - final chapterStart = chapter.startTimeOffset ?? 0; - if (chapterStart > currentPositionMs) { - await _seekToPosition(Duration(milliseconds: chapterStart)); - return; - } - } - } else { - // Find previous/current chapter - for (int i = _chapters.length - 1; i >= 0; i--) { - final chapterStart = _chapters[i].startTimeOffset ?? 0; - if (currentPositionMs > chapterStart + 3000) { - // If more than 3 seconds into chapter, go to start of current chapter - await _seekToPosition(Duration(milliseconds: chapterStart)); - return; - } - } - // If at start of first chapter, go to beginning - await _seekToPosition(Duration.zero); - } - } - - Future _seekToPosition(Duration position, {bool notifyCompletion = true}) async { - final clamped = clampSeekPosition(widget.player, position); - await widget.player.seek(clamped); - if (notifyCompletion && mounted) { - widget.onSeekCompleted?.call(clamped); - } - } - - Future _seekByOffset(Duration delta, {bool notifyCompletion = true}) async { - // Route through live seek callback for time-shifted live TV - if (widget.isLive && widget.onLiveSeek != null && widget.currentPositionEpoch != null) { - widget.onLiveSeek!(widget.currentPositionEpoch! + delta.inSeconds); - return; - } - final target = widget.player.state.position + delta; - final clamped = clampSeekPosition(widget.player, target); - await widget.player.seek(clamped); - if (notifyCompletion && mounted) { - widget.onSeekCompleted?.call(clamped); - } - } - - Future _playOrPause() async { - if (!widget.player.state.playing && _rewindOnResume > 0) { - final target = widget.player.state.position - Duration(seconds: _rewindOnResume); - final clamped = clampSeekPosition(widget.player, target); - await widget.player.seek(clamped); - } - await widget.player.playOrPause(); - } - - /// Throttled seek for timeline slider - executes immediately then throttles to 200ms - void _throttledSeek(Duration position) => _seekThrottle([position]); - - /// Finalizes the seek when user stops scrubbing the timeline - void _finalizeSeek(Duration position) { - _seekThrottle.cancel(); - unawaited(_seekToPosition(position)); - } - - /// Timing-based double-click detection: avoids `onDoubleTap`'s ~300 ms - /// tap-resolution delay and the arena competition it introduces. - void _handleOuterTap() { - if (widget.canControl && _clickVideoTogglesPlayback) { - _playOrPause(); - } else { - _toggleControls(); - } - - if (PlatformDetector.isMobile(context)) return; - - final now = DateTime.now(); - if (_lastSkipTapTime != null && now.difference(_lastSkipTapTime!).inMilliseconds < 250) { - _lastSkipTapTime = null; - _toggleFullscreen(); - return; - } - _lastSkipTapTime = now; - } - - /// Handle tap in skip zone with custom double-tap detection - void _handleTapInSkipZone({required bool isForward}) { - final now = DateTime.now(); - - // Cancel any pending single-tap action - _singleTapTimer?.cancel(); - _singleTapTimer = null; - - // Debounce: ignore taps within 200ms of last skip action - // This prevents double-taps from counting as two separate skips - if (_lastSkipActionTime != null && now.difference(_lastSkipActionTime!).inMilliseconds < 200) { - return; - } - - // Check if this qualifies as a double-tap (within 250ms of last tap, same side) - final isDoubleTap = - _lastSkipTapTime != null && - now.difference(_lastSkipTapTime!).inMilliseconds < 250 && - _lastSkipTapWasForward == isForward; - - // Skip ONLY on detected double-tap (no single-tap-to-add behavior) - if (isDoubleTap) { - _lastSkipTapTime = null; // Reset to prevent triple-tap chaining - - if (_showDoubleTapFeedback && _lastDoubleTapWasForward == isForward) { - // Stacking skip - add to accumulated - unawaited(_handleStackingSkip(isForward: isForward)); - } else { - // First double-tap - initiate skip - unawaited(_handleDoubleTapSkip(isForward: isForward)); - } - } else { - // First tap - record timestamp and start timer for single-tap action - _lastSkipTapTime = now; - _lastSkipTapWasForward = isForward; - - // If no second tap within 250ms, treat as single tap to toggle controls - _singleTapTimer = Timer(const Duration(milliseconds: 250), () { - if (mounted) { - _toggleControls(); - } - }); - } - } - - /// Handle stacking skip - add to accumulated skip when feedback is active - Future _handleStackingSkip({required bool isForward}) async { - if (!widget.canControl) return; - - // Add to accumulated skip - _accumulatedSkipSeconds += _seekTimeSmall; - - // Calculate and perform seek - final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall); - await _seekByOffset(delta); - - // Refresh feedback (extends timer, updates display) - _showSkipFeedback(isForward: isForward); - - // Record skip time for debounce - _lastSkipActionTime = DateTime.now(); - } - - /// Handle double-tap skip forward or backward - Future _handleDoubleTapSkip({required bool isForward}) async { - // Ignore if user cannot control playback - if (!widget.canControl) return; - - // Reset accumulated skip for new gesture - _accumulatedSkipSeconds = _seekTimeSmall; - - final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall); - await _seekByOffset(delta); - - // Show visual feedback - _showSkipFeedback(isForward: isForward); - - // Record skip time for debounce - _lastSkipActionTime = DateTime.now(); - } - - /// Show animated visual feedback for skip gesture - void _showSkipFeedback({required bool isForward}) { - _feedbackTimer?.cancel(); - - setState(() { - _lastDoubleTapWasForward = isForward; - _showDoubleTapFeedback = true; - _doubleTapFeedbackOpacity = 1.0; - }); - - // Capture duration before timer to avoid context access in callback - final slowDuration = tokens(context).slow; - - // Fade out after delay (1200ms gives time to see value and continue tapping) - _feedbackTimer = Timer(const Duration(milliseconds: 1200), () { - if (mounted) { - setState(() { - _doubleTapFeedbackOpacity = 0.0; - }); - - Timer(slowDuration, () { - if (mounted) { - setState(() { - _showDoubleTapFeedback = false; - _accumulatedSkipSeconds = 0; // Reset when feedback hides - }); - } - }); - } - }); - } - - /// Handle tap on controls overlay - route to skip zones or toggle controls - void _handleControlsOverlayTap(TapUpDetails details, BoxConstraints constraints) { - final isMobile = PlatformDetector.isMobile(context); - - if (!isMobile) { - final DateTime now = DateTime.now(); - - // Always perform the single-click behavior immediately - if (widget.canControl && _clickVideoTogglesPlayback) { - _playOrPause(); - } else { - _toggleControls(); - } - - // Detect double-click - final bool isDoubleClick = _lastSkipTapTime != null && now.difference(_lastSkipTapTime!).inMilliseconds < 250; - - if (isDoubleClick) { - _lastSkipTapTime = null; - - // Perform desktop double-click action: toggle fullscreen - _toggleFullscreen(); - - return; - } - - // Record this click as a candidate for double-click detection - _lastSkipTapTime = now; - return; - } - - final width = constraints.maxWidth; - final height = constraints.maxHeight; - final tapX = details.localPosition.dx; - final tapY = details.localPosition.dy; - - // Skip zone dimensions (must match the skip zone Positioned widgets) - final topExclude = height * 0.15; - final bottomExclude = height * 0.15; - final leftZoneWidth = width * 0.35; - - // Check if tap is in vertical range for skip zones - final inVerticalRange = tapY > topExclude && tapY < (height - bottomExclude); - - if (inVerticalRange) { - if (tapX < leftZoneWidth) { - // Left skip zone - _handleTapInSkipZone(isForward: false); - return; - } else if (tapX > (width - leftZoneWidth)) { - // Right skip zone - _handleTapInSkipZone(isForward: true); - return; - } - } - - // Not in skip zone, toggle controls - _toggleControls(); - } - - /// Handle long-press start - activate 2x speed - void _handleLongPressStart() { - if (!widget.canControl || widget.isLive) return; - - setState(() { - _isLongPressing = true; - _rateBeforeLongPress = widget.player.state.rate; - _showSpeedIndicator = true; - }); - widget.player.setRate(2.0); - } - - /// Handle long-press end - restore original speed - void _handleLongPressEnd() { - if (!_isLongPressing) return; - // Swallow the rate-restore emission so the stream-driven toast doesn't - // flash as the rate snaps back to the prior value. - _suppressRateToastUntil = DateTime.now().add(const Duration(milliseconds: 250)); - widget.player.setRate(_rateBeforeLongPress ?? 1.0); - setState(() { - _isLongPressing = false; - _rateBeforeLongPress = null; - _showSpeedIndicator = false; - }); - } - - /// Handle long-press cancel (same as end) - void _handleLongPressCancel() => _handleLongPressEnd(); - - /// Build the visual feedback widget for double-tap skip - Widget _buildDoubleTapFeedback() { - return Align( - alignment: _lastDoubleTapWasForward ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 60), - padding: const EdgeInsets.all(20), - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.6), shape: BoxShape.circle), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon( - _lastDoubleTapWasForward ? Symbols.forward_media_rounded : Symbols.replay_rounded, - fill: 1, - color: Colors.white, - size: 32, - ), - const SizedBox(height: 4), - Text( - '$_accumulatedSkipSeconds${t.settings.secondsShort}', - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), - ), - ], - ), - ), - ); - } - - /// Build the visual indicator for long-press 2x speed. - /// Manual (persistent for duration of press) — separate from the stream-driven - /// toast so it stays visible for the full long-press rather than auto-hiding. - Widget _buildSpeedIndicator() => const PlayerToastIndicator(icon: Symbols.fast_forward_rounded, text: '2x'); - - Future _toggleFullscreen() async { - if (!PlatformDetector.isMobile(context)) { - await FullscreenStateManager().toggleFullscreen(); - } - } - - /// Exit fullscreen if the window is actually fullscreen (async check). - /// Used by ESC handler on Windows/Linux to avoid relying on _isFullscreen flag. - Future _exitFullscreenIfNeeded() async { - if (await windowManager.isFullScreen()) { - await FullscreenStateManager().exitFullscreen(); - } - } - - /// Initialize always-on-top state from window manager (desktop only) - Future _initAlwaysOnTopState() async { - final isOnTop = await windowManager.isAlwaysOnTop(); - if (mounted && isOnTop != _isAlwaysOnTop) { - setState(() { - _isAlwaysOnTop = isOnTop; - }); - } - } - - /// Toggle always-on-top window mode (desktop only) - Future _toggleAlwaysOnTop() async { - if (!PlatformDetector.isMobile(context)) { - final newValue = !_isAlwaysOnTop; - await windowManager.setAlwaysOnTop(newValue); - if (!mounted) return; - setState(() { - _isAlwaysOnTop = newValue; - }); - } - } - - /// Check if a key is a directional key (arrow keys) - bool _isDirectionalKey(LogicalKeyboardKey key) { - return key == LogicalKeyboardKey.arrowUp || - key == LogicalKeyboardKey.arrowDown || - key == LogicalKeyboardKey.arrowLeft || - key == LogicalKeyboardKey.arrowRight; - } - - /// Check if a key is a select/enter key - bool _isSelectKey(LogicalKeyboardKey key) { - return key == LogicalKeyboardKey.select || - key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.numpadEnter || - key == LogicalKeyboardKey.gameButtonA; - } - - /// Determine if the key event should toggle play/pause based on configured hotkeys. - bool _isPlayPauseKey(KeyEvent event) { - final logicalKey = event.logicalKey; - final physicalKey = event.physicalKey; - - // Always accept hardware media play/pause keys (Android TV remotes) - if (logicalKey == LogicalKeyboardKey.mediaPlayPause || - logicalKey == LogicalKeyboardKey.mediaPlay || - logicalKey == LogicalKeyboardKey.mediaPause) { - return true; - } - - // When the shortcuts service is available, respect the configured play/pause hotkey - if (_keyboardService != null) { - final hotkey = _keyboardService!.hotkeys['play_pause']; - if (hotkey == null) return false; - return hotkey.key == physicalKey; - } - - // Fallback to defaults while the service is loading - return physicalKey == PhysicalKeyboardKey.space || physicalKey == PhysicalKeyboardKey.mediaPlayPause; - } - - /// Check if a key is a media seek key (Android TV remotes) - bool _isMediaSeekKey(LogicalKeyboardKey key) { - return key == LogicalKeyboardKey.mediaFastForward || - key == LogicalKeyboardKey.mediaRewind || - key == LogicalKeyboardKey.mediaSkipForward || - key == LogicalKeyboardKey.mediaSkipBackward; - } - - /// Check if a key is a media track key (Android TV remotes) - bool _isMediaTrackKey(LogicalKeyboardKey key) { - return key == LogicalKeyboardKey.mediaTrackNext || key == LogicalKeyboardKey.mediaTrackPrevious; - } - - bool _isPlayPauseActivation(KeyEvent event) { - return event is KeyDownEvent && _isPlayPauseKey(event); - } - - /// Global key event handler for focus-independent shortcuts (desktop only) - bool _handleGlobalKeyEvent(KeyEvent event) { - if (!mounted) return false; - - // When an overlay sheet is open (e.g. subtitle search with text fields), - // don't consume key events — let text input work normally. - if (OverlaySheetController.maybeOf(context)?.isOpen ?? false) { - return false; - } - - // Back key fallback when _focusNode lost focus (TV, or desktop with nav on). - // Focus.onKeyEvent won't fire if _focusNode lost focus, so handle ESC here. - if ((_videoPlayerNavigationEnabled || PlatformDetector.isTV()) && event.logicalKey.isBackKey) { - if (!_focusNode.hasFocus) { - // Skip if an overlay sheet is open — the sheet's FocusScope handles - // back keys via its own onKeyEvent. Without this check, this global - // handler would call Navigator.pop() alongside the sheet's handler. - final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; - if (sheetOpen) return false; - // On TV, mark coordinator early (KeyDown) so PopScope.onPopInvokedWithResult - // sees it before KeyUp — prevents the system back from racing ahead. - if (PlatformDetector.isTV() && event is KeyDownEvent) { - BackKeyCoordinator.markHandled(); - } - final backResult = handleBackKeyAction(event, () { - if (PlatformDetector.isTV()) { - if (_showControls) { - if (_isContentStripVisible) { - _desktopControlsKey.currentState?.dismissContentStrip(); - setState(() => _isContentStripVisible = false); - _restartHideTimerIfPlaying(); - return; - } - _hideControls(); - return; - } - (widget.onBack ?? () => Navigator.of(context).pop(true))(); - return; - } - if (!_showControls) { - _showControlsWithFocus(); - } else { - (widget.onBack ?? () => Navigator.of(context).pop(true))(); - } - }); - if (backResult != KeyEventResult.ignored) return true; - } - } - - // Only handle when video player navigation is disabled (desktop mode without D-pad nav) - if (_videoPlayerNavigationEnabled) return false; - - // Skip on mobile (unless TV) - final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV(); - if (isMobile) return false; - - // Handle play/pause globally - works regardless of focus - if (_isPlayPauseActivation(event)) { - _playOrPause(); - _showControlsWithFocus(requestFocus: false); - return true; // Event handled, stop propagation - } - - // Fallback: handle all other shortcuts when focus has drifted away - // (e.g. after controls auto-hide). The !hasFocus guard prevents - // double-handling when the Focus onKeyEvent already processes the event. - if (!_focusNode.hasFocus && _keyboardService != null) { - // On Windows/Linux with navigation off, ESC only exits fullscreen — - // never exits the player. Intercept before the keyboard shortcuts - // service which would call onBack and pop the route. - // Skip if an overlay sheet is open — let the sheet handle ESC. - if (!_videoPlayerNavigationEnabled && (Platform.isWindows || Platform.isLinux) && event.logicalKey.isBackKey) { - final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; - if (!sheetOpen) { - if (event is KeyUpEvent) { - _exitFullscreenIfNeeded(); - } - _focusNode.requestFocus(); - return true; - } - } - final result = _keyboardService!.handleVideoPlayerKeyEvent( - event, - widget.player, - _toggleFullscreen, - _toggleSubtitles, - _nextAudioTrack, - _nextSubtitleTrack, - _nextChapter, - _previousChapter, - onBack: widget.onBack ?? () => Navigator.of(context).pop(true), - onToggleShader: _toggleShader, - onNextEpisode: widget.onNext, - onPreviousEpisode: widget.onPrevious, - currentPositionEpoch: widget.currentPositionEpoch, - onLiveSeek: widget.onLiveSeek, - ); - if (result == KeyEventResult.handled) { - _focusNode.requestFocus(); // self-heal focus - return true; - } - } - - return true; // Consume all events while video player is active - } - - /// Show controls and optionally focus play/pause on keyboard input (desktop only) - void _showControlsWithFocus({bool requestFocus = true}) { - if (!_showControls) { - setState(() { - _showControls = true; - }); - // Notify parent of visibility change (for popup positioning) - widget.controlsVisible?.value = true; - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } - } - _startHideTimer(); - - // Request focus on play/pause button after controls are shown - if (requestFocus) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _desktopControlsKey.currentState?.requestPlayPauseFocus(); - }); - } else { - // When not requesting focus on play/pause, ensure main focus node keeps focus - // This prevents focus from being lost when controls become visible - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_focusNode.hasFocus) { - _focusNode.requestFocus(); - } - }); - } - } - - /// Show controls and focus timeline on LEFT/RIGHT input (TV/desktop) - void _showControlsWithTimelineFocus() { - if (!_showControls) { - setState(() { - _showControls = true; - }); - // Notify parent of visibility change (for popup positioning) - widget.controlsVisible?.value = true; - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } - } - _startHideTimer(); - - // Request focus on timeline after controls are shown - WidgetsBinding.instance.addPostFrameCallback((_) { - _desktopControlsKey.currentState?.requestTimelineFocus(); - }); - } - - /// Hide controls when navigating up from timeline (keyboard mode) - /// If skip marker button or Play Next dialog is visible, focus it instead of hiding controls - void _hideControlsFromKeyboard() { - // If skip marker button is visible, focus it instead of hiding controls - if (_currentMarker != null) { - _skipMarkerFocusNode.requestFocus(); - return; - } - - // If Play Next dialog is visible (focus node provided), focus it instead of hiding controls - if (widget.playNextFocusNode != null) { - widget.playNextFocusNode!.requestFocus(); - return; - } - - if (_showControls) { - _hideControls(); - } - } - @override Widget build(BuildContext context) { // Use desktop controls for desktop platforms AND Android TV @@ -1950,153 +569,7 @@ class _PlexVideoControlsState extends State with WindowListen return Focus( focusNode: _focusNode, autofocus: true, - onKeyEvent: (node, event) { - // On Windows/Linux with navigation off, ESC only exits fullscreen — - // never exits the player. Consume all back key events and check - // actual window state asynchronously. - if (!_videoPlayerNavigationEnabled && - (Platform.isWindows || Platform.isLinux) && - event.logicalKey.isBackKey) { - if (event is KeyUpEvent) { - _exitFullscreenIfNeeded(); - } - return KeyEventResult.handled; - } - // On TV, mark coordinator early (KeyDown) so PopScope.onPopInvokedWithResult - // sees it before KeyUp — prevents the system back from racing ahead. - if (PlatformDetector.isTV() && event.logicalKey.isBackKey && event is KeyDownEvent) { - BackKeyCoordinator.markHandled(); - } - final backResult = handleBackKeyAction(event, () { - if (PlatformDetector.isTV()) { - if (_showControls) { - if (_isContentStripVisible) { - _desktopControlsKey.currentState?.dismissContentStrip(); - setState(() => _isContentStripVisible = false); - _restartHideTimerIfPlaying(); - return; - } - _hideControls(); - return; - } - (widget.onBack ?? () => Navigator.of(context).pop(true))(); - return; - } - if (!_showControls) { - _showControlsWithFocus(); - return; - } - // Controls visible - navigate back - (widget.onBack ?? () => Navigator.of(context).pop(true))(); - }); - if (backResult != KeyEventResult.ignored) { - return backResult; - } - - // Only handle KeyDown and KeyRepeat events - // Consume KeyUp events for navigation keys to prevent leaking to previous routes - // Let non-navigation keys (volume, etc.) pass through to the OS - if (!event.isActionable) { - if (!event.logicalKey.isNavigationKey) return KeyEventResult.ignored; - return KeyEventResult.handled; - } - - // Reset hide timer on any keyboard/controller input when controls are visible - if (_showControls) { - _restartHideTimerIfPlaying(); - } - - final key = event.logicalKey; - final isPlayPauseKey = _isPlayPauseKey(event); - - // Always consume play/pause keys to prevent propagation to background routes - // On TV/mobile, handle play/pause here; on desktop, the global handler does it - if (isPlayPauseKey) { - if (_videoPlayerNavigationEnabled || isMobile) { - if (_isPlayPauseActivation(event)) { - _playOrPause(); - _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); - } - } - return KeyEventResult.handled; - } - - // Handle media seek keys (Android TV remotes) - // Uses chapter navigation if chapters are available, otherwise seeks by configured time - if (event is KeyDownEvent && _isMediaSeekKey(key)) { - if (widget.canControl) { - final isForward = - key == LogicalKeyboardKey.mediaFastForward || key == LogicalKeyboardKey.mediaSkipForward; - unawaited(_seekToChapter(forward: isForward)); - } - _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); - return KeyEventResult.handled; - } - - // Handle next/previous track keys (Android TV remotes) - // Uses same behavior as seek keys: chapter navigation or time-based seek - if (event is KeyDownEvent && _isMediaTrackKey(key)) { - if (widget.canControl) { - unawaited(_seekToChapter(forward: key == LogicalKeyboardKey.mediaTrackNext)); - } - _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); - return KeyEventResult.handled; - } - - // Handle Select/Enter when controls are hidden: pause and show controls - // Only intercept if this Focus node itself has primary focus (not a descendant) - if (_isSelectKey(key) && !_showControls && _focusNode.hasPrimaryFocus) { - _playOrPause(); - _showControlsWithFocus(); - return KeyEventResult.handled; - } - - // On desktop/TV, show controls on directional input - // LEFT/RIGHT focuses timeline for seeking, UP/DOWN focuses play/pause - if (!isMobile && _isDirectionalKey(key) && (_videoPlayerNavigationEnabled || PlatformDetector.isTV())) { - if (!_showControls) { - final isHorizontal = key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight; - if (isHorizontal) { - _showControlsWithTimelineFocus(); - if (widget.canControl) { - final forward = key == LogicalKeyboardKey.arrowRight; - unawaited(_seekByTime(forward: forward)); - } - } else { - _showControlsWithFocus(); - } - return KeyEventResult.handled; - } - // Children (DesktopVideoControls) handle navigation first via their own onKeyEvent. - // If we reach here, children already declined the event — consume it to prevent leaking. - return KeyEventResult.handled; - } - - // Pass other events to the keyboard shortcuts service - if (_keyboardService == null) return KeyEventResult.handled; - - final result = _keyboardService!.handleVideoPlayerKeyEvent( - event, - widget.player, - _toggleFullscreen, - _toggleSubtitles, - _nextAudioTrack, - _nextSubtitleTrack, - _nextChapter, - _previousChapter, - onBack: widget.onBack ?? () => Navigator.of(context).pop(true), - onToggleShader: _toggleShader, - onSkipMarker: _performAutoSkip, - onNextEpisode: widget.onNext, - onPreviousEpisode: widget.onPrevious, - currentPositionEpoch: widget.currentPositionEpoch, - onLiveSeek: widget.onLiveSeek, - ); - // Let non-navigation keys (volume, etc.) pass through to the OS - if (!event.logicalKey.isNavigationKey) return KeyEventResult.ignored; - // Never return .ignored for navigation keys — prevent leaking to previous routes - return result == KeyEventResult.ignored ? KeyEventResult.handled : result; - }, + onKeyEvent: (node, event) => _handleControlsKeyEvent(event, isMobile), child: Listener( behavior: HitTestBehavior.translucent, onPointerHover: (_) => _showControlsFromPointerActivity(), @@ -2110,7 +583,7 @@ class _PlexVideoControlsState extends State with WindowListen // Keep-alive: 1px widget that continuously repaints to prevent // Flutter animations from freezing when the frame clock goes idle if (Platform.isLinux || Platform.isWindows) - const Positioned(top: 0, left: 0, child: _LinuxKeepAlive()), + const Positioned(top: 0, left: 0, child: LinuxKeepAlive()), // Invisible tap detector that always covers the full area. // Also handles long-press for 2x speed. Positioned.fill( @@ -2125,51 +598,11 @@ class _PlexVideoControlsState extends State with WindowListen ), // Mobile double-tap zones for skip forward/backward if (isMobile) - Positioned.fill( - child: LayoutBuilder( - builder: (context, constraints) { - final height = constraints.maxHeight; - final width = constraints.maxWidth; - final topExclude = height * 0.15; // Exclude top 15% (top bar) - final bottomExclude = height * 0.15; // Exclude bottom 15% (seek slider) - final leftZoneWidth = width * 0.35; // Left 35% - - return Stack( - children: [ - // Left zone - skip backward (custom double-tap detection) - Positioned( - left: 0, - top: topExclude, - bottom: bottomExclude, - width: leftZoneWidth, - child: GestureDetector( - onTap: () => _handleTapInSkipZone(isForward: false), - onLongPressStart: (_) => _handleLongPressStart(), - onLongPressEnd: (_) => _handleLongPressEnd(), - onLongPressCancel: _handleLongPressCancel, - behavior: HitTestBehavior.opaque, - child: const ColoredBox(color: Colors.transparent), - ), - ), - // Right zone - skip forward (custom double-tap detection) - Positioned( - right: 0, - top: topExclude, - bottom: bottomExclude, - width: leftZoneWidth, - child: GestureDetector( - onTap: () => _handleTapInSkipZone(isForward: true), - onLongPressStart: (_) => _handleLongPressStart(), - onLongPressEnd: (_) => _handleLongPressEnd(), - onLongPressCancel: _handleLongPressCancel, - behavior: HitTestBehavior.opaque, - child: const ColoredBox(color: Colors.transparent), - ), - ), - ], - ); - }, - ), + MobileSkipZones( + onTapInSkipZone: (isForward) => _handleTapInSkipZone(isForward: isForward), + onLongPressStart: (_) => _handleLongPressStart(), + onLongPressEnd: (_) => _handleLongPressEnd(), + onLongPressCancel: _handleLongPressCancel, ), // Custom controls overlay // Positioned AFTER double-tap zones so controls receive taps first @@ -2287,7 +720,10 @@ class _PlexVideoControlsState extends State with WindowListen child: AnimatedOpacity( opacity: _doubleTapFeedbackOpacity, duration: tokens(context).slow, - child: _buildDoubleTapFeedback(), + child: DoubleTapFeedback( + isForward: _lastDoubleTapWasForward, + seconds: _accumulatedSkipSeconds, + ), ), ), ), @@ -2391,374 +827,4 @@ class _PlexVideoControlsState extends State with WindowListen }, ); } - - Widget _buildDesktopControlsListener() { - final playbackState = context.watch(); - final trackControlsState = _buildTrackControlsState( - playbackState: playbackState, - onToggleAlwaysOnTop: Platform.isMacOS ? null : _toggleAlwaysOnTop, - ); - final useDpad = _videoPlayerNavigationEnabled || PlatformDetector.isTV(); - - return Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) => _restartHideTimerIfPlaying(), - child: DesktopVideoControls( - key: _desktopControlsKey, - player: widget.player, - metadata: widget.metadata, - onNext: widget.onNext, - onPrevious: widget.onPrevious, - chapters: _chapters, - chaptersLoaded: _chaptersLoaded, - seekTimeSmall: _seekTimeSmall, - onSeekToPreviousChapter: _seekToPreviousChapter, - onSeekToNextChapter: _seekToNextChapter, - onSeekBackward: () => unawaited(_seekByTime(forward: false)), - onSeekForward: () => unawaited(_seekByTime(forward: true)), - onSeek: _throttledSeek, - onSeekEnd: _finalizeSeek, - getReplayIcon: getReplayIcon, - getForwardIcon: getForwardIcon, - onFocusActivity: _restartHideTimerIfPlaying, - onHideControls: _hideControlsFromKeyboard, - trackControlsState: trackControlsState, - onBack: widget.onBack, - hasFirstFrame: widget.hasFirstFrame, - thumbnailDataBuilder: widget.thumbnailDataBuilder, - liveChannelName: widget.liveChannelName, - captureBuffer: widget.captureBuffer, - isAtLiveEdge: widget.isAtLiveEdge, - streamStartEpoch: widget.streamStartEpoch, - currentPositionEpoch: widget.currentPositionEpoch, - onLiveSeek: widget.onLiveSeek, - onJumpToLive: widget.onJumpToLive, - useDpadNavigation: useDpad, - serverId: widget.metadata.serverId, - showQueueTab: playbackState.isQueueActive, - onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, - onCancelAutoHide: () => _hideTimer?.cancel(), - onStartAutoHide: _startHideTimer, - onSeekCompleted: widget.onSeekCompleted, - onContentStripVisibilityChanged: (visible) { - setState(() => _isContentStripVisible = visible); - if (visible) { - _hideTimer?.cancel(); - } else { - _restartHideTimerIfPlaying(); - } - }, - ), - ); - } - - Widget _buildSkipMarkerButton() { - final isCredits = _currentMarker!.isCredits; - final hasNextEpisode = widget.onNext != null; - - // Show "Next Episode" only when credits extend to end AND there's a next episode - final bool creditsAtEnd = - isCredits && - widget.player.state.duration > Duration.zero && - (widget.player.state.duration - _currentMarker!.endTime).inMilliseconds <= 1000; - final bool showNextEpisode = creditsAtEnd && hasNextEpisode; - String baseButtonText; - if (showNextEpisode) { - baseButtonText = 'Next Episode'; - } else if (isCredits) { - baseButtonText = 'Skip Credits'; - } else { - baseButtonText = 'Skip Intro'; - } - - final isAutoSkipActive = _autoSkipTimer?.isActive ?? false; - final shouldShowAutoSkip = _shouldShowAutoSkip(); - - final int remainingSeconds = isAutoSkipActive && shouldShowAutoSkip - ? (_autoSkipDelay - (_autoSkipProgress * _autoSkipDelay)).ceil().clamp(0, _autoSkipDelay) - : 0; - - final String buttonText = isAutoSkipActive && shouldShowAutoSkip && remainingSeconds > 0 - ? '$baseButtonText ($remainingSeconds)' - : baseButtonText; - final IconData buttonIcon = showNextEpisode ? Symbols.skip_next_rounded : Symbols.fast_forward_rounded; - - return FocusableWrapper( - focusNode: _skipMarkerFocusNode, - onSelect: () { - if (isAutoSkipActive) { - _cancelAutoSkipTimer(); - } - _performAutoSkip(); - }, - borderRadius: tokens(context).radiusSm, - useBackgroundFocus: true, - autoScroll: false, - onKeyEvent: (node, event) { - // DOWN arrow returns focus to play/pause button - if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowDown) { - _desktopControlsKey.currentState?.requestPlayPauseFocus(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - if (isAutoSkipActive) { - _cancelAutoSkipTimer(); - } - _performAutoSkip(); - }, - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: Stack( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 2)), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - buttonText, - style: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600), - ), - const SizedBox(width: 8), - AppIcon(buttonIcon, fill: 1, color: Colors.black, size: 20), - ], - ), - ), - // Progress indicator overlay - if (isAutoSkipActive && shouldShowAutoSkip) - Positioned.fill( - child: ClipRRect( - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: Row( - children: [ - Expanded( - flex: (_autoSkipProgress * 100).round(), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - ), - ), - ), - Expanded( - flex: ((1.0 - _autoSkipProgress) * 100).round(), - child: Container(decoration: const BoxDecoration(color: Colors.transparent)), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } - - /// Switch to a different media version - void _onQueueItemSelected(MediaItem item) { - final videoPlayerState = context.findAncestorStateOfType(); - videoPlayerState?.navigateToQueueItem(item); - } - - Future _onSubtitleDownloaded() async { - if (!mounted) return; - - // Plex-only: the OpenSubtitles polling flow uses [getVideoPlaybackData] - // and the Plex token. Jellyfin has no analogue and the entry point - // (`subtitleSearchSupported`) is already gated on backend, but guard - // here too in case a future caller wires the same handler elsewhere. - if (widget.metadata.backend != MediaBackend.plex) return; - final serverId = widget.metadata.serverId; - if (serverId == null) return; - - try { - final client = context.getPlexClientForServer(serverId); - final token = client.config.token; - if (token == null) return; - - // Plex's OpenSubtitles download is asynchronous: the PUT returns immediately - // but the new stream entry shows up in metadata seconds later. Poll until it - // appears. Up to 15s matches what Plex-web tolerates before giving up. - // Snapshot what's already attached so we can identify the new download. - final existingUris = widget.player.state.tracks.subtitle.where((t) => t.uri != null).map((t) => t.uri!).toSet(); - - final deadline = DateTime.now().add(const Duration(seconds: 15)); - MediaSubtitleTrack? newTrack; - String? newUrl; - MediaSourceInfo? latestInfo; - - while (mounted && DateTime.now().isBefore(deadline)) { - await Future.delayed(const Duration(seconds: 2)); - if (!mounted) return; - - try { - final data = await client.getVideoPlaybackData(widget.metadata.id); - if (!mounted) return; - if (data.mediaInfo == null) continue; - latestInfo = data.mediaInfo; - - for (final plexTrack in data.mediaInfo!.subtitleTracks) { - if (!plexTrack.isExternal) continue; - final url = client.buildExternalSubtitleUrl(plexTrack); - if (url == null) continue; - if (existingUris.any((uri) => uri.contains(plexTrack.key!))) continue; - - newTrack = plexTrack; - newUrl = url; - break; - } - if (newTrack != null) break; - } catch (e) { - appLogger.w('Subtitle download poll iteration failed', error: e); - } - } - - if (!mounted || newTrack == null || newUrl == null) return; - - await widget.player.addSubtitleTrack( - uri: newUrl, - title: newTrack.displayTitle ?? newTrack.language ?? 'Downloaded', - language: newTrack.languageCode, - select: true, - ); - - final partId = latestInfo?.partId; - if (partId != null) { - await client.selectStreams(partId, subtitleStreamID: newTrack.id); - } - } catch (e) { - appLogger.w('Failed to refresh subtitles after download', error: e); - } - } - - /// Switch version, quality preset, or audio stream ID. Any combination may - /// change in one invocation; unspecified values retain their current value. - /// Always routes through pushReplacement, preserving playback position and - /// the transcode session identifiers. - Future _switchVersionAndQuality({ - int? newMediaIndex, - TranscodeQualityPreset? newPreset, - int? newAudioStreamId, - }) async { - final effectiveMediaIndex = newMediaIndex ?? widget.selectedMediaIndex; - final effectivePreset = newPreset ?? widget.selectedQualityPreset; - final effectiveAudioStreamId = newAudioStreamId ?? widget.selectedAudioStreamId; - - final isVersionChange = effectiveMediaIndex != widget.selectedMediaIndex; - final isPresetChange = effectivePreset != widget.selectedQualityPreset; - final isAudioChange = effectiveAudioStreamId != widget.selectedAudioStreamId; - if (!isVersionChange && !isPresetChange && !isAudioChange) { - return; - } - - try { - // Save current playback position - final currentPosition = widget.player.state.position; - - // Get state reference before async operations - final videoPlayerState = context.findAncestorStateOfType(); - - if (isVersionChange) { - final settingsService = await SettingsService.getInstance(); - final seriesKey = widget.metadata.grandparentId ?? widget.metadata.id; - await settingsService.write(SettingsService.mediaVersionPreferences, { - ...settingsService.read(SettingsService.mediaVersionPreferences), - seriesKey: effectiveMediaIndex, - }); - } - - // Preserve session identifiers across the reload so Plex reuses the - // transcode session rather than spinning up a new one. - final sessionId = videoPlayerState?.playbackSessionIdentifier; - final transcodeSessionId = videoPlayerState?.playbackTranscodeSessionId; - - // Set flag on parent VideoPlayerScreen to skip orientation restoration - videoPlayerState?.setReplacingWithVideo(); - // Dispose the existing player before spinning up the replacement to avoid race conditions - await videoPlayerState?.disposePlayerForNavigation(); - - // Navigate to new player screen with the updated selection - // Use PageRouteBuilder with zero-duration transitions to prevent orientation reset - if (mounted) { - unawaited( - Navigator.pushReplacement( - context, - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( - metadata: widget.metadata.copyWith(viewOffsetMs: currentPosition.inMilliseconds), - selectedMediaIndex: effectiveMediaIndex, - selectedQualityPreset: effectivePreset, - selectedAudioStreamId: effectiveAudioStreamId, - reusedSessionIdentifier: sessionId, - reusedTranscodeSessionId: transcodeSessionId, - ), - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - ), - ), - ); - } - } catch (e) { - if (mounted) { - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - } - } - } -} - -/// A 1x1 pixel widget that continuously repaints to keep Flutter's frame clock active on Linux. -/// This prevents animations from freezing when GTK's frame clock goes idle. -class _LinuxKeepAlive extends StatefulWidget { - const _LinuxKeepAlive(); - - @override - State<_LinuxKeepAlive> createState() => _LinuxKeepAliveState(); -} - -class _LinuxKeepAliveState extends State<_LinuxKeepAlive> { - Timer? _timer; - int _tick = 0; - - @override - void initState() { - super.initState(); - // Repaint every 100ms to keep Flutter's frame scheduler active - _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { - if (mounted) { - setState(() { - _tick++; - }); - } - }); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // Use _tick to force rebuild, render a 1x1 transparent pixel - return SizedBox( - width: 1, - height: 1, - child: ColoredBox( - color: Color.fromRGBO(0, 0, 0, _tick % 2 == 0 ? 0.1 : 0.2), // Alternate alpha - ), - ); - } } diff --git a/lib/widgets/video_controls/widgets/double_tap_feedback.dart b/lib/widgets/video_controls/widgets/double_tap_feedback.dart new file mode 100644 index 00000000..e02edfe4 --- /dev/null +++ b/lib/widgets/video_controls/widgets/double_tap_feedback.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../i18n/strings.g.dart'; +import '../../app_icon.dart'; + +class DoubleTapFeedback extends StatelessWidget { + final bool isForward; + final int seconds; + + const DoubleTapFeedback({super.key, required this.isForward, required this.seconds}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: isForward ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 60), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.6), shape: BoxShape.circle), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon( + isForward ? Symbols.forward_media_rounded : Symbols.replay_rounded, + fill: 1, + color: Colors.white, + size: 32, + ), + const SizedBox(height: 4), + Text( + '$seconds${t.settings.secondsShort}', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/video_controls/widgets/linux_keep_alive.dart b/lib/widgets/video_controls/widgets/linux_keep_alive.dart new file mode 100644 index 00000000..be3e5d55 --- /dev/null +++ b/lib/widgets/video_controls/widgets/linux_keep_alive.dart @@ -0,0 +1,41 @@ +import 'dart:async' show Timer; + +import 'package:flutter/material.dart'; + +/// A 1x1 pixel widget that continuously repaints to keep Flutter's frame clock active on Linux. +/// This prevents animations from freezing when GTK's frame clock goes idle. +class LinuxKeepAlive extends StatefulWidget { + const LinuxKeepAlive({super.key}); + + @override + State createState() => _LinuxKeepAliveState(); +} + +class _LinuxKeepAliveState extends State { + Timer? _timer; + int _tick = 0; + + @override + void initState() { + super.initState(); + // Repaint every 100ms to keep Flutter's frame scheduler active. + _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { + if (mounted) { + setState(() { + _tick++; + }); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox(width: 1, height: 1, child: ColoredBox(color: Color.fromRGBO(0, 0, 0, _tick % 2 == 0 ? 0.1 : 0.2))); + } +} diff --git a/lib/widgets/video_controls/widgets/mobile_skip_zones.dart b/lib/widgets/video_controls/widgets/mobile_skip_zones.dart new file mode 100644 index 00000000..598d50ee --- /dev/null +++ b/lib/widgets/video_controls/widgets/mobile_skip_zones.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +bool? mobileSkipZoneForTap({required Offset position, required Size size}) { + final dimensions = _mobileSkipZoneDimensions(size); + final inVerticalRange = position.dy > dimensions.topExclude && position.dy < (size.height - dimensions.bottomExclude); + if (!inVerticalRange) return null; + if (position.dx < dimensions.leftZoneWidth) return false; + if (position.dx > (size.width - dimensions.leftZoneWidth)) return true; + return null; +} + +({double topExclude, double bottomExclude, double leftZoneWidth}) _mobileSkipZoneDimensions(Size size) { + return (topExclude: size.height * 0.15, bottomExclude: size.height * 0.15, leftZoneWidth: size.width * 0.35); +} + +class MobileSkipZones extends StatelessWidget { + final void Function(bool isForward) onTapInSkipZone; + final GestureLongPressStartCallback onLongPressStart; + final GestureLongPressEndCallback onLongPressEnd; + final VoidCallback onLongPressCancel; + + const MobileSkipZones({ + super.key, + required this.onTapInSkipZone, + required this.onLongPressStart, + required this.onLongPressEnd, + required this.onLongPressCancel, + }); + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: LayoutBuilder( + builder: (context, constraints) { + final size = Size(constraints.maxWidth, constraints.maxHeight); + final dimensions = _mobileSkipZoneDimensions(size); + + return Stack( + children: [ + Positioned( + left: 0, + top: dimensions.topExclude, + bottom: dimensions.bottomExclude, + width: dimensions.leftZoneWidth, + child: GestureDetector( + onTap: () => onTapInSkipZone(false), + onLongPressStart: onLongPressStart, + onLongPressEnd: onLongPressEnd, + onLongPressCancel: onLongPressCancel, + behavior: HitTestBehavior.opaque, + child: const ColoredBox(color: Colors.transparent), + ), + ), + Positioned( + right: 0, + top: dimensions.topExclude, + bottom: dimensions.bottomExclude, + width: dimensions.leftZoneWidth, + child: GestureDetector( + onTap: () => onTapInSkipZone(true), + onLongPressStart: onLongPressStart, + onLongPressEnd: onLongPressEnd, + onLongPressCancel: onLongPressCancel, + behavior: HitTestBehavior.opaque, + child: const ColoredBox(color: Colors.transparent), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/widgets/video_controls/widgets/skip_marker_button.dart b/lib/widgets/video_controls/widgets/skip_marker_button.dart new file mode 100644 index 00000000..a69c1555 --- /dev/null +++ b/lib/widgets/video_controls/widgets/skip_marker_button.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../focus/focusable_wrapper.dart'; +import '../../../media/media_source_info.dart'; +import '../../../theme/mono_tokens.dart'; +import '../../app_icon.dart'; + +class SkipMarkerButton extends StatelessWidget { + final MediaMarker marker; + final Duration playerDuration; + final bool hasNextEpisode; + final bool isAutoSkipActive; + final bool shouldShowAutoSkip; + final int autoSkipDelay; + final double autoSkipProgress; + final FocusNode focusNode; + final VoidCallback onCancelAutoSkip; + final VoidCallback onPerformAutoSkip; + final VoidCallback onFocusDown; + + const SkipMarkerButton({ + super.key, + required this.marker, + required this.playerDuration, + required this.hasNextEpisode, + required this.isAutoSkipActive, + required this.shouldShowAutoSkip, + required this.autoSkipDelay, + required this.autoSkipProgress, + required this.focusNode, + required this.onCancelAutoSkip, + required this.onPerformAutoSkip, + required this.onFocusDown, + }); + + @override + Widget build(BuildContext context) { + final isCredits = marker.isCredits; + final creditsAtEnd = + isCredits && playerDuration > Duration.zero && (playerDuration - marker.endTime).inMilliseconds <= 1000; + final showNextEpisode = creditsAtEnd && hasNextEpisode; + String baseButtonText; + if (showNextEpisode) { + baseButtonText = 'Next Episode'; + } else if (isCredits) { + baseButtonText = 'Skip Credits'; + } else { + baseButtonText = 'Skip Intro'; + } + + final remainingSeconds = isAutoSkipActive && shouldShowAutoSkip + ? (autoSkipDelay - (autoSkipProgress * autoSkipDelay)).ceil().clamp(0, autoSkipDelay) + : 0; + + final buttonText = isAutoSkipActive && shouldShowAutoSkip && remainingSeconds > 0 + ? '$baseButtonText ($remainingSeconds)' + : baseButtonText; + final buttonIcon = showNextEpisode ? Symbols.skip_next_rounded : Symbols.fast_forward_rounded; + + return FocusableWrapper( + focusNode: focusNode, + onSelect: _activate, + borderRadius: tokens(context).radiusSm, + useBackgroundFocus: true, + autoScroll: false, + onKeyEvent: (node, event) { + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowDown) { + onFocusDown(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: _activate, + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: Stack( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + buttonText, + style: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(width: 8), + AppIcon(buttonIcon, fill: 1, color: Colors.black, size: 20), + ], + ), + ), + if (isAutoSkipActive && shouldShowAutoSkip) + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: Row( + children: [ + Expanded( + flex: (autoSkipProgress * 100).round(), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + ), + ), + ), + Expanded( + flex: ((1.0 - autoSkipProgress) * 100).round(), + child: Container(decoration: const BoxDecoration(color: Colors.transparent)), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _activate() { + if (isAutoSkipActive) { + onCancelAutoSkip(); + } + onPerformAutoSkip(); + } +} diff --git a/test/services/plex_playback_mapper_test.dart b/test/services/plex_playback_mapper_test.dart new file mode 100644 index 00000000..bca2618d --- /dev/null +++ b/test/services/plex_playback_mapper_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/plex_playback_mapper.dart'; + +void main() { + group('parsePlexVideoPlaybackDataFromJson', () { + test('falls back from inaccessible selected version to playable version', () { + late (int, int) fallback; + + final result = parsePlexVideoPlaybackDataFromJson( + { + 'Media': [ + { + 'id': 1, + 'videoResolution': '2160', + 'Part': [ + {'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 0, 'exists': 1}, + ], + }, + { + 'id': 2, + 'videoResolution': '1080', + 'Part': [ + { + 'id': 20, + 'key': '/library/parts/20/file.mkv', + 'accessible': 1, + 'exists': 1, + 'Stream': [ + {'streamType': 1, 'frameRate': 23.976}, + {'streamType': 2, 'id': 201, 'index': 0, 'languageCode': 'eng', 'selected': 1}, + ], + }, + ], + }, + ], + }, + baseUrl: 'http://plex:32400', + token: 'tok', + onVersionFallback: (requested, selected) => fallback = (requested, selected), + ); + + expect(fallback, (0, 1)); + expect(result.videoUrl, 'http://plex:32400/library/parts/20/file.mkv?X-Plex-Token=tok'); + expect(result.availableVersions, hasLength(2)); + expect(result.availableVersions.first.isPlayable, isFalse); + expect(result.mediaInfo?.partId, 20); + expect(result.mediaInfo?.frameRate, 23.976); + expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng'); + }); + }); + + group('parsePlexFileInfoFromJson', () { + test('maps media, part, and stream fields', () { + final info = parsePlexFileInfoFromJson({ + 'Media': [ + { + 'container': 'mkv', + 'videoCodec': 'h264', + 'videoResolution': '1080', + 'width': 1920, + 'height': 1080, + 'aspectRatio': 1.78, + 'bitrate': 8000, + 'duration': 120000, + 'audioCodec': 'aac', + 'audioChannels': 2, + 'optimizedForStreaming': '1', + 'has64bitOffsets': 0, + 'Part': [ + { + 'file': '/media/movie.mkv', + 'size': 123456, + 'Stream': [ + {'streamType': 1, 'frameRate': 24, 'colorSpace': 'bt709', 'bitDepth': 8, 'bitrate': 7000}, + { + 'streamType': 2, + 'id': 301, + 'index': 0, + 'language': 'English', + 'languageCode': 'eng', + 'channels': 2, + 'selected': true, + 'audioChannelLayout': 'stereo', + }, + {'streamType': 3, 'id': 401, 'index': 0, 'languageCode': 'eng', 'forced': 0, 'key': '/subtitles/401'}, + ], + }, + ], + }, + ], + }); + + expect(info?.container, 'mkv'); + expect(info?.videoCodec, 'h264'); + expect(info?.filePath, '/media/movie.mkv'); + expect(info?.fileSize, 123456); + expect(info?.optimizedForStreaming, isTrue); + expect(info?.has64bitOffsets, isFalse); + expect(info?.frameRate, 24); + expect(info?.bitDepth, 8); + expect(info?.audioTracks.single.id, 301); + expect(info?.audioTracks.single.selected, isTrue); + expect(info?.subtitleTracks.single.key, '/subtitles/401'); + }); + }); +} diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index 952253ca..348b296f 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -1,7 +1,9 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/media/media_version.dart'; import 'package:plezy/widgets/video_controls/video_controls.dart'; +import 'package:plezy/widgets/video_controls/widgets/mobile_skip_zones.dart'; void main() { group('effectiveVersionQualityControls', () { @@ -47,4 +49,22 @@ void main() { expect(result.selectedAudioStreamId, 1); }); }); + + group('mobileSkipZoneForTap', () { + const size = Size(1000, 600); + + test('returns backward for left skip zone', () { + expect(mobileSkipZoneForTap(position: const Offset(100, 300), size: size), isFalse); + }); + + test('returns forward for right skip zone', () { + expect(mobileSkipZoneForTap(position: const Offset(900, 300), size: size), isTrue); + }); + + test('returns null outside skip zones', () { + expect(mobileSkipZoneForTap(position: const Offset(500, 300), size: size), isNull); + expect(mobileSkipZoneForTap(position: const Offset(100, 20), size: size), isNull); + expect(mobileSkipZoneForTap(position: const Offset(900, 580), size: size), isNull); + }); + }); }