refactor: use play queues & other improvements

This commit is contained in:
edde746
2025-11-22 03:24:01 +01:00
parent 6e8a1a373e
commit 166654d374
61 changed files with 15248 additions and 9459 deletions
+43 -86
View File
@@ -1102,92 +1102,6 @@ class PlexClient {
}
}
/// Find adjacent episode in a given direction
///
/// [direction]: +1 for next episode, -1 for previous episode
///
/// Handles navigation within current season and across seasons automatically.
Future<PlexMetadata?> findAdjacentEpisode(
PlexMetadata currentEpisode,
int direction,
) async {
if (currentEpisode.type.toLowerCase() != 'episode') {
return null;
}
final parentKey = currentEpisode.parentRatingKey;
final grandparentKey = currentEpisode.grandparentRatingKey;
if (parentKey == null || grandparentKey == null) {
return null;
}
// Extract serverId/serverName from currentEpisode to propagate
final serverId = currentEpisode.serverId;
final serverName = currentEpisode.serverName;
try {
// Get all episodes in the current season
final episodes = await getChildren(parentKey);
// Find the current episode index
final currentIndex = episodes.indexWhere(
(e) => e.ratingKey == currentEpisode.ratingKey,
);
if (currentIndex == -1) return null;
final targetIndex = currentIndex + direction;
// Check if target episode is within current season
if (targetIndex >= 0 && targetIndex < episodes.length) {
return episodes[targetIndex].copyWith(
serverId: serverId,
serverName: serverName,
);
}
// Need to move to adjacent season
final isAtBoundary = direction > 0
? currentIndex == episodes.length - 1
: currentIndex == 0;
if (isAtBoundary) {
// Get all seasons
final seasons = await getChildren(grandparentKey);
final currentSeasonIndex = seasons.indexWhere(
(s) => s.ratingKey == parentKey,
);
if (currentSeasonIndex == -1) return null;
final targetSeasonIndex = currentSeasonIndex + direction;
// Check if target season exists
if (targetSeasonIndex >= 0 && targetSeasonIndex < seasons.length) {
final targetSeason = seasons[targetSeasonIndex];
final targetSeasonEpisodes = await getChildren(
targetSeason.ratingKey,
);
if (targetSeasonEpisodes.isNotEmpty) {
// Return first episode for next season, last for previous
return (direction > 0
? targetSeasonEpisodes.first
: targetSeasonEpisodes.last).copyWith(
serverId: serverId,
serverName: serverName,
);
}
}
}
} catch (e) {
// Silently handle errors
}
return null;
}
/// Get library hubs (recommendations for a specific library section)
/// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc.
Future<List<PlexHub>> getLibraryHubs(
@@ -1736,6 +1650,49 @@ class PlexClient {
}
}
/// Create a play queue for a TV show (all episodes)
///
/// This is a convenience method that creates a play queue from a show's URI.
/// Perfect for sequential or shuffle playback of an entire series.
///
/// Parameters:
/// - [showRatingKey]: The rating key of the show
/// - [shuffle]: Whether to shuffle the episodes (0 = off, 1 = on)
/// - [startingEpisodeKey]: Optional rating key of episode to start from
///
/// Returns a PlayQueueResponse with all episodes from the show
Future<PlayQueueResponse?> createShowPlayQueue({
required String showRatingKey,
int shuffle = 0,
String? startingEpisodeKey,
}) async {
try {
// Get machine identifier for building the URI
final machineId =
config.machineIdentifier ?? await getMachineIdentifier();
if (machineId == null) {
throw Exception('Could not get server machine identifier');
}
// Build the URI for the show's episodes
final uri =
'server://$machineId/com.plexapp.plugins.library/library/metadata/$showRatingKey/children';
// Create the play queue with optional starting episode
return await createPlayQueue(
uri: uri,
type: 'video',
shuffle: shuffle,
key: startingEpisodeKey != null
? '/library/metadata/$startingEpisodeKey'
: null,
);
} catch (e) {
appLogger.e('Failed to create show play queue', error: e);
return null;
}
}
/// Extract both Metadata and Directory entries from response
/// Folders can come back as either type
List<PlexMetadata> _extractMetadataAndDirectories(Response response) {
+12313 -6277
View File
File diff suppressed because it is too large Load Diff
+19 -16
View File
@@ -92,7 +92,8 @@ class MainApp extends StatelessWidget {
ChangeNotifierProvider(create: (context) => PlexClientProvider()),
// New multi-server providers
ChangeNotifierProvider(
create: (context) => MultiServerProvider(serverManager, aggregationService),
create: (context) =>
MultiServerProvider(serverManager, aggregationService),
),
ChangeNotifierProvider(create: (context) => ServerStateProvider()),
// Existing providers
@@ -268,21 +269,22 @@ class _SetupScreenState extends State<SetupScreen> {
final clientId = storage.getClientIdentifier();
// Connect to all servers in parallel
final connectedCount = await multiServerProvider.serverManager.connectToAllServers(
servers,
clientIdentifier: clientId,
timeout: const Duration(seconds: 10),
onServerConnected: (serverId, client) {
// Set first connected client in legacy provider for backward compatibility
final legacyProvider = Provider.of<PlexClientProvider>(
context,
listen: false,
final connectedCount = await multiServerProvider.serverManager
.connectToAllServers(
servers,
clientIdentifier: clientId,
timeout: const Duration(seconds: 10),
onServerConnected: (serverId, client) {
// Set first connected client in legacy provider for backward compatibility
final legacyProvider = Provider.of<PlexClientProvider>(
context,
listen: false,
);
if (legacyProvider.client == null) {
legacyProvider.setClient(client);
}
},
);
if (legacyProvider.client == null) {
legacyProvider.setClient(client);
}
},
);
if (connectedCount > 0) {
// At least one server connected successfully
@@ -294,7 +296,8 @@ class _SetupScreenState extends State<SetupScreen> {
// Navigate to main screen
// Get first connected client for backward compatibility
final firstClient = multiServerProvider.serverManager.onlineClients.values.first;
final firstClient =
multiServerProvider.serverManager.onlineClients.values.first;
Navigator.pushReplacement(
context,
+7 -55
View File
@@ -1,63 +1,15 @@
import 'package:flutter/material.dart';
import '../client/plex_client.dart';
import '../models/plex_library.dart';
import '../models/plex_metadata.dart';
import '../utils/provider_extensions.dart';
/// Mixin providing common state management for library tab screens
/// Standardizes loading, error handling, and lifecycle management
/// Mixin providing common functionality for library tab screens
/// Provides server-specific client resolution for multi-server support
mixin LibraryTabStateMixin<T extends StatefulWidget> on State<T> {
/// The list of items to display
List<PlexMetadata> get items;
set items(List<PlexMetadata> value);
/// Whether data is currently loading
bool get isLoading;
set isLoading(bool value);
/// Error message if loading failed
String? get errorMessage;
set errorMessage(String? value);
/// The library being displayed
PlexLibrary get library;
/// Load or reload the content
Future<void> loadContent();
/// Common lifecycle: reload if library changed
@mustCallSuper
void didUpdateLibrary(PlexLibrary oldLibrary) {
if (oldLibrary.key != library.key) {
loadContent();
}
}
/// Helper to set loading state
void setLoadingState(bool loading) {
if (mounted) {
setState(() {
isLoading = loading;
});
}
}
/// Helper to set error state
void setErrorState(String? error) {
if (mounted) {
setState(() {
errorMessage = error;
isLoading = false;
});
}
}
/// Helper to set success state with items
void setSuccessState(List<PlexMetadata> newItems) {
if (mounted) {
setState(() {
items = newItems;
isLoading = false;
errorMessage = null;
});
}
}
/// Get the correct PlexClient for this library's server
/// Throws an exception if no client is available
PlexClient getClientForLibrary() => context.getClientForLibrary(library);
}
@@ -0,0 +1,15 @@
import 'package:json_annotation/json_annotation.dart';
/// Mixin that provides multi-server support fields for models.
///
/// This mixin adds serverId and serverName fields that are excluded from
/// JSON serialization but can be used to track which server an item belongs to.
mixin MultiServerFields {
/// Server machine identifier (not from API)
@JsonKey(includeFromJson: false, includeToJson: false)
String? get serverId;
/// Server display name (not from API)
@JsonKey(includeFromJson: false, includeToJson: false)
String? get serverName;
}
+8 -4
View File
@@ -1,9 +1,11 @@
import 'package:json_annotation/json_annotation.dart';
import 'mixins/multi_server_fields.dart';
part 'plex_library.g.dart';
@JsonSerializable()
class PlexLibrary {
class PlexLibrary with MultiServerFields {
final String key;
final String title;
final String type;
@@ -15,11 +17,13 @@ class PlexLibrary {
final int? createdAt;
final int? hidden;
// Multi-server support fields
// Multi-server support fields (from MultiServerFields mixin)
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverId; // Server machine identifier (not from API)
final String? serverId;
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverName; // Server display name (not from API)
final String? serverName;
/// Global unique identifier across all servers (serverId:key)
String get globalKey => serverId != null ? '$serverId:$key' : key;
+7 -4
View File
@@ -1,11 +1,12 @@
import 'package:json_annotation/json_annotation.dart';
import 'mixins/multi_server_fields.dart';
import 'plex_role.dart';
part 'plex_metadata.g.dart';
@JsonSerializable()
class PlexMetadata {
class PlexMetadata with MultiServerFields {
final String ratingKey;
final String key;
final String? guid;
@@ -45,11 +46,13 @@ class PlexMetadata {
final int? playQueueItemID; // Play queue item ID (unique even for duplicates)
final int? librarySectionID; // Library section ID this item belongs to
// Multi-server support fields
// Multi-server support fields (from MultiServerFields mixin)
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverId; // Server machine identifier (not from API)
final String? serverId;
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverName; // Server display name (not from API)
final String? serverName;
// Transient field for clear logo (extracted from Image array)
String? _clearLogo;
+6 -2
View File
@@ -1,9 +1,11 @@
import 'package:json_annotation/json_annotation.dart';
import 'mixins/multi_server_fields.dart';
part 'plex_playlist.g.dart';
@JsonSerializable()
class PlexPlaylist {
class PlexPlaylist with MultiServerFields {
final String ratingKey;
final String key;
final String type; // "playlist"
@@ -22,9 +24,11 @@ class PlexPlaylist {
final String? guid;
final String? thumb;
// Multi-server support: Track which server this playlist belongs to
// Multi-server support fields (from MultiServerFields mixin)
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverId;
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverName;
+29 -44
View File
@@ -4,10 +4,10 @@ import '../models/play_queue_response.dart';
import '../client/plex_client.dart';
/// Playback mode types
///
/// All playback now uses Plex play queues.
enum PlaybackMode {
none, // No active playback queue
sequential, // Normal episode-to-episode playback (uses Plex API)
playQueue, // Play queue-based playback (playlists, collections, shuffle)
playQueue, // Play queue-based playback (sequential, shuffle, playlists, collections)
}
/// Result of trying to locate the current queue index.
@@ -38,13 +38,13 @@ class PlaybackStateProvider with ChangeNotifier {
// Legacy state for backward compatibility
String? _contextKey; // The show/season/playlist ratingKey for this session
PlaybackMode _playbackMode = PlaybackMode.none;
PlaybackMode? _playbackMode;
// Client reference for loading more items
PlexClient? _client;
/// Current playback mode
PlaybackMode get playbackMode => _playbackMode;
/// Current playback mode (null if no queue active)
PlaybackMode? get playbackMode => _playbackMode;
/// Whether shuffle mode is currently active
bool get isShuffleActive => _playQueueShuffled;
@@ -92,8 +92,10 @@ class PlaybackStateProvider with ChangeNotifier {
/// Call this after creating a play queue via the API
Future<void> setPlaybackFromPlayQueue(
PlayQueueResponse playQueue,
String? contextKey,
) async {
String? contextKey, {
String? serverId,
String? serverName,
}) async {
_playQueueId = playQueue.playQueueID;
// Use size or items length as fallback if totalCount is null
_playQueueTotalCount =
@@ -102,31 +104,12 @@ class PlaybackStateProvider with ChangeNotifier {
(playQueue.items?.length ?? 0);
_playQueueShuffled = playQueue.playQueueShuffled;
_currentPlayQueueItemID = playQueue.playQueueSelectedItemID;
_loadedItems = playQueue.items ?? [];
_contextKey = contextKey;
_playbackMode = PlaybackMode.playQueue;
notifyListeners();
}
/// Legacy method for backward compatibility with shuffle play
/// This now creates a play queue on the server
@Deprecated('Use createPlayQueueFromUri instead')
void setShuffleQueue(List<PlexMetadata> episodes, String contextKey) {
// This is kept for backward compatibility but should not be used
// New code should use the play queue API
_loadedItems = List.from(episodes);
_contextKey = contextKey;
_playbackMode = PlaybackMode.playQueue;
notifyListeners();
}
// Preserve serverId/serverName on all items
_loadedItems = (playQueue.items ?? []).map((item) {
return item.copyWith(serverId: serverId, serverName: serverName);
}).toList();
/// Legacy method for backward compatibility with playlist playback
/// This now creates a play queue on the server
@Deprecated('Use createPlayQueueFromUri instead')
void setPlaybackQueue(List<PlexMetadata> items, String contextKey) {
// This is kept for backward compatibility but should not be used
// New code should use the play queue API
_loadedItems = List.from(items);
_contextKey = contextKey;
_playbackMode = PlaybackMode.playQueue;
notifyListeners();
@@ -154,14 +137,15 @@ class PlaybackStateProvider with ChangeNotifier {
if (response != null && response.items != null) {
// Preserve serverId from existing items
final serverId = _loadedItems.isNotEmpty ? _loadedItems.first.serverId : null;
final serverName = _loadedItems.isNotEmpty ? _loadedItems.first.serverName : null;
final serverId = _loadedItems.isNotEmpty
? _loadedItems.first.serverId
: null;
final serverName = _loadedItems.isNotEmpty
? _loadedItems.first.serverName
: null;
_loadedItems = response.items!.map((item) {
return item.copyWith(
serverId: serverId,
serverName: serverName,
);
return item.copyWith(serverId: serverId, serverName: serverName);
}).toList();
// Use size or items length as fallback if totalCount is null
_playQueueTotalCount =
@@ -255,14 +239,15 @@ class PlaybackStateProvider with ChangeNotifier {
response.items != null &&
response.items!.isNotEmpty) {
// Preserve serverId when looping
final serverId = _loadedItems.isNotEmpty ? _loadedItems.first.serverId : null;
final serverName = _loadedItems.isNotEmpty ? _loadedItems.first.serverName : null;
final serverId = _loadedItems.isNotEmpty
? _loadedItems.first.serverId
: null;
final serverName = _loadedItems.isNotEmpty
? _loadedItems.first.serverName
: null;
_loadedItems = response.items!.map((item) {
return item.copyWith(
serverId: serverId,
serverName: serverName,
);
return item.copyWith(serverId: serverId, serverName: serverName);
}).toList();
final firstItem = _loadedItems.first;
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
@@ -336,7 +321,7 @@ class PlaybackStateProvider with ChangeNotifier {
_currentPlayQueueItemID = null;
_loadedItems = [];
_contextKey = null;
_playbackMode = PlaybackMode.none;
_playbackMode = null;
notifyListeners();
}
}
-33
View File
@@ -7,9 +7,6 @@ class SettingsProvider extends ChangeNotifier {
ViewMode _viewMode = ViewMode.grid;
bool _useSeasonPoster = false;
bool _showHeroSection = true;
bool _shuffleUnwatchedOnly = true;
bool _shuffleOrderNavigation = true;
bool _shuffleLoopQueue = false;
SettingsProvider() {
_initializeSettings();
@@ -21,9 +18,6 @@ class SettingsProvider extends ChangeNotifier {
_viewMode = _settingsService.getViewMode();
_useSeasonPoster = _settingsService.getUseSeasonPoster();
_showHeroSection = _settingsService.getShowHeroSection();
_shuffleUnwatchedOnly = _settingsService.getShuffleUnwatchedOnly();
_shuffleOrderNavigation = _settingsService.getShuffleOrderNavigation();
_shuffleLoopQueue = _settingsService.getShuffleLoopQueue();
notifyListeners();
}
@@ -31,9 +25,6 @@ class SettingsProvider extends ChangeNotifier {
ViewMode get viewMode => _viewMode;
bool get useSeasonPoster => _useSeasonPoster;
bool get showHeroSection => _showHeroSection;
bool get shuffleUnwatchedOnly => _shuffleUnwatchedOnly;
bool get shuffleOrderNavigation => _shuffleOrderNavigation;
bool get shuffleLoopQueue => _shuffleLoopQueue;
Future<void> setLibraryDensity(LibraryDensity density) async {
if (_libraryDensity != density) {
@@ -67,30 +58,6 @@ class SettingsProvider extends ChangeNotifier {
}
}
Future<void> setShuffleUnwatchedOnly(bool value) async {
if (_shuffleUnwatchedOnly != value) {
_shuffleUnwatchedOnly = value;
await _settingsService.setShuffleUnwatchedOnly(value);
notifyListeners();
}
}
Future<void> setShuffleOrderNavigation(bool value) async {
if (_shuffleOrderNavigation != value) {
_shuffleOrderNavigation = value;
await _settingsService.setShuffleOrderNavigation(value);
notifyListeners();
}
}
Future<void> setShuffleLoopQueue(bool value) async {
if (_shuffleLoopQueue != value) {
_shuffleLoopQueue = value;
await _settingsService.setShuffleLoopQueue(value);
notifyListeners();
}
}
String get libraryDensityDisplayName {
switch (_libraryDensity) {
case LibraryDensity.compact:
+4 -2
View File
@@ -72,7 +72,8 @@ class _AuthScreenState extends State<AuthScreen> {
// Connect to all servers
if (!mounted) return;
final multiServerProvider = context.read<MultiServerProvider>();
final connectedCount = await multiServerProvider.serverManager.connectToAllServers(servers);
final connectedCount = await multiServerProvider.serverManager
.connectToAllServers(servers);
if (connectedCount == 0) {
setState(() {
@@ -84,7 +85,8 @@ class _AuthScreenState extends State<AuthScreen> {
// Get the first connected client for backward compatibility
if (!mounted) return;
final firstClient = multiServerProvider.serverManager.onlineClients.values.first;
final firstClient =
multiServerProvider.serverManager.onlineClients.values.first;
// Set it as the legacy client
final plexClientProvider = context.read<PlexClientProvider>();
+162 -20
View File
@@ -1,14 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/plex_client_provider.dart';
import '../providers/multi_server_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/collection_playlist_play_helper.dart';
import '../utils/app_logger.dart';
import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
import '../i18n/strings.g.dart';
/// Abstract base class for screens displaying media lists (collections/playlists)
/// Provides common state management and playback functionality
@@ -32,6 +30,9 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
/// Message to show when list is empty
String get emptyMessage;
/// Optional icon to show when list is empty
IconData? get emptyIcon => null;
/// Get the correct PlexClient for this media item's server
PlexClient _getClientForMediaItem() {
// Try to get serverId from the media item
@@ -40,9 +41,8 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
// Check if mediaItem has serverId property
if (mediaItem is PlexMetadata) {
serverId = (mediaItem as PlexMetadata).serverId;
} else if (mediaItem != null &&
mediaItem.runtimeType.toString().contains('PlexPlaylist')) {
// For playlists, use reflection-like access
} else if (mediaItem != null) {
// For playlists or other types, use dynamic access
try {
final dynamic item = mediaItem;
serverId = item.serverId as String?;
@@ -51,20 +51,7 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
}
}
if (serverId == null) {
appLogger.w('Media item has no serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
return client;
return context.getClientForServer(serverId);
}
@override
@@ -119,4 +106,159 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
void refresh() {
loadItems();
}
/// Build common error/loading/empty state slivers
/// Returns a list of slivers to display based on current state
List<Widget> buildStateSlivers() {
if (errorMessage != null) {
return [
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: loadItems,
child: Text(t.common.retry),
),
],
),
),
),
];
}
if (items.isEmpty && isLoading) {
return [
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
];
}
if (items.isEmpty) {
final icon = emptyIcon;
return [
SliverFillRemaining(
child: Center(
child: icon != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
emptyMessage,
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
)
: Text(emptyMessage),
),
),
];
}
return [];
}
/// Build standard app bar actions (play, shuffle, delete)
/// Subclasses can override to customize actions
List<Widget> buildAppBarActions({
VoidCallback? onDelete,
String? deleteTooltip,
Color? deleteColor,
bool showDelete = true,
}) {
return [
// Play button
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.play_arrow),
tooltip: t.discover.play,
onPressed: playItems,
),
// Shuffle button
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.shuffle),
tooltip: t.common.shuffle,
onPressed: shufflePlayItems,
),
// Delete button
if (showDelete && onDelete != null)
IconButton(
icon: const Icon(Icons.delete),
tooltip: deleteTooltip ?? t.common.delete,
onPressed: onDelete,
color: deleteColor ?? Colors.red,
),
];
}
}
/// Mixin that provides standard loadItems implementation for media lists
/// Handles the common pattern of fetching, tagging, and setting items
mixin StandardItemLoader<T extends StatefulWidget>
on BaseMediaListDetailScreen<T> {
/// Fetch items from the API (must be implemented by subclass)
Future<List<PlexMetadata>> fetchItems();
/// Get error message for failed load (can be overridden)
String getLoadErrorMessage(Object error) {
return 'Failed to load items: ${error.toString()}';
}
/// Get log message for successful load (can be overridden)
String getLoadSuccessMessage(int itemCount) {
return 'Loaded $itemCount items';
}
@override
Future<void> loadItems() async {
if (mounted) {
setState(() {
isLoading = true;
errorMessage = null;
});
}
try {
final newItems = await fetchItems();
// Tag items with server info for correct client resolution
final serverId = (mediaItem as dynamic).serverId as String?;
final serverName = (mediaItem as dynamic).serverName as String?;
final taggedItems = newItems
.map(
(item) => item.copyWith(serverId: serverId, serverName: serverName),
)
.toList();
if (mounted) {
setState(() {
items = taggedItems;
isLoading = false;
});
}
appLogger.d(getLoadSuccessMessage(newItems.length));
} catch (e) {
appLogger.e('Failed to load items', error: e);
if (mounted) {
setState(() {
errorMessage = getLoadErrorMessage(e);
isLoading = false;
});
}
}
}
}
+15 -122
View File
@@ -1,17 +1,13 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/plex_client_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/settings_provider.dart';
import '../utils/app_logger.dart';
import '../widgets/media_card.dart';
import '../widgets/desktop_app_bar.dart';
import '../i18n/strings.g.dart';
import '../utils/grid_size_calculator.dart';
import '../utils/dialogs.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import 'base_media_list_detail_screen.dart';
/// Screen to display the contents of a collection
@@ -25,7 +21,8 @@ class CollectionDetailScreen extends StatefulWidget {
}
class _CollectionDetailScreenState
extends BaseMediaListDetailScreen<CollectionDetailScreen> {
extends BaseMediaListDetailScreen<CollectionDetailScreen>
with StandardItemLoader<CollectionDetailScreen> {
@override
PlexMetadata get mediaItem => widget.collection;
@@ -35,69 +32,19 @@ class _CollectionDetailScreenState
@override
String get emptyMessage => t.collections.empty;
/// Get the correct PlexClient for this collection's server
PlexClient? _getClientForCollection() {
final serverId = widget.collection.serverId;
if (serverId == null) {
appLogger.w('Collection ${widget.collection.title} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
@override
Future<List<PlexMetadata>> fetchItems() async {
return await client.getCollectionItems(widget.collection.ratingKey);
}
@override
Future<void> loadItems() async {
if (mounted) {
setState(() {
isLoading = true;
errorMessage = null;
});
}
String getLoadErrorMessage(Object error) {
return t.collections.failedToLoadItems(error: error.toString());
}
try {
final client = this.client;
final newItems = await client.getCollectionItems(
widget.collection.ratingKey,
);
// Tag items with server info for correct client resolution
final taggedItems = newItems
.map(
(item) => item.copyWith(
serverId: widget.collection.serverId,
serverName: widget.collection.serverName,
),
)
.toList();
if (mounted) {
setState(() {
items = taggedItems;
isLoading = false;
});
}
appLogger.d(
'Loaded ${newItems.length} items for collection: ${widget.collection.title}',
);
} catch (e) {
appLogger.e('Failed to load collection items', error: e);
if (mounted) {
setState(() {
errorMessage = t.collections.failedToLoadItems(error: e.toString());
isLoading = false;
});
}
}
@override
String getLoadSuccessMessage(int itemCount) {
return 'Loaded $itemCount items for collection: ${widget.collection.title}';
}
Future<void> _deleteCollection() async {
@@ -129,9 +76,6 @@ class _CollectionDetailScreenState
if (!mounted) return;
try {
final client = _getClientForCollection();
if (client == null) return;
final success = await client.deleteCollection(
sectionId.toString(),
widget.collection.ratingKey,
@@ -176,61 +120,10 @@ class _CollectionDetailScreenState
CustomAppBar(
title: Text(widget.collection.title),
pinned: true,
actions: [
// Play button
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.play_arrow),
tooltip: t.discover.play,
onPressed: playItems,
),
// Shuffle button
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.shuffle),
tooltip: t.common.shuffle,
onPressed: shufflePlayItems,
),
// Delete button
IconButton(
icon: const Icon(Icons.delete),
tooltip: t.common.delete,
onPressed: _deleteCollection,
color: Colors.red,
),
],
actions: buildAppBarActions(onDelete: _deleteCollection),
),
if (errorMessage != null)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: loadItems,
child: Text(t.common.retry),
),
],
),
),
)
else if (items.isEmpty && isLoading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (items.isEmpty)
SliverFillRemaining(
child: Center(child: Text(t.collections.noItems)),
)
else
...buildStateSlivers(),
if (items.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
sliver: Consumer<SettingsProvider>(
+1 -16
View File
@@ -55,23 +55,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
bool _isAutoScrollPaused = false;
/// Get the correct PlexClient for an item's server
/// If item is null, returns legacy client for backward compatibility
PlexClient _getClientForItem(PlexMetadata? item) {
final serverId = item?.serverId;
if (serverId == null) {
// Fallback to legacy client if no serverId
return context.read<PlexClientProvider>().client!;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
return client;
return context.getClientForServer(item?.serverId);
}
@override
+1 -17
View File
@@ -4,8 +4,6 @@ import '../client/plex_client.dart';
import '../models/plex_hub.dart';
import '../models/plex_metadata.dart';
import '../models/plex_sort.dart';
import '../providers/plex_client_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/settings_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
@@ -39,21 +37,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
/// Get the correct PlexClient for this hub's server
PlexClient _getClientForHub() {
final serverId = widget.hub.serverId;
if (serverId == null) {
appLogger.w('Hub ${widget.hub.title} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
return client;
return context.getClientForServer(widget.hub.serverId);
}
@override
+45 -65
View File
@@ -5,11 +5,10 @@ import '../client/plex_client.dart';
import '../models/plex_library.dart';
import '../models/plex_metadata.dart';
import '../models/plex_sort.dart';
import '../providers/plex_client_provider.dart';
import '../providers/hidden_libraries_provider.dart';
import '../providers/multi_server_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../utils/provider_extensions.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/context_menu_wrapper.dart';
import '../widgets/server_badge.dart';
@@ -34,37 +33,10 @@ class LibrariesScreen extends StatefulWidget {
class _LibrariesScreenState extends State<LibrariesScreen>
with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin {
@override
PlexClient get client => _getClientForLibrary(null);
PlexClient get client => context.getClientForServer(null);
late TabController _tabController;
/// Get the correct PlexClient for a library's server
/// Returns legacy client if libraryKey is null or library not found
PlexClient _getClientForLibrary(String? libraryGlobalKey) {
if (libraryGlobalKey == null) {
return context.read<PlexClientProvider>().client!;
}
// Find the library to get its serverId
final library = _allLibraries.where((lib) => lib.globalKey == libraryGlobalKey).firstOrNull;
final serverId = library?.serverId;
if (serverId == null) {
appLogger.w('Library $libraryGlobalKey has no serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
return client;
}
// GlobalKeys for tabs to enable refresh
final _recommendedTabKey = GlobalKey<State<LibraryRecommendedTab>>();
final _browseTabKey = GlobalKey<State<LibraryBrowseTab>>();
@@ -99,7 +71,10 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// Save tab index when changed
if (_selectedLibraryGlobalKey != null && !_tabController.indexIsChanging) {
StorageService.getInstance().then((storage) {
storage.saveLibraryTab(_selectedLibraryGlobalKey!, _tabController.index);
storage.saveLibraryTab(
_selectedLibraryGlobalKey!,
_tabController.index,
);
});
}
// Rebuild to update chip selection state
@@ -162,7 +137,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final storage = await StorageService.getInstance();
// Fetch libraries from all servers
final allLibraries = await multiServerProvider.aggregationService.getLibrariesFromAllServers();
final allLibraries = await multiServerProvider.aggregationService
.getLibrariesFromAllServers();
// Filter out music libraries (type: 'artist') since music playback is not yet supported
// Only show movie and TV show libraries
@@ -191,36 +167,36 @@ class _LibrariesScreenState extends State<LibrariesScreen>
.toList();
// Load saved preferences
final savedLibraryKey = storage.getSelectedLibraryKey();
final savedLibraryKey = storage.getSelectedLibraryKey();
// Find the library by key in visible libraries
String? libraryGlobalKeyToLoad;
if (savedLibraryKey != null) {
// Check if saved library exists and is visible
final libraryExists = visibleLibraries.any(
(lib) => lib.globalKey == savedLibraryKey,
);
if (libraryExists) {
libraryGlobalKeyToLoad = savedLibraryKey;
// Find the library by key in visible libraries
String? libraryGlobalKeyToLoad;
if (savedLibraryKey != null) {
// Check if saved library exists and is visible
final libraryExists = visibleLibraries.any(
(lib) => lib.globalKey == savedLibraryKey,
);
if (libraryExists) {
libraryGlobalKeyToLoad = savedLibraryKey;
}
}
// Fallback to first visible library if saved key not found
if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) {
libraryGlobalKeyToLoad = visibleLibraries.first.globalKey;
}
if (libraryGlobalKeyToLoad != null && mounted) {
final savedFilters = storage.getLibraryFilters(
sectionId: libraryGlobalKeyToLoad,
);
if (savedFilters.isNotEmpty) {
_selectedFilters = Map.from(savedFilters);
}
_loadLibraryContent(libraryGlobalKeyToLoad);
}
}
// Fallback to first visible library if saved key not found
if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) {
libraryGlobalKeyToLoad = visibleLibraries.first.globalKey;
}
if (libraryGlobalKeyToLoad != null && mounted) {
final savedFilters = storage.getLibraryFilters(
sectionId: libraryGlobalKeyToLoad,
);
if (savedFilters.isNotEmpty) {
_selectedFilters = Map.from(savedFilters);
}
_loadLibraryContent(libraryGlobalKeyToLoad);
}
}
} catch (e) {
} catch (e) {
_updateState(() {
_errorMessage = _getErrorMessage(e, 'libraries');
_isLoadingLibraries = false;
@@ -290,7 +266,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
!_isInitialLoad && _selectedLibraryGlobalKey != libraryGlobalKey;
// Get the correct client for this library's server
final client = _getClientForLibrary(libraryGlobalKey);
final client = context.getClientForLibrary(library);
_updateState(() {
_selectedLibraryGlobalKey = libraryGlobalKey;
@@ -414,7 +390,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
Future<void> _loadSortOptions(PlexLibrary library) async {
try {
final client = _getClientForLibrary(library.globalKey);
final client = context.getClientForLibrary(library);
final sortOptions = await client.getLibrarySorts(library.key);
@@ -530,7 +506,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
await hiddenLibrariesProvider.unhideLibrary(library.key);
} else {
// Check if we're hiding the currently selected library
final isCurrentlySelected = _selectedLibraryGlobalKey == library.globalKey;
final isCurrentlySelected =
_selectedLibraryGlobalKey == library.globalKey;
await hiddenLibrariesProvider.hideLibrary(library.key);
@@ -648,7 +625,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
required String Function(Object error) failureMessage,
}) async {
try {
final client = _getClientForLibrary(library.globalKey);
final client = context.getClientForLibrary(library);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -888,7 +865,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
Text(
selectedLibrary.serverName!,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
],
@@ -920,7 +899,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
body: CustomScrollView(
slivers: [
DesktopSliverAppBar(
title: visibleLibraries.isNotEmpty && _selectedLibraryGlobalKey != null
title:
visibleLibraries.isNotEmpty && _selectedLibraryGlobalKey != null
? _buildLibraryDropdownTitle(visibleLibraries)
: Text(t.libraries.title),
floating: true,
@@ -0,0 +1,157 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../models/plex_library.dart';
import '../../utils/app_logger.dart';
import '../../mixins/library_tab_state.dart';
import '../../mixins/refreshable.dart';
import '../../widgets/content_state_builder.dart';
/// Base class for library tab screens that provides common state management
/// and lifecycle handling for tabs that display library content.
///
/// Type parameter T: The type of items this tab displays
///
/// Subclasses must implement:
/// - [loadData]: Load data from the Plex API
/// - [buildContent]: Build the UI for displaying loaded items
///
/// Optional overrides:
/// - [emptyIcon]: Icon to show when there are no items
/// - [emptyMessage]: Message to show when there are no items
/// - [errorContext]: Context for error messages (defaults to "content")
/// - [getRefreshStream]: Stream to listen for refresh events
abstract class BaseLibraryTab<T> extends StatefulWidget {
final PlexLibrary library;
final String? viewMode;
final String? density;
const BaseLibraryTab({
super.key,
required this.library,
this.viewMode,
this.density,
});
}
/// State mixin that provides the common implementation for library tabs
/// This preserves AutomaticKeepAliveClientMixin functionality
abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>>
extends State<W>
with AutomaticKeepAliveClientMixin, Refreshable, LibraryTabStateMixin {
@override
bool get wantKeepAlive => true;
@override
PlexLibrary get library => widget.library;
@override
void refresh() {
loadItems();
}
// State management
List<T> _items = [];
bool _isLoading = false;
String? _errorMessage;
StreamSubscription<void>? _refreshSubscription;
// Getters for subclasses
List<T> get items => _items;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
@override
void initState() {
super.initState();
loadItems();
// Subscribe to refresh stream if provided
final refreshStream = getRefreshStream();
if (refreshStream != null) {
_refreshSubscription = refreshStream.listen((_) {
if (mounted) {
loadItems();
}
});
}
}
@override
void dispose() {
_refreshSubscription?.cancel();
super.dispose();
}
@override
void didUpdateWidget(W oldWidget) {
super.didUpdateWidget(oldWidget);
// Reload if library changed
if (oldWidget.library.globalKey != widget.library.globalKey) {
loadItems();
}
}
/// Load items from the API
/// This is the main data loading function that subclasses must implement
Future<List<T>> loadData();
/// Build the content widget given the loaded items
/// This is called by ContentStateBuilder when items are available
Widget buildContent(List<T> items);
/// Icon to display when there are no items (empty state)
IconData get emptyIcon;
/// Message to display when there are no items (empty state)
String get emptyMessage;
/// Context string for error messages (e.g., "playlists", "collections")
String get errorContext;
/// Optional refresh stream to listen for external refresh events
/// Return null if no refresh stream is needed
Stream<void>? getRefreshStream() => null;
/// Load items with error handling and state management
Future<void> loadItems() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final loadedItems = await loadData();
if (!mounted) return;
setState(() {
_items = loadedItems;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
appLogger.e('Error loading $errorContext', error: e);
setState(() {
_errorMessage = 'Failed to load $errorContext: ${e.toString()}';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
return ContentStateBuilder<T>(
isLoading: _isLoading,
errorMessage: _errorMessage,
items: _items,
emptyIcon: emptyIcon,
emptyMessage: emptyMessage,
onRetry: loadItems,
builder: (items) =>
RefreshIndicator(onRefresh: loadItems, child: buildContent(items)),
);
}
}
@@ -6,22 +6,22 @@ import '../../models/plex_library.dart';
import '../../models/plex_metadata.dart';
import '../../models/plex_filter.dart';
import '../../models/plex_sort.dart';
import '../../providers/plex_client_provider.dart';
import '../../providers/multi_server_provider.dart';
import '../../providers/settings_provider.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/error_message_utils.dart';
import '../../utils/grid_size_calculator.dart';
import '../../utils/server_tagging_extensions.dart';
import '../../widgets/media_card.dart';
import '../../widgets/folder_tree_view.dart';
import '../../widgets/filters_bottom_sheet.dart';
import '../../widgets/sort_bottom_sheet.dart';
import '../../widgets/empty_state_widget.dart';
import '../../widgets/error_state_widget.dart';
import '../../services/storage_service.dart';
import '../../services/settings_service.dart' show ViewMode;
import '../../mixins/item_updatable.dart';
import '../../mixins/library_tab_state.dart';
import '../../mixins/refreshable.dart';
import '../../i18n/strings.g.dart';
import '../../utils/app_logger.dart';
/// Browse tab for library screen
/// Shows library items with grouping, filtering, and sorting
@@ -42,32 +42,19 @@ class LibraryBrowseTab extends StatefulWidget {
}
class _LibraryBrowseTabState extends State<LibraryBrowseTab>
with AutomaticKeepAliveClientMixin, ItemUpdatable, Refreshable {
with
AutomaticKeepAliveClientMixin,
ItemUpdatable,
Refreshable,
LibraryTabStateMixin {
@override
bool get wantKeepAlive => true;
@override
PlexClient get client => context.clientSafe;
PlexLibrary get library => widget.library;
/// Get the correct PlexClient for this library's server
PlexClient? _getClientForLibrary(BuildContext context) {
final serverId = widget.library.serverId;
if (serverId == null) {
// Fallback to legacy client if no serverId
appLogger.w('Library ${widget.library.title} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
@override
PlexClient get client => getClientForLibrary();
@override
void refresh() {
@@ -129,7 +116,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
final currentRequestId = ++_requestId;
// Extract context dependencies before async gap - use server-specific client
final client = _getClientForLibrary(context);
final client = getClientForLibrary();
setState(() {
_isLoading = true;
@@ -140,10 +127,6 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
});
try {
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
final storage = await StorageService.getInstance();
// Load filters and sorts for this library
@@ -155,7 +138,9 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
sectionId: widget.library.globalKey,
);
final savedSort = storage.getLibrarySort(widget.library.globalKey);
final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey);
final savedGrouping = storage.getLibraryGrouping(
widget.library.globalKey,
);
// Check if request was cancelled
if (currentRequestId != _requestId) return;
@@ -214,10 +199,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
try {
// Use server-specific client for this library
final client = _getClientForLibrary(context);
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
final client = getClientForLibrary();
// Build filter params
final filterParams = Map<String, String>.from(_selectedFilters);
@@ -246,14 +228,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
);
// Tag items with server info for multi-server support
final taggedItems = items
.map(
(item) => item.copyWith(
serverId: widget.library.serverId,
serverName: widget.library.serverName,
),
)
.toList();
final taggedItems = items.tagWithLibrary(widget.library);
if (currentRequestId != _requestId) return;
@@ -356,8 +331,11 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
_selectedGrouping = value;
});
final storage = await StorageService.getInstance();
await storage.saveLibraryGrouping(widget.library.globalKey, value);
final storage = await StorageService.getInstance();
await storage.saveLibraryGrouping(
widget.library.globalKey,
value,
);
if (!mounted) return;
@@ -529,33 +507,18 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
}
if (_errorMessage != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadContent,
child: Text(t.common.retry),
),
],
),
return ErrorStateWidget(
message: _errorMessage!,
icon: Icons.error_outline,
onRetry: _loadContent,
retryLabel: t.common.retry,
);
}
if (_items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(t.libraries.thisLibraryIsEmpty),
],
),
return EmptyStateWidget(
message: t.libraries.thisLibraryIsEmpty,
icon: Icons.folder_open,
);
}
@@ -1,162 +1,53 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../client/plex_client.dart';
import '../../models/plex_library.dart';
import '../../models/plex_metadata.dart';
import '../../providers/plex_client_provider.dart';
import '../../providers/multi_server_provider.dart';
import '../../utils/app_logger.dart';
import '../../utils/library_refresh_notifier.dart';
import '../../utils/server_tagging_extensions.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/refreshable.dart';
import '../../widgets/content_state_builder.dart';
import '../../widgets/adaptive_media_grid.dart';
import 'base_library_tab.dart';
/// Collections tab for library screen
/// Shows collections for the current library
class LibraryCollectionsTab extends StatefulWidget {
final PlexLibrary library;
final String? viewMode;
final String? density;
class LibraryCollectionsTab extends BaseLibraryTab<PlexMetadata> {
const LibraryCollectionsTab({
super.key,
required this.library,
this.viewMode,
this.density,
required super.library,
super.viewMode,
super.density,
});
@override
State<LibraryCollectionsTab> createState() => _LibraryCollectionsTabState();
}
class _LibraryCollectionsTabState extends State<LibraryCollectionsTab>
with AutomaticKeepAliveClientMixin, Refreshable {
class _LibraryCollectionsTabState
extends BaseLibraryTabState<PlexMetadata, LibraryCollectionsTab> {
@override
bool get wantKeepAlive => true;
IconData get emptyIcon => Icons.collections;
@override
void refresh() {
_loadCollections();
}
/// Get the correct PlexClient for this library's server
PlexClient? _getClientForLibrary(BuildContext context) {
final serverId = widget.library.serverId;
if (serverId == null) {
// Fallback to legacy client if no serverId
appLogger.w('Library ${widget.library.title} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
List<PlexMetadata> _collections = [];
bool _isLoading = false;
String? _errorMessage;
StreamSubscription<void>? _refreshSubscription;
String get emptyMessage => t.libraries.noCollections;
@override
void initState() {
super.initState();
_loadCollections();
String get errorContext => t.collections.title;
// Listen for refresh notifications
_refreshSubscription = LibraryRefreshNotifier().collectionsStream.listen((
_,
) {
if (mounted) {
_loadCollections();
}
});
@override
Stream<void>? getRefreshStream() =>
LibraryRefreshNotifier().collectionsStream;
@override
Future<List<PlexMetadata>> loadData() async {
// Use server-specific client for this library
final client = getClientForLibrary();
final collections = await client.getLibraryCollections(widget.library.key);
// Tag collections with server info
return collections.tagWithLibrary(widget.library);
}
@override
void dispose() {
_refreshSubscription?.cancel();
super.dispose();
}
@override
void didUpdateWidget(LibraryCollectionsTab oldWidget) {
super.didUpdateWidget(oldWidget);
// Reload if library changed
if (oldWidget.library.globalKey != widget.library.globalKey) {
_loadCollections();
}
}
Future<void> _loadCollections() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Use server-specific client for this library
final client = _getClientForLibrary(context);
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
final collections = await client.getLibraryCollections(
widget.library.key,
);
if (!mounted) return;
final taggedCollections = collections
.map(
(item) => item.copyWith(
serverId: widget.library.serverId,
serverName: widget.library.serverName,
),
)
.toList();
setState(() {
_collections = taggedCollections;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
appLogger.e('Error loading collections', error: e);
setState(() {
_errorMessage = t.errors.failedToLoad(
context: t.collections.title,
error: e.toString(),
);
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
return ContentStateBuilder<PlexMetadata>(
isLoading: _isLoading,
errorMessage: _errorMessage,
items: _collections,
emptyIcon: Icons.collections,
emptyMessage: t.libraries.noCollections,
onRetry: _loadCollections,
builder: (items) => RefreshIndicator(
onRefresh: _loadCollections,
child: AdaptiveMediaGrid(items: items, onRefresh: _loadCollections),
),
);
Widget buildContent(List<PlexMetadata> items) {
return AdaptiveMediaGrid(items: items, onRefresh: loadItems);
}
}
@@ -1,204 +1,99 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../client/plex_client.dart';
import '../../models/plex_library.dart';
import '../../models/plex_playlist.dart';
import '../../providers/plex_client_provider.dart';
import '../../providers/multi_server_provider.dart';
import '../../providers/settings_provider.dart';
import '../../utils/app_logger.dart';
import '../../utils/library_refresh_notifier.dart';
import '../../services/settings_service.dart' show ViewMode;
import '../../utils/grid_size_calculator.dart';
import '../../utils/server_tagging_extensions.dart';
import '../../widgets/media_card.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/refreshable.dart';
import '../../widgets/content_state_builder.dart';
import 'base_library_tab.dart';
/// Playlists tab for library screen
/// Shows playlists that contain items from the current library
class LibraryPlaylistsTab extends StatefulWidget {
final PlexLibrary library;
final String? viewMode;
final String? density;
class LibraryPlaylistsTab extends BaseLibraryTab<PlexPlaylist> {
const LibraryPlaylistsTab({
super.key,
required this.library,
this.viewMode,
this.density,
required super.library,
super.viewMode,
super.density,
});
@override
State<LibraryPlaylistsTab> createState() => _LibraryPlaylistsTabState();
}
class _LibraryPlaylistsTabState extends State<LibraryPlaylistsTab>
with AutomaticKeepAliveClientMixin, Refreshable {
class _LibraryPlaylistsTabState
extends BaseLibraryTabState<PlexPlaylist, LibraryPlaylistsTab> {
@override
bool get wantKeepAlive => true;
IconData get emptyIcon => Icons.playlist_play;
@override
void refresh() {
_loadPlaylists();
}
/// Get the correct PlexClient for this library's server
PlexClient? _getClientForLibrary(BuildContext context) {
final serverId = widget.library.serverId;
if (serverId == null) {
// Fallback to legacy client if no serverId
appLogger.w('Library ${widget.library.title} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
List<PlexPlaylist> _playlists = [];
bool _isLoading = false;
String? _errorMessage;
StreamSubscription<void>? _refreshSubscription;
String get emptyMessage => t.playlists.noPlaylists;
@override
void initState() {
super.initState();
_loadPlaylists();
String get errorContext => t.playlists.title;
// Listen for refresh notifications
_refreshSubscription = LibraryRefreshNotifier().playlistsStream.listen((_) {
if (mounted) {
_loadPlaylists();
}
});
@override
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().playlistsStream;
@override
Future<List<PlexPlaylist>> loadData() async {
// Use server-specific client for this library
final client = getClientForLibrary();
// Get playlists for this library
final playlists = await client.getLibraryPlaylists(
sectionId: widget.library.key,
playlistType: 'video',
);
// Tag playlists with server info
return playlists.tagWithLibrary(widget.library);
}
@override
void dispose() {
_refreshSubscription?.cancel();
super.dispose();
}
@override
void didUpdateWidget(LibraryPlaylistsTab oldWidget) {
super.didUpdateWidget(oldWidget);
// Reload if library changed
if (oldWidget.library.globalKey != widget.library.globalKey) {
_loadPlaylists();
}
}
Future<void> _loadPlaylists() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Use server-specific client for this library
final client = _getClientForLibrary(context);
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
// Get playlists for this library
final playlists = await client.getLibraryPlaylists(
sectionId: widget.library.key,
playlistType: 'video',
);
if (!mounted) return;
final taggedPlaylists = playlists
.map(
(playlist) => playlist.copyWith(
serverId: widget.library.serverId,
serverName: widget.library.serverName,
Widget buildContent(List<PlexPlaylist> items) {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
itemCount: items.length,
itemBuilder: (context, index) {
final playlist = items[index];
return MediaCard(
key: Key(playlist.ratingKey),
item: playlist,
onListRefresh: loadItems,
);
},
);
} else {
return GridView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
context,
settingsProvider.libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
)
.toList();
setState(() {
_playlists = taggedPlaylists;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
appLogger.e('Error loading playlists', error: e);
setState(() {
_errorMessage = t.errors.failedToLoad(
context: t.playlists.title,
error: e.toString(),
);
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
return ContentStateBuilder<PlexPlaylist>(
isLoading: _isLoading,
errorMessage: _errorMessage,
items: _playlists,
emptyIcon: Icons.playlist_play,
emptyMessage: t.playlists.noPlaylists,
onRetry: _loadPlaylists,
builder: (items) => RefreshIndicator(
onRefresh: _loadPlaylists,
child: Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
itemCount: items.length,
itemBuilder: (context, index) {
final playlist = items[index];
return MediaCard(
key: Key(playlist.ratingKey),
item: playlist,
onListRefresh: _loadPlaylists,
);
},
itemCount: items.length,
itemBuilder: (context, index) {
final playlist = items[index];
return MediaCard(
key: Key(playlist.ratingKey),
item: playlist,
onListRefresh: loadItems,
);
} else {
return GridView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
context,
settingsProvider.libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
itemCount: items.length,
itemBuilder: (context, index) {
final playlist = items[index];
return MediaCard(
key: Key(playlist.ratingKey),
item: playlist,
onListRefresh: _loadPlaylists,
);
},
);
}
},
),
),
},
);
}
},
);
}
}
@@ -1,133 +1,65 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../client/plex_client.dart';
import '../../models/plex_library.dart';
import '../../models/plex_hub.dart';
import '../../providers/plex_client_provider.dart';
import '../../providers/multi_server_provider.dart';
import '../../utils/app_logger.dart';
import '../../utils/server_tagging_extensions.dart';
import '../../widgets/hub_section.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/refreshable.dart';
import '../../widgets/content_state_builder.dart';
import 'base_library_tab.dart';
/// Recommended tab for library screen
/// Shows library-specific hubs and recommendations
class LibraryRecommendedTab extends StatefulWidget {
final PlexLibrary library;
const LibraryRecommendedTab({super.key, required this.library});
class LibraryRecommendedTab extends BaseLibraryTab<PlexHub> {
const LibraryRecommendedTab({super.key, required super.library});
@override
State<LibraryRecommendedTab> createState() => _LibraryRecommendedTabState();
}
class _LibraryRecommendedTabState extends State<LibraryRecommendedTab>
with AutomaticKeepAliveClientMixin, Refreshable {
class _LibraryRecommendedTabState
extends BaseLibraryTabState<PlexHub, LibraryRecommendedTab> {
@override
bool get wantKeepAlive => true;
IconData get emptyIcon => Icons.recommend;
@override
void refresh() {
_loadHubs();
}
/// Get the correct PlexClient for this library's server
PlexClient? _getClientForLibrary(BuildContext context) {
final serverId = widget.library.serverId;
if (serverId == null) {
// Fallback to legacy client if no serverId
appLogger.w('Library ${widget.library.title} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
List<PlexHub> _hubs = [];
bool _isLoading = false;
String? _errorMessage;
String get emptyMessage => t.libraries.noRecommendations;
@override
void initState() {
super.initState();
_loadHubs();
String get errorContext => t.libraries.tabs.recommended;
@override
Future<List<PlexHub>> loadData() async {
// Use server-specific client for this library
final client = getClientForLibrary();
final hubs = await client.getLibraryHubs(widget.library.key, limit: 12);
// Tag hubs and items with server info
return hubs
.map(
(hub) => PlexHub(
hubKey: hub.hubKey,
title: hub.title,
type: hub.type,
hubIdentifier: hub.hubIdentifier,
size: hub.size,
more: hub.more,
items: hub.items.tagWithLibrary(widget.library),
serverId: widget.library.serverId,
serverName: widget.library.serverName,
),
)
.toList();
}
@override
void didUpdateWidget(LibraryRecommendedTab oldWidget) {
super.didUpdateWidget(oldWidget);
// Reload if library changed
if (oldWidget.library.globalKey != widget.library.globalKey) {
_loadHubs();
}
}
Future<void> _loadHubs() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Use server-specific client for this library
final client = _getClientForLibrary(context);
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
final hubs = await client.getLibraryHubs(widget.library.key, limit: 12);
if (!mounted) return;
// Tag hubs and items with server info
final taggedHubs = hubs
.map(
(hub) => PlexHub(
hubKey: hub.hubKey,
title: hub.title,
type: hub.type,
hubIdentifier: hub.hubIdentifier,
size: hub.size,
more: hub.more,
items: hub.items
.map(
(item) => item.copyWith(
serverId: widget.library.serverId,
serverName: widget.library.serverName,
),
)
.toList(),
serverId: widget.library.serverId,
serverName: widget.library.serverName,
),
)
.toList();
setState(() {
_hubs = taggedHubs;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
appLogger.e('Error loading library hubs', error: e);
setState(() {
_errorMessage = t.errors.failedToLoad(
context: t.libraries.tabs.recommended,
error: e.toString(),
);
_isLoading = false;
});
}
Widget buildContent(List<PlexHub> items) {
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
itemBuilder: (context, index) {
final hub = items[index];
return HubSection(hub: hub, icon: _getHubIcon(hub));
},
);
}
IconData _getHubIcon(PlexHub hub) {
@@ -149,29 +81,4 @@ class _LibraryRecommendedTabState extends State<LibraryRecommendedTab>
}
return Icons.movie;
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
return ContentStateBuilder<PlexHub>(
isLoading: _isLoading,
errorMessage: _errorMessage,
items: _hubs,
emptyIcon: Icons.recommend,
emptyMessage: t.libraries.noRecommendations,
onRetry: _loadHubs,
builder: (items) => RefreshIndicator(
onRefresh: _loadHubs,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
itemBuilder: (context, index) {
final hub = items[index];
return HubSection(hub: hub, icon: _getHubIcon(hub));
},
),
),
);
}
}
+121 -30
View File
@@ -5,14 +5,12 @@ import 'package:provider/provider.dart';
import '../i18n/strings.g.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/plex_client_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/playback_state_provider.dart';
import '../theme/theme_helper.dart';
import '../utils/app_logger.dart';
import '../utils/content_rating_formatter.dart';
import '../utils/duration_formatter.dart';
import '../utils/provider_extensions.dart';
import '../utils/shuffle_play_helper.dart';
import '../utils/video_player_navigation.dart';
import '../widgets/app_bar_back_button.dart';
import '../widgets/desktop_app_bar.dart';
@@ -52,23 +50,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
}
/// Get the correct PlexClient for this metadata's server
PlexClient? _getClientForMetadata(BuildContext context) {
final serverId = widget.metadata.serverId;
if (serverId == null) {
// Fallback to legacy client if no serverId
appLogger.w('Metadata ${widget.metadata.title} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
PlexClient _getClientForMetadata(BuildContext context) {
return context.getClientForServer(widget.metadata.serverId);
}
Future<void> _loadFullMetadata() async {
@@ -150,10 +133,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
final seasons = await client.getChildren(widget.metadata.ratingKey);
// Preserve serverId for each season
final seasonsWithServerId = seasons.map((season) => season.copyWith(
serverId: widget.metadata.serverId,
serverName: widget.metadata.serverName,
)).toList();
final seasonsWithServerId = seasons
.map(
(season) => season.copyWith(
serverId: widget.metadata.serverId,
serverName: widget.metadata.serverName,
),
)
.toList();
setState(() {
_seasons = seasonsWithServerId;
_isLoadingSeasons = false;
@@ -191,10 +178,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
if (metadata.type.toLowerCase() == 'show') {
final seasons = await client.getChildren(widget.metadata.ratingKey);
// Preserve serverId for each season
updatedSeasons = seasons.map((season) => season.copyWith(
serverId: widget.metadata.serverId,
serverName: widget.metadata.serverName,
)).toList();
updatedSeasons = seasons
.map(
(season) => season.copyWith(
serverId: widget.metadata.serverId,
serverName: widget.metadata.serverName,
),
)
.toList();
}
// Single setState to minimize rebuilds - scroll position is preserved by controller
@@ -277,6 +268,101 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
}
}
/// Handle shuffle play using play queues
Future<void> _handleShufflePlayWithQueue(
BuildContext context,
PlexMetadata metadata,
) async {
final client = _getClientForMetadata(context);
if (client == null) return;
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()),
);
}
// 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);
// Refresh metadata when returning from video player
_loadFullMetadata();
}
} 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()))),
);
}
}
}
@override
Widget build(BuildContext context) {
// Use full metadata if loaded, otherwise use passed metadata
@@ -382,7 +468,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
width: 400,
child: Builder(
builder: (context) {
final client = _getClientForMetadata(context);
final client = _getClientForMetadata(
context,
);
if (client == null) {
return Text(
metadata.title,
@@ -697,7 +785,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
metadata.type.toLowerCase() == 'season') ...[
IconButton.filledTonal(
onPressed: () async {
await handleShufflePlay(context, metadata);
await _handleShufflePlayWithQueue(
context,
metadata,
);
},
icon: const Icon(Icons.shuffle),
tooltip: t.tooltips.shufflePlay,
+60 -166
View File
@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_playlist.dart';
import '../providers/plex_client_provider.dart';
import '../providers/multi_server_provider.dart';
import '../models/plex_metadata.dart';
import '../providers/settings_provider.dart';
import '../providers/playback_state_provider.dart';
import '../utils/app_logger.dart';
@@ -28,7 +27,8 @@ class PlaylistDetailScreen extends StatefulWidget {
}
class _PlaylistDetailScreenState
extends BaseMediaListDetailScreen<PlaylistDetailScreen> {
extends BaseMediaListDetailScreen<PlaylistDetailScreen>
with StandardItemLoader<PlaylistDetailScreen> {
@override
dynamic get mediaItem => widget.playlist;
@@ -38,67 +38,22 @@ class _PlaylistDetailScreenState
@override
String get emptyMessage => t.playlists.emptyPlaylist;
/// Get the correct PlexClient for this playlist's server
PlexClient? _getClientForPlaylist() {
final serverId = widget.playlist.serverId;
if (serverId == null) {
appLogger.w('Playlist ${widget.playlist.title} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
@override
IconData get emptyIcon => Icons.playlist_play;
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
@override
Future<List<PlexMetadata>> fetchItems() async {
return await client.getPlaylist(widget.playlist.ratingKey);
}
@override
Future<void> loadItems() async {
if (mounted) {
setState(() {
isLoading = true;
errorMessage = null;
});
}
String getLoadSuccessMessage(int itemCount) {
return 'Loaded $itemCount items for playlist: ${widget.playlist.title}';
}
try {
final client = this.client;
final newItems = await client.getPlaylist(widget.playlist.ratingKey);
// Tag items with server info for correct client resolution
final taggedItems = newItems
.map(
(item) => item.copyWith(
serverId: widget.playlist.serverId,
serverName: widget.playlist.serverName,
),
)
.toList();
if (mounted) {
setState(() {
items = taggedItems;
isLoading = false;
});
}
appLogger.d(
'Loaded ${newItems.length} items for playlist: ${widget.playlist.title}',
);
} catch (e) {
appLogger.e('Failed to load playlist items', error: e);
if (mounted) {
setState(() {
errorMessage = 'Failed to load playlist items: ${e.toString()}';
isLoading = false;
});
}
}
/// Get the correct PlexClient for this playlist's server
PlexClient _getClientForPlaylist() {
return context.getClientForServer(widget.playlist.serverId);
}
Future<void> _deletePlaylist() async {
@@ -254,7 +209,6 @@ class _PlaylistDetailScreenState
try {
final client = _getClientForPlaylist();
if (client == null) return;
final selectedItem = items[index];
@@ -284,6 +238,8 @@ class _PlaylistDetailScreenState
await playbackState.setPlaybackFromPlayQueue(
playQueue,
widget.playlist.ratingKey,
serverId: widget.playlist.serverId,
serverName: widget.playlist.serverName,
);
// Navigate to selected item (should be first in the queue response)
@@ -343,113 +299,51 @@ class _PlaylistDetailScreenState
],
),
pinned: true,
actions: [
// Play button
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.play_arrow),
tooltip: t.discover.play,
onPressed: playItems,
),
// Shuffle button
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.shuffle),
tooltip: t.playlists.shuffle,
onPressed: shufflePlayItems,
),
// Delete button for non-smart playlists
if (!widget.playlist.smart)
IconButton(
icon: const Icon(Icons.delete),
tooltip: t.playlists.delete,
onPressed: _deletePlaylist,
color: Colors.red,
),
],
),
if (errorMessage != null)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: loadItems,
child: Text(t.common.retry),
),
],
),
),
)
else if (items.isEmpty && isLoading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (items.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.playlist_play,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
t.playlists.emptyPlaylist,
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
),
)
else if (widget.playlist.smart)
// Smart playlists: Use grid view (cannot be reordered)
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
context,
context.watch<SettingsProvider>().libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
delegate: SliverChildBuilderDelegate((context, index) {
return MediaCard(item: items[index], onRefresh: updateItem);
}, childCount: items.length),
),
)
else
// Regular playlists: Use reorderable list view
SliverReorderableList(
itemBuilder: (context, index) {
final item = items[index];
return PlaylistItemCard(
key: ValueKey(item.playlistItemID ?? item.ratingKey),
item: item,
index: index,
onRemove: () => _removeItem(index),
onTap: () => _playFromItem(index),
canReorder: !widget.playlist.smart,
);
},
itemCount: items.length,
onReorder: _onReorder,
actions: buildAppBarActions(
onDelete: widget.playlist.smart ? null : _deletePlaylist,
deleteTooltip: t.playlists.delete,
showDelete: !widget.playlist.smart,
),
),
...buildStateSlivers(),
if (items.isNotEmpty)
if (widget.playlist.smart)
// Smart playlists: Use grid view (cannot be reordered)
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent:
GridSizeCalculator.getMaxCrossAxisExtent(
context,
context.watch<SettingsProvider>().libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
delegate: SliverChildBuilderDelegate((context, index) {
return MediaCard(item: items[index], onRefresh: updateItem);
}, childCount: items.length),
),
)
else
// Regular playlists: Use reorderable list view
SliverReorderableList(
itemBuilder: (context, index) {
final item = items[index];
return PlaylistItemCard(
key: ValueKey(item.playlistItemID ?? item.ratingKey),
item: item,
index: index,
onRemove: () => _removeItem(index),
onTap: () => _playFromItem(index),
canReorder: !widget.playlist.smart,
);
},
itemCount: items.length,
onReorder: _onReorder,
),
],
),
);
+7 -13
View File
@@ -25,11 +25,7 @@ class SearchScreen extends StatefulWidget {
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen>
with Refreshable, ItemUpdatable {
@override
PlexClient get client => context.clientSafe;
class _SearchScreenState extends State<SearchScreen> with Refreshable {
final _searchController = TextEditingController();
List<PlexMetadata> _searchResults = [];
bool _isSearching = false;
@@ -103,7 +99,8 @@ class _SearchScreenState extends State<SearchScreen>
}
// Search across all connected servers
final results = await multiServerProvider.aggregationService.searchAcrossServers(query);
final results = await multiServerProvider.aggregationService
.searchAcrossServers(query);
if (mounted) {
setState(() {
_searchResults = results;
@@ -146,13 +143,10 @@ class _SearchScreenState extends State<SearchScreen>
});
}
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
final index = _searchResults.indexWhere(
(item) => item.ratingKey == ratingKey,
);
if (index != -1) {
_searchResults[index] = updatedMetadata;
void updateItem(String ratingKey) {
// Trigger a refresh of the search to get updated metadata
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
}
+10 -21
View File
@@ -3,8 +3,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/plex_client_provider.dart';
import '../providers/multi_server_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../utils/duration_formatter.dart';
import '../utils/app_logger.dart';
@@ -36,21 +35,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
/// Get the correct PlexClient for this season's server
PlexClient _getClientForSeason(BuildContext context) {
final serverId = widget.season.serverId;
if (serverId == null) {
appLogger.w('Season ${widget.season.title} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
return client;
return context.getClientForServer(widget.season.serverId);
}
@override
@@ -71,10 +56,14 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
try {
final episodes = await _client.getChildren(widget.season.ratingKey);
// Preserve serverId for each episode
final episodesWithServerId = episodes.map((episode) => episode.copyWith(
serverId: widget.season.serverId,
serverName: widget.season.serverName,
)).toList();
final episodesWithServerId = episodes
.map(
(episode) => episode.copyWith(
serverId: widget.season.serverId,
serverName: widget.season.serverName,
),
)
.toList();
setState(() {
_episodes = episodesWithServerId;
_isLoadingEpisodes = false;
-60
View File
@@ -79,8 +79,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 24),
_buildVideoPlaybackSection(),
const SizedBox(height: 24),
_buildShufflePlaySection(),
const SizedBox(height: 24),
_buildKeyboardShortcutsSection(),
const SizedBox(height: 24),
_buildAdvancedSection(),
@@ -283,64 +281,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildShufflePlaySection() {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
t.settings.shufflePlay,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
secondary: const Icon(Icons.visibility_off),
title: Text(t.settings.unwatchedOnly),
subtitle: Text(t.settings.unwatchedOnlyDescription),
value: settingsProvider.shuffleUnwatchedOnly,
onChanged: (value) async {
await settingsProvider.setShuffleUnwatchedOnly(value);
},
);
},
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
secondary: const Icon(Icons.shuffle),
title: Text(t.settings.shuffleOrderNavigation),
subtitle: Text(t.settings.shuffleOrderNavigationDescription),
value: settingsProvider.shuffleOrderNavigation,
onChanged: (value) async {
await settingsProvider.setShuffleOrderNavigation(value);
},
);
},
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
secondary: const Icon(Icons.loop),
title: Text(t.settings.loopShuffleQueue),
subtitle: Text(t.settings.loopShuffleQueueDescription),
value: settingsProvider.shuffleLoopQueue,
onChanged: (value) async {
await settingsProvider.setShuffleLoopQueue(value);
},
);
},
),
],
),
);
}
Widget _buildKeyboardShortcutsSection() {
return Card(
child: Column(
+153 -175
View File
@@ -11,9 +11,105 @@ class SubtitleStylingScreen extends StatefulWidget {
State<SubtitleStylingScreen> createState() => _SubtitleStylingScreenState();
}
class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
static const EdgeInsets _sliderPadding = EdgeInsets.fromLTRB(16, 16, 16, 8);
// Composable widget for slider sections
class _StylingSliderSection extends StatelessWidget {
final String label;
final int value;
final double min;
final double max;
final int divisions;
final ValueChanged<double> onChanged;
final ValueChanged<double>? onChangeEnd;
final String Function(int)? valueFormatter;
const _StylingSliderSection({
required this.label,
required this.value,
required this.min,
required this.max,
required this.divisions,
required this.onChanged,
this.onChangeEnd,
this.valueFormatter,
});
@override
Widget build(BuildContext context) {
final formattedValue = valueFormatter?.call(value) ?? value.toString();
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(label), Text(formattedValue)],
),
const SizedBox(height: 8),
Row(
children: [
Text(
valueFormatter?.call(min.toInt()) ?? min.toInt().toString(),
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
Expanded(
child: Slider(
value: value.toDouble(),
min: min,
max: max,
divisions: divisions,
label: formattedValue,
onChanged: onChanged,
onChangeEnd: onChangeEnd,
),
),
Text(
valueFormatter?.call(max.toInt()) ?? max.toInt().toString(),
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
],
),
);
}
}
// Composable widget for color picker tiles
class _ColorSettingTile extends StatelessWidget {
final String label;
final String currentColor;
final VoidCallback onTap;
final Color Function(String) hexToColor;
const _ColorSettingTile({
required this.label,
required this.currentColor,
required this.onTap,
required this.hexToColor,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: hexToColor(currentColor),
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
),
title: Text(label),
subtitle: Text(currentColor),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
}
class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
late SettingsService _settingsService;
bool _isLoading = true;
@@ -134,66 +230,27 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
),
),
// Font Size Slider
Padding(
padding: _sliderPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(t.subtitlingStyling.fontSize),
Text('$_fontSize'),
],
),
const SizedBox(height: 8),
Row(
children: [
const Text(
'30',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
Expanded(
child: Slider(
value: _fontSize.toDouble(),
min: 30,
max: 80,
divisions: 50,
label: _fontSize.toString(),
onChanged: (value) {
setState(() {
_fontSize = value.toInt();
});
},
onChangeEnd: (value) {
_settingsService.setSubtitleFontSize(_fontSize);
},
),
),
const Text(
'80',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
],
),
_StylingSliderSection(
label: t.subtitlingStyling.fontSize,
value: _fontSize,
min: 30,
max: 80,
divisions: 50,
onChanged: (value) {
setState(() {
_fontSize = value.toInt();
});
},
onChangeEnd: (value) {
_settingsService.setSubtitleFontSize(_fontSize);
},
),
const Divider(),
// Text Color
ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _hexToColor(_textColor),
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
),
title: Text(t.subtitlingStyling.textColor),
subtitle: Text(_textColor),
trailing: const Icon(Icons.chevron_right),
_ColorSettingTile(
label: t.subtitlingStyling.textColor,
currentColor: _textColor,
hexToColor: _hexToColor,
onTap: () {
_showColorPicker(t.subtitlingStyling.textColor, _textColor, (
color,
@@ -207,66 +264,27 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
),
const Divider(),
// Border Size Slider
Padding(
padding: _sliderPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(t.subtitlingStyling.borderSize),
Text('$_borderSize'),
],
),
const SizedBox(height: 8),
Row(
children: [
const Text(
'0',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
Expanded(
child: Slider(
value: _borderSize.toDouble(),
min: 0,
max: 5,
divisions: 5,
label: _borderSize.toString(),
onChanged: (value) {
setState(() {
_borderSize = value.toInt();
});
},
onChangeEnd: (value) {
_settingsService.setSubtitleBorderSize(_borderSize);
},
),
),
const Text(
'5',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
],
),
_StylingSliderSection(
label: t.subtitlingStyling.borderSize,
value: _borderSize,
min: 0,
max: 5,
divisions: 5,
onChanged: (value) {
setState(() {
_borderSize = value.toInt();
});
},
onChangeEnd: (value) {
_settingsService.setSubtitleBorderSize(_borderSize);
},
),
const Divider(),
// Border Color
ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _hexToColor(_borderColor),
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
),
title: Text(t.subtitlingStyling.borderColor),
subtitle: Text(_borderColor),
trailing: const Icon(Icons.chevron_right),
_ColorSettingTile(
label: t.subtitlingStyling.borderColor,
currentColor: _borderColor,
hexToColor: _hexToColor,
onTap: () {
_showColorPicker(t.subtitlingStyling.borderColor, _borderColor, (
color,
@@ -280,68 +298,28 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
),
const Divider(),
// Background Opacity Slider
Padding(
padding: _sliderPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(t.subtitlingStyling.backgroundOpacity),
Text('$_backgroundOpacity%'),
],
),
const SizedBox(height: 8),
Row(
children: [
const Text(
'0%',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
Expanded(
child: Slider(
value: _backgroundOpacity.toDouble(),
min: 0,
max: 100,
divisions: 20,
label: '$_backgroundOpacity%',
onChanged: (value) {
setState(() {
_backgroundOpacity = value.toInt();
});
},
onChangeEnd: (value) {
_settingsService.setSubtitleBackgroundOpacity(
_backgroundOpacity,
);
},
),
),
const Text(
'100%',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
],
),
_StylingSliderSection(
label: t.subtitlingStyling.backgroundOpacity,
value: _backgroundOpacity,
min: 0,
max: 100,
divisions: 20,
valueFormatter: (value) => '$value%',
onChanged: (value) {
setState(() {
_backgroundOpacity = value.toInt();
});
},
onChangeEnd: (value) {
_settingsService.setSubtitleBackgroundOpacity(_backgroundOpacity);
},
),
const Divider(),
// Background Color
ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _hexToColor(_backgroundColor),
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
),
title: Text(t.subtitlingStyling.backgroundColor),
subtitle: Text(_backgroundColor),
trailing: const Icon(Icons.chevron_right),
_ColorSettingTile(
label: t.subtitlingStyling.backgroundColor,
currentColor: _backgroundColor,
hexToColor: _hexToColor,
onTap: () {
_showColorPicker(
t.subtitlingStyling.backgroundColor,
File diff suppressed because it is too large Load Diff
+75 -111
View File
@@ -1,10 +1,12 @@
import 'dart:async';
import '../client/plex_client.dart';
import '../models/plex_hub.dart';
import '../models/plex_library.dart';
import '../models/plex_metadata.dart';
import '../utils/app_logger.dart';
import 'multi_server_manager.dart';
import 'plex_auth_service.dart';
/// Service for aggregating data from multiple Plex servers
class DataAggregationService {
@@ -14,24 +16,9 @@ class DataAggregationService {
/// Fetch libraries from all online servers and tag them with server info
Future<List<PlexLibrary>> getLibrariesFromAllServers() async {
final clients = _serverManager.onlineClients;
if (clients.isEmpty) {
appLogger.w('No online servers available for fetching libraries');
return [];
}
appLogger.d('Fetching libraries from ${clients.length} servers');
final allLibraries = <PlexLibrary>[];
// Fetch from all servers in parallel
final libraryFutures = clients.entries.map((entry) async {
final serverId = entry.key;
final client = entry.value;
final server = _serverManager.getServer(serverId);
try {
return _perServer<PlexLibrary>(
operationName: 'fetching libraries',
operation: (serverId, client, server) async {
final libraries = await client.getLibraries();
// Tag each library with server info
@@ -51,75 +38,23 @@ class DataAggregationService {
serverName: server?.name,
);
}).toList();
} catch (e, stackTrace) {
appLogger.e(
'Failed to fetch libraries from server $serverId',
error: e,
stackTrace: stackTrace,
);
// Mark server as offline
_serverManager.updateServerStatus(serverId, false);
return <PlexLibrary>[];
}
});
final results = await Future.wait(libraryFutures);
// Flatten results
for (final libraries in results) {
allLibraries.addAll(libraries);
}
appLogger.i(
'Fetched ${allLibraries.length} total libraries from all servers',
},
);
return allLibraries;
}
/// Fetch "On Deck" (Continue Watching) from all servers and merge by recency
Future<List<PlexMetadata>> getOnDeckFromAllServers({int? limit}) async {
final clients = _serverManager.onlineClients;
if (clients.isEmpty) {
appLogger.w('No online servers available for fetching on deck');
return [];
}
appLogger.d('Fetching on deck from ${clients.length} servers');
final allOnDeck = <PlexMetadata>[];
// Fetch from all servers in parallel
final onDeckFutures = clients.entries.map((entry) async {
final serverId = entry.key;
final client = entry.value;
final server = _serverManager.getServer(serverId);
try {
final allOnDeck = await _perServer<PlexMetadata>(
operationName: 'fetching on deck',
operation: (serverId, client, server) async {
final items = await client.getOnDeck();
// Tag each item with server info
return items.map((item) {
return item.copyWith(serverId: serverId, serverName: server?.name);
}).toList();
} catch (e, stackTrace) {
appLogger.e(
'Failed to fetch on deck from server $serverId',
error: e,
stackTrace: stackTrace,
);
_serverManager.updateServerStatus(serverId, false);
return <PlexMetadata>[];
}
});
final results = await Future.wait(onDeckFutures);
// Flatten results
for (final items in results) {
allOnDeck.addAll(items);
}
},
);
// Sort by most recent (lastViewedAt is stored in viewOffset metadata)
// For on deck items, we use updatedAt or addedAt as proxy for recency
@@ -249,51 +184,21 @@ class DataAggregationService {
String query, {
int? limit,
}) async {
final clients = _serverManager.onlineClients;
if (clients.isEmpty) {
appLogger.w('No online servers available for search');
return [];
}
if (query.trim().isEmpty) {
return [];
}
appLogger.d('Searching for "$query" across ${clients.length} servers');
final allResults = <PlexMetadata>[];
// Search all servers in parallel
final searchFutures = clients.entries.map((entry) async {
final serverId = entry.key;
final client = entry.value;
final server = _serverManager.getServer(serverId);
try {
final allResults = await _perServer<PlexMetadata>(
operationName: 'searching for "$query"',
operation: (serverId, client, server) async {
final results = await client.search(query);
// Tag each result with server info
return results.map((item) {
return item.copyWith(serverId: serverId, serverName: server?.name);
}).toList();
} catch (e, stackTrace) {
appLogger.e(
'Failed to search on server $serverId',
error: e,
stackTrace: stackTrace,
);
_serverManager.updateServerStatus(serverId, false);
return <PlexMetadata>[];
}
});
final results = await Future.wait(searchFutures);
// Flatten results
for (final items in results) {
allResults.addAll(items);
}
},
);
// Apply limit if specified
final result = limit != null && limit < allResults.length
@@ -361,4 +266,63 @@ class DataAggregationService {
return grouped;
}
// Private helper methods
/// Higher-order helper for per-server fan-out operations
///
/// Iterates over all online clients, executes the operation for each server,
/// handles errors, updates server status, and aggregates results.
///
/// Type parameter `T` is the item type returned by the operation
/// [operationName] is used for logging (e.g., "fetching libraries")
/// [operation] is the async function to run per server, returning `List<T>`
Future<List<T>> _perServer<T>({
required String operationName,
required Future<List<T>> Function(
String serverId,
PlexClient client,
PlexServer? server,
)
operation,
}) async {
final clients = _serverManager.onlineClients;
if (clients.isEmpty) {
appLogger.w('No online servers available for $operationName');
return [];
}
appLogger.d('$operationName from ${clients.length} servers');
final allResults = <T>[];
// Execute operation on all servers in parallel
final Iterable<Future<List<T>>> futures = clients.entries.map((entry) async {
final serverId = entry.key;
final client = entry.value;
final server = _serverManager.getServer(serverId);
try {
return await operation(serverId, client, server);
} catch (e, stackTrace) {
appLogger.e(
'Failed $operationName from server $serverId',
error: e,
stackTrace: stackTrace,
);
_serverManager.updateServerStatus(serverId, false);
return <T>[];
}
});
final List<List<T>> results = await Future.wait<List<T>>(futures);
// Flatten results
for (final items in results) {
allResults.addAll(items);
}
return allResults;
}
}
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/playback_state_provider.dart';
import '../utils/app_logger.dart';
import '../utils/video_player_navigation.dart';
/// Result of loading adjacent episodes
class AdjacentEpisodes {
final PlexMetadata? next;
final PlexMetadata? previous;
AdjacentEpisodes({this.next, this.previous});
bool get hasNext => next != null;
bool get hasPrevious => previous != null;
}
/// Manages episode navigation for TV show playback.
///
/// Handles:
/// - Loading next/previous episodes from play queues
/// - Navigating between episodes while preserving track selections
/// - Supporting both sequential and shuffle playback modes
///
/// All episode navigation uses Plex play queues for consistent behavior.
class EpisodeNavigationService {
/// Load the next and previous episodes for the current episode
///
/// Returns null for episodes if:
/// - Not applicable (e.g., movie content)
/// - Next episode doesn't exist (end of season/series)
/// - Previous episode doesn't exist (first episode)
Future<AdjacentEpisodes> loadAdjacentEpisodes({
required BuildContext context,
required PlexClient client,
required PlexMetadata metadata,
}) async {
try {
final playbackState = context.read<PlaybackStateProvider>();
// All episode navigation now uses play queues (sequential, shuffle, playlists)
// If no queue is active, navigation is not available
if (!playbackState.isQueueActive) {
return AdjacentEpisodes();
}
// Use the play queue for next/previous navigation
final next = await playbackState.getNextEpisode(
metadata.ratingKey,
loopQueue: false,
);
final previous = await playbackState.getPreviousEpisode(
metadata.ratingKey,
);
final mode = playbackState.isShuffleActive ? 'Shuffle' : 'Sequential';
appLogger.d(
'$mode mode - Next: ${next?.title}, Previous: ${previous?.title}',
);
return AdjacentEpisodes(next: next, previous: previous);
} catch (e) {
// Non-critical: Failed to load next/previous episode metadata
appLogger.d('Could not load adjacent episodes', error: e);
return AdjacentEpisodes();
}
}
/// Navigate to the next or previous episode
///
/// Preserves the current audio track, subtitle track, and playback rate
/// selections when transitioning between episodes.
Future<void> navigateToEpisode({
required BuildContext context,
required PlexMetadata episode,
required Player? player,
bool usePushReplacement = true,
}) async {
if (!context.mounted) return;
// Capture current player state before navigation
AudioTrack? currentAudioTrack;
SubtitleTrack? currentSubtitleTrack;
double? currentPlaybackRate;
if (player != null) {
currentAudioTrack = player.state.track.audio;
currentSubtitleTrack = player.state.track.subtitle;
currentPlaybackRate = player.state.rate;
appLogger.d(
'Navigating to episode with preserved settings - Audio: ${currentAudioTrack.id}, Subtitle: ${currentSubtitleTrack.id}, Rate: ${currentPlaybackRate}x',
);
}
// Navigate to the new episode
if (context.mounted) {
navigateToVideoPlayer(
context,
metadata: episode,
preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: currentSubtitleTrack,
preferredPlaybackRate: currentPlaybackRate,
usePushReplacement: usePushReplacement,
);
}
}
}
+193
View File
@@ -0,0 +1,193 @@
import 'dart:async';
import 'package:os_media_controls/os_media_controls.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../utils/app_logger.dart';
/// Manages OS media controls integration for video playback.
///
/// Handles:
/// - Metadata updates (title, artwork, etc.)
/// - Playback state updates (playing/paused, position, speed)
/// - Control event streaming (play, pause, next, previous, seek)
/// - Position update throttling to prevent excessive API calls
class MediaControlsManager {
/// Stream of control events from OS media controls
Stream<dynamic> get controlEvents => OsMediaControls.controlEvents;
/// Last time position was updated (for throttling)
DateTime? _lastPositionUpdate;
/// Throttle interval for position updates (default: 1 second)
final Duration throttleInterval;
MediaControlsManager({this.throttleInterval = const Duration(seconds: 1)});
/// Update media metadata displayed in OS media controls
///
/// This includes title, artist, artwork, and duration.
Future<void> updateMetadata({
required PlexMetadata metadata,
PlexClient? client,
Duration? duration,
}) async {
try {
// Build artwork URL if client is available
String? artworkUrl;
if (client != null && metadata.thumb != null) {
try {
artworkUrl = client.getThumbnailUrl(metadata.thumb!);
appLogger.d('Artwork URL for media controls: $artworkUrl');
} catch (e) {
appLogger.w('Failed to build artwork URL', error: e);
}
}
// Update OS media controls
await OsMediaControls.setMetadata(
MediaMetadata(
title: metadata.title,
artist: _buildArtist(metadata),
artworkUrl: artworkUrl,
duration: duration,
),
);
appLogger.d('Updated media controls metadata: ${metadata.title}');
} catch (e) {
appLogger.w('Failed to update media controls metadata', error: e);
}
}
/// Update playback state in OS media controls
///
/// Updates the current playing state, position, and playback speed.
/// Position updates are throttled to avoid excessive API calls.
Future<void> updatePlaybackState({
required bool isPlaying,
required Duration position,
required double speed,
bool force = false,
}) async {
try {
// Throttle position updates unless forced
if (!force) {
final now = DateTime.now();
if (_lastPositionUpdate != null) {
final timeSinceLastUpdate = now.difference(_lastPositionUpdate!);
if (timeSinceLastUpdate < throttleInterval) {
return; // Skip this update
}
}
_lastPositionUpdate = now;
}
// Only update if playing (avoid excessive updates when paused)
if (isPlaying) {
await OsMediaControls.setPlaybackState(
MediaPlaybackState(
state: PlaybackState.playing,
position: position,
speed: speed,
),
);
} else {
await OsMediaControls.setPlaybackState(
MediaPlaybackState(
state: PlaybackState.paused,
position: position,
speed: speed,
),
);
}
} catch (e) {
appLogger.w('Failed to update media controls playback state', error: e);
}
}
/// Enable or disable next/previous track controls
///
/// This should be called based on content type and playback mode.
/// For example:
/// - Episodes: Enable both if there are adjacent episodes
/// - Playlist items: Enable based on playlist position
/// - Movies: Usually disabled
Future<void> setControlsEnabled({
bool canGoNext = false,
bool canGoPrevious = false,
}) async {
try {
final controls = <MediaControl>[];
if (canGoPrevious) controls.add(MediaControl.previous);
if (canGoNext) controls.add(MediaControl.next);
if (controls.isNotEmpty) {
await OsMediaControls.enableControls(controls);
appLogger.d(
'Media controls enabled - Previous: $canGoPrevious, Next: $canGoNext',
);
} else {
await OsMediaControls.disableControls([
MediaControl.previous,
MediaControl.next,
]);
appLogger.d('Media controls disabled');
}
} catch (e) {
appLogger.w('Failed to set media controls enabled state', error: e);
}
}
/// Clear all media controls
///
/// Should be called when playback stops or screen is disposed.
Future<void> clear() async {
try {
await OsMediaControls.clear();
_lastPositionUpdate = null;
appLogger.d('Media controls cleared');
} catch (e) {
appLogger.w('Failed to clear media controls', error: e);
}
}
/// Dispose resources
void dispose() {
_lastPositionUpdate = null;
}
/// Build artist string from metadata
///
/// For episodes: "Show Name - Season X Episode Y"
/// For movies: Director or studio
/// For other content: Fallback to year or empty
String _buildArtist(PlexMetadata metadata) {
if (metadata.type.toLowerCase() == 'episode') {
final parts = <String>[];
// Add show name
if (metadata.grandparentTitle != null) {
parts.add(metadata.grandparentTitle!);
}
// Add season/episode info
if (metadata.parentIndex != null && metadata.index != null) {
parts.add('S${metadata.parentIndex} E${metadata.index}');
} else if (metadata.parentTitle != null) {
parts.add(metadata.parentTitle!);
}
return parts.join('');
} else if (metadata.type.toLowerCase() == 'movie') {
// For movies, use director or studio
// Note: These fields may need to be added to PlexMetadata model
if (metadata.year != null) {
return metadata.year.toString();
}
}
return '';
}
}
+13 -9
View File
@@ -74,7 +74,8 @@ class MultiServerManager {
appLogger.i('Connecting to ${servers.length} servers...');
// Use provided client ID or generate a unique one for this app instance
final effectiveClientId = clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString();
final effectiveClientId =
clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString();
// Create connection tasks for all servers
final connectionFutures = servers.map((server) async {
@@ -135,13 +136,15 @@ class MultiServerManager {
// Wait for all connections with timeout
final results = await Future.wait(
connectionFutures.map((f) => f.timeout(
timeout,
onTimeout: () {
appLogger.w('Server connection timed out');
return null;
},
)),
connectionFutures.map(
(f) => f.timeout(
timeout,
onTimeout: () {
appLogger.w('Server connection timed out');
return null;
},
),
),
);
// Count successful connections
@@ -164,7 +167,8 @@ class MultiServerManager {
Duration timeout = const Duration(seconds: 10),
}) async {
final serverId = server.clientIdentifier;
final effectiveClientId = clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString();
final effectiveClientId =
clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString();
try {
appLogger.d('Adding server: ${server.name}');
+121
View File
@@ -0,0 +1,121 @@
import 'dart:async';
import 'package:media_kit/media_kit.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../utils/app_logger.dart';
/// Tracks playback progress and reports it to the Plex server.
///
/// Handles:
/// - Periodic timeline updates during playback
/// - Resume position tracking
/// - State change reporting (playing, paused, stopped)
class PlaybackProgressTracker {
final PlexClient client;
final PlexMetadata metadata;
final Player player;
/// Timer for periodic progress updates
Timer? _progressTimer;
/// Update interval (default: 10 seconds)
final Duration updateInterval;
PlaybackProgressTracker({
required this.client,
required this.metadata,
required this.player,
this.updateInterval = const Duration(seconds: 10),
});
/// Start tracking playback progress
///
/// Begins periodic timeline updates to the Plex server.
void startTracking() {
if (_progressTimer != null) {
appLogger.w('Progress tracking already started');
return;
}
_progressTimer = Timer.periodic(updateInterval, (timer) {
if (player.state.playing) {
_sendProgress('playing');
}
});
appLogger.d(
'Started progress tracking (interval: ${updateInterval.inSeconds}s)',
);
}
/// Stop tracking playback progress
///
/// Cancels the periodic timer.
void stopTracking() {
_progressTimer?.cancel();
_progressTimer = null;
appLogger.d('Stopped progress tracking');
}
/// Send progress update to Plex server
///
/// [state] can be 'playing', 'paused', or 'stopped'
Future<void> sendProgress(String state) async {
await _sendProgress(state);
}
Future<void> _sendProgress(String state) async {
try {
final position = player.state.position;
final duration = player.state.duration;
// Don't send progress if no duration (not ready)
if (duration.inMilliseconds == 0) {
return;
}
// Build timeline query parameters
final queryParams = {
'ratingKey': metadata.ratingKey,
'key': '/library/metadata/${metadata.ratingKey}',
'state': state,
'time': position.inMilliseconds.toString(),
'duration': duration.inMilliseconds.toString(),
};
// Add playQueueItemID if available (for playlist/shuffle playback)
if (metadata.playQueueItemID != null) {
queryParams['playQueueItemID'] = metadata.playQueueItemID.toString();
}
// Send timeline update
await client
.updateProgress(
metadata.ratingKey,
time: position.inMilliseconds,
state: state,
duration: duration.inMilliseconds,
)
.catchError((error) {
// Silent handling - don't interrupt playback for progress errors
appLogger.d(
'Failed to update progress (non-critical)',
error: error,
);
});
appLogger.d(
'Progress update sent: $state at ${position.inSeconds}s / ${duration.inSeconds}s',
);
} catch (e) {
appLogger.d('Failed to send progress update (non-critical)', error: e);
}
}
/// Dispose resources
void dispose() {
stopTracking();
}
}
+5 -3
View File
@@ -138,13 +138,15 @@ class ServerRegistry {
}
/// Update server status (called when server connection status changes)
Future<void> updateServerStatus(String serverId, {
Future<void> updateServerStatus(
String serverId, {
bool? online,
DateTime? lastSeen,
}) async {
final servers = await getServers();
final serverIndex =
servers.indexWhere((s) => s.clientIdentifier == serverId);
final serverIndex = servers.indexWhere(
(s) => s.clientIdentifier == serverId,
);
if (serverIndex == -1) {
appLogger.w('Server not found for status update: $serverId');
+7 -37
View File
@@ -39,9 +39,6 @@ class SettingsService {
static const String _keySubtitleBackgroundColor = 'subtitle_background_color';
static const String _keySubtitleBackgroundOpacity =
'subtitle_background_opacity';
static const String _keyShuffleUnwatchedOnly = 'shuffle_unwatched_only';
static const String _keyShuffleOrderNavigation = 'shuffle_order_navigation';
static const String _keyShuffleLoopQueue = 'shuffle_loop_queue';
static const String _keyAppLocale = 'app_locale';
static const String _keyRememberTrackSelections = 'remember_track_selections';
@@ -777,9 +774,14 @@ class SettingsService {
final jsonString = _prefs.getString(_keyMediaVersionPreferences);
if (jsonString == null) return {};
final decoded = _decodeJsonStringToMap(jsonString);
return decoded.map((key, value) => MapEntry(key, value as int));
}
/// Helper to decode JSON string to Map with error handling
Map<String, dynamic> _decodeJsonStringToMap(String jsonString) {
try {
final decoded = json.decode(jsonString) as Map<String, dynamic>;
return decoded.map((key, value) => MapEntry(key, value as int));
return json.decode(jsonString) as Map<String, dynamic>;
} catch (e) {
return {};
}
@@ -800,35 +802,6 @@ class SettingsService {
);
}
// Shuffle Play Settings
/// Shuffle Unwatched Only - Filter shuffle queue to unwatched episodes only
Future<void> setShuffleUnwatchedOnly(bool enabled) async {
await _prefs.setBool(_keyShuffleUnwatchedOnly, enabled);
}
bool getShuffleUnwatchedOnly() {
return _prefs.getBool(_keyShuffleUnwatchedOnly) ?? true; // Default: true
}
/// Shuffle Order Navigation - Next/previous buttons follow shuffled order
Future<void> setShuffleOrderNavigation(bool enabled) async {
await _prefs.setBool(_keyShuffleOrderNavigation, enabled);
}
bool getShuffleOrderNavigation() {
return _prefs.getBool(_keyShuffleOrderNavigation) ?? true; // Default: true
}
/// Shuffle Loop Queue - Restart queue when reaching the end
Future<void> setShuffleLoopQueue(bool enabled) async {
await _prefs.setBool(_keyShuffleLoopQueue, enabled);
}
bool getShuffleLoopQueue() {
return _prefs.getBool(_keyShuffleLoopQueue) ?? false; // Default: false
}
// Track Selection Settings
/// Remember Track Selections - Save per-media audio/subtitle language preferences
@@ -868,9 +841,6 @@ class SettingsService {
_prefs.remove(_keySubtitleBorderColor),
_prefs.remove(_keySubtitleBackgroundColor),
_prefs.remove(_keySubtitleBackgroundOpacity),
_prefs.remove(_keyShuffleUnwatchedOnly),
_prefs.remove(_keyShuffleOrderNavigation),
_prefs.remove(_keyShuffleLoopQueue),
_prefs.remove(_keyAppLocale),
_prefs.remove(_keyRememberTrackSelections),
]);
+45 -47
View File
@@ -89,14 +89,7 @@ class StorageService {
}
Map<String, dynamic>? getServerData() {
final jsonString = _prefs.getString(_keyServerData);
if (jsonString == null) return null;
try {
return json.decode(jsonString) as Map<String, dynamic>;
} catch (e) {
return null;
}
return _readJsonMap(_keyServerData);
}
// Client Identifier
@@ -194,12 +187,8 @@ class StorageService {
_prefs.getString(_keyLibraryFilters);
if (jsonString == null) return {};
try {
final decoded = json.decode(jsonString) as Map<String, dynamic>;
return decoded.map((key, value) => MapEntry(key, value.toString()));
} catch (e) {
return {};
}
final decoded = _decodeJsonStringToMap(jsonString);
return decoded.map((key, value) => MapEntry(key, value.toString()));
}
// Library Sort (per-library, stored individually with descending flag)
@@ -213,15 +202,7 @@ class StorageService {
}
Map<String, dynamic>? getLibrarySort(String sectionId) {
final jsonString = _prefs.getString('library_sort_$sectionId');
if (jsonString == null) return null;
try {
return json.decode(jsonString) as Map<String, dynamic>;
} catch (e) {
// Legacy support: if it's just a string, return it as the key
return {'key': jsonString, 'descending': false};
}
return _readJsonMap('library_sort_$sectionId', legacyStringOk: true);
}
// Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes')
@@ -301,14 +282,7 @@ class StorageService {
}
Map<String, dynamic>? getUserProfile() {
final jsonString = _prefs.getString(_keyUserProfile);
if (jsonString == null) return null;
try {
return json.decode(jsonString) as Map<String, dynamic>;
} catch (e) {
return null;
}
return _readJsonMap(_keyUserProfile);
}
// Current User UUID
@@ -340,14 +314,7 @@ class StorageService {
return null;
}
final jsonString = _prefs.getString(_keyHomeUsersCache);
if (jsonString == null) return null;
try {
return json.decode(jsonString) as Map<String, dynamic>;
} catch (e) {
return null;
}
return _readJsonMap(_keyHomeUsersCache);
}
Future<void> clearHomeUsersCache() async {
@@ -364,10 +331,7 @@ class StorageService {
// Clear all user-related data (for logout)
Future<void> clearUserData() async {
await Future.wait([
clearCredentials(),
clearLibraryPreferences(),
]);
await Future.wait([clearCredentials(), clearLibraryPreferences()]);
}
// Update current user after switching
@@ -412,9 +376,43 @@ class StorageService {
/// Clear all multi-server data
Future<void> clearMultiServerData() async {
await Future.wait([
clearServersList(),
clearEnabledServers(),
]);
await Future.wait([clearServersList(), clearEnabledServers()]);
}
// Private helper methods
/// Helper to read and decode JSON Map from preferences
///
/// [key] - The preference key to read
/// [legacyStringOk] - If true, returns {'key': value, 'descending': false}
/// when value is a plain string (for legacy library sort)
Map<String, dynamic>? _readJsonMap(
String key, {
bool legacyStringOk = false,
}) {
final jsonString = _prefs.getString(key);
if (jsonString == null) return null;
return _decodeJsonStringToMap(jsonString, legacyStringOk: legacyStringOk);
}
/// Helper to decode JSON string to Map with error handling
///
/// [jsonString] - The JSON string to decode
/// [legacyStringOk] - If true, returns {'key': value, 'descending': false}
/// when value is a plain string (for legacy library sort)
Map<String, dynamic> _decodeJsonStringToMap(
String jsonString, {
bool legacyStringOk = false,
}) {
try {
return json.decode(jsonString) as Map<String, dynamic>;
} catch (e) {
if (legacyStringOk) {
// Legacy support: if it's just a string, return it as the key
return {'key': jsonString, 'descending': false};
}
return {};
}
}
}
+25 -18
View File
@@ -25,8 +25,12 @@ Future<void> playCollectionOrPlaylist({
}
String ratingKey = item.ratingKey;
String? serverId = isCollection ? (item as PlexMetadata).serverId : (item as PlexPlaylist).serverId;
String? serverName = isCollection ? (item as PlexMetadata).serverName : (item as PlexPlaylist).serverName;
String? serverId = isCollection
? (item as PlexMetadata).serverId
: (item as PlexPlaylist).serverId;
String? serverName = isCollection
? (item as PlexMetadata).serverName
: (item as PlexPlaylist).serverName;
final PlayQueueResponse? playQueue;
if (isCollection) {
@@ -63,17 +67,15 @@ Future<void> playCollectionOrPlaylist({
// Preserve serverId for all items in the queue
final itemsWithServerId = fetchedQueue.items!.map((queueItem) {
return queueItem.copyWith(
serverId: serverId,
serverName: serverName,
);
return queueItem.copyWith(serverId: serverId, serverName: serverName);
}).toList();
final queueWithServerId = PlayQueueResponse(
playQueueID: fetchedQueue.playQueueID,
playQueueSelectedItemID: fetchedQueue.playQueueSelectedItemID,
playQueueSelectedItemOffset: fetchedQueue.playQueueSelectedItemOffset,
playQueueSelectedMetadataItemID: fetchedQueue.playQueueSelectedMetadataItemID,
playQueueSelectedMetadataItemID:
fetchedQueue.playQueueSelectedMetadataItemID,
playQueueShuffled: fetchedQueue.playQueueShuffled,
playQueueSourceURI: fetchedQueue.playQueueSourceURI,
playQueueTotalCount: fetchedQueue.playQueueTotalCount,
@@ -85,15 +87,17 @@ Future<void> playCollectionOrPlaylist({
// Set play queue in provider
final playbackState = context.read<PlaybackStateProvider>();
playbackState.setClient(client);
await playbackState.setPlaybackFromPlayQueue(queueWithServerId, ratingKey);
await playbackState.setPlaybackFromPlayQueue(
queueWithServerId,
ratingKey,
serverId: serverId,
serverName: serverName,
);
if (!context.mounted) return;
// Navigate to first item
await navigateToVideoPlayer(
context,
metadata: itemsWithServerId.first,
);
await navigateToVideoPlayer(context, metadata: itemsWithServerId.first);
return;
}
}
@@ -113,17 +117,15 @@ Future<void> playCollectionOrPlaylist({
// Preserve serverId for all items in the queue
final itemsWithServerId = playQueue.items!.map((queueItem) {
return queueItem.copyWith(
serverId: serverId,
serverName: serverName,
);
return queueItem.copyWith(serverId: serverId, serverName: serverName);
}).toList();
final queueWithServerId = PlayQueueResponse(
playQueueID: playQueue.playQueueID,
playQueueSelectedItemID: playQueue.playQueueSelectedItemID,
playQueueSelectedItemOffset: playQueue.playQueueSelectedItemOffset,
playQueueSelectedMetadataItemID: playQueue.playQueueSelectedMetadataItemID,
playQueueSelectedMetadataItemID:
playQueue.playQueueSelectedMetadataItemID,
playQueueShuffled: playQueue.playQueueShuffled,
playQueueSourceURI: playQueue.playQueueSourceURI,
playQueueTotalCount: playQueue.playQueueTotalCount,
@@ -135,7 +137,12 @@ Future<void> playCollectionOrPlaylist({
// Set play queue in provider
final playbackState = context.read<PlaybackStateProvider>();
playbackState.setClient(client);
await playbackState.setPlaybackFromPlayQueue(queueWithServerId, ratingKey);
await playbackState.setPlaybackFromPlayQueue(
queueWithServerId,
ratingKey,
serverId: serverId,
serverName: serverName,
);
if (!context.mounted) return;
+49 -9
View File
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../i18n/strings.g.dart';
import '../models/plex_library.dart';
import '../models/plex_user_profile.dart';
import '../providers/hidden_libraries_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/plex_client_provider.dart';
import '../providers/user_profile_provider.dart';
import '../providers/hidden_libraries_provider.dart';
import '../client/plex_client.dart';
import '../models/plex_user_profile.dart';
import 'app_logger.dart';
extension ProviderExtensions on BuildContext {
PlexClientProvider get plexClient =>
@@ -25,12 +29,48 @@ extension ProviderExtensions on BuildContext {
HiddenLibrariesProvider watchHiddenLibraries() =>
Provider.of<HiddenLibrariesProvider>(this, listen: true);
// Direct client access (nullable)
PlexClient? get client => plexClient.client;
// Null-safe client access
PlexClient get clientSafe => plexClient.client!;
// Direct profile settings access (nullable)
PlexUserProfile? get profileSettings => userProfile.profileSettings;
/// Get PlexClient for a specific server ID
/// If serverId is null, returns the first available online client
/// Throws an exception if no client is available
PlexClient getClientForServer(String? serverId) {
final multiServerProvider = Provider.of<MultiServerProvider>(
this,
listen: false,
);
if (serverId == null) {
// No serverId specified - try to get first online server
appLogger.w('No serverId provided, using first available online server');
if (!multiServerProvider.hasConnectedServers) {
throw Exception(t.errors.noClientAvailable);
}
final firstServerId = multiServerProvider.onlineServerIds.first;
final client = multiServerProvider.getClientForServer(firstServerId);
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
return client;
}
final serverClient = multiServerProvider.getClientForServer(serverId);
if (serverClient == null) {
appLogger.e('No client found for server $serverId');
throw Exception(t.errors.noClientAvailable);
}
return serverClient;
}
/// Get PlexClient for a library
/// Throws an exception if no client is available
PlexClient getClientForLibrary(PlexLibrary library) {
return getClientForServer(library.serverId);
}
}
+39
View File
@@ -0,0 +1,39 @@
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../models/plex_library.dart';
/// Extension methods for tagging items with server information
/// Used for multi-server support to track which server each item belongs to
extension PlexMetadataServerTagging on Iterable<PlexMetadata> {
/// Tags all items in the collection with the given server ID and name
/// Returns a new list with all items updated
List<PlexMetadata> tagWithServer(String? serverId, String? serverName) {
return map(
(item) => item.copyWith(serverId: serverId, serverName: serverName),
).toList();
}
/// Tags all items in the collection with server info from a library
/// Returns a new list with all items updated
List<PlexMetadata> tagWithLibrary(PlexLibrary library) {
return tagWithServer(library.serverId, library.serverName);
}
}
extension PlexPlaylistServerTagging on Iterable<PlexPlaylist> {
/// Tags all playlists in the collection with the given server ID and name
/// Returns a new list with all playlists updated
List<PlexPlaylist> tagWithServer(String? serverId, String? serverName) {
return map(
(playlist) =>
playlist.copyWith(serverId: serverId, serverName: serverName),
).toList();
}
/// Tags all playlists in the collection with server info from a library
/// Returns a new list with all playlists updated
List<PlexPlaylist> tagWithLibrary(PlexLibrary library) {
return tagWithServer(library.serverId, library.serverName);
}
}
-155
View File
@@ -1,155 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/multi_server_provider.dart';
import '../providers/playback_state_provider.dart';
import '../providers/plex_client_provider.dart';
import '../providers/settings_provider.dart';
import '../utils/app_logger.dart';
import '../utils/video_player_navigation.dart';
import '../i18n/strings.g.dart';
/// Get the correct PlexClient for metadata's server
PlexClient? _getClientForMetadata(BuildContext context, PlexMetadata metadata) {
final serverId = metadata.serverId;
if (serverId == null) {
// Fallback to legacy client if no serverId
appLogger.w('Metadata ${metadata.title} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
/// Handle shuffle play action for shows and seasons
///
/// Fetches episodes based on user settings (unwatched only or including watched),
/// shuffles them, and starts playback from the first shuffled episode.
/// The shuffle queue is stored in the PlaybackStateProvider for continuous shuffle playback.
Future<void> handleShufflePlay(
BuildContext context,
PlexMetadata metadata,
) async {
final client = _getClientForMetadata(context, metadata);
if (client == null) return;
final playbackState = context.read<PlaybackStateProvider>();
final settingsProvider = context.read<SettingsProvider>();
final itemType = metadata.type.toLowerCase();
// Get shuffle setting
final unwatchedOnly = settingsProvider.shuffleUnwatchedOnly;
try {
// Show loading indicator
if (context.mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()),
);
}
// Get episodes based on type and settings
List<PlexMetadata> episodes;
if (itemType == 'show') {
if (unwatchedOnly) {
// Get only unwatched episodes
final fetchedEpisodes = await client.getAllUnwatchedEpisodes(metadata.ratingKey);
// Preserve serverId for each episode
episodes = fetchedEpisodes.map((ep) => ep.copyWith(
serverId: metadata.serverId,
serverName: metadata.serverName,
)).toList();
} else {
// Get all episodes from all seasons
final allEpisodes = <PlexMetadata>[];
final seasons = await client.getChildren(metadata.ratingKey);
for (final season in seasons) {
if (season.type == 'season') {
final seasonEpisodes = await client.getChildren(season.ratingKey);
final episodesOnly = seasonEpisodes
.where((ep) => ep.type == 'episode')
.map((ep) => ep.copyWith(
serverId: metadata.serverId,
serverName: metadata.serverName,
))
.toList();
allEpisodes.addAll(episodesOnly);
}
}
episodes = allEpisodes;
}
} else {
// season
if (unwatchedOnly) {
// Get only unwatched episodes
final fetchedEpisodes = await client.getUnwatchedEpisodesInSeason(
metadata.ratingKey,
);
// Preserve serverId for each episode
episodes = fetchedEpisodes.map((ep) => ep.copyWith(
serverId: metadata.serverId,
serverName: metadata.serverName,
)).toList();
} else {
// Get all episodes in season
final seasonEpisodes = await client.getChildren(metadata.ratingKey);
episodes = seasonEpisodes
.where((ep) => ep.type == 'episode')
.map((ep) => ep.copyWith(
serverId: metadata.serverId,
serverName: metadata.serverName,
))
.toList();
}
}
// Close loading indicator
if (context.mounted) {
Navigator.pop(context);
}
if (episodes.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.noEpisodesFound)));
}
return;
}
// Shuffle the episodes
episodes.shuffle();
// Store shuffle queue in provider
// ignore: deprecated_member_use_from_same_package
playbackState.setShuffleQueue(episodes, metadata.ratingKey);
// Navigate to first episode
if (context.mounted) {
await navigateToVideoPlayer(context, metadata: episodes.first);
}
} 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()))),
);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
/// A reusable header widget for bottom sheets
/// Provides consistent styling with title, optional leading widget, optional action, and close button
class BottomSheetHeader extends StatelessWidget {
/// The title text to display
final String title;
/// Optional leading widget (e.g., icon or back button)
final Widget? leading;
/// Optional action widget (e.g., clear button)
final Widget? action;
/// Optional callback when close button is pressed
/// Defaults to Navigator.pop(context)
final VoidCallback? onClose;
const BottomSheetHeader({
super.key,
required this.title,
this.leading,
this.action,
this.onClose,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor),
),
),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 8)],
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
if (action != null) action!,
IconButton(
icon: const Icon(Icons.close),
onPressed: onClose ?? () => Navigator.pop(context),
),
],
),
);
}
}
+8 -25
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../i18n/strings.g.dart';
import 'empty_state_widget.dart';
import 'error_state_widget.dart';
/// A widget that handles loading, error, empty, and content states
/// Provides a consistent UI pattern across the app for data-driven screens
@@ -45,36 +47,17 @@ class ContentStateBuilder<T> extends StatelessWidget {
// Error state (only show error if items list is empty)
if (errorMessage != null && items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(
errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
ElevatedButton(onPressed: onRetry, child: Text(t.common.retry)),
],
),
return ErrorStateWidget(
message: errorMessage!,
icon: Icons.error_outline,
onRetry: onRetry,
retryLabel: t.common.retry,
);
}
// Empty state
if (items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(emptyIcon, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(emptyMessage, style: const TextStyle(color: Colors.white70)),
],
),
);
return EmptyStateWidget(message: emptyMessage, icon: emptyIcon);
}
// Content state - delegate to builder
+7 -36
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'state_message_widget.dart';
/// A reusable widget for displaying empty states throughout the app
class EmptyStateWidget extends StatelessWidget {
@@ -24,42 +25,12 @@ class EmptyStateWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(
icon,
size: 64,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
],
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
if (onAction != null && actionLabel != null) ...[
const SizedBox(height: 24),
FilledButton.icon(
onPressed: onAction,
icon: const Icon(Icons.add),
label: Text(actionLabel!),
),
],
],
),
),
return StateMessageWidget(
message: message,
icon: icon,
onAction: onAction,
actionLabel: actionLabel,
actionIcon: Icons.add,
);
}
}
+9 -28
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'state_message_widget.dart';
/// A reusable widget for displaying error states throughout the app
class ErrorStateWidget extends StatelessWidget {
@@ -24,34 +25,14 @@ class ErrorStateWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 64, color: Theme.of(context).colorScheme.error),
const SizedBox(height: 16),
],
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
if (onRetry != null) ...[
const SizedBox(height: 24),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(retryLabel ?? 'Retry'),
),
],
],
),
),
return StateMessageWidget(
message: message,
icon: icon,
iconColor: Theme.of(context).colorScheme.error,
textColor: Theme.of(context).colorScheme.error,
onAction: onRetry,
actionLabel: retryLabel ?? 'Retry',
actionIcon: Icons.refresh,
);
}
}
+14 -60
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../models/plex_filter.dart';
import '../widgets/app_bar_back_button.dart';
import '../widgets/bottom_sheet_header.dart';
import '../utils/provider_extensions.dart';
import '../i18n/strings.g.dart';
@@ -59,10 +60,7 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
});
try {
final client = context.client;
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
final client = context.getClientForServer(null);
final values = await client.getFilterValues(filter.key);
setState(() {
@@ -114,34 +112,11 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
return Column(
children: [
// Header with back button
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor),
),
),
child: Row(
children: [
AppBarBackButton(
style: BackButtonStyle.plain,
onPressed: _goBack,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_currentFilter!.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
BottomSheetHeader(
title: _currentFilter!.title,
leading: AppBarBackButton(
style: BackButtonStyle.plain,
onPressed: _goBack,
),
),
@@ -209,27 +184,11 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
return Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor),
),
),
child: Row(
children: [
const Icon(Icons.filter_alt),
const SizedBox(width: 12),
Text(
t.libraries.filters,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (_tempSelectedFilters.isNotEmpty)
TextButton.icon(
BottomSheetHeader(
title: t.libraries.filters,
leading: const Icon(Icons.filter_alt),
action: _tempSelectedFilters.isNotEmpty
? TextButton.icon(
onPressed: () {
setState(() {
_tempSelectedFilters.clear();
@@ -238,13 +197,8 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
},
icon: const Icon(Icons.clear_all),
label: Text(t.libraries.clearAll),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
)
: null,
),
// All Filters (boolean toggles first, then regular filters)
+18 -61
View File
@@ -1,15 +1,14 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/plex_client_provider.dart';
import '../providers/multi_server_provider.dart';
import '../utils/app_logger.dart';
import '../utils/video_player_navigation.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import 'folder_tree_item.dart';
import '../utils/app_logger.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../i18n/strings.g.dart';
import 'folder_tree_item.dart';
import 'empty_state_widget.dart';
import 'error_state_widget.dart';
/// Expandable tree view for browsing library folders
/// Shows a hierarchical file/folder structure
@@ -37,25 +36,6 @@ class _FolderTreeViewState extends State<FolderTreeView> {
bool _isLoadingRoot = false;
String? _errorMessage;
/// Get the correct PlexClient for this library's server
PlexClient? _getClientForLibrary() {
final serverId = widget.serverId;
if (serverId == null) {
appLogger.w('Library ${widget.libraryKey} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
@override
void initState() {
super.initState();
@@ -69,10 +49,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
});
try {
final client = _getClientForLibrary();
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
final client = context.getClientForServer(widget.serverId);
final folders = await client.getLibraryFolders(widget.libraryKey);
@@ -124,10 +101,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
});
try {
final client = _getClientForLibrary();
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
final client = context.getClientForServer(widget.serverId);
final children = await client.getFolderChildren(folder.key);
@@ -135,10 +109,8 @@ class _FolderTreeViewState extends State<FolderTreeView> {
final taggedChildren = children
.map(
(child) => child.copyWith(
serverId: widget.serverId,
serverName: null,
),
(child) =>
child.copyWith(serverId: widget.serverId, serverName: null),
)
.toList();
@@ -270,33 +242,18 @@ class _FolderTreeViewState extends State<FolderTreeView> {
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadRootFolders,
child: Text(t.common.retry),
),
],
),
return ErrorStateWidget(
message: _errorMessage!,
icon: Icons.error_outline,
onRetry: _loadRootFolders,
retryLabel: t.common.retry,
);
}
if (_rootFolders.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(t.libraries.noFoldersFound),
],
),
return EmptyStateWidget(
message: t.libraries.noFoldersFound,
icon: Icons.folder_open,
);
}
+2 -21
View File
@@ -4,15 +4,12 @@ import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../providers/plex_client_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/settings_provider.dart';
import '../services/settings_service.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../utils/content_rating_formatter.dart';
import '../utils/duration_formatter.dart';
import '../utils/app_logger.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../screens/playlist_detail_screen.dart';
@@ -97,9 +94,6 @@ class _MediaCardState extends State<MediaCard> {
}
void _handleTap(BuildContext context) async {
final client = context.client;
if (client == null) return;
// Handle playlists
if (widget.item is PlexPlaylist) {
await Navigator.push(
@@ -656,7 +650,7 @@ class _MediaCardList extends StatelessWidget {
}
/// Helper to get the correct PlexClient for an item's server
PlexClient? _getClientForItem(BuildContext context, dynamic item) {
PlexClient _getClientForItem(BuildContext context, dynamic item) {
String? serverId;
if (item is PlexMetadata) {
@@ -665,20 +659,7 @@ PlexClient? _getClientForItem(BuildContext context, dynamic item) {
serverId = item.serverId;
}
if (serverId == null) {
appLogger.w('Item has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
return context.getClientForServer(serverId);
}
Widget _buildPosterImage(BuildContext context, dynamic item) {
+96 -18
View File
@@ -6,14 +6,15 @@ import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../providers/plex_client_provider.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';
import '../utils/shuffle_play_helper.dart';
import '../i18n/strings.g.dart';
/// Helper class to store menu action data
@@ -62,7 +63,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
}
/// Get the correct PlexClient for this item's server
PlexClient? _getClientForItem() {
PlexClient _getClientForItem() {
String? serverId;
// Get serverId from the item (could be PlexMetadata or PlexPlaylist)
@@ -72,25 +73,11 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
serverId = (widget.item as PlexPlaylist).serverId;
}
if (serverId == null) {
appLogger.w('Item has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
return context.getClientForServer(serverId);
}
void _showContextMenu(BuildContext context) async {
final client = _getClientForItem();
if (client == null) return;
final isPlaylist = widget.item is PlexPlaylist;
final metadata = isPlaylist ? null : widget.item as PlexMetadata;
@@ -405,7 +392,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
break;
case 'shuffle_play':
await handleShufflePlay(context, metadata!);
await _handleShufflePlayWithQueue(context);
break;
case 'play':
@@ -530,6 +517,97 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
}
}
/// Handle shuffle play using play queues
Future<void> _handleShufflePlayWithQueue(BuildContext context) async {
final client = _getClientForItem();
if (client == null) return;
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()),
);
}
// 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()))),
);
}
}
}
/// Show submenu for Add to... (Playlist or Collection)
Future<void> _showAddToSubmenu(BuildContext context) async {
final useBottomSheet = Platform.isIOS || Platform.isAndroid;
+3 -22
View File
@@ -3,10 +3,8 @@ import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/plex_client_provider.dart';
import '../providers/multi_server_provider.dart';
import '../utils/duration_formatter.dart';
import '../utils/app_logger.dart';
import '../utils/provider_extensions.dart';
import '../i18n/strings.g.dart';
/// Custom list item widget for playlist items
@@ -122,22 +120,8 @@ class PlaylistItemCard extends StatelessWidget {
}
/// Get the correct PlexClient for this item's server
PlexClient? _getClientForItem(BuildContext context) {
final serverId = item.serverId;
if (serverId == null) {
appLogger.w('Playlist item ${item.title} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
PlexClient _getClientForItem(BuildContext context) {
return context.getClientForServer(item.serverId);
}
Widget _buildPosterImage(BuildContext context) {
@@ -146,9 +130,6 @@ class PlaylistItemCard extends StatelessWidget {
return Builder(
builder: (context) {
final client = _getClientForItem(context);
if (client == null) {
return _buildPlaceholder();
}
return ClipRRect(
borderRadius: BorderRadius.circular(6),
+3 -6
View File
@@ -25,8 +25,8 @@ class ServerBadge extends StatelessWidget {
}
final theme = Theme.of(context);
final bgColor = backgroundColor ??
theme.colorScheme.primaryContainer.withOpacity(0.8);
final bgColor =
backgroundColor ?? theme.colorScheme.primaryContainer.withOpacity(0.8);
final fgColor = textColor ?? theme.colorScheme.onPrimaryContainer;
final displayText = showFullName
@@ -59,10 +59,7 @@ class ServerBadge extends StatelessWidget {
}
// Show tooltip with full server name on hover/long press
return Tooltip(
message: serverName!,
child: badge,
);
return Tooltip(message: serverName!, child: badge);
}
}
+7 -27
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../models/plex_sort.dart';
import '../widgets/bottom_sheet_header.dart';
import '../i18n/strings.g.dart';
class SortBottomSheet extends StatefulWidget {
@@ -61,35 +62,14 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
builder: (context, scrollController) {
return Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor),
),
),
child: Row(
children: [
Expanded(
child: Text(
t.libraries.sortBy,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
if (widget.onClear != null)
TextButton(
BottomSheetHeader(
title: t.libraries.sortBy,
action: widget.onClear != null
? TextButton(
onPressed: _handleClear,
child: Text(t.common.clear),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
)
: null,
),
Expanded(
child: RadioGroup<PlexSort>(
+82
View File
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
/// Base widget for displaying state messages (empty, error, etc.)
/// Provides a consistent UI pattern for showing icons, messages, and actions
class StateMessageWidget extends StatelessWidget {
/// The message to display
final String message;
/// Optional icon to display above the message
final IconData? icon;
/// Optional color for the icon
final Color? iconColor;
/// Optional color for the message text
final Color? textColor;
/// Optional callback for action button
final VoidCallback? onAction;
/// Optional label for the action button
final String? actionLabel;
/// Optional icon for the action button
final IconData? actionIcon;
const StateMessageWidget({
super.key,
required this.message,
this.icon,
this.iconColor,
this.textColor,
this.onAction,
this.actionLabel,
this.actionIcon,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(
icon,
size: 64,
color:
iconColor ??
Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
],
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color:
textColor ??
Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
if (onAction != null && actionLabel != null) ...[
const SizedBox(height: 24),
FilledButton.icon(
onPressed: onAction,
icon: Icon(actionIcon ?? Icons.refresh),
label: Text(actionLabel!),
),
],
],
),
),
);
}
}
@@ -1,180 +0,0 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:plezy/services/settings_service.dart';
import '../../../i18n/strings.g.dart';
import '../../../utils/duration_formatter.dart';
import 'base_video_control_sheet.dart';
/// Bottom sheet for adjusting audio sync offset
class AudioSyncSheet extends StatefulWidget {
final Player player;
final int initialOffset;
const AudioSyncSheet({
super.key,
required this.player,
required this.initialOffset,
});
static void show(BuildContext context, Player player, int initialOffset) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.grey[900],
isScrollControlled: true,
constraints: BaseVideoControlSheet.getBottomSheetConstraints(context),
builder: (context) =>
AudioSyncSheet(player: player, initialOffset: initialOffset),
);
}
@override
State<AudioSyncSheet> createState() => _AudioSyncSheetState();
}
class _AudioSyncSheetState extends State<AudioSyncSheet> {
late double _currentOffset;
@override
void initState() {
super.initState();
_currentOffset = widget.initialOffset.toDouble();
}
void _applyOffset(double offsetMs) async {
// Convert milliseconds to seconds for media_kit
final offsetSeconds = offsetMs / 1000.0;
// Apply to player using setProperty
await (widget.player.platform as dynamic).setProperty(
'audio-delay',
offsetSeconds.toString(),
);
// Save to settings
final settings = await SettingsService.getInstance();
await settings.setAudioSyncOffset(offsetMs.round());
}
void _resetOffset() {
setState(() {
_currentOffset = 0;
});
_applyOffset(0);
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.sync, color: Colors.white),
const SizedBox(width: 12),
const Text(
'Audio Sync',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(color: Colors.white24, height: 1),
Expanded(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Current offset display
Text(
formatSyncOffset(_currentOffset),
style: const TextStyle(
color: Colors.white,
fontSize: 48,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_currentOffset > 0
? 'Audio plays later'
: _currentOffset < 0
? 'Audio plays earlier'
: 'No offset',
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
),
),
const SizedBox(height: 48),
// Slider
Row(
children: [
Text(
t.videoControls.minusTime(amount: "2", unit: "s"),
style: const TextStyle(color: Colors.white70),
),
Expanded(
child: Slider(
value: _currentOffset,
min: -2000,
max: 2000,
divisions: 80, // 50ms steps
activeColor: Colors.blue,
inactiveColor: Colors.white24,
onChanged: (value) {
setState(() {
_currentOffset = value;
});
},
onChangeEnd: (value) {
_applyOffset(value);
},
),
),
Text(
t.videoControls.addTime(amount: "2", unit: "s"),
style: const TextStyle(color: Colors.white70),
),
],
),
const SizedBox(height: 24),
// Reset button
ElevatedButton.icon(
onPressed: _currentOffset != 0 ? _resetOffset : null,
icon: const Icon(Icons.restart_alt),
label: Text(t.videoControls.resetToZero),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[800],
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey[850],
disabledForegroundColor: Colors.white38,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
],
),
),
),
],
),
),
);
}
}
@@ -1,107 +1,40 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../i18n/strings.g.dart';
import 'base_video_control_sheet.dart';
import 'track_selection_sheet.dart';
/// Bottom sheet for selecting audio tracks
class AudioTrackSheet extends StatelessWidget {
final Player player;
final Function(AudioTrack)? onTrackChanged;
const AudioTrackSheet({super.key, required this.player, this.onTrackChanged});
class AudioTrackSheet {
static void show(
BuildContext context,
Player player, {
Function(AudioTrack)? onTrackChanged,
}) {
BaseVideoControlSheet.showSheet(
TrackSelectionSheet.show<AudioTrack>(
context: context,
builder: (context) =>
AudioTrackSheet(player: player, onTrackChanged: onTrackChanged),
);
}
@override
Widget build(BuildContext context) {
return StreamBuilder<Tracks>(
stream: player.stream.tracks,
initialData: player.state.tracks,
builder: (context, snapshot) {
final tracks = snapshot.data;
final audioTracks = (tracks?.audio ?? [])
.where((track) => track.id != 'auto' && track.id != 'no')
.toList();
return BaseVideoControlSheet(
title: t.videoControls.audioLabel,
icon: Icons.audiotrack,
child: audioTracks.isEmpty
? const Center(
child: Text(
'No audio tracks available',
style: TextStyle(color: Colors.white70),
),
)
: StreamBuilder<Track>(
stream: player.stream.track,
initialData: player.state.track,
builder: (context, selectedSnapshot) {
// Use snapshot data or fall back to current state
final currentTrack =
selectedSnapshot.data ?? player.state.track;
final selectedTrack = currentTrack.audio;
final selectedId = selectedTrack.id;
return ListView.builder(
itemCount: audioTracks.length,
itemBuilder: (context, index) {
final audioTrack = audioTracks[index];
final isSelected = audioTrack.id == selectedId;
final parts = <String>[];
if (audioTrack.title != null &&
audioTrack.title!.isNotEmpty) {
parts.add(audioTrack.title!);
}
if (audioTrack.language != null &&
audioTrack.language!.isNotEmpty) {
parts.add(audioTrack.language!.toUpperCase());
}
if (audioTrack.codec != null &&
audioTrack.codec!.isNotEmpty) {
parts.add(audioTrack.codec!.toUpperCase());
}
if (audioTrack.channelscount != null) {
parts.add('${audioTrack.channelscount}ch');
}
final label = parts.isEmpty
? 'Audio Track ${index + 1}'
: parts.join(' · ');
return ListTile(
title: Text(
label,
style: TextStyle(
color: isSelected ? Colors.blue : Colors.white,
),
),
trailing: isSelected
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () {
player.setAudioTrack(audioTrack);
onTrackChanged?.call(audioTrack);
Navigator.pop(context);
},
);
},
);
},
),
);
player: player,
title: t.videoControls.audioLabel,
icon: Icons.audiotrack,
extractTracks: (tracks) => tracks?.audio ?? [],
getCurrentTrack: (track) => track.audio,
buildLabel: (audioTrack, index) {
final parts = <String>[];
if (audioTrack.title != null && audioTrack.title!.isNotEmpty) {
parts.add(audioTrack.title!);
}
if (audioTrack.language != null && audioTrack.language!.isNotEmpty) {
parts.add(audioTrack.language!.toUpperCase());
}
if (audioTrack.codec != null && audioTrack.codec!.isNotEmpty) {
parts.add(audioTrack.codec!.toUpperCase());
}
if (audioTrack.channelscount != null) {
parts.add('${audioTrack.channelscount}ch');
}
return parts.isEmpty ? 'Audio Track ${index + 1}' : parts.join(' · ');
},
setTrack: (track) => player.setAudioTrack(track),
onTrackChanged: onTrackChanged,
);
}
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'video_sheet_header.dart';
/// Base class for video control bottom sheets providing common UI structure
class BaseVideoControlSheet extends StatelessWidget {
@@ -6,6 +7,7 @@ class BaseVideoControlSheet extends StatelessWidget {
final IconData icon;
final Widget child;
final Color? iconColor;
final VoidCallback? onBack;
const BaseVideoControlSheet({
super.key,
@@ -13,6 +15,7 @@ class BaseVideoControlSheet extends StatelessWidget {
required this.icon,
required this.child,
this.iconColor,
this.onBack,
});
/// Get consistent bottom sheet constraints across all video control sheets
@@ -48,7 +51,12 @@ class BaseVideoControlSheet extends StatelessWidget {
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
children: [
_buildHeader(context),
VideoSheetHeader(
title: title,
icon: icon,
iconColor: iconColor,
onBack: onBack,
),
const Divider(color: Colors.white24, height: 1),
Expanded(child: child),
],
@@ -56,29 +64,4 @@ class BaseVideoControlSheet extends StatelessWidget {
),
);
}
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, color: iconColor ?? Colors.white),
const SizedBox(width: 12),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
],
),
);
}
}
@@ -1,12 +1,9 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:provider/provider.dart';
import '../../../client/plex_client.dart';
import '../../../models/plex_media_info.dart';
import '../../../providers/plex_client_provider.dart';
import '../../../providers/multi_server_provider.dart';
import '../../../utils/duration_formatter.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/provider_extensions.dart';
import 'base_video_control_sheet.dart';
/// Bottom sheet for selecting chapters
@@ -43,21 +40,8 @@ class ChapterSheet extends StatelessWidget {
}
/// Get the correct PlexClient for the metadata's server
PlexClient? _getClientForChapters(BuildContext context) {
if (serverId == null) {
appLogger.w('Chapters have no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId!);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
PlexClient _getClientForChapters(BuildContext context) {
return context.getClientForServer(serverId);
}
@override
@@ -1,144 +1,51 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../i18n/strings.g.dart';
import 'base_video_control_sheet.dart';
import 'track_selection_sheet.dart';
/// Bottom sheet for selecting subtitle tracks
class SubtitleTrackSheet extends StatelessWidget {
final Player player;
final Function(SubtitleTrack)? onTrackChanged;
const SubtitleTrackSheet({
super.key,
required this.player,
this.onTrackChanged,
});
class SubtitleTrackSheet {
static void show(
BuildContext context,
Player player, {
Function(SubtitleTrack)? onTrackChanged,
}) {
BaseVideoControlSheet.showSheet(
TrackSelectionSheet.show<SubtitleTrack>(
context: context,
builder: (context) =>
SubtitleTrackSheet(player: player, onTrackChanged: onTrackChanged),
);
}
@override
Widget build(BuildContext context) {
return StreamBuilder<Tracks>(
stream: player.stream.tracks,
initialData: player.state.tracks,
builder: (context, snapshot) {
final tracks = snapshot.data;
final subtitles = (tracks?.subtitle ?? [])
.where((track) => track.id != 'auto' && track.id != 'no')
.toList();
return BaseVideoControlSheet(
title: t.videoControls.subtitlesLabel,
icon: Icons.subtitles,
child: subtitles.isEmpty
? const Center(
child: Text(
'No subtitles available',
style: TextStyle(color: Colors.white70),
),
)
: StreamBuilder<Track>(
stream: player.stream.track,
initialData: player.state.track,
builder: (context, selectedSnapshot) {
// Use snapshot data or fall back to current state
final currentTrack =
selectedSnapshot.data ?? player.state.track;
final selectedTrack = currentTrack.subtitle;
final selectedId = selectedTrack.id;
final isOffSelected = selectedId == 'no';
return ListView.builder(
itemCount: subtitles.length + 1, // +1 for "Off" option
itemBuilder: (context, index) {
// First item is "Off"
if (index == 0) {
return ListTile(
title: Text(
'Off',
style: TextStyle(
color: isOffSelected
? Colors.blue
: Colors.white,
),
),
trailing: isOffSelected
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () {
player.setSubtitleTrack(SubtitleTrack.no());
onTrackChanged?.call(SubtitleTrack.no());
Navigator.pop(context);
},
);
}
// Subsequent items are subtitle tracks
final subtitle = subtitles[index - 1];
final isSelected = subtitle.id == selectedId;
// Build label with available info
final parts = <String>[];
if (subtitle.title != null &&
subtitle.title!.isNotEmpty) {
parts.add(subtitle.title!);
}
if (subtitle.language != null &&
subtitle.language!.isNotEmpty) {
parts.add(subtitle.language!.toUpperCase());
}
if (subtitle.codec != null &&
subtitle.codec!.isNotEmpty) {
// Format codec names nicely
String codecName = subtitle.codec!.toUpperCase();
if (codecName == 'SUBRIP') {
codecName = 'SRT';
} else if (codecName == 'DVD_SUBTITLE') {
codecName = 'DVD';
} else if (codecName == 'ASS' || codecName == 'SSA') {
codecName = codecName; // Keep as-is
} else if (codecName == 'WEBVTT') {
codecName = 'VTT';
}
parts.add(codecName);
}
final label = parts.isEmpty
? 'Track $index'
: parts.join(' · ');
return ListTile(
title: Text(
label,
style: TextStyle(
color: isSelected ? Colors.blue : Colors.white,
),
),
trailing: isSelected
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () {
player.setSubtitleTrack(subtitle);
onTrackChanged?.call(subtitle);
Navigator.pop(context);
},
);
},
);
},
),
);
player: player,
title: t.videoControls.subtitlesLabel,
icon: Icons.subtitles,
extractTracks: (tracks) => tracks?.subtitle ?? [],
getCurrentTrack: (track) => track.subtitle,
buildLabel: (subtitle, index) {
final parts = <String>[];
if (subtitle.title != null && subtitle.title!.isNotEmpty) {
parts.add(subtitle.title!);
}
if (subtitle.language != null && subtitle.language!.isNotEmpty) {
parts.add(subtitle.language!.toUpperCase());
}
if (subtitle.codec != null && subtitle.codec!.isNotEmpty) {
// Format codec names nicely
String codecName = subtitle.codec!.toUpperCase();
if (codecName == 'SUBRIP') {
codecName = 'SRT';
} else if (codecName == 'DVD_SUBTITLE') {
codecName = 'DVD';
} else if (codecName == 'ASS' || codecName == 'SSA') {
codecName = codecName; // Keep as-is
} else if (codecName == 'WEBVTT') {
codecName = 'VTT';
}
parts.add(codecName);
}
return parts.isEmpty ? 'Track ${index + 1}' : parts.join(' · ');
},
setTrack: (track) => player.setSubtitleTrack(track),
onTrackChanged: onTrackChanged,
showOffOption: true,
createOffTrack: () => SubtitleTrack.no(),
isOffTrack: (track) => track.id == 'no',
);
}
}
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'base_video_control_sheet.dart';
/// Generic track selection sheet for audio and subtitle tracks
///
/// Type parameter [T] should be either [AudioTrack] or [SubtitleTrack]
class TrackSelectionSheet<T> extends StatelessWidget {
final Player player;
final String title;
final IconData icon;
final List<T> Function(Tracks?) extractTracks;
final T Function(Track) getCurrentTrack;
final String Function(T track, int index) buildLabel;
final void Function(T track) setTrack;
final Function(T)? onTrackChanged;
final bool showOffOption;
final T Function()? createOffTrack;
final bool Function(T track)? isOffTrack;
const TrackSelectionSheet({
super.key,
required this.player,
required this.title,
required this.icon,
required this.extractTracks,
required this.getCurrentTrack,
required this.buildLabel,
required this.setTrack,
this.onTrackChanged,
this.showOffOption = false,
this.createOffTrack,
this.isOffTrack,
});
static void show<T>({
required BuildContext context,
required Player player,
required String title,
required IconData icon,
required List<T> Function(Tracks?) extractTracks,
required T Function(Track) getCurrentTrack,
required String Function(T track, int index) buildLabel,
required void Function(T track) setTrack,
Function(T)? onTrackChanged,
bool showOffOption = false,
T Function()? createOffTrack,
bool Function(T track)? isOffTrack,
}) {
BaseVideoControlSheet.showSheet(
context: context,
builder: (context) => TrackSelectionSheet<T>(
player: player,
title: title,
icon: icon,
extractTracks: extractTracks,
getCurrentTrack: getCurrentTrack,
buildLabel: buildLabel,
setTrack: setTrack,
onTrackChanged: onTrackChanged,
showOffOption: showOffOption,
createOffTrack: createOffTrack,
isOffTrack: isOffTrack,
),
);
}
String _getEmptyMessage() {
if (T == SubtitleTrack) {
return 'No subtitles available';
} else if (T == AudioTrack) {
return 'No audio tracks available';
}
return 'No tracks available';
}
@override
Widget build(BuildContext context) {
return StreamBuilder<Tracks>(
stream: player.stream.tracks,
initialData: player.state.tracks,
builder: (context, snapshot) {
final tracks = snapshot.data;
final availableTracks = extractTracks(tracks).where((track) {
// Filter out 'auto' and 'no' tracks from the list
if (track is AudioTrack) {
return track.id != 'auto' && track.id != 'no';
} else if (track is SubtitleTrack) {
return track.id != 'auto' && track.id != 'no';
}
return true;
}).toList();
return BaseVideoControlSheet(
title: title,
icon: icon,
child: availableTracks.isEmpty
? Center(
child: Text(
_getEmptyMessage(),
style: const TextStyle(color: Colors.white70),
),
)
: StreamBuilder<Track>(
stream: player.stream.track,
initialData: player.state.track,
builder: (context, selectedSnapshot) {
final currentTrack =
selectedSnapshot.data ?? player.state.track;
final selectedTrack = getCurrentTrack(currentTrack);
// Determine if "Off" is selected
final isOffSelected =
isOffTrack?.call(selectedTrack) ?? false;
final itemCount =
availableTracks.length + (showOffOption ? 1 : 0);
return ListView.builder(
itemCount: itemCount,
itemBuilder: (context, index) {
// First item is "Off" if enabled
if (showOffOption && index == 0) {
return ListTile(
title: Text(
'Off',
style: TextStyle(
color: isOffSelected
? Colors.blue
: Colors.white,
),
),
trailing: isOffSelected
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () {
if (createOffTrack != null) {
final offTrack = createOffTrack!();
setTrack(offTrack);
onTrackChanged?.call(offTrack);
}
Navigator.pop(context);
},
);
}
// Subsequent items are tracks
final trackIndex = showOffOption ? index - 1 : index;
final track = availableTracks[trackIndex];
// Check if this track is selected
String trackId;
if (track is AudioTrack) {
trackId = track.id;
} else if (track is SubtitleTrack) {
trackId = track.id;
} else {
trackId = '';
}
String selectedId;
if (selectedTrack is AudioTrack) {
selectedId = selectedTrack.id;
} else if (selectedTrack is SubtitleTrack) {
selectedId = selectedTrack.id;
} else {
selectedId = '';
}
final isSelected = trackId == selectedId;
final label = buildLabel(track, trackIndex);
return ListTile(
title: Text(
label,
style: TextStyle(
color: isSelected ? Colors.blue : Colors.white,
),
),
trailing: isSelected
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () {
setTrack(track);
onTrackChanged?.call(track);
Navigator.pop(context);
},
);
},
);
},
),
);
},
);
}
}
@@ -7,6 +7,7 @@ import '../widgets/sync_offset_control.dart';
import '../widgets/sleep_timer_content.dart';
import '../../../i18n/strings.g.dart';
import 'base_video_control_sheet.dart';
import 'video_sheet_header.dart';
enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice }
@@ -183,34 +184,11 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
_audioSyncOffset != 0 ||
_subtitleSyncOffset != 0);
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Back button or icon
if (_currentView != _SettingsView.menu)
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: _navigateBack,
)
else
Icon(_getIcon(), color: isIconActive ? Colors.amber : Colors.white),
const SizedBox(width: 12),
Text(
_getTitle(),
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
],
),
return VideoSheetHeader(
title: _getTitle(),
icon: _getIcon(),
iconColor: isIconActive ? Colors.amber : Colors.white,
onBack: _currentView != _SettingsView.menu ? _navigateBack : null,
);
}
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
/// Shared header widget for video control sheets
///
/// Provides a consistent header with an icon/back button, title, and close button
class VideoSheetHeader extends StatelessWidget {
final String title;
final IconData? icon;
final Color? iconColor;
final VoidCallback? onBack;
final VoidCallback? onClose;
const VideoSheetHeader({
super.key,
required this.title,
this.icon,
this.iconColor,
this.onBack,
this.onClose,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Back button or icon
if (onBack != null)
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: onBack,
)
else if (icon != null)
Icon(icon, color: iconColor ?? Colors.white),
const SizedBox(width: 12),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: onClose ?? () => Navigator.pop(context),
),
],
),
);
}
}
+3 -16
View File
@@ -106,23 +106,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
bool _isRotationLocked = true; // Default locked (landscape only)
/// Get the correct PlexClient for this metadata's server
PlexClient? _getClientForMetadata() {
final serverId = widget.metadata.serverId;
if (serverId == null) {
appLogger.w('Metadata ${widget.metadata.title} has no serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
PlexClient _getClientForMetadata() {
return context.getClientForServer(widget.metadata.serverId);
}
// Double-tap feedback state
bool _showDoubleTapFeedback = false;
double _doubleTapFeedbackOpacity = 0.0;