diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 90badcfb..f78de493 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -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 _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(); - 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 diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart new file mode 100644 index 00000000..47b67ff4 --- /dev/null +++ b/lib/services/play_queue_launcher.dart @@ -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 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 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 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 _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(); + 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 _executeWithLoading({ + required bool showLoading, + required String action, + required Future 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); + } + } +} diff --git a/lib/utils/collection_playlist_play_helper.dart b/lib/utils/collection_playlist_play_helper.dart index 4f5b48db..8a1da4b5 100644 --- a/lib/utils/collection_playlist_play_helper.dart +++ b/lib/utils/collection_playlist_play_helper.dart @@ -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 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(); - 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(); - 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 + ); } diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 24bae237..2199310e 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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 { /// Handle shuffle play using play queues Future _handleShufflePlayWithQueue(BuildContext context) async { final client = _getClientForItem(); - final metadata = widget.item as PlexMetadata; - final playbackState = context.read(); - 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) diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index ab9a67c8..565b13c3 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -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( - stream: player.streams.position, - initialData: player.state.position, - builder: (context, positionSnapshot) { - return StreamBuilder( - 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 diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 56a2cbc8..19a7bb0a 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -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( - stream: player.streams.position, - initialData: player.state.position, - builder: (context, positionSnapshot) { - return StreamBuilder( - 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, ), ), ); diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 6a02a8a5..39792611 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -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 { 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 { _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 { _SettingsMenuItem( icon: Icons.subtitles, title: 'Subtitle Sync', - valueText: _formatAudioSync(_subtitleSyncOffset), + valueText: formatSyncOffset(_subtitleSyncOffset.toDouble()), isHighlighted: _subtitleSyncOffset != 0, onTap: () => _navigateTo(_SettingsView.subtitleSync), ), diff --git a/lib/widgets/video_controls/widgets/video_controls_header.dart b/lib/widgets/video_controls/widgets/video_controls_header.dart new file mode 100644 index 00000000..bf4bfe66 --- /dev/null +++ b/lib/widgets/video_controls/widgets/video_controls_header.dart @@ -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, + ), + ], + ); + } +} diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart new file mode 100644 index 00000000..86d06ba3 --- /dev/null +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -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 chapters; + final bool chaptersLoaded; + final ValueChanged onSeek; + final ValueChanged 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( + stream: player.streams.position, + initialData: player.state.position, + builder: (context, positionSnapshot) { + return StreamBuilder( + 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, + ); + } +}