refactor: split playback and media hotspots
This commit is contained in:
@@ -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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user