refactor: deduplicate video controls, formatters & play queue wiring

This commit is contained in:
edde746
2025-12-07 16:31:08 +01:00
parent 4d65607e59
commit f24bb9dc85
9 changed files with 572 additions and 475 deletions
@@ -1,18 +1,16 @@
import 'package:flutter/material.dart';
import '../../services/plex_client.dart';
import '../../services/play_queue_launcher.dart';
import '../../models/plex_playlist.dart';
import '../../models/plex_metadata.dart';
import '../../providers/playback_state_provider.dart';
import '../../utils/app_logger.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/video_player_navigation.dart';
import '../../widgets/media_grid_sliver.dart';
import 'playlist_item_card.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../i18n/strings.g.dart';
import '../../utils/dialogs.dart';
import '../base_media_list_detail_screen.dart';
import 'package:provider/provider.dart';
/// Screen to display the contents of a playlist
class PlaylistDetailScreen extends StatefulWidget {
@@ -205,61 +203,21 @@ class _PlaylistDetailScreenState
Future<void> _playFromItem(int index) async {
if (items.isEmpty || index < 0 || index >= items.length) return;
try {
final client = _getClientForPlaylist();
final plexClient = _getClientForPlaylist();
final selectedItem = items[index];
final selectedItem = items[index];
final launcher = PlayQueueLauncher(
context: context,
client: plexClient,
serverId: widget.playlist.serverId,
serverName: widget.playlist.serverName,
);
// Create play queue from playlist, starting at the selected item
final playQueue = await client.createPlayQueue(
playlistID: int.parse(widget.playlist.ratingKey),
type: 'video',
key: selectedItem.key,
);
if (playQueue == null ||
playQueue.items == null ||
playQueue.items!.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.failedToCreatePlayQueue)),
);
}
return;
}
if (!mounted) return;
// Set play queue in provider
final playbackState = context.read<PlaybackStateProvider>();
playbackState.setClient(client);
await playbackState.setPlaybackFromPlayQueue(
playQueue,
widget.playlist.ratingKey,
);
// Navigate to selected item
if (mounted) {
await navigateToVideoPlayer(
context,
metadata: playQueue.selectedItem ?? playQueue.items!.first,
);
}
} catch (e) {
appLogger.e('Failed to play from item', error: e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
t.messages.failedPlayback(
action: t.discover.play,
error: e.toString(),
),
),
),
);
}
}
await launcher.launchFromPlaylistItem(
playlist: widget.playlist,
selectedItem: selectedItem,
showLoadingIndicator: true,
);
}
@override
+290
View File
@@ -0,0 +1,290 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/play_queue_response.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../providers/playback_state_provider.dart';
import '../utils/app_logger.dart';
import '../utils/video_player_navigation.dart';
import '../i18n/strings.g.dart';
import 'plex_client.dart';
/// Result type for play queue operations
sealed class PlayQueueResult {
const PlayQueueResult();
}
class PlayQueueSuccess extends PlayQueueResult {
const PlayQueueSuccess();
}
class PlayQueueEmpty extends PlayQueueResult {
const PlayQueueEmpty();
}
class PlayQueueError extends PlayQueueResult {
final Object error;
const PlayQueueError(this.error);
}
/// Service to handle play queue creation and navigation.
///
/// Centralizes the common pattern of:
/// 1. Creating a play queue via various methods
/// 2. Setting up PlaybackStateProvider
/// 3. Navigating to the video player
/// 4. Handling errors with appropriate feedback
class PlayQueueLauncher {
final BuildContext context;
final PlexClient client;
final String? serverId;
final String? serverName;
PlayQueueLauncher({
required this.context,
required this.client,
this.serverId,
this.serverName,
});
/// Launch playback from a collection or playlist.
Future<PlayQueueResult> launchFromCollectionOrPlaylist({
required dynamic item, // PlexMetadata (collection) or PlexPlaylist
required bool shuffle,
bool showLoadingIndicator = true,
}) async {
final isCollection = item is PlexMetadata;
final isPlaylist = item is PlexPlaylist;
if (!isCollection && !isPlaylist) {
return PlayQueueError(Exception('Item must be either a collection or playlist'));
}
return _executeWithLoading(
showLoading: showLoadingIndicator,
action: t.common.shuffle,
execute: () async {
final String ratingKey = item.ratingKey;
final String? itemServerId = item.serverId ?? serverId;
final String? itemServerName = item.serverName ?? serverName;
PlayQueueResponse? playQueue;
if (isCollection) {
// Get machine identifier (fetch if not cached in config)
final machineId =
client.config.machineIdentifier ??
await client.getMachineIdentifier();
if (machineId == null) {
throw Exception('Could not get server machine identifier');
}
final collectionUri =
'server://$machineId/com.plexapp.plugins.library/library/collections/${item.ratingKey}';
playQueue = await client.createPlayQueue(
uri: collectionUri,
type: 'video',
shuffle: shuffle ? 1 : 0,
);
} else {
// For playlists, use playlistID parameter
playQueue = await client.createPlayQueue(
playlistID: int.parse(item.ratingKey),
type: 'video',
shuffle: shuffle ? 1 : 0,
);
}
// If the queue is empty, try fetching it again with getPlayQueue
if (playQueue != null &&
(playQueue.items == null || playQueue.items!.isEmpty)) {
final fetchedQueue = await client.getPlayQueue(playQueue.playQueueID);
if (fetchedQueue != null &&
fetchedQueue.items != null &&
fetchedQueue.items!.isNotEmpty) {
playQueue = fetchedQueue;
}
}
return _launchFromQueue(
playQueue: playQueue,
ratingKey: ratingKey,
serverId: itemServerId,
serverName: itemServerName,
);
},
);
}
/// Launch playback from a playlist starting at a specific item.
Future<PlayQueueResult> launchFromPlaylistItem({
required PlexPlaylist playlist,
required PlexMetadata selectedItem,
bool showLoadingIndicator = true,
}) async {
return _executeWithLoading(
showLoading: showLoadingIndicator,
action: t.discover.play,
execute: () async {
final playQueue = await client.createPlayQueue(
playlistID: int.parse(playlist.ratingKey),
type: 'video',
key: selectedItem.key,
);
return _launchFromQueue(
playQueue: playQueue,
ratingKey: playlist.ratingKey,
serverId: serverId,
serverName: serverName,
selectedItem: playQueue?.selectedItem,
);
},
);
}
/// Launch shuffled playback for a show or season.
Future<PlayQueueResult> launchShuffledShow({
required PlexMetadata metadata,
bool showLoadingIndicator = true,
}) async {
final itemType = metadata.type.toLowerCase();
if (itemType != 'show' && itemType != 'season') {
return PlayQueueError(Exception('Shuffle play only works for shows and seasons'));
}
return _executeWithLoading(
showLoading: showLoadingIndicator,
action: t.common.shuffle,
execute: () async {
// Determine the rating key for the play queue
String showRatingKey;
if (itemType == 'show') {
showRatingKey = metadata.ratingKey;
} else {
// For seasons, we need the show's rating key
if (metadata.parentRatingKey == null) {
throw Exception('Season is missing parentRatingKey');
}
showRatingKey = metadata.parentRatingKey!;
}
final playQueue = await client.createShowPlayQueue(
showRatingKey: showRatingKey,
shuffle: 1,
);
return _launchFromQueue(
playQueue: playQueue,
ratingKey: showRatingKey,
serverId: metadata.serverId ?? serverId,
serverName: metadata.serverName ?? serverName,
copyServerInfo: true,
);
},
);
}
/// Core method to launch playback from a play queue.
Future<PlayQueueResult> _launchFromQueue({
required PlayQueueResponse? playQueue,
required String ratingKey,
String? serverId,
String? serverName,
PlexMetadata? selectedItem,
bool copyServerInfo = false,
}) async {
if (playQueue == null ||
playQueue.items == null ||
playQueue.items!.isEmpty) {
return const PlayQueueEmpty();
}
if (!context.mounted) return const PlayQueueError('Context not mounted');
// Set up playback state
final playbackState = context.read<PlaybackStateProvider>();
playbackState.setClient(client);
await playbackState.setPlaybackFromPlayQueue(
playQueue,
ratingKey,
serverId: serverId,
serverName: serverName,
);
if (!context.mounted) return const PlayQueueError('Context not mounted');
// Determine which item to navigate to
var itemToPlay = selectedItem ?? playQueue.items!.first;
// Copy server info if needed
if (copyServerInfo && serverId != null) {
itemToPlay = itemToPlay.copyWith(
serverId: serverId,
serverName: serverName,
);
}
// Navigate to video player
await navigateToVideoPlayer(context, metadata: itemToPlay);
return const PlayQueueSuccess();
}
/// Execute an action with optional loading indicator and error handling.
Future<PlayQueueResult> _executeWithLoading({
required bool showLoading,
required String action,
required Future<PlayQueueResult> Function() execute,
}) async {
try {
// Show loading indicator
if (showLoading && context.mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) =>
const Center(child: CircularProgressIndicator()),
);
}
final result = await execute();
// Close loading indicator
if (showLoading && context.mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
// Handle empty queue result
if (result is PlayQueueEmpty && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.failedToCreatePlayQueueNoItems)),
);
}
return result;
} catch (e) {
appLogger.e('Failed to $action', error: e);
// Close loading indicator if it's still open
if (showLoading && context.mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
t.messages.failedPlayback(action: action, error: e.toString()),
),
),
);
}
return PlayQueueError(e);
}
}
}
+15 -120
View File
@@ -1,133 +1,28 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/plex_client.dart';
import '../services/play_queue_launcher.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../models/play_queue_response.dart';
import '../providers/playback_state_provider.dart';
import '../utils/app_logger.dart';
import '../utils/video_player_navigation.dart';
import '../i18n/strings.g.dart';
/// Helper function to play a collection or playlist
/// Helper function to play a collection or playlist.
///
/// This is a convenience wrapper around [PlayQueueLauncher.launchFromCollectionOrPlaylist].
Future<void> playCollectionOrPlaylist({
required BuildContext context,
required PlexClient client,
required dynamic item, // PlexMetadata (collection) or PlexPlaylist
required bool shuffle,
}) async {
try {
final isCollection = item is PlexMetadata;
final isPlaylist = item is PlexPlaylist;
final launcher = PlayQueueLauncher(
context: context,
client: client,
serverId: item is PlexMetadata ? item.serverId : (item as PlexPlaylist).serverId,
serverName: item is PlexMetadata ? item.serverName : (item as PlexPlaylist).serverName,
);
if (!isCollection && !isPlaylist) {
throw Exception('Item must be either a collection or playlist');
}
String ratingKey = item.ratingKey;
String? serverId = item.serverId;
String? serverName = item.serverName;
final PlayQueueResponse? playQueue;
if (isCollection) {
// Get machine identifier (fetch if not cached in config)
final machineId =
client.config.machineIdentifier ??
await client.getMachineIdentifier();
if (machineId == null) {
throw Exception('Could not get server machine identifier');
}
final collectionUri =
'server://$machineId/com.plexapp.plugins.library/library/collections/${item.ratingKey}';
playQueue = await client.createPlayQueue(
uri: collectionUri,
type: 'video',
shuffle: shuffle ? 1 : 0,
);
} else {
// For playlists, use playlistID parameter
playQueue = await client.createPlayQueue(
playlistID: int.parse(item.ratingKey),
type: 'video',
shuffle: shuffle ? 1 : 0,
);
}
// If the queue is empty, try fetching it again with getPlayQueue
if (playQueue != null &&
(playQueue.items == null || playQueue.items!.isEmpty)) {
final fetchedQueue = await client.getPlayQueue(playQueue.playQueueID);
if (fetchedQueue != null &&
fetchedQueue.items != null &&
fetchedQueue.items!.isNotEmpty) {
if (!context.mounted) return;
// Items are automatically tagged with server info by PlexClient
// Set play queue in provider
final playbackState = context.read<PlaybackStateProvider>();
playbackState.setClient(client);
await playbackState.setPlaybackFromPlayQueue(
fetchedQueue,
ratingKey,
serverId: serverId,
serverName: serverName,
);
if (!context.mounted) return;
// Navigate to first item
await navigateToVideoPlayer(
context,
metadata: fetchedQueue.items!.first,
);
return;
}
}
if (playQueue == null ||
playQueue.items == null ||
playQueue.items!.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.failedToCreatePlayQueueNoItems)),
);
}
return;
}
if (!context.mounted) return;
// Items are automatically tagged with server info by PlexClient
// Set play queue in provider
final playbackState = context.read<PlaybackStateProvider>();
playbackState.setClient(client);
await playbackState.setPlaybackFromPlayQueue(
playQueue,
ratingKey,
serverId: serverId,
serverName: serverName,
);
if (!context.mounted) return;
// Navigate to first item
await navigateToVideoPlayer(context, metadata: playQueue.items!.first);
} catch (e) {
appLogger.e('Failed to ${shuffle ? "shuffle play" : "play"}', error: e);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
t.messages.failedPlayback(
action: shuffle ? t.common.shuffle : t.discover.play,
error: e.toString(),
),
),
),
);
}
}
await launcher.launchFromCollectionOrPlaylist(
item: item,
shuffle: shuffle,
showLoadingIndicator: false, // Caller typically handles loading UI
);
}
+12 -85
View File
@@ -1,16 +1,15 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/plex_client.dart';
import '../services/plex_client.dart';
import '../services/play_queue_launcher.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../providers/multi_server_provider.dart';
import '../providers/playback_state_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../utils/collection_playlist_play_helper.dart';
import '../utils/library_refresh_notifier.dart';
import '../utils/video_player_navigation.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../widgets/file_info_bottom_sheet.dart';
@@ -524,91 +523,19 @@ class MediaContextMenuState extends State<MediaContextMenu> {
/// Handle shuffle play using play queues
Future<void> _handleShufflePlayWithQueue(BuildContext context) async {
final client = _getClientForItem();
final metadata = widget.item as PlexMetadata;
final playbackState = context.read<PlaybackStateProvider>();
final itemType = metadata.type.toLowerCase();
try {
// Show loading indicator
if (context.mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) =>
const Center(child: CircularProgressIndicator()),
);
}
final launcher = PlayQueueLauncher(
context: context,
client: client,
serverId: metadata.serverId,
serverName: metadata.serverName,
);
// Determine the rating key for the play queue
String showRatingKey;
if (itemType == 'show') {
showRatingKey = metadata.ratingKey;
} else if (itemType == 'season') {
// For seasons, we need the show's rating key
// The season's parentRatingKey should point to the show
if (metadata.parentRatingKey == null) {
throw Exception('Season is missing parentRatingKey');
}
showRatingKey = metadata.parentRatingKey!;
} else {
throw Exception('Shuffle play only works for shows and seasons');
}
// Create a shuffled play queue for the show
final playQueue = await client.createShowPlayQueue(
showRatingKey: showRatingKey,
shuffle: 1,
);
// Close loading indicator
if (context.mounted) {
Navigator.pop(context);
}
if (playQueue == null ||
playQueue.items == null ||
playQueue.items!.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.noEpisodesFound)));
}
return;
}
// Initialize playback state with the play queue
await playbackState.setPlaybackFromPlayQueue(
playQueue,
showRatingKey,
serverId: metadata.serverId,
serverName: metadata.serverName,
);
// Set the client for the playback state provider
playbackState.setClient(client);
// Navigate to the first episode in the shuffled queue
final firstEpisode = playQueue.items!.first.copyWith(
serverId: metadata.serverId,
serverName: metadata.serverName,
);
if (context.mounted) {
await navigateToVideoPlayer(context, metadata: firstEpisode);
}
} catch (e) {
// Close loading indicator if it's still open
if (context.mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
await launcher.launchShuffledShow(
metadata: metadata,
showLoadingIndicator: true,
);
}
/// Show submenu for Add to... (Playlist or Collection)
@@ -7,10 +7,9 @@ import '../../models/plex_media_info.dart';
import '../../models/plex_metadata.dart';
import '../../services/fullscreen_state_manager.dart';
import '../../utils/desktop_window_padding.dart';
import '../../utils/duration_formatter.dart';
import '../../i18n/strings.g.dart';
import '../app_bar_back_button.dart';
import 'widgets/timeline_slider.dart';
import 'widgets/video_controls_header.dart';
import 'widgets/video_timeline_bar.dart';
/// Desktop-specific video controls layout with top bar and bottom controls
class DesktopVideoControls extends StatelessWidget {
@@ -84,123 +83,30 @@ class DesktopVideoControls extends StatelessWidget {
Widget _buildTopBarContent(BuildContext context, double leftPadding) {
final topBar = Padding(
padding: EdgeInsets.only(left: leftPadding, right: 16),
child: Row(
children: [
AppBarBackButton(
style: BackButtonStyle.video,
semanticLabel: t.videoControls.backButton,
onPressed: () => Navigator.of(context).pop(true),
),
const SizedBox(width: 16),
Expanded(
child: Platform.isMacOS
? _buildMacOSSingleLineTitle()
: _buildMultiLineTitle(),
),
],
child: VideoControlsHeader(
metadata: metadata,
style: Platform.isMacOS
? VideoHeaderStyle.singleLine
: VideoHeaderStyle.multiLine,
),
);
return DesktopAppBarHelper.wrapWithGestureDetector(topBar, opaque: true);
}
Widget _buildMacOSSingleLineTitle() {
// Build single-line title combining series and episode info
final seriesName = metadata.grandparentTitle ?? metadata.title;
final hasEpisodeInfo =
metadata.parentIndex != null && metadata.index != null;
final titleText = hasEpisodeInfo
? '$seriesName · S${metadata.parentIndex} E${metadata.index} · ${metadata.title}'
: seriesName;
return Text(
titleText,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
Widget _buildMultiLineTitle() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
metadata.grandparentTitle ?? metadata.title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (metadata.parentIndex != null && metadata.index != null)
Text(
'S${metadata.parentIndex} · E${metadata.index} · ${metadata.title}',
style: const TextStyle(color: Colors.white70, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
Widget _buildBottomControls(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
children: [
// Row 1: Timeline with time indicators
StreamBuilder<Duration>(
stream: player.streams.position,
initialData: player.state.position,
builder: (context, positionSnapshot) {
return StreamBuilder<Duration>(
stream: player.streams.duration,
initialData: player.state.duration,
builder: (context, durationSnapshot) {
final position = positionSnapshot.data ?? Duration.zero;
final duration = durationSnapshot.data ?? Duration.zero;
return Row(
children: [
Text(
formatDurationTimestamp(position),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
const SizedBox(width: 12),
Expanded(
child: TimelineSlider(
position: position,
duration: duration,
chapters: chapters,
chaptersLoaded: chaptersLoaded,
onSeek: onSeek,
onSeekEnd: onSeekEnd,
),
),
const SizedBox(width: 12),
Text(
formatDurationTimestamp(duration),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
],
);
},
);
},
VideoTimelineBar(
player: player,
chapters: chapters,
chaptersLoaded: chaptersLoaded,
onSeek: onSeek,
onSeekEnd: onSeekEnd,
horizontalLayout: true,
),
const SizedBox(height: 4),
// Row 2: Playback controls and options
@@ -4,12 +4,11 @@ import '../../mpv/mpv.dart';
import '../../models/plex_media_info.dart';
import '../../models/plex_metadata.dart';
import '../../utils/desktop_window_padding.dart';
import '../../utils/duration_formatter.dart';
import '../../utils/player_utils.dart';
import '../../utils/video_control_icons.dart';
import '../../i18n/strings.g.dart';
import '../app_bar_back_button.dart';
import 'widgets/timeline_slider.dart';
import 'widgets/video_controls_header.dart';
import 'widgets/video_timeline_bar.dart';
/// Mobile video controls layout for Plex video player
///
@@ -67,44 +66,10 @@ class MobileVideoControls extends StatelessWidget {
bottom: false, // Only respect top safe area when in portrait
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
AppBarBackButton(
style: BackButtonStyle.video,
semanticLabel: t.videoControls.backButton,
onPressed: () => Navigator.of(context).pop(true),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
metadata.grandparentTitle ?? metadata.title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (metadata.parentIndex != null && metadata.index != null)
Text(
'S${metadata.parentIndex} · E${metadata.index} · ${metadata.title}',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Track and chapter controls in top right
trackChapterControls,
],
child: VideoControlsHeader(
metadata: metadata,
style: VideoHeaderStyle.multiLine,
trailing: trackChapterControls,
),
),
);
@@ -171,54 +136,13 @@ class MobileVideoControls extends StatelessWidget {
top: false, // Only respect bottom safe area when in portrait
child: Padding(
padding: const EdgeInsets.all(16),
child: StreamBuilder<Duration>(
stream: player.streams.position,
initialData: player.state.position,
builder: (context, positionSnapshot) {
return StreamBuilder<Duration>(
stream: player.streams.duration,
initialData: player.state.duration,
builder: (context, durationSnapshot) {
final position = positionSnapshot.data ?? Duration.zero;
final duration = durationSnapshot.data ?? Duration.zero;
return Column(
children: [
TimelineSlider(
position: position,
duration: duration,
chapters: chapters,
chaptersLoaded: chaptersLoaded,
onSeek: onSeek,
onSeekEnd: onSeekEnd,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
formatDurationTimestamp(position),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
Text(
formatDurationTimestamp(duration),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
],
),
),
],
);
},
);
},
child: VideoTimelineBar(
player: player,
chapters: chapters,
chaptersLoaded: chaptersLoaded,
onSeek: onSeek,
onSeekEnd: onSeekEnd,
horizontalLayout: false,
),
),
);
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../../../mpv/mpv.dart';
import '../../../services/settings_service.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../utils/duration_formatter.dart';
import '../../../utils/platform_detector.dart';
import '../widgets/sync_offset_control.dart';
import '../widgets/sleep_timer_content.dart';
@@ -178,25 +179,11 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return '${speed.toStringAsFixed(2)}x';
}
String _formatAudioSync(int offsetMs) {
if (offsetMs == 0) return '0ms';
final sign = offsetMs >= 0 ? '+' : '';
return '$sign${offsetMs}ms';
}
String _formatSleepTimer(SleepTimerService sleepTimer) {
if (!sleepTimer.isActive) return 'Off';
final remaining = sleepTimer.remainingTime;
if (remaining == null) return 'Off';
final minutes = remaining.inMinutes;
final seconds = remaining.inSeconds.remainder(60);
if (minutes > 0) {
return 'Active (${minutes}m ${seconds}s)';
} else {
return 'Active (${seconds}s)';
}
return 'Active (${formatDurationWithSeconds(remaining)})';
}
Widget _buildMenuView() {
@@ -239,7 +226,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
_SettingsMenuItem(
icon: Icons.sync,
title: 'Audio Sync',
valueText: _formatAudioSync(_audioSyncOffset),
valueText: formatSyncOffset(_audioSyncOffset.toDouble()),
isHighlighted: _audioSyncOffset != 0,
onTap: () => _navigateTo(_SettingsView.audioSync),
),
@@ -248,7 +235,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
_SettingsMenuItem(
icon: Icons.subtitles,
title: 'Subtitle Sync',
valueText: _formatAudioSync(_subtitleSyncOffset),
valueText: formatSyncOffset(_subtitleSyncOffset.toDouble()),
isHighlighted: _subtitleSyncOffset != 0,
onTap: () => _navigateTo(_SettingsView.subtitleSync),
),
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import '../../../models/plex_metadata.dart';
import '../../../i18n/strings.g.dart';
import '../../app_bar_back_button.dart';
/// Header layout style for video controls
enum VideoHeaderStyle {
/// Multi-line: Series name on first line, episode info on second line
multiLine,
/// Single-line: All info combined with separators (for macOS)
singleLine,
}
/// Shared header widget for video controls with back button and title.
///
/// Displays the video title with optional series/episode information.
/// Supports both single-line (macOS) and multi-line (other platforms) layouts.
class VideoControlsHeader extends StatelessWidget {
final PlexMetadata metadata;
final VideoHeaderStyle style;
/// Optional trailing widget (e.g., track/chapter controls)
final Widget? trailing;
const VideoControlsHeader({
super.key,
required this.metadata,
this.style = VideoHeaderStyle.multiLine,
this.trailing,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
AppBarBackButton(
style: BackButtonStyle.video,
semanticLabel: t.videoControls.backButton,
onPressed: () => Navigator.of(context).pop(true),
),
const SizedBox(width: 16),
Expanded(
child: style == VideoHeaderStyle.singleLine
? _buildSingleLineTitle()
: _buildMultiLineTitle(),
),
if (trailing != null) trailing!,
],
);
}
Widget _buildSingleLineTitle() {
// Build single-line title combining series and episode info
final seriesName = metadata.grandparentTitle ?? metadata.title;
final hasEpisodeInfo =
metadata.parentIndex != null && metadata.index != null;
final titleText = hasEpisodeInfo
? '$seriesName · S${metadata.parentIndex} E${metadata.index} · ${metadata.title}'
: seriesName;
return Text(
titleText,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
Widget _buildMultiLineTitle() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
metadata.grandparentTitle ?? metadata.title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (metadata.parentIndex != null && metadata.index != null)
Text(
'S${metadata.parentIndex} · E${metadata.index} · ${metadata.title}',
style: const TextStyle(color: Colors.white70, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
}
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import '../../../mpv/mpv.dart';
import '../../../models/plex_media_info.dart';
import '../../../utils/duration_formatter.dart';
import 'timeline_slider.dart';
/// Encapsulates the StreamBuilder stack for video timeline with timestamps.
///
/// This widget listens to player position and duration streams, and displays
/// a timeline slider with formatted timestamps. Supports both horizontal
/// layout (timestamps beside slider) and vertical layout (timestamps below slider).
class VideoTimelineBar extends StatelessWidget {
final Player player;
final List<PlexChapter> chapters;
final bool chaptersLoaded;
final ValueChanged<Duration> onSeek;
final ValueChanged<Duration> onSeekEnd;
/// If true, timestamps are shown in a row beside the slider (desktop layout).
/// If false, timestamps are shown in a row below the slider (mobile layout).
final bool horizontalLayout;
const VideoTimelineBar({
super.key,
required this.player,
required this.chapters,
required this.chaptersLoaded,
required this.onSeek,
required this.onSeekEnd,
this.horizontalLayout = true,
});
@override
Widget build(BuildContext context) {
return StreamBuilder<Duration>(
stream: player.streams.position,
initialData: player.state.position,
builder: (context, positionSnapshot) {
return StreamBuilder<Duration>(
stream: player.streams.duration,
initialData: player.state.duration,
builder: (context, durationSnapshot) {
final position = positionSnapshot.data ?? Duration.zero;
final duration = durationSnapshot.data ?? Duration.zero;
if (horizontalLayout) {
return _buildHorizontalLayout(position, duration);
} else {
return _buildVerticalLayout(position, duration);
}
},
);
},
);
}
Widget _buildHorizontalLayout(Duration position, Duration duration) {
return Row(
children: [
_buildTimestamp(position),
const SizedBox(width: 12),
Expanded(
child: _buildSlider(position, duration),
),
const SizedBox(width: 12),
_buildTimestamp(duration),
],
);
}
Widget _buildVerticalLayout(Duration position, Duration duration) {
return Column(
children: [
_buildSlider(position, duration),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTimestamp(position),
_buildTimestamp(duration),
],
),
),
],
);
}
Widget _buildTimestamp(Duration time) {
return Text(
formatDurationTimestamp(time),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
);
}
Widget _buildSlider(Duration position, Duration duration) {
return TimelineSlider(
position: position,
duration: duration,
chapters: chapters,
chaptersLoaded: chaptersLoaded,
onSeek: onSeek,
onSeekEnd: onSeekEnd,
);
}
}