refactor: split playback and media hotspots

This commit is contained in:
edde746
2026-05-02 06:33:05 +02:00
parent b1e218f0ad
commit 8fabf56337
39 changed files with 7092 additions and 6533 deletions
@@ -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<void> 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<OfflineWatchProvider>();
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<DownloadProvider>(
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(),
);
},
);
}
}
+178 -688
View File
@@ -70,6 +70,8 @@ import '../widgets/focusable_tab_chip.dart';
import '../widgets/hub_section.dart'; import '../widgets/hub_section.dart';
import '../widgets/loading_indicator_box.dart'; import '../widgets/loading_indicator_box.dart';
part 'media_detail/action_buttons.dart';
enum _SyncRuleAction { edit, remove, delete } enum _SyncRuleAction { edit, remove, delete }
class MediaDetailScreen extends StatefulWidget { class MediaDetailScreen extends StatefulWidget {
@@ -475,513 +477,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
} }
/// Build action buttons row (play, shuffle, download, mark watched) /// 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<void> 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<DownloadProvider>(
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<OfflineWatchProvider>();
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 /// Build a metadata chip with optional leading icon or widget
Widget _buildMetadataChip(String text, {IconData? icon, Widget? leading}) { Widget _buildMetadataChip(String text, {IconData? icon, Widget? leading}) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -2614,187 +2109,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
// Hero header with background art // Hero header with background art
SliverToBoxAdapter( SliverToBoxAdapter(child: _buildHeroHeader(context, metadata, size, headerHeight, theme)),
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),
],
),
),
),
),
],
),
),
// Main content // Main content
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -3048,6 +2363,181 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
); );
} }
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 /// Get the primary trailer from the extras list
MediaItem? _getPrimaryTrailer() { MediaItem? _getPrimaryTrailer() {
if (_extras == null || _extras!.isEmpty) return null; if (_extras == null || _extras!.isEmpty) return null;
+257
View File
@@ -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<WatchTogetherProvider, bool>(
(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<WatchTogetherProvider>();
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),
],
),
),
),
);
}
}
@@ -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>();
_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<void> _toggleFullscreen() async {
if (PlatformDetector.isMobile(context)) return;
await FullscreenStateManager().toggleFullscreen();
}
}
@@ -0,0 +1,143 @@
part of '../../video_player_screen.dart';
extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
Future<void> _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<void> _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<void> _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<void> _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);
}
}
}
@@ -0,0 +1,280 @@
part of '../../video_player_screen.dart';
extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
Future<void> _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<void> _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<void> _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<void> _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 <String, String>{};
final offlineWatchService = context.read<OfflineWatchSyncService>();
final userProfileProvider = context.read<UserProfileProvider>();
final playbackState = context.read<PlaybackStateProvider>();
final database = context.read<AppDatabase>();
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);
}
}
}
@@ -0,0 +1,148 @@
part of '../../video_player_screen.dart';
extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
/// Ensure a play queue exists for sequential episode playback
Future<void> _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<PlaybackStateProvider>();
// 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<void> _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<DownloadProvider>();
final episodes = downloadProvider.getDownloadedEpisodesForShow(showKey);
if (episodes.isEmpty) return;
// Sort by aired date, falling back to season/episode number
final sorted = List<MediaItem>.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);
}
}
}
@@ -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<void> _showServerLimitDialog() async {
if (!mounted) return;
await showServerLimitDialog(context);
if (mounted) unawaited(_handleBackButton());
}
/// Handle notification when native player switched from ExoPlayer to MPV
Future<void> _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();
}
}
@@ -0,0 +1,147 @@
part of '../../video_player_screen.dart';
extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
void _enqueueLifecycleTransition(String label, Future<void> 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 = <String, dynamic>{
'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<void> _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<void> _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');
}
}
+346
View File
@@ -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<void> _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<void> _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<void> _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<bool?> _showWatchFromStartDialog(int effectiveStartEpoch, int nowEpoch) {
final minutesAgo = ((nowEpoch - effectiveStartEpoch) / 60).round();
return showOptionPickerDialog<bool>(
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<void> _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<void> _jumpToLiveEdge() async {
if (_captureBuffer == null) return;
await _seekLivePosition(_captureBuffer!.seekableEndEpoch);
}
Future<void> _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<MultiServerProvider>();
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;
}
@@ -0,0 +1,74 @@
part of '../../video_player_screen.dart';
extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
Future<void> _syncMediaControlsAvailability() async {
final manager = _mediaControlsManager;
final currentPlayer = player;
if (!mounted || manager == null || currentPlayer == null) return;
final playbackState = context.read<PlaybackStateProvider>();
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<void> _seekBackForRewind(Player p) async {
if (_rewindOnResume <= 0) return;
final target = p.state.position - Duration(seconds: _rewindOnResume);
await p.seek(clampSeekPosition(p, target));
}
Future<void> _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
);
}
}
@@ -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<void> _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<void> _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<void> _applySavedShaderPreset() async {
if (_shaderService == null || !_shaderService!.isSupported) return;
try {
final shaderProvider = context.read<ShaderProvider>();
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<void> _restoreAmbientLighting() async {
final shaderProvider = context.read<ShaderProvider>();
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<void> _toggleAmbientLighting() async {
final ambientLighting = _ambientLightingService;
if (ambientLighting == null || !ambientLighting.isSupported) return;
final shaderProvider = context.read<ShaderProvider>();
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();
});
}
}
@@ -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;
});
}
}
}
@@ -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<void> _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<OfflineWatchSyncService>();
// 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);
}
}
@@ -0,0 +1,424 @@
part of '../../video_player_screen.dart';
extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
Future<void> _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<OfflineWatchSyncService>();
try {
PlaybackInitializationResult result;
Map<String, String>? 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<AppDatabase>(),
);
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<UserProfileProvider>().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()));
}
}
}
}
@@ -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>();
_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<WatchTogetherProvider>();
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<WatchTogetherProvider>();
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<void> _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<MultiServerProvider>();
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));
}
}
@@ -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<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
if (!isInPip) return const SizedBox.shrink();
return Positioned.fill(
child: Container(
color: Colors.black,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.picture_in_picture_alt_rounded, size: 48, color: Colors.white.withValues(alpha: 0.5)),
const SizedBox(height: 12),
Text(
t.videoControls.pipActive,
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 14),
),
],
),
),
),
);
},
);
}
}
class VideoPlayerBufferingOverlay extends StatelessWidget {
final ValueListenable<bool> isBuffering;
final ValueListenable<bool> hasFirstFrame;
final ValueListenable<bool> isExiting;
const VideoPlayerBufferingOverlay({
super.key,
required this.isBuffering,
required this.hasFirstFrame,
required this.isExiting,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
if (isInPip) return const SizedBox.shrink();
return ValueListenableBuilder<bool>(
valueListenable: isBuffering,
builder: (context, buffering, child) {
return ValueListenableBuilder<bool>(
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<WatchTogetherProvider, bool>(
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<bool> isExiting;
const VideoPlayerExitOverlay({super.key, required this.isExiting});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
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<bool> 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<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
final episode = nextEpisode;
if (isInPip || !visible || episode == null) {
return const SizedBox.shrink();
}
return ValueListenableBuilder<bool>(
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<PlaybackStateProvider>(
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<bool> 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<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
if (isInPip || !visible) {
return const SizedBox.shrink();
}
return ValueListenableBuilder<bool>(
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),
],
),
),
),
),
],
),
],
),
),
);
},
);
},
);
}
}
File diff suppressed because it is too large Load Diff
+31 -127
View File
@@ -45,11 +45,14 @@ import 'jellyfin_api_cache.dart';
import 'jellyfin_mappers.dart'; import 'jellyfin_mappers.dart';
import 'jellyfin_media_info_mapper.dart'; import 'jellyfin_media_info_mapper.dart';
import 'jellyfin_playback_bundle.dart'; import 'jellyfin_playback_bundle.dart';
import 'jellyfin_playback_urls.dart';
import 'jellyfin_trickplay_service.dart'; import 'jellyfin_trickplay_service.dart';
import 'playback_initialization_types.dart'; import 'playback_initialization_types.dart';
import 'scrub_preview_source.dart'; import 'scrub_preview_source.dart';
import '../mpv/mpv.dart'; import '../mpv/mpv.dart';
part 'jellyfin_client/live_tv_support.dart';
/// [MediaServerClient] over a Jellyfin server. /// [MediaServerClient] over a Jellyfin server.
/// ///
/// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the /// 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 /// can be omitted; for items with multiple versions Jellyfin uses the
/// param to pick which file to serve. /// param to pick which file to serve.
String buildDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) { String buildDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) {
final params = <String, String>{ return buildJellyfinDirectStreamUrl(
'Static': 'true', baseUrl: connection.baseUrl,
'api_key': connection.accessToken, accessToken: connection.accessToken,
'DeviceId': connection.deviceId, deviceId: connection.deviceId,
'Container': ?container, itemId: itemId,
'MediaSourceId': ?mediaSourceId, 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';
} }
/// Trickplay sprite-sheet URL. [width] picks one of the resolutions /// 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 /// Pass [mediaSourceId] when the item has more than one source so the
/// server returns the matching version's tiles. /// server returns the matching version's tiles.
String buildTrickplayTileUrl(String itemId, int width, int sheetIndex, {String? mediaSourceId}) { String buildTrickplayTileUrl(String itemId, int width, int sheetIndex, {String? mediaSourceId}) {
final params = <String, String>{ return buildJellyfinTrickplayTileUrl(
'api_key': connection.accessToken, baseUrl: connection.baseUrl,
'DeviceId': connection.deviceId, accessToken: connection.accessToken,
'MediaSourceId': ?mediaSourceId, deviceId: connection.deviceId,
}; itemId: itemId,
final query = params.entries.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}').join('&'); width: width,
final encodedItem = Uri.encodeComponent(itemId); sheetIndex: sheetIndex,
return '${connection.baseUrl}/Videos/$encodedItem/Trickplay/$width/$sheetIndex.jpg?$query'; mediaSourceId: mediaSourceId,
);
} }
/// HLS master playlist URL for transcoded playback. Use when the file /// HLS master playlist URL for transcoded playback. Use when the file
@@ -1601,18 +1603,17 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
int? subtitleStreamIndex, int? subtitleStreamIndex,
String? playSessionId, String? playSessionId,
}) { }) {
final params = <String, String>{ return buildJellyfinHlsStreamUrl(
'DeviceId': connection.deviceId, baseUrl: connection.baseUrl,
'MediaSourceId': mediaSourceId, accessToken: connection.accessToken,
'api_key': connection.accessToken, deviceId: connection.deviceId,
'VideoBitrate': ?videoBitrate?.toString(), itemId: itemId,
'AudioStreamIndex': ?audioStreamIndex?.toString(), mediaSourceId: mediaSourceId,
'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(), videoBitrate: videoBitrate,
'PlaySessionId': ?playSessionId, audioStreamIndex: audioStreamIndex,
}; subtitleStreamIndex: subtitleStreamIndex,
final query = params.entries.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}').join('&'); playSessionId: playSessionId,
final encodedItem = Uri.encodeComponent(itemId); );
return '${connection.baseUrl}/Videos/$encodedItem/master.m3u8?$query';
} }
/// Negotiate playback: returns the parsed `MediaSources[]` array and the /// 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); 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<bool> isAvailable() => _client.hasLiveTv();
@override
Future<List<LiveTvDvr>> fetchDvrs() async => const [];
@override
Future<List<LiveTvChannel>> fetchChannels({String? lineup}) => _client.fetchLiveTvChannels();
@override
Future<List<LiveTvProgram>> 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<LiveTvStreamResolution?> 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<String, dynamic>
? sources.first as Map<String, dynamic>
: 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<String> 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<List<FavoriteChannel>> 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<void> setFavoriteChannels(List<FavoriteChannel> 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);
}
}
}
@@ -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<bool> isAvailable() => _client.hasLiveTv();
@override
Future<List<LiveTvDvr>> fetchDvrs() async => const [];
@override
Future<List<LiveTvChannel>> fetchChannels({String? lineup}) => _client.fetchLiveTvChannels();
@override
Future<List<LiveTvProgram>> 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<LiveTvStreamResolution?> 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<String, dynamic>
? sources.first as Map<String, dynamic>
: 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<String> 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<List<FavoriteChannel>> 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<void> setFavoriteChannels(List<FavoriteChannel> 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);
}
}
}
+59
View File
@@ -0,0 +1,59 @@
String buildJellyfinDirectStreamUrl({
required String baseUrl,
required String accessToken,
required String deviceId,
required String itemId,
String? container,
String? mediaSourceId,
}) {
final params = <String, String>{
'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 = <String, String>{'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 = <String, String>{
'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<String, String> params) =>
params.entries.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}').join('&');
+22 -816
View File
@@ -16,12 +16,10 @@ import '../media/media_kind.dart';
import '../media/media_library.dart'; import '../media/media_library.dart';
import '../media/media_playlist.dart'; import '../media/media_playlist.dart';
import '../media/media_server_client.dart'; import '../media/media_server_client.dart';
import '../media/media_version.dart';
import '../media/server_capabilities.dart'; import '../media/server_capabilities.dart';
import '../utils/external_ids.dart'; import '../utils/external_ids.dart';
import 'bif_thumbnail_service.dart'; import 'bif_thumbnail_service.dart';
import 'download_artwork_helpers.dart'; import 'download_artwork_helpers.dart';
import 'file_info_parser.dart';
import 'library_query_translator.dart'; import 'library_query_translator.dart';
import 'scrub_preview_source.dart'; import 'scrub_preview_source.dart';
import '../utils/media_server_http_client.dart'; import '../utils/media_server_http_client.dart';
@@ -57,8 +55,11 @@ import '../mpv/mpv.dart';
import 'api_cache.dart'; import 'api_cache.dart';
import 'plex_api_cache.dart'; import 'plex_api_cache.dart';
import 'plex_mappers.dart'; import 'plex_mappers.dart';
import 'plex_playback_mapper.dart';
import 'playback_initialization_types.dart'; import 'playback_initialization_types.dart';
part 'plex_client/parts/live_tv.dart';
/// Result of a paginated library content fetch /// Result of a paginated library content fetch
class _LibraryContentResult { class _LibraryContentResult {
final List<PlexMetadataDto> items; final List<PlexMetadataDto> items;
@@ -125,8 +126,11 @@ class ConnectionTestResult {
ConnectionTestResult({required this.success, required this.latencyMs, this.error, this.transcoderVideo}); 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; PlexConfig config;
@override
late final MediaServerHttpClient _http; late final MediaServerHttpClient _http;
final EndpointFailoverManager? _endpointManager; final EndpointFailoverManager? _endpointManager;
final Future<void> Function(String newBaseUrl)? _onEndpointChanged; final Future<void> Function(String newBaseUrl)? _onEndpointChanged;
@@ -164,6 +168,7 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
List<PlexLibraryDto> _providerLibraries = const []; List<PlexLibraryDto> _providerLibraries = const [];
/// EPG providers parsed from /media/providers /// EPG providers parsed from /media/providers
@override
List<({String identifier, String gridEndpoint})> _providerEpg = const []; List<({String identifier, String gridEndpoint})> _providerEpg = const [];
/// Server-level preferences fetched from /:/prefs /// 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 /// Execute a GET request with endpoint failover retry. On timeout/connection
/// errors the next endpoint is tried (once). Non-GET methods are not retried. /// errors the next endpoint is tried (once). Non-GET methods are not retried.
@override
Future<MediaServerResponse> _getWithFailover( Future<MediaServerResponse> _getWithFailover(
String path, { String path, {
Map<String, dynamic>? queryParameters, Map<String, dynamic>? queryParameters,
@@ -341,18 +347,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
return sc != null && sc >= 500 && sc <= 599; return sc != null && sc >= 500 && sc <= 599;
} }
/// POST the tune endpoint with one retry on transient HTTP failure.
Future<MediaServerResponse> _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. /// Fetch /media/providers and parse libraries + EPG providers from the response.
/// This discovers individually shared items that don't appear in /library/sections. /// This discovers individually shared items that don't appear in /library/sections.
Future<void> _initMediaProviders() async { Future<void> _initMediaProviders() async {
@@ -553,6 +547,7 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
return ConnectionTestResult(success: true, latencyMs: avgLatency); return ConnectionTestResult(success: true, latencyMs: avgLatency);
} }
@override
Map<String, dynamic>? _getMediaContainer(MediaServerResponse response) { Map<String, dynamic>? _getMediaContainer(MediaServerResponse response) {
if (response.data is Map && response.data.containsKey('MediaContainer')) { if (response.data is Map && response.data.containsKey('MediaContainer')) {
return response.data['MediaContainer']; return response.data['MediaContainer'];
@@ -563,8 +558,10 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
PlexMetadataDto _tagMetadata(PlexMetadataDto metadata) => PlexMetadataDto _tagMetadata(PlexMetadataDto metadata) =>
metadata.copyWith(serverId: serverId, serverName: serverName); metadata.copyWith(serverId: serverId, serverName: serverName);
@override
PlexMetadataDto _createTaggedMetadata(Map<String, dynamic> json) => _tagMetadata(PlexMetadataDto.fromJson(json)); PlexMetadataDto _createTaggedMetadata(Map<String, dynamic> json) => _tagMetadata(PlexMetadataDto.fromJson(json));
@override
List<PlexMetadataDto> _extractMetadataList(MediaServerResponse response) { List<PlexMetadataDto> _extractMetadataList(MediaServerResponse response) {
final container = _getMediaContainer(response); final container = _getMediaContainer(response);
if (container != null && container['Metadata'] != null) { 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 /// Wraps an API call that returns a list, returning empty list on error
@override
Future<List<T>> _wrapListApiCall<T>( Future<List<T>> _wrapListApiCall<T>(
Future<MediaServerResponse> Function() apiCall, Future<MediaServerResponse> Function() apiCall,
List<T> Function(MediaServerResponse response) parseResponse, List<T> Function(MediaServerResponse response) parseResponse,
@@ -908,14 +906,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
return all; return all;
} }
static const _streamReader = PlexFileInfoStreamReader();
/// Parse chapters from metadata JSON
List<MediaChapter> _parseChapters(Map<String, dynamic>? metadataJson) => plexChaptersFromCacheJson(metadataJson);
/// Parse markers from metadata JSON
List<MediaMarker> _parseMarkers(Map<String, dynamic>? metadataJson) => plexMarkersFromCacheJson(metadataJson);
/// Set per-media language preferences (audio and subtitle) /// Set per-media language preferences (audio and subtitle)
/// For TV shows, use grandparentRatingKey to set preference for the entire series /// For TV shows, use grandparentRatingKey to set preference for the entire series
/// For movies, use the movie's ratingKey /// 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 /// Used by [getVideoPlaybackData] to avoid redundant fetches when the
/// response is already available. /// response is already available.
PlexVideoPlaybackData parseVideoPlaybackDataFromJson(Map<String, dynamic>? metadataJson, {int mediaIndex = 0}) { PlexVideoPlaybackData parseVideoPlaybackDataFromJson(Map<String, dynamic>? metadataJson, {int mediaIndex = 0}) {
String? videoUrl; return parsePlexVideoPlaybackDataFromJson(
MediaSourceInfo? mediaInfo; metadataJson,
List<MediaVersion> availableVersions = []; baseUrl: config.baseUrl,
final markers = _parseMarkers(metadataJson); token: config.token,
mediaIndex: mediaIndex,
if (metadataJson != null) { onVersionFallback: (requested, fallback) {
if (metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { appLogger.w('Version $requested inaccessible/missing — falling back to version $fallback');
final mediaList = metadataJson['Media'] as List; },
availableVersions = mediaList
.map((media) => PlexMappers.mediaVersionFromJson(media as Map<String, dynamic>))
.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<dynamic>?, _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,
); );
} }
@@ -1325,54 +1269,7 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
); );
final metadataJson = _getFirstMetadataJsonFromData(data); final metadataJson = _getFirstMetadataJsonFromData(data);
if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { return parsePlexFileInfoFromJson(metadataJson);
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<dynamic>?, _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;
} catch (e) { } catch (e) {
appLogger.e('Failed to get file info: $e'); appLogger.e('Failed to get file info: $e');
return null; return null;
@@ -1451,69 +1348,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
throwIfHttpError(response); 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<CaptureBuffer?> 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<String, dynamic>) return null;
final container = data['MediaContainer'] as Map<String, dynamic>? ?? data;
// Try CaptureBuffer wrapper first, then TranscodeSession directly
final captureBufferWrapper = container['CaptureBuffer'];
if (captureBufferWrapper != null) {
final cbMap = captureBufferWrapper is List
? captureBufferWrapper.firstOrNull as Map<String, dynamic>?
: captureBufferWrapper as Map<String, dynamic>?;
if (cbMap != null) {
final ts = cbMap['TranscodeSession'];
final tsMap = ts is List ? ts.firstOrNull as Map<String, dynamic>? : ts as Map<String, dynamic>?;
if (tsMap != null) return CaptureBuffer.fromTranscodeSession(tsMap);
}
}
final transcodeSessions = container['TranscodeSession'];
if (transcodeSessions is List && transcodeSessions.isNotEmpty) {
return CaptureBuffer.fromTranscodeSession(transcodeSessions.first as Map<String, dynamic>);
} else if (transcodeSessions is Map<String, dynamic>) {
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 /// 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 /// This uses the same endpoint Plex Web uses to hide items from Continue Watching
Future<void> removeFromOnDeck(String ratingKey) async { Future<void> removeFromOnDeck(String ratingKey) async {
@@ -2477,271 +2311,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
await _getWithFailover('/library/sections/$sectionId/analyze'); await _getWithFailover('/library/sections/$sectionId/analyze');
} }
/// Get all DVR devices configured on this server
Future<List<LiveTvDvr>> getDvrs() async {
return _wrapListApiCall<LiveTvDvr>(() => _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<String, dynamic>)).toList();
}
return [];
}, 'Failed to get DVRs');
}
/// Check if this server has at least one DVR configured
Future<bool> hasDvr() async {
final dvrs = await getDvrs();
return dvrs.isNotEmpty;
}
/// Get EPG channels using provider lineup endpoints (matches official Plex web client)
Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async {
List<LiveTvChannel> 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<String, dynamic>,
).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<String, dynamic>,
).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 = <LiveTvChannel>[];
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<List<({String identifier, String gridEndpoint})>> _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<LiveTvProgram> _parseLiveTvPrograms(List items) {
final programs = <LiveTvProgram>[];
for (final item in items) {
try {
final map = item as Map<String, dynamic>;
final mediaList = (map['Media'] as List?)?.whereType<Map<String, dynamic>>().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<List<LiveTvProgram>> getEpgGrid({int? beginsAt, int? endsAt}) async {
final providers = await _discoverEpgProviders();
if (providers.isEmpty) return [];
final queryParams = <String, dynamic>{};
if (beginsAt != null) queryParams['endsAt>'] = beginsAt;
if (endsAt != null) queryParams['beginsAt<'] = endsAt;
final allPrograms = <LiveTvProgram>[];
for (final provider in providers) {
try {
final programs = await _wrapListApiCall<LiveTvProgram>(
() => _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<LiveTvProgram> _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 = <LiveTvProgram>[];
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<List<LiveTvHubResult>> getLiveTvHubs({int count = 12}) async {
final providers = await _discoverEpgProviders();
if (providers.isEmpty) return [];
final allHubs = <LiveTvHubResult>[];
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 = <LiveTvHubEntry>[];
for (final itemJson in metadataList) {
if (itemJson is! Map<String, dynamic>) 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<String, dynamic> 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 — /// Generate 24-char random alphanumeric string. Backend-neutral helper —
/// prefer importing `utils/session_identifier.dart` directly. This thin /// prefer importing `utils/session_identifier.dart` directly. This thin
/// forwarder stays for callers that already had a `PlexClient.` reference; /// 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<String, dynamic>? 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<String, dynamic>;
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<String, dynamic>;
final nested = op['Metadata'];
if (nested is Map<String, dynamic>) {
metadataJson = nested;
} else if (nested is List && nested.isNotEmpty) {
metadataJson = nested.first as Map<String, dynamic>;
}
}
}
if (metadataJson == null) {
final fallback = container['Metadata'];
if (fallback is List && fallback.isNotEmpty) {
metadataJson = fallback.first as Map<String, dynamic>;
} else if (fallback is Map<String, dynamic>) {
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<String, dynamic>);
} else if (tsSource is Map<String, dynamic>) {
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<String, dynamic>) {
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<String?> buildLiveStreamPath({
required String sessionPath,
required String sessionIdentifier,
required String transcodeSessionId,
int? offsetSeconds,
bool directStream = true,
bool directStreamAudio = true,
}) async {
try {
final allParams = <String, String>{
'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<String, String>.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. /// Checks whether the server has video transcoding enabled.
/// ///
/// Reads `transcoderVideo` from the root MediaContainer. Result is cached /// 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<List<PlexMetadataDto>> _getLiveTvSessions() {
return _wrapListApiCall<PlexMetadataDto>(
() => _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<String> 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<List<FavoriteChannel>> 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<String, dynamic>))
.toList();
}
return [];
} catch (e) {
appLogger.e('Failed to get favorite channels', error: e);
return [];
}
}
/// Update favorite channels on the Plex cloud.
Future<void> setFavoriteChannels(List<FavoriteChannel> 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<void> _handleEndpointSwitch(String newBaseUrl, {bool persist = true}) async { Future<void> _handleEndpointSwitch(String newBaseUrl, {bool persist = true}) async {
if (config.baseUrl == newBaseUrl) { if (config.baseUrl == newBaseUrl) {
return; return;
@@ -3874,12 +3132,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
return raw.map((m) => PlexMappers.mediaItem(m)).toList(); return raw.map((m) => PlexMappers.mediaItem(m)).toList();
} }
/// Plex-specific: live TV sessions (active recordings/playback).
Future<List<MediaItem>> fetchLiveTvSessions() async {
final raw = await _getLiveTvSessions();
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
}
/// Plex-specific: library-scoped playlists. /// Plex-specific: library-scoped playlists.
Future<List<MediaPlaylist>> fetchLibraryPlaylists({String playlistType = 'video'}) async { Future<List<MediaPlaylist>> fetchLibraryPlaylists({String playlistType = 'video'}) async {
final raw = await _getLibraryPlaylists(playlistType: playlistType); final raw = await _getLibraryPlaylists(playlistType: playlistType);
@@ -4005,9 +3257,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
String? mediaSourceId, String? mediaSourceId,
}) => updateProgress(itemId, time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds); }) => updateProgress(itemId, time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds);
@override
LiveTvSupport get liveTv => _PlexLiveTvSupport(this);
// ── Downloads ──────────────────────────────────────────────────── // ── Downloads ────────────────────────────────────────────────────
@override @override
@@ -4047,46 +3296,3 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
return buildArtworkSpecs(item, getThumbnailUrl); 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<bool> isAvailable() => _client.hasDvr();
@override
Future<List<LiveTvDvr>> fetchDvrs() => _client.getDvrs();
@override
Future<List<LiveTvChannel>> fetchChannels({String? lineup}) => _client.getEpgChannels(lineup: lineup);
@override
Future<List<LiveTvProgram>> 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<LiveTvStreamResolution?> resolveStreamUrl(String channelKey, {String? dvrKey}) async => null;
@override
Future<String> buildFavoriteChannelSource({String? lineup}) => _client.buildFavoriteChannelSource(lineup: lineup);
@override
String get favoriteStoreKey => 'plex:${_client.config.clientIdentifier}';
@override
FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.sharedFullList;
@override
Future<List<FavoriteChannel>> fetchFavoriteChannels() => _client.getFavoriteChannels();
@override
Future<void> setFavoriteChannels(List<FavoriteChannel> channels) => _client.setFavoriteChannels(channels);
}
+729
View File
@@ -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<MediaServerResponse> _getWithFailover(String path, {Map<String, dynamic>? queryParameters});
Map<String, dynamic>? _getMediaContainer(MediaServerResponse response);
PlexMetadataDto _createTaggedMetadata(Map<String, dynamic> json);
List<PlexMetadataDto> _extractMetadataList(MediaServerResponse response);
Future<List<T>> _wrapListApiCall<T>(
Future<MediaServerResponse> Function() apiCall,
List<T> Function(MediaServerResponse response) parseResponse,
String errorMessage,
);
/// POST the tune endpoint with one retry on transient HTTP failure.
Future<MediaServerResponse> _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<CaptureBuffer?> 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<String, dynamic>) return null;
final container = data['MediaContainer'] as Map<String, dynamic>? ?? data;
// Try CaptureBuffer wrapper first, then TranscodeSession directly
final captureBufferWrapper = container['CaptureBuffer'];
if (captureBufferWrapper != null) {
final cbMap = captureBufferWrapper is List
? captureBufferWrapper.firstOrNull as Map<String, dynamic>?
: captureBufferWrapper as Map<String, dynamic>?;
if (cbMap != null) {
final ts = cbMap['TranscodeSession'];
final tsMap = ts is List ? ts.firstOrNull as Map<String, dynamic>? : ts as Map<String, dynamic>?;
if (tsMap != null) return CaptureBuffer.fromTranscodeSession(tsMap);
}
}
final transcodeSessions = container['TranscodeSession'];
if (transcodeSessions is List && transcodeSessions.isNotEmpty) {
return CaptureBuffer.fromTranscodeSession(transcodeSessions.first as Map<String, dynamic>);
} else if (transcodeSessions is Map<String, dynamic>) {
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<List<LiveTvDvr>> getDvrs() async {
return _wrapListApiCall<LiveTvDvr>(() => _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<String, dynamic>)).toList();
}
return [];
}, 'Failed to get DVRs');
}
/// Check if this server has at least one DVR configured
Future<bool> hasDvr() async {
final dvrs = await getDvrs();
return dvrs.isNotEmpty;
}
/// Get EPG channels using provider lineup endpoints (matches official Plex web client)
Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async {
List<LiveTvChannel> 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<String, dynamic>,
).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<String, dynamic>,
).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 = <LiveTvChannel>[];
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<List<({String identifier, String gridEndpoint})>> _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<LiveTvProgram> _parseLiveTvPrograms(List items) {
final programs = <LiveTvProgram>[];
for (final item in items) {
try {
final map = item as Map<String, dynamic>;
final mediaList = (map['Media'] as List?)?.whereType<Map<String, dynamic>>().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<List<LiveTvProgram>> getEpgGrid({int? beginsAt, int? endsAt}) async {
final providers = await _discoverEpgProviders();
if (providers.isEmpty) return [];
final queryParams = <String, dynamic>{};
if (beginsAt != null) queryParams['endsAt>'] = beginsAt;
if (endsAt != null) queryParams['beginsAt<'] = endsAt;
final allPrograms = <LiveTvProgram>[];
for (final provider in providers) {
try {
final programs = await _wrapListApiCall<LiveTvProgram>(
() => _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<LiveTvProgram> _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 = <LiveTvProgram>[];
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<List<LiveTvHubResult>> getLiveTvHubs({int count = 12}) async {
final providers = await _discoverEpgProviders();
if (providers.isEmpty) return [];
final allHubs = <LiveTvHubResult>[];
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 = <LiveTvHubEntry>[];
for (final itemJson in metadataList) {
if (itemJson is! Map<String, dynamic>) 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<String, dynamic> 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<String, dynamic>? 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<String, dynamic>;
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<String, dynamic>;
final nested = op['Metadata'];
if (nested is Map<String, dynamic>) {
metadataJson = nested;
} else if (nested is List && nested.isNotEmpty) {
metadataJson = nested.first as Map<String, dynamic>;
}
}
}
if (metadataJson == null) {
final fallback = container['Metadata'];
if (fallback is List && fallback.isNotEmpty) {
metadataJson = fallback.first as Map<String, dynamic>;
} else if (fallback is Map<String, dynamic>) {
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<String, dynamic>);
} else if (tsSource is Map<String, dynamic>) {
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<String, dynamic>) {
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<String?> buildLiveStreamPath({
required String sessionPath,
required String sessionIdentifier,
required String transcodeSessionId,
int? offsetSeconds,
bool directStream = true,
bool directStreamAudio = true,
}) async {
try {
final allParams = <String, String>{
'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<String, String>.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<List<PlexMetadataDto>> _getLiveTvSessions() {
return _wrapListApiCall<PlexMetadataDto>(
() => _http.get('/livetv/sessions'),
_extractMetadataList,
'Failed to get live TV sessions',
);
}
/// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}`
Future<String> 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<List<FavoriteChannel>> 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<String, dynamic>))
.toList();
}
return [];
} catch (e) {
appLogger.e('Failed to get favorite channels', error: e);
return [];
}
}
/// Update favorite channels on the Plex cloud.
Future<void> setFavoriteChannels(List<FavoriteChannel> 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<List<MediaItem>> 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<bool> isAvailable() => _client.hasDvr();
@override
Future<List<LiveTvDvr>> fetchDvrs() => _client.getDvrs();
@override
Future<List<LiveTvChannel>> fetchChannels({String? lineup}) => _client.getEpgChannels(lineup: lineup);
@override
Future<List<LiveTvProgram>> 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<LiveTvStreamResolution?> resolveStreamUrl(String channelKey, {String? dvrKey}) async => null;
@override
Future<String> buildFavoriteChannelSource({String? lineup}) => _client.buildFavoriteChannelSource(lineup: lineup);
@override
String get favoriteStoreKey => 'plex:${_client.config.clientIdentifier}';
@override
FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.sharedFullList;
@override
Future<List<FavoriteChannel>> fetchFavoriteChannels() => _client.getFavoriteChannels();
@override
Future<void> setFavoriteChannels(List<FavoriteChannel> channels) => _client.setFavoriteChannels(channels);
}
+125
View File
@@ -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<String, dynamic>? metadataJson, {
required String baseUrl,
required String? token,
int mediaIndex = 0,
void Function(int requestedIndex, int fallbackIndex)? onVersionFallback,
}) {
String? videoUrl;
MediaSourceInfo? mediaInfo;
List<MediaVersion> 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<String, dynamic>))
.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<dynamic>?, _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<String, dynamic>? 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<dynamic>?, _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;
}
@@ -0,0 +1,314 @@
part of '../video_controls.dart';
extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
Future<void> _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;
}
}
@@ -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<void> _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(),
);
}
}
@@ -0,0 +1,214 @@
part of '../video_controls.dart';
extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
Widget _buildDesktopControlsListener() {
final playbackState = context.watch<PlaybackStateProvider>();
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<VideoPlayerScreenState>();
videoPlayerState?.navigateToQueueItem(item);
}
Future<void> _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<void> _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<VideoPlayerScreenState>();
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<bool>(
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()));
}
}
}
}
@@ -0,0 +1,37 @@
part of '../video_controls.dart';
extension _PlexVideoControlsPlaybackExtrasMethods on _PlexVideoControlsState {
Future<void> _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<AppDatabase>();
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;
});
}
}
@@ -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<void> _seekByTime({required bool forward}) async {
final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall);
await _seekByOffset(delta);
}
Future<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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');
}
@@ -0,0 +1,226 @@
part of '../video_controls.dart';
extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
Future<void> _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<ShaderProvider>();
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<MultiServerProvider>().serverManager;
final c = manager.getClient(serverId);
return c?.capabilities.externalSubtitleSearch ?? false;
} catch (_) {
return false;
}
}
Widget _buildTrackChapterControlsWidget({bool hideChaptersAndQueue = false}) {
final playbackState = context.watch<PlaybackStateProvider>();
final trackControlsState = _buildTrackControlsState(
playbackState: playbackState,
onToggleAlwaysOnTop: _toggleAlwaysOnTop,
);
return TrackChapterControls(
player: widget.player,
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
trackControlsState: trackControlsState,
onSeekCompleted: widget.onSeekCompleted,
hideChaptersAndQueue: hideChaptersAndQueue,
);
}
}
@@ -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<void> _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<void> _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<void> _exitFullscreenIfNeeded() async {
if (await windowManager.isFullScreen()) {
await FullscreenStateManager().exitFullscreen();
}
}
/// Initialize always-on-top state from window manager (desktop only)
Future<void> _initAlwaysOnTopState() async {
final isOnTop = await windowManager.isAlwaysOnTop();
if (mounted && isOnTop != _isAlwaysOnTop) {
_setControlsState(() {
_isAlwaysOnTop = isOnTop;
});
}
}
/// Toggle always-on-top window mode (desktop only)
Future<void> _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();
}
}
}
@@ -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<PlaybackExtras?> 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<PlaybackExtras?> _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<String?> _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;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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),
),
],
),
),
);
}
}
@@ -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<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) {
return SizedBox(width: 1, height: 1, child: ColoredBox(color: Color.fromRGBO(0, 0, 0, _tick % 2 == 0 ? 0.1 : 0.2)));
}
}
@@ -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),
),
),
],
);
},
),
);
}
}
@@ -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();
}
}
@@ -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');
});
});
}
+20
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/media/media_version.dart'; import 'package:plezy/media/media_version.dart';
import 'package:plezy/widgets/video_controls/video_controls.dart'; import 'package:plezy/widgets/video_controls/video_controls.dart';
import 'package:plezy/widgets/video_controls/widgets/mobile_skip_zones.dart';
void main() { void main() {
group('effectiveVersionQualityControls', () { group('effectiveVersionQualityControls', () {
@@ -47,4 +49,22 @@ void main() {
expect(result.selectedAudioStreamId, 1); 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);
});
});
} }