refactor: use play queues & other improvements
This commit is contained in:
+43
-86
@@ -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
File diff suppressed because it is too large
Load Diff
+19
-16
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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}');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user