refactor: deduplicate video controls, formatters & play queue wiring
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user