feat: playlists
This commit is contained in:
+262
-11
@@ -10,6 +10,7 @@ import '../models/plex_library.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../models/plex_video_playback_data.dart';
|
||||
import '../network/endpoint_failover_interceptor.dart';
|
||||
@@ -43,11 +44,11 @@ class PlexClient {
|
||||
this.config, {
|
||||
List<String>? prioritizedEndpoints,
|
||||
Future<void> Function(String newBaseUrl)? onEndpointChanged,
|
||||
}) : _endpointManager = (prioritizedEndpoints != null &&
|
||||
prioritizedEndpoints.isNotEmpty)
|
||||
? EndpointFailoverManager(prioritizedEndpoints)
|
||||
: null,
|
||||
_onEndpointChanged = onEndpointChanged {
|
||||
}) : _endpointManager =
|
||||
(prioritizedEndpoints != null && prioritizedEndpoints.isNotEmpty)
|
||||
? EndpointFailoverManager(prioritizedEndpoints)
|
||||
: null,
|
||||
_onEndpointChanged = onEndpointChanged {
|
||||
LogRedactionManager.registerServerUrl(config.baseUrl);
|
||||
LogRedactionManager.registerToken(config.token);
|
||||
|
||||
@@ -104,12 +105,10 @@ class PlexClient {
|
||||
return;
|
||||
}
|
||||
|
||||
final targetBaseUrl =
|
||||
switchToFirst ? prioritizedEndpoints.first : config.baseUrl;
|
||||
_endpointManager.reset(
|
||||
prioritizedEndpoints,
|
||||
currentBaseUrl: targetBaseUrl,
|
||||
);
|
||||
final targetBaseUrl = switchToFirst
|
||||
? prioritizedEndpoints.first
|
||||
: config.baseUrl;
|
||||
_endpointManager.reset(prioritizedEndpoints, currentBaseUrl: targetBaseUrl);
|
||||
|
||||
if (switchToFirst && targetBaseUrl != config.baseUrl) {
|
||||
await _handleEndpointSwitch(targetBaseUrl);
|
||||
@@ -304,6 +303,30 @@ class PlexClient {
|
||||
return _extractSingleMetadata(response);
|
||||
}
|
||||
|
||||
/// Get the server's machine identifier
|
||||
Future<String?> getMachineIdentifier() async {
|
||||
try {
|
||||
final response = await _dio.get('/');
|
||||
final container = _getMediaContainer(response);
|
||||
if (container == null) return null;
|
||||
return container['machineIdentifier'] as String?;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get machine identifier', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a proper metadata URI for adding to playlists
|
||||
/// Returns URI in format: server://{machineId}/com.plexapp.plugins.library/library/metadata/{ratingKey}
|
||||
Future<String> buildMetadataUri(String ratingKey) async {
|
||||
// Use cached machine identifier from config if available
|
||||
final machineId = config.machineIdentifier ?? await getMachineIdentifier();
|
||||
if (machineId == null) {
|
||||
throw Exception('Could not get server machine identifier');
|
||||
}
|
||||
return 'server://$machineId/com.plexapp.plugins.library/library/metadata/$ratingKey';
|
||||
}
|
||||
|
||||
/// Get metadata by rating key with images (includes clearLogo and OnDeck)
|
||||
Future<Map<String, dynamic>> getMetadataWithImagesAndOnDeck(
|
||||
String ratingKey,
|
||||
@@ -1233,6 +1256,234 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all playlists
|
||||
/// Filters by playlistType=video by default
|
||||
/// Set smart to true/false to filter smart playlists, or null for all
|
||||
Future<List<PlexPlaylist>> getPlaylists({
|
||||
String playlistType = 'video',
|
||||
bool? smart,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{'playlistType': playlistType};
|
||||
if (smart != null) {
|
||||
queryParams['smart'] = smart ? '1' : '0';
|
||||
}
|
||||
|
||||
final response = await _dio.get(
|
||||
'/playlists',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
final container = _getMediaContainer(response);
|
||||
|
||||
if (container == null || container['Metadata'] == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<dynamic> metadata = container['Metadata'] as List;
|
||||
|
||||
if (metadata.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return metadata
|
||||
.map((item) => PlexPlaylist.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get playlists: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlist metadata by playlist ID
|
||||
/// Returns the playlist details (not the items)
|
||||
Future<PlexPlaylist?> getPlaylistMetadata(String playlistId) async {
|
||||
try {
|
||||
final response = await _dio.get('/playlists/$playlistId');
|
||||
final container = _getMediaContainer(response);
|
||||
|
||||
if (container == null || container['Metadata'] == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<dynamic> metadata = container['Metadata'] as List;
|
||||
|
||||
if (metadata.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PlexPlaylist.fromJson(metadata.first as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get playlist metadata: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new playlist
|
||||
/// [title] - Name of the playlist
|
||||
/// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234")
|
||||
/// [playQueueId] - Optional play queue ID to create playlist from
|
||||
Future<PlexPlaylist?> createPlaylist({
|
||||
required String title,
|
||||
String? uri,
|
||||
int? playQueueId,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'type': 'video',
|
||||
'title': title,
|
||||
'smart': '0',
|
||||
};
|
||||
|
||||
if (uri != null) {
|
||||
queryParams['uri'] = uri;
|
||||
}
|
||||
if (playQueueId != null) {
|
||||
queryParams['playQueueID'] = playQueueId.toString();
|
||||
}
|
||||
|
||||
final response = await _dio.post(
|
||||
'/playlists',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
final container = _getMediaContainer(response);
|
||||
|
||||
if (container == null || container['Metadata'] == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<dynamic> metadata = container['Metadata'] as List;
|
||||
|
||||
if (metadata.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PlexPlaylist.fromJson(metadata.first as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create playlist: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a playlist
|
||||
Future<bool> deletePlaylist(String playlistId) async {
|
||||
try {
|
||||
await _dio.delete('/playlists/$playlistId');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete playlist: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add items to a playlist
|
||||
/// [playlistId] - The playlist to add items to
|
||||
/// [uri] - Comma-separated list of item URIs to add
|
||||
Future<bool> addToPlaylist({
|
||||
required String playlistId,
|
||||
required String uri,
|
||||
}) async {
|
||||
try {
|
||||
appLogger.d(
|
||||
'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}',
|
||||
);
|
||||
final response = await _dio.put(
|
||||
'/playlists/$playlistId/items',
|
||||
queryParameters: {'uri': uri},
|
||||
);
|
||||
appLogger.d('Add to playlist response status: ${response.statusCode}');
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to add to playlist', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an item from a playlist
|
||||
/// [playlistId] - The playlist to remove from
|
||||
/// [playlistItemId] - The playlist item ID to remove (from the item's playlistItemID field)
|
||||
Future<bool> removeFromPlaylist({
|
||||
required String playlistId,
|
||||
required String playlistItemId,
|
||||
}) async {
|
||||
try {
|
||||
await _dio.delete('/playlists/$playlistId/items/$playlistItemId');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to remove from playlist: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a playlist item to a new position
|
||||
/// Only works with non-smart playlists
|
||||
/// [playlistId] - The playlist rating key
|
||||
/// [playlistItemId] - The playlist item ID to move
|
||||
/// [afterPlaylistItemId] - Move the item after this playlist item ID (0 = move to top)
|
||||
Future<bool> movePlaylistItem({
|
||||
required String playlistId,
|
||||
required int playlistItemId,
|
||||
required int afterPlaylistItemId,
|
||||
}) async {
|
||||
try {
|
||||
appLogger.d(
|
||||
'Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId',
|
||||
);
|
||||
await _dio.put(
|
||||
'/playlists/$playlistId/items/$playlistItemId/move',
|
||||
queryParameters: {'after': afterPlaylistItemId},
|
||||
);
|
||||
appLogger.d('Successfully moved playlist item');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to move playlist item', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all items from a playlist
|
||||
Future<bool> clearPlaylist(String playlistId) async {
|
||||
try {
|
||||
await _dio.delete('/playlists/$playlistId/items');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to clear playlist: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update playlist metadata (e.g., title, summary)
|
||||
/// Uses the same metadata editing mechanism as other items
|
||||
Future<bool> updatePlaylist({
|
||||
required String playlistId,
|
||||
String? title,
|
||||
String? summary,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'type': 'playlist',
|
||||
'id': playlistId,
|
||||
};
|
||||
|
||||
if (title != null) {
|
||||
queryParams['title.value'] = title;
|
||||
queryParams['title.locked'] = '1';
|
||||
}
|
||||
if (summary != null) {
|
||||
queryParams['summary.value'] = summary;
|
||||
queryParams['summary.locked'] = '1';
|
||||
}
|
||||
|
||||
await _dio.put(
|
||||
'/library/metadata/$playlistId',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to update playlist: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Library Management Methods
|
||||
// ============================================================================
|
||||
|
||||
@@ -9,6 +9,7 @@ class PlexConfig {
|
||||
final String platform;
|
||||
final String? device;
|
||||
final bool acceptJson;
|
||||
final String? machineIdentifier;
|
||||
|
||||
PlexConfig({
|
||||
required this.baseUrl,
|
||||
@@ -19,6 +20,7 @@ class PlexConfig {
|
||||
this.platform = 'Flutter',
|
||||
this.device,
|
||||
this.acceptJson = true,
|
||||
this.machineIdentifier,
|
||||
});
|
||||
|
||||
static Future<PlexConfig> create({
|
||||
@@ -29,6 +31,7 @@ class PlexConfig {
|
||||
String? platform,
|
||||
String? device,
|
||||
bool acceptJson = true,
|
||||
String? machineIdentifier,
|
||||
}) async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
return PlexConfig(
|
||||
@@ -40,6 +43,7 @@ class PlexConfig {
|
||||
platform: platform ?? 'Flutter',
|
||||
device: device,
|
||||
acceptJson: acceptJson,
|
||||
machineIdentifier: machineIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +74,7 @@ class PlexConfig {
|
||||
String? platform,
|
||||
String? device,
|
||||
bool? acceptJson,
|
||||
String? machineIdentifier,
|
||||
}) {
|
||||
return PlexConfig(
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
@@ -80,6 +85,7 @@ class PlexConfig {
|
||||
platform: platform ?? this.platform,
|
||||
device: device ?? this.device,
|
||||
acceptJson: acceptJson ?? this.acceptJson,
|
||||
machineIdentifier: machineIdentifier ?? this.machineIdentifier,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+470
-2
@@ -4,9 +4,9 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 6
|
||||
/// Strings: 1896 (316 per locale)
|
||||
/// Strings: 2100 (350 per locale)
|
||||
///
|
||||
/// Built on 2025-11-15 at 00:12 UTC
|
||||
/// Built on 2025-11-15 at 02:45 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
@@ -179,6 +179,7 @@ class Translations implements BaseTranslations<AppLocale, Translations> {
|
||||
late final _StringsLogsEn logs = _StringsLogsEn._(_root);
|
||||
late final _StringsLicensesEn licenses = _StringsLicensesEn._(_root);
|
||||
late final _StringsNavigationEn navigation = _StringsNavigationEn._(_root);
|
||||
late final _StringsPlaylistsEn playlists = _StringsPlaylistsEn._(_root);
|
||||
}
|
||||
|
||||
// Path: app
|
||||
@@ -740,6 +741,49 @@ class _StringsNavigationEn {
|
||||
String get settings => 'Settings';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
class _StringsPlaylistsEn {
|
||||
_StringsPlaylistsEn._(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
String get title => 'Playlists';
|
||||
String get noPlaylists => 'No playlists found';
|
||||
String get create => 'Create Playlist';
|
||||
String get newPlaylist => 'New Playlist';
|
||||
String get playlistName => 'Playlist Name';
|
||||
String get enterPlaylistName => 'Enter playlist name';
|
||||
String get edit => 'Edit Playlist';
|
||||
String get delete => 'Delete Playlist';
|
||||
String get addTo => 'Add to Playlist';
|
||||
String get addItems => 'Add Items';
|
||||
String get removeItem => 'Remove from Playlist';
|
||||
String get clearPlaylist => 'Clear Playlist';
|
||||
String get playAll => 'Play All';
|
||||
String get shuffle => 'Shuffle';
|
||||
String get smartPlaylist => 'Smart Playlist';
|
||||
String get regularPlaylist => 'Regular Playlist';
|
||||
String itemCount({required Object count}) => '${count} items';
|
||||
String get oneItem => '1 item';
|
||||
String get emptyPlaylist => 'This playlist is empty';
|
||||
String get deleteConfirm => 'Delete Playlist?';
|
||||
String deleteMessage({required Object name}) => 'Are you sure you want to delete "${name}"?';
|
||||
String get created => 'Playlist created';
|
||||
String get updated => 'Playlist updated';
|
||||
String get deleted => 'Playlist deleted';
|
||||
String get itemAdded => 'Added to playlist';
|
||||
String get itemRemoved => 'Removed from playlist';
|
||||
String get selectPlaylist => 'Select Playlist';
|
||||
String get createNewPlaylist => 'Create New Playlist';
|
||||
String get errorCreating => 'Failed to create playlist';
|
||||
String get errorDeleting => 'Failed to delete playlist';
|
||||
String get errorLoading => 'Failed to load playlists';
|
||||
String get errorAdding => 'Failed to add to playlist';
|
||||
String get errorReordering => 'Failed to reorder playlist item';
|
||||
String get errorRemoving => 'Failed to remove from playlist';
|
||||
}
|
||||
|
||||
// Path: <root>
|
||||
class _StringsDe implements Translations {
|
||||
/// You can call this constructor and build your own translation instance of this locale.
|
||||
@@ -791,6 +835,7 @@ class _StringsDe implements Translations {
|
||||
@override late final _StringsLogsDe logs = _StringsLogsDe._(_root);
|
||||
@override late final _StringsLicensesDe licenses = _StringsLicensesDe._(_root);
|
||||
@override late final _StringsNavigationDe navigation = _StringsNavigationDe._(_root);
|
||||
@override late final _StringsPlaylistsDe playlists = _StringsPlaylistsDe._(_root);
|
||||
}
|
||||
|
||||
// Path: app
|
||||
@@ -1352,6 +1397,49 @@ class _StringsNavigationDe implements _StringsNavigationEn {
|
||||
@override String get settings => 'Einstellungen';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
class _StringsPlaylistsDe implements _StringsPlaylistsEn {
|
||||
_StringsPlaylistsDe._(this._root);
|
||||
|
||||
@override final _StringsDe _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Playlists';
|
||||
@override String get noPlaylists => 'Keine Playlists gefunden';
|
||||
@override String get create => 'Playlist erstellen';
|
||||
@override String get newPlaylist => 'Neue Playlist';
|
||||
@override String get playlistName => 'Playlist-Name';
|
||||
@override String get enterPlaylistName => 'Playlist-Namen eingeben';
|
||||
@override String get edit => 'Playlist bearbeiten';
|
||||
@override String get delete => 'Playlist löschen';
|
||||
@override String get addTo => 'Zur Playlist hinzufügen';
|
||||
@override String get addItems => 'Elemente hinzufügen';
|
||||
@override String get removeItem => 'Aus Playlist entfernen';
|
||||
@override String get clearPlaylist => 'Playlist leeren';
|
||||
@override String get playAll => 'Alle abspielen';
|
||||
@override String get shuffle => 'Zufällig';
|
||||
@override String get smartPlaylist => 'Intelligente Playlist';
|
||||
@override String get regularPlaylist => 'Normale Playlist';
|
||||
@override String itemCount({required Object count}) => '${count} Elemente';
|
||||
@override String get oneItem => '1 Element';
|
||||
@override String get emptyPlaylist => 'Diese Playlist ist leer';
|
||||
@override String get deleteConfirm => 'Playlist löschen?';
|
||||
@override String deleteMessage({required Object name}) => 'Möchten Sie "${name}" wirklich löschen?';
|
||||
@override String get created => 'Playlist erstellt';
|
||||
@override String get updated => 'Playlist aktualisiert';
|
||||
@override String get deleted => 'Playlist gelöscht';
|
||||
@override String get itemAdded => 'Zur Playlist hinzugefügt';
|
||||
@override String get itemRemoved => 'Aus Playlist entfernt';
|
||||
@override String get selectPlaylist => 'Playlist auswählen';
|
||||
@override String get createNewPlaylist => 'Neue Playlist erstellen';
|
||||
@override String get errorCreating => 'Fehler beim Erstellen der Playlist';
|
||||
@override String get errorDeleting => 'Fehler beim Löschen der Playlist';
|
||||
@override String get errorLoading => 'Fehler beim Laden der Playlists';
|
||||
@override String get errorAdding => 'Fehler beim Hinzufügen zur Playlist';
|
||||
@override String get errorReordering => 'Fehler beim Neuordnen des Playlist-Elements';
|
||||
@override String get errorRemoving => 'Fehler beim Entfernen aus der Playlist';
|
||||
}
|
||||
|
||||
// Path: <root>
|
||||
class _StringsIt implements Translations {
|
||||
/// You can call this constructor and build your own translation instance of this locale.
|
||||
@@ -1403,6 +1491,7 @@ class _StringsIt implements Translations {
|
||||
@override late final _StringsLogsIt logs = _StringsLogsIt._(_root);
|
||||
@override late final _StringsLicensesIt licenses = _StringsLicensesIt._(_root);
|
||||
@override late final _StringsNavigationIt navigation = _StringsNavigationIt._(_root);
|
||||
@override late final _StringsPlaylistsIt playlists = _StringsPlaylistsIt._(_root);
|
||||
}
|
||||
|
||||
// Path: app
|
||||
@@ -1964,6 +2053,49 @@ class _StringsNavigationIt implements _StringsNavigationEn {
|
||||
@override String get settings => 'Impostazioni';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
class _StringsPlaylistsIt implements _StringsPlaylistsEn {
|
||||
_StringsPlaylistsIt._(this._root);
|
||||
|
||||
@override final _StringsIt _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Playlist';
|
||||
@override String get noPlaylists => 'Nessuna playlist trovata';
|
||||
@override String get create => 'Crea playlist';
|
||||
@override String get newPlaylist => 'Nuova playlist';
|
||||
@override String get playlistName => 'Nome playlist';
|
||||
@override String get enterPlaylistName => 'Inserisci nome playlist';
|
||||
@override String get edit => 'Modifica playlist';
|
||||
@override String get delete => 'Elimina playlist';
|
||||
@override String get addTo => 'Aggiungi a playlist';
|
||||
@override String get addItems => 'Aggiungi elementi';
|
||||
@override String get removeItem => 'Rimuovi da playlist';
|
||||
@override String get clearPlaylist => 'Svuota playlist';
|
||||
@override String get playAll => 'Riproduci tutto';
|
||||
@override String get shuffle => 'Casuale';
|
||||
@override String get smartPlaylist => 'Playlist intelligente';
|
||||
@override String get regularPlaylist => 'Playlist normale';
|
||||
@override String itemCount({required Object count}) => '${count} elementi';
|
||||
@override String get oneItem => '1 elemento';
|
||||
@override String get emptyPlaylist => 'Questa playlist è vuota';
|
||||
@override String get deleteConfirm => 'Eliminare playlist?';
|
||||
@override String deleteMessage({required Object name}) => 'Sei sicuro di voler eliminare "${name}"?';
|
||||
@override String get created => 'Playlist creata';
|
||||
@override String get updated => 'Playlist aggiornata';
|
||||
@override String get deleted => 'Playlist eliminata';
|
||||
@override String get itemAdded => 'Aggiunto alla playlist';
|
||||
@override String get itemRemoved => 'Rimosso dalla playlist';
|
||||
@override String get selectPlaylist => 'Seleziona playlist';
|
||||
@override String get createNewPlaylist => 'Crea nuova playlist';
|
||||
@override String get errorCreating => 'Errore durante la creazione della playlist';
|
||||
@override String get errorDeleting => 'Errore durante l\'eliminazione della playlist';
|
||||
@override String get errorLoading => 'Errore durante il caricamento delle playlist';
|
||||
@override String get errorAdding => 'Errore durante l\'aggiunta alla playlist';
|
||||
@override String get errorReordering => 'Errore durante il riordino dell\'elemento della playlist';
|
||||
@override String get errorRemoving => 'Errore durante la rimozione dalla playlist';
|
||||
}
|
||||
|
||||
// Path: <root>
|
||||
class _StringsNl implements Translations {
|
||||
/// You can call this constructor and build your own translation instance of this locale.
|
||||
@@ -2015,6 +2147,7 @@ class _StringsNl implements Translations {
|
||||
@override late final _StringsLogsNl logs = _StringsLogsNl._(_root);
|
||||
@override late final _StringsLicensesNl licenses = _StringsLicensesNl._(_root);
|
||||
@override late final _StringsNavigationNl navigation = _StringsNavigationNl._(_root);
|
||||
@override late final _StringsPlaylistsNl playlists = _StringsPlaylistsNl._(_root);
|
||||
}
|
||||
|
||||
// Path: app
|
||||
@@ -2576,6 +2709,49 @@ class _StringsNavigationNl implements _StringsNavigationEn {
|
||||
@override String get settings => 'Instellingen';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
class _StringsPlaylistsNl implements _StringsPlaylistsEn {
|
||||
_StringsPlaylistsNl._(this._root);
|
||||
|
||||
@override final _StringsNl _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Afspeellijsten';
|
||||
@override String get noPlaylists => 'Geen afspeellijsten gevonden';
|
||||
@override String get create => 'Afspeellijst maken';
|
||||
@override String get newPlaylist => 'Nieuwe afspeellijst';
|
||||
@override String get playlistName => 'Naam afspeellijst';
|
||||
@override String get enterPlaylistName => 'Voer naam afspeellijst in';
|
||||
@override String get edit => 'Afspeellijst bewerken';
|
||||
@override String get delete => 'Afspeellijst verwijderen';
|
||||
@override String get addTo => 'Toevoegen aan afspeellijst';
|
||||
@override String get addItems => 'Items toevoegen';
|
||||
@override String get removeItem => 'Verwijderen uit afspeellijst';
|
||||
@override String get clearPlaylist => 'Afspeellijst wissen';
|
||||
@override String get playAll => 'Alles afspelen';
|
||||
@override String get shuffle => 'Shuffle';
|
||||
@override String get smartPlaylist => 'Slimme afspeellijst';
|
||||
@override String get regularPlaylist => 'Normale afspeellijst';
|
||||
@override String itemCount({required Object count}) => '${count} items';
|
||||
@override String get oneItem => '1 item';
|
||||
@override String get emptyPlaylist => 'Deze afspeellijst is leeg';
|
||||
@override String get deleteConfirm => 'Afspeellijst verwijderen?';
|
||||
@override String deleteMessage({required Object name}) => 'Weet je zeker dat je "${name}" wilt verwijderen?';
|
||||
@override String get created => 'Afspeellijst gemaakt';
|
||||
@override String get updated => 'Afspeellijst bijgewerkt';
|
||||
@override String get deleted => 'Afspeellijst verwijderd';
|
||||
@override String get itemAdded => 'Toegevoegd aan afspeellijst';
|
||||
@override String get itemRemoved => 'Verwijderd uit afspeellijst';
|
||||
@override String get selectPlaylist => 'Selecteer afspeellijst';
|
||||
@override String get createNewPlaylist => 'Nieuwe afspeellijst maken';
|
||||
@override String get errorCreating => 'Fout bij maken afspeellijst';
|
||||
@override String get errorDeleting => 'Fout bij verwijderen afspeellijst';
|
||||
@override String get errorLoading => 'Fout bij laden afspeellijsten';
|
||||
@override String get errorAdding => 'Fout bij toevoegen aan afspeellijst';
|
||||
@override String get errorReordering => 'Fout bij herschikken van afspeellijstitem';
|
||||
@override String get errorRemoving => 'Fout bij verwijderen uit afspeellijst';
|
||||
}
|
||||
|
||||
// Path: <root>
|
||||
class _StringsSv implements Translations {
|
||||
/// You can call this constructor and build your own translation instance of this locale.
|
||||
@@ -2627,6 +2803,7 @@ class _StringsSv implements Translations {
|
||||
@override late final _StringsLogsSv logs = _StringsLogsSv._(_root);
|
||||
@override late final _StringsLicensesSv licenses = _StringsLicensesSv._(_root);
|
||||
@override late final _StringsNavigationSv navigation = _StringsNavigationSv._(_root);
|
||||
@override late final _StringsPlaylistsSv playlists = _StringsPlaylistsSv._(_root);
|
||||
}
|
||||
|
||||
// Path: app
|
||||
@@ -3188,6 +3365,49 @@ class _StringsNavigationSv implements _StringsNavigationEn {
|
||||
@override String get settings => 'Inställningar';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
class _StringsPlaylistsSv implements _StringsPlaylistsEn {
|
||||
_StringsPlaylistsSv._(this._root);
|
||||
|
||||
@override final _StringsSv _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Spellistor';
|
||||
@override String get noPlaylists => 'Inga spellistor hittades';
|
||||
@override String get create => 'Skapa spellista';
|
||||
@override String get newPlaylist => 'Ny spellista';
|
||||
@override String get playlistName => 'Spellistans namn';
|
||||
@override String get enterPlaylistName => 'Ange spellistans namn';
|
||||
@override String get edit => 'Redigera spellista';
|
||||
@override String get delete => 'Ta bort spellista';
|
||||
@override String get addTo => 'Lägg till i spellista';
|
||||
@override String get addItems => 'Lägg till objekt';
|
||||
@override String get removeItem => 'Ta bort från spellista';
|
||||
@override String get clearPlaylist => 'Rensa spellista';
|
||||
@override String get playAll => 'Spela alla';
|
||||
@override String get shuffle => 'Blanda';
|
||||
@override String get smartPlaylist => 'Smart spellista';
|
||||
@override String get regularPlaylist => 'Vanlig spellista';
|
||||
@override String itemCount({required Object count}) => '${count} objekt';
|
||||
@override String get oneItem => '1 objekt';
|
||||
@override String get emptyPlaylist => 'Denna spellista är tom';
|
||||
@override String get deleteConfirm => 'Ta bort spellista?';
|
||||
@override String deleteMessage({required Object name}) => 'Är du säker på att du vill ta bort "${name}"?';
|
||||
@override String get created => 'Spellista skapad';
|
||||
@override String get updated => 'Spellista uppdaterad';
|
||||
@override String get deleted => 'Spellista borttagen';
|
||||
@override String get itemAdded => 'Tillagd i spellista';
|
||||
@override String get itemRemoved => 'Borttagen från spellista';
|
||||
@override String get selectPlaylist => 'Välj spellista';
|
||||
@override String get createNewPlaylist => 'Skapa ny spellista';
|
||||
@override String get errorCreating => 'Det gick inte att skapa spellista';
|
||||
@override String get errorDeleting => 'Det gick inte att ta bort spellista';
|
||||
@override String get errorLoading => 'Det gick inte att ladda spellistor';
|
||||
@override String get errorAdding => 'Det gick inte att lägga till i spellista';
|
||||
@override String get errorReordering => 'Det gick inte att omordna spellisteobjekt';
|
||||
@override String get errorRemoving => 'Det gick inte att ta bort från spellista';
|
||||
}
|
||||
|
||||
// Path: <root>
|
||||
class _StringsZh implements Translations {
|
||||
/// You can call this constructor and build your own translation instance of this locale.
|
||||
@@ -3239,6 +3459,7 @@ class _StringsZh implements Translations {
|
||||
@override late final _StringsLogsZh logs = _StringsLogsZh._(_root);
|
||||
@override late final _StringsLicensesZh licenses = _StringsLicensesZh._(_root);
|
||||
@override late final _StringsNavigationZh navigation = _StringsNavigationZh._(_root);
|
||||
@override late final _StringsPlaylistsZh playlists = _StringsPlaylistsZh._(_root);
|
||||
}
|
||||
|
||||
// Path: app
|
||||
@@ -3800,6 +4021,49 @@ class _StringsNavigationZh implements _StringsNavigationEn {
|
||||
@override String get settings => '设置';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
class _StringsPlaylistsZh implements _StringsPlaylistsEn {
|
||||
_StringsPlaylistsZh._(this._root);
|
||||
|
||||
@override final _StringsZh _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => '播放列表';
|
||||
@override String get noPlaylists => '未找到播放列表';
|
||||
@override String get create => '创建播放列表';
|
||||
@override String get newPlaylist => '新播放列表';
|
||||
@override String get playlistName => '播放列表名称';
|
||||
@override String get enterPlaylistName => '输入播放列表名称';
|
||||
@override String get edit => '编辑播放列表';
|
||||
@override String get delete => '删除播放列表';
|
||||
@override String get addTo => '添加到播放列表';
|
||||
@override String get addItems => '添加项目';
|
||||
@override String get removeItem => '从播放列表中移除';
|
||||
@override String get clearPlaylist => '清空播放列表';
|
||||
@override String get playAll => '全部播放';
|
||||
@override String get shuffle => '随机播放';
|
||||
@override String get smartPlaylist => '智能播放列表';
|
||||
@override String get regularPlaylist => '普通播放列表';
|
||||
@override String itemCount({required Object count}) => '${count} 个项目';
|
||||
@override String get oneItem => '1 个项目';
|
||||
@override String get emptyPlaylist => '此播放列表为空';
|
||||
@override String get deleteConfirm => '删除播放列表?';
|
||||
@override String deleteMessage({required Object name}) => '确定要删除 "${name}" 吗?';
|
||||
@override String get created => '播放列表已创建';
|
||||
@override String get updated => '播放列表已更新';
|
||||
@override String get deleted => '播放列表已删除';
|
||||
@override String get itemAdded => '已添加到播放列表';
|
||||
@override String get itemRemoved => '已从播放列表中移除';
|
||||
@override String get selectPlaylist => '选择播放列表';
|
||||
@override String get createNewPlaylist => '创建新播放列表';
|
||||
@override String get errorCreating => '创建播放列表失败';
|
||||
@override String get errorDeleting => '删除播放列表失败';
|
||||
@override String get errorLoading => '加载播放列表失败';
|
||||
@override String get errorAdding => '添加到播放列表失败';
|
||||
@override String get errorReordering => '重新排序播放列表项目失败';
|
||||
@override String get errorRemoving => '从播放列表中移除失败';
|
||||
}
|
||||
|
||||
/// Flat map(s) containing all translations.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
|
||||
@@ -4122,6 +4386,40 @@ extension on Translations {
|
||||
case 'navigation.search': return 'Search';
|
||||
case 'navigation.libraries': return 'Libraries';
|
||||
case 'navigation.settings': return 'Settings';
|
||||
case 'playlists.title': return 'Playlists';
|
||||
case 'playlists.noPlaylists': return 'No playlists found';
|
||||
case 'playlists.create': return 'Create Playlist';
|
||||
case 'playlists.newPlaylist': return 'New Playlist';
|
||||
case 'playlists.playlistName': return 'Playlist Name';
|
||||
case 'playlists.enterPlaylistName': return 'Enter playlist name';
|
||||
case 'playlists.edit': return 'Edit Playlist';
|
||||
case 'playlists.delete': return 'Delete Playlist';
|
||||
case 'playlists.addTo': return 'Add to Playlist';
|
||||
case 'playlists.addItems': return 'Add Items';
|
||||
case 'playlists.removeItem': return 'Remove from Playlist';
|
||||
case 'playlists.clearPlaylist': return 'Clear Playlist';
|
||||
case 'playlists.playAll': return 'Play All';
|
||||
case 'playlists.shuffle': return 'Shuffle';
|
||||
case 'playlists.smartPlaylist': return 'Smart Playlist';
|
||||
case 'playlists.regularPlaylist': return 'Regular Playlist';
|
||||
case 'playlists.itemCount': return ({required Object count}) => '${count} items';
|
||||
case 'playlists.oneItem': return '1 item';
|
||||
case 'playlists.emptyPlaylist': return 'This playlist is empty';
|
||||
case 'playlists.deleteConfirm': return 'Delete Playlist?';
|
||||
case 'playlists.deleteMessage': return ({required Object name}) => 'Are you sure you want to delete "${name}"?';
|
||||
case 'playlists.created': return 'Playlist created';
|
||||
case 'playlists.updated': return 'Playlist updated';
|
||||
case 'playlists.deleted': return 'Playlist deleted';
|
||||
case 'playlists.itemAdded': return 'Added to playlist';
|
||||
case 'playlists.itemRemoved': return 'Removed from playlist';
|
||||
case 'playlists.selectPlaylist': return 'Select Playlist';
|
||||
case 'playlists.createNewPlaylist': return 'Create New Playlist';
|
||||
case 'playlists.errorCreating': return 'Failed to create playlist';
|
||||
case 'playlists.errorDeleting': return 'Failed to delete playlist';
|
||||
case 'playlists.errorLoading': return 'Failed to load playlists';
|
||||
case 'playlists.errorAdding': return 'Failed to add to playlist';
|
||||
case 'playlists.errorReordering': return 'Failed to reorder playlist item';
|
||||
case 'playlists.errorRemoving': return 'Failed to remove from playlist';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -4446,6 +4744,40 @@ extension on _StringsDe {
|
||||
case 'navigation.search': return 'Suche';
|
||||
case 'navigation.libraries': return 'Bibliotheken';
|
||||
case 'navigation.settings': return 'Einstellungen';
|
||||
case 'playlists.title': return 'Playlists';
|
||||
case 'playlists.noPlaylists': return 'Keine Playlists gefunden';
|
||||
case 'playlists.create': return 'Playlist erstellen';
|
||||
case 'playlists.newPlaylist': return 'Neue Playlist';
|
||||
case 'playlists.playlistName': return 'Playlist-Name';
|
||||
case 'playlists.enterPlaylistName': return 'Playlist-Namen eingeben';
|
||||
case 'playlists.edit': return 'Playlist bearbeiten';
|
||||
case 'playlists.delete': return 'Playlist löschen';
|
||||
case 'playlists.addTo': return 'Zur Playlist hinzufügen';
|
||||
case 'playlists.addItems': return 'Elemente hinzufügen';
|
||||
case 'playlists.removeItem': return 'Aus Playlist entfernen';
|
||||
case 'playlists.clearPlaylist': return 'Playlist leeren';
|
||||
case 'playlists.playAll': return 'Alle abspielen';
|
||||
case 'playlists.shuffle': return 'Zufällig';
|
||||
case 'playlists.smartPlaylist': return 'Intelligente Playlist';
|
||||
case 'playlists.regularPlaylist': return 'Normale Playlist';
|
||||
case 'playlists.itemCount': return ({required Object count}) => '${count} Elemente';
|
||||
case 'playlists.oneItem': return '1 Element';
|
||||
case 'playlists.emptyPlaylist': return 'Diese Playlist ist leer';
|
||||
case 'playlists.deleteConfirm': return 'Playlist löschen?';
|
||||
case 'playlists.deleteMessage': return ({required Object name}) => 'Möchten Sie "${name}" wirklich löschen?';
|
||||
case 'playlists.created': return 'Playlist erstellt';
|
||||
case 'playlists.updated': return 'Playlist aktualisiert';
|
||||
case 'playlists.deleted': return 'Playlist gelöscht';
|
||||
case 'playlists.itemAdded': return 'Zur Playlist hinzugefügt';
|
||||
case 'playlists.itemRemoved': return 'Aus Playlist entfernt';
|
||||
case 'playlists.selectPlaylist': return 'Playlist auswählen';
|
||||
case 'playlists.createNewPlaylist': return 'Neue Playlist erstellen';
|
||||
case 'playlists.errorCreating': return 'Fehler beim Erstellen der Playlist';
|
||||
case 'playlists.errorDeleting': return 'Fehler beim Löschen der Playlist';
|
||||
case 'playlists.errorLoading': return 'Fehler beim Laden der Playlists';
|
||||
case 'playlists.errorAdding': return 'Fehler beim Hinzufügen zur Playlist';
|
||||
case 'playlists.errorReordering': return 'Fehler beim Neuordnen des Playlist-Elements';
|
||||
case 'playlists.errorRemoving': return 'Fehler beim Entfernen aus der Playlist';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -4770,6 +5102,40 @@ extension on _StringsIt {
|
||||
case 'navigation.search': return 'Cerca';
|
||||
case 'navigation.libraries': return 'Librerie';
|
||||
case 'navigation.settings': return 'Impostazioni';
|
||||
case 'playlists.title': return 'Playlist';
|
||||
case 'playlists.noPlaylists': return 'Nessuna playlist trovata';
|
||||
case 'playlists.create': return 'Crea playlist';
|
||||
case 'playlists.newPlaylist': return 'Nuova playlist';
|
||||
case 'playlists.playlistName': return 'Nome playlist';
|
||||
case 'playlists.enterPlaylistName': return 'Inserisci nome playlist';
|
||||
case 'playlists.edit': return 'Modifica playlist';
|
||||
case 'playlists.delete': return 'Elimina playlist';
|
||||
case 'playlists.addTo': return 'Aggiungi a playlist';
|
||||
case 'playlists.addItems': return 'Aggiungi elementi';
|
||||
case 'playlists.removeItem': return 'Rimuovi da playlist';
|
||||
case 'playlists.clearPlaylist': return 'Svuota playlist';
|
||||
case 'playlists.playAll': return 'Riproduci tutto';
|
||||
case 'playlists.shuffle': return 'Casuale';
|
||||
case 'playlists.smartPlaylist': return 'Playlist intelligente';
|
||||
case 'playlists.regularPlaylist': return 'Playlist normale';
|
||||
case 'playlists.itemCount': return ({required Object count}) => '${count} elementi';
|
||||
case 'playlists.oneItem': return '1 elemento';
|
||||
case 'playlists.emptyPlaylist': return 'Questa playlist è vuota';
|
||||
case 'playlists.deleteConfirm': return 'Eliminare playlist?';
|
||||
case 'playlists.deleteMessage': return ({required Object name}) => 'Sei sicuro di voler eliminare "${name}"?';
|
||||
case 'playlists.created': return 'Playlist creata';
|
||||
case 'playlists.updated': return 'Playlist aggiornata';
|
||||
case 'playlists.deleted': return 'Playlist eliminata';
|
||||
case 'playlists.itemAdded': return 'Aggiunto alla playlist';
|
||||
case 'playlists.itemRemoved': return 'Rimosso dalla playlist';
|
||||
case 'playlists.selectPlaylist': return 'Seleziona playlist';
|
||||
case 'playlists.createNewPlaylist': return 'Crea nuova playlist';
|
||||
case 'playlists.errorCreating': return 'Errore durante la creazione della playlist';
|
||||
case 'playlists.errorDeleting': return 'Errore durante l\'eliminazione della playlist';
|
||||
case 'playlists.errorLoading': return 'Errore durante il caricamento delle playlist';
|
||||
case 'playlists.errorAdding': return 'Errore durante l\'aggiunta alla playlist';
|
||||
case 'playlists.errorReordering': return 'Errore durante il riordino dell\'elemento della playlist';
|
||||
case 'playlists.errorRemoving': return 'Errore durante la rimozione dalla playlist';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -5094,6 +5460,40 @@ extension on _StringsNl {
|
||||
case 'navigation.search': return 'Zoeken';
|
||||
case 'navigation.libraries': return 'Bibliotheken';
|
||||
case 'navigation.settings': return 'Instellingen';
|
||||
case 'playlists.title': return 'Afspeellijsten';
|
||||
case 'playlists.noPlaylists': return 'Geen afspeellijsten gevonden';
|
||||
case 'playlists.create': return 'Afspeellijst maken';
|
||||
case 'playlists.newPlaylist': return 'Nieuwe afspeellijst';
|
||||
case 'playlists.playlistName': return 'Naam afspeellijst';
|
||||
case 'playlists.enterPlaylistName': return 'Voer naam afspeellijst in';
|
||||
case 'playlists.edit': return 'Afspeellijst bewerken';
|
||||
case 'playlists.delete': return 'Afspeellijst verwijderen';
|
||||
case 'playlists.addTo': return 'Toevoegen aan afspeellijst';
|
||||
case 'playlists.addItems': return 'Items toevoegen';
|
||||
case 'playlists.removeItem': return 'Verwijderen uit afspeellijst';
|
||||
case 'playlists.clearPlaylist': return 'Afspeellijst wissen';
|
||||
case 'playlists.playAll': return 'Alles afspelen';
|
||||
case 'playlists.shuffle': return 'Shuffle';
|
||||
case 'playlists.smartPlaylist': return 'Slimme afspeellijst';
|
||||
case 'playlists.regularPlaylist': return 'Normale afspeellijst';
|
||||
case 'playlists.itemCount': return ({required Object count}) => '${count} items';
|
||||
case 'playlists.oneItem': return '1 item';
|
||||
case 'playlists.emptyPlaylist': return 'Deze afspeellijst is leeg';
|
||||
case 'playlists.deleteConfirm': return 'Afspeellijst verwijderen?';
|
||||
case 'playlists.deleteMessage': return ({required Object name}) => 'Weet je zeker dat je "${name}" wilt verwijderen?';
|
||||
case 'playlists.created': return 'Afspeellijst gemaakt';
|
||||
case 'playlists.updated': return 'Afspeellijst bijgewerkt';
|
||||
case 'playlists.deleted': return 'Afspeellijst verwijderd';
|
||||
case 'playlists.itemAdded': return 'Toegevoegd aan afspeellijst';
|
||||
case 'playlists.itemRemoved': return 'Verwijderd uit afspeellijst';
|
||||
case 'playlists.selectPlaylist': return 'Selecteer afspeellijst';
|
||||
case 'playlists.createNewPlaylist': return 'Nieuwe afspeellijst maken';
|
||||
case 'playlists.errorCreating': return 'Fout bij maken afspeellijst';
|
||||
case 'playlists.errorDeleting': return 'Fout bij verwijderen afspeellijst';
|
||||
case 'playlists.errorLoading': return 'Fout bij laden afspeellijsten';
|
||||
case 'playlists.errorAdding': return 'Fout bij toevoegen aan afspeellijst';
|
||||
case 'playlists.errorReordering': return 'Fout bij herschikken van afspeellijstitem';
|
||||
case 'playlists.errorRemoving': return 'Fout bij verwijderen uit afspeellijst';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -5418,6 +5818,40 @@ extension on _StringsSv {
|
||||
case 'navigation.search': return 'Sök';
|
||||
case 'navigation.libraries': return 'Bibliotek';
|
||||
case 'navigation.settings': return 'Inställningar';
|
||||
case 'playlists.title': return 'Spellistor';
|
||||
case 'playlists.noPlaylists': return 'Inga spellistor hittades';
|
||||
case 'playlists.create': return 'Skapa spellista';
|
||||
case 'playlists.newPlaylist': return 'Ny spellista';
|
||||
case 'playlists.playlistName': return 'Spellistans namn';
|
||||
case 'playlists.enterPlaylistName': return 'Ange spellistans namn';
|
||||
case 'playlists.edit': return 'Redigera spellista';
|
||||
case 'playlists.delete': return 'Ta bort spellista';
|
||||
case 'playlists.addTo': return 'Lägg till i spellista';
|
||||
case 'playlists.addItems': return 'Lägg till objekt';
|
||||
case 'playlists.removeItem': return 'Ta bort från spellista';
|
||||
case 'playlists.clearPlaylist': return 'Rensa spellista';
|
||||
case 'playlists.playAll': return 'Spela alla';
|
||||
case 'playlists.shuffle': return 'Blanda';
|
||||
case 'playlists.smartPlaylist': return 'Smart spellista';
|
||||
case 'playlists.regularPlaylist': return 'Vanlig spellista';
|
||||
case 'playlists.itemCount': return ({required Object count}) => '${count} objekt';
|
||||
case 'playlists.oneItem': return '1 objekt';
|
||||
case 'playlists.emptyPlaylist': return 'Denna spellista är tom';
|
||||
case 'playlists.deleteConfirm': return 'Ta bort spellista?';
|
||||
case 'playlists.deleteMessage': return ({required Object name}) => 'Är du säker på att du vill ta bort "${name}"?';
|
||||
case 'playlists.created': return 'Spellista skapad';
|
||||
case 'playlists.updated': return 'Spellista uppdaterad';
|
||||
case 'playlists.deleted': return 'Spellista borttagen';
|
||||
case 'playlists.itemAdded': return 'Tillagd i spellista';
|
||||
case 'playlists.itemRemoved': return 'Borttagen från spellista';
|
||||
case 'playlists.selectPlaylist': return 'Välj spellista';
|
||||
case 'playlists.createNewPlaylist': return 'Skapa ny spellista';
|
||||
case 'playlists.errorCreating': return 'Det gick inte att skapa spellista';
|
||||
case 'playlists.errorDeleting': return 'Det gick inte att ta bort spellista';
|
||||
case 'playlists.errorLoading': return 'Det gick inte att ladda spellistor';
|
||||
case 'playlists.errorAdding': return 'Det gick inte att lägga till i spellista';
|
||||
case 'playlists.errorReordering': return 'Det gick inte att omordna spellisteobjekt';
|
||||
case 'playlists.errorRemoving': return 'Det gick inte att ta bort från spellista';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -5742,6 +6176,40 @@ extension on _StringsZh {
|
||||
case 'navigation.search': return '搜索';
|
||||
case 'navigation.libraries': return '媒体库';
|
||||
case 'navigation.settings': return '设置';
|
||||
case 'playlists.title': return '播放列表';
|
||||
case 'playlists.noPlaylists': return '未找到播放列表';
|
||||
case 'playlists.create': return '创建播放列表';
|
||||
case 'playlists.newPlaylist': return '新播放列表';
|
||||
case 'playlists.playlistName': return '播放列表名称';
|
||||
case 'playlists.enterPlaylistName': return '输入播放列表名称';
|
||||
case 'playlists.edit': return '编辑播放列表';
|
||||
case 'playlists.delete': return '删除播放列表';
|
||||
case 'playlists.addTo': return '添加到播放列表';
|
||||
case 'playlists.addItems': return '添加项目';
|
||||
case 'playlists.removeItem': return '从播放列表中移除';
|
||||
case 'playlists.clearPlaylist': return '清空播放列表';
|
||||
case 'playlists.playAll': return '全部播放';
|
||||
case 'playlists.shuffle': return '随机播放';
|
||||
case 'playlists.smartPlaylist': return '智能播放列表';
|
||||
case 'playlists.regularPlaylist': return '普通播放列表';
|
||||
case 'playlists.itemCount': return ({required Object count}) => '${count} 个项目';
|
||||
case 'playlists.oneItem': return '1 个项目';
|
||||
case 'playlists.emptyPlaylist': return '此播放列表为空';
|
||||
case 'playlists.deleteConfirm': return '删除播放列表?';
|
||||
case 'playlists.deleteMessage': return ({required Object name}) => '确定要删除 "${name}" 吗?';
|
||||
case 'playlists.created': return '播放列表已创建';
|
||||
case 'playlists.updated': return '播放列表已更新';
|
||||
case 'playlists.deleted': return '播放列表已删除';
|
||||
case 'playlists.itemAdded': return '已添加到播放列表';
|
||||
case 'playlists.itemRemoved': return '已从播放列表中移除';
|
||||
case 'playlists.selectPlaylist': return '选择播放列表';
|
||||
case 'playlists.createNewPlaylist': return '创建新播放列表';
|
||||
case 'playlists.errorCreating': return '创建播放列表失败';
|
||||
case 'playlists.errorDeleting': return '删除播放列表失败';
|
||||
case 'playlists.errorLoading': return '加载播放列表失败';
|
||||
case 'playlists.errorAdding': return '添加到播放列表失败';
|
||||
case 'playlists.errorReordering': return '重新排序播放列表项目失败';
|
||||
case 'playlists.errorRemoving': return '从播放列表中移除失败';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,5 +368,41 @@
|
||||
"search": "Search",
|
||||
"libraries": "Libraries",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Playlists",
|
||||
"noPlaylists": "No playlists found",
|
||||
"create": "Create Playlist",
|
||||
"newPlaylist": "New Playlist",
|
||||
"playlistName": "Playlist Name",
|
||||
"enterPlaylistName": "Enter playlist name",
|
||||
"edit": "Edit Playlist",
|
||||
"delete": "Delete Playlist",
|
||||
"addTo": "Add to Playlist",
|
||||
"addItems": "Add Items",
|
||||
"removeItem": "Remove from Playlist",
|
||||
"clearPlaylist": "Clear Playlist",
|
||||
"playAll": "Play All",
|
||||
"shuffle": "Shuffle",
|
||||
"smartPlaylist": "Smart Playlist",
|
||||
"regularPlaylist": "Regular Playlist",
|
||||
"itemCount": "${count} items",
|
||||
"oneItem": "1 item",
|
||||
"emptyPlaylist": "This playlist is empty",
|
||||
"deleteConfirm": "Delete Playlist?",
|
||||
"deleteMessage": "Are you sure you want to delete \"${name}\"?",
|
||||
"created": "Playlist created",
|
||||
"updated": "Playlist updated",
|
||||
"deleted": "Playlist deleted",
|
||||
"itemAdded": "Added to playlist",
|
||||
"itemRemoved": "Removed from playlist",
|
||||
"selectPlaylist": "Select Playlist",
|
||||
"createNewPlaylist": "Create New Playlist",
|
||||
"errorCreating": "Failed to create playlist",
|
||||
"errorDeleting": "Failed to delete playlist",
|
||||
"errorLoading": "Failed to load playlists",
|
||||
"errorAdding": "Failed to add to playlist",
|
||||
"errorReordering": "Failed to reorder playlist item",
|
||||
"errorRemoving": "Failed to remove from playlist"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,5 +368,41 @@
|
||||
"search": "Suche",
|
||||
"libraries": "Bibliotheken",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Playlists",
|
||||
"noPlaylists": "Keine Playlists gefunden",
|
||||
"create": "Playlist erstellen",
|
||||
"newPlaylist": "Neue Playlist",
|
||||
"playlistName": "Playlist-Name",
|
||||
"enterPlaylistName": "Playlist-Namen eingeben",
|
||||
"edit": "Playlist bearbeiten",
|
||||
"delete": "Playlist löschen",
|
||||
"addTo": "Zur Playlist hinzufügen",
|
||||
"addItems": "Elemente hinzufügen",
|
||||
"removeItem": "Aus Playlist entfernen",
|
||||
"clearPlaylist": "Playlist leeren",
|
||||
"playAll": "Alle abspielen",
|
||||
"shuffle": "Zufällig",
|
||||
"smartPlaylist": "Intelligente Playlist",
|
||||
"regularPlaylist": "Normale Playlist",
|
||||
"itemCount": "${count} Elemente",
|
||||
"oneItem": "1 Element",
|
||||
"emptyPlaylist": "Diese Playlist ist leer",
|
||||
"deleteConfirm": "Playlist löschen?",
|
||||
"deleteMessage": "Möchten Sie \"${name}\" wirklich löschen?",
|
||||
"created": "Playlist erstellt",
|
||||
"updated": "Playlist aktualisiert",
|
||||
"deleted": "Playlist gelöscht",
|
||||
"itemAdded": "Zur Playlist hinzugefügt",
|
||||
"itemRemoved": "Aus Playlist entfernt",
|
||||
"selectPlaylist": "Playlist auswählen",
|
||||
"createNewPlaylist": "Neue Playlist erstellen",
|
||||
"errorCreating": "Fehler beim Erstellen der Playlist",
|
||||
"errorDeleting": "Fehler beim Löschen der Playlist",
|
||||
"errorLoading": "Fehler beim Laden der Playlists",
|
||||
"errorAdding": "Fehler beim Hinzufügen zur Playlist",
|
||||
"errorReordering": "Fehler beim Neuordnen des Playlist-Elements",
|
||||
"errorRemoving": "Fehler beim Entfernen aus der Playlist"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,5 +368,41 @@
|
||||
"search": "Cerca",
|
||||
"libraries": "Librerie",
|
||||
"settings": "Impostazioni"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Playlist",
|
||||
"noPlaylists": "Nessuna playlist trovata",
|
||||
"create": "Crea playlist",
|
||||
"newPlaylist": "Nuova playlist",
|
||||
"playlistName": "Nome playlist",
|
||||
"enterPlaylistName": "Inserisci nome playlist",
|
||||
"edit": "Modifica playlist",
|
||||
"delete": "Elimina playlist",
|
||||
"addTo": "Aggiungi a playlist",
|
||||
"addItems": "Aggiungi elementi",
|
||||
"removeItem": "Rimuovi da playlist",
|
||||
"clearPlaylist": "Svuota playlist",
|
||||
"playAll": "Riproduci tutto",
|
||||
"shuffle": "Casuale",
|
||||
"smartPlaylist": "Playlist intelligente",
|
||||
"regularPlaylist": "Playlist normale",
|
||||
"itemCount": "${count} elementi",
|
||||
"oneItem": "1 elemento",
|
||||
"emptyPlaylist": "Questa playlist è vuota",
|
||||
"deleteConfirm": "Eliminare playlist?",
|
||||
"deleteMessage": "Sei sicuro di voler eliminare \"${name}\"?",
|
||||
"created": "Playlist creata",
|
||||
"updated": "Playlist aggiornata",
|
||||
"deleted": "Playlist eliminata",
|
||||
"itemAdded": "Aggiunto alla playlist",
|
||||
"itemRemoved": "Rimosso dalla playlist",
|
||||
"selectPlaylist": "Seleziona playlist",
|
||||
"createNewPlaylist": "Crea nuova playlist",
|
||||
"errorCreating": "Errore durante la creazione della playlist",
|
||||
"errorDeleting": "Errore durante l'eliminazione della playlist",
|
||||
"errorLoading": "Errore durante il caricamento delle playlist",
|
||||
"errorAdding": "Errore durante l'aggiunta alla playlist",
|
||||
"errorReordering": "Errore durante il riordino dell'elemento della playlist",
|
||||
"errorRemoving": "Errore durante la rimozione dalla playlist"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,5 +368,41 @@
|
||||
"search": "Zoeken",
|
||||
"libraries": "Bibliotheken",
|
||||
"settings": "Instellingen"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Afspeellijsten",
|
||||
"noPlaylists": "Geen afspeellijsten gevonden",
|
||||
"create": "Afspeellijst maken",
|
||||
"newPlaylist": "Nieuwe afspeellijst",
|
||||
"playlistName": "Naam afspeellijst",
|
||||
"enterPlaylistName": "Voer naam afspeellijst in",
|
||||
"edit": "Afspeellijst bewerken",
|
||||
"delete": "Afspeellijst verwijderen",
|
||||
"addTo": "Toevoegen aan afspeellijst",
|
||||
"addItems": "Items toevoegen",
|
||||
"removeItem": "Verwijderen uit afspeellijst",
|
||||
"clearPlaylist": "Afspeellijst wissen",
|
||||
"playAll": "Alles afspelen",
|
||||
"shuffle": "Shuffle",
|
||||
"smartPlaylist": "Slimme afspeellijst",
|
||||
"regularPlaylist": "Normale afspeellijst",
|
||||
"itemCount": "${count} items",
|
||||
"oneItem": "1 item",
|
||||
"emptyPlaylist": "Deze afspeellijst is leeg",
|
||||
"deleteConfirm": "Afspeellijst verwijderen?",
|
||||
"deleteMessage": "Weet je zeker dat je \"${name}\" wilt verwijderen?",
|
||||
"created": "Afspeellijst gemaakt",
|
||||
"updated": "Afspeellijst bijgewerkt",
|
||||
"deleted": "Afspeellijst verwijderd",
|
||||
"itemAdded": "Toegevoegd aan afspeellijst",
|
||||
"itemRemoved": "Verwijderd uit afspeellijst",
|
||||
"selectPlaylist": "Selecteer afspeellijst",
|
||||
"createNewPlaylist": "Nieuwe afspeellijst maken",
|
||||
"errorCreating": "Fout bij maken afspeellijst",
|
||||
"errorDeleting": "Fout bij verwijderen afspeellijst",
|
||||
"errorLoading": "Fout bij laden afspeellijsten",
|
||||
"errorAdding": "Fout bij toevoegen aan afspeellijst",
|
||||
"errorReordering": "Fout bij herschikken van afspeellijstitem",
|
||||
"errorRemoving": "Fout bij verwijderen uit afspeellijst"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,5 +368,41 @@
|
||||
"search": "Sök",
|
||||
"libraries": "Bibliotek",
|
||||
"settings": "Inställningar"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Spellistor",
|
||||
"noPlaylists": "Inga spellistor hittades",
|
||||
"create": "Skapa spellista",
|
||||
"newPlaylist": "Ny spellista",
|
||||
"playlistName": "Spellistans namn",
|
||||
"enterPlaylistName": "Ange spellistans namn",
|
||||
"edit": "Redigera spellista",
|
||||
"delete": "Ta bort spellista",
|
||||
"addTo": "Lägg till i spellista",
|
||||
"addItems": "Lägg till objekt",
|
||||
"removeItem": "Ta bort från spellista",
|
||||
"clearPlaylist": "Rensa spellista",
|
||||
"playAll": "Spela alla",
|
||||
"shuffle": "Blanda",
|
||||
"smartPlaylist": "Smart spellista",
|
||||
"regularPlaylist": "Vanlig spellista",
|
||||
"itemCount": "${count} objekt",
|
||||
"oneItem": "1 objekt",
|
||||
"emptyPlaylist": "Denna spellista är tom",
|
||||
"deleteConfirm": "Ta bort spellista?",
|
||||
"deleteMessage": "Är du säker på att du vill ta bort \"${name}\"?",
|
||||
"created": "Spellista skapad",
|
||||
"updated": "Spellista uppdaterad",
|
||||
"deleted": "Spellista borttagen",
|
||||
"itemAdded": "Tillagd i spellista",
|
||||
"itemRemoved": "Borttagen från spellista",
|
||||
"selectPlaylist": "Välj spellista",
|
||||
"createNewPlaylist": "Skapa ny spellista",
|
||||
"errorCreating": "Det gick inte att skapa spellista",
|
||||
"errorDeleting": "Det gick inte att ta bort spellista",
|
||||
"errorLoading": "Det gick inte att ladda spellistor",
|
||||
"errorAdding": "Det gick inte att lägga till i spellista",
|
||||
"errorReordering": "Det gick inte att omordna spellisteobjekt",
|
||||
"errorRemoving": "Det gick inte att ta bort från spellista"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,5 +368,41 @@
|
||||
"search": "搜索",
|
||||
"libraries": "媒体库",
|
||||
"settings": "设置"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "播放列表",
|
||||
"noPlaylists": "未找到播放列表",
|
||||
"create": "创建播放列表",
|
||||
"newPlaylist": "新播放列表",
|
||||
"playlistName": "播放列表名称",
|
||||
"enterPlaylistName": "输入播放列表名称",
|
||||
"edit": "编辑播放列表",
|
||||
"delete": "删除播放列表",
|
||||
"addTo": "添加到播放列表",
|
||||
"addItems": "添加项目",
|
||||
"removeItem": "从播放列表中移除",
|
||||
"clearPlaylist": "清空播放列表",
|
||||
"playAll": "全部播放",
|
||||
"shuffle": "随机播放",
|
||||
"smartPlaylist": "智能播放列表",
|
||||
"regularPlaylist": "普通播放列表",
|
||||
"itemCount": "${count} 个项目",
|
||||
"oneItem": "1 个项目",
|
||||
"emptyPlaylist": "此播放列表为空",
|
||||
"deleteConfirm": "删除播放列表?",
|
||||
"deleteMessage": "确定要删除 \"${name}\" 吗?",
|
||||
"created": "播放列表已创建",
|
||||
"updated": "播放列表已更新",
|
||||
"deleted": "播放列表已删除",
|
||||
"itemAdded": "已添加到播放列表",
|
||||
"itemRemoved": "已从播放列表中移除",
|
||||
"selectPlaylist": "选择播放列表",
|
||||
"createNewPlaylist": "创建新播放列表",
|
||||
"errorCreating": "创建播放列表失败",
|
||||
"errorDeleting": "删除播放列表失败",
|
||||
"errorLoading": "加载播放列表失败",
|
||||
"errorAdding": "添加到播放列表失败",
|
||||
"errorReordering": "重新排序播放列表项目失败",
|
||||
"errorRemoving": "从播放列表中移除失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ class PlexMetadata {
|
||||
final List<PlexRole>? role; // Cast members
|
||||
final String? audioLanguage; // Per-media preferred audio language
|
||||
final String? subtitleLanguage; // Per-media preferred subtitle language
|
||||
final int? playlistItemID; // Playlist item ID (for dumb playlists only)
|
||||
|
||||
// Transient field for clear logo (extracted from Image array)
|
||||
String? _clearLogo;
|
||||
@@ -79,6 +80,7 @@ class PlexMetadata {
|
||||
this.role,
|
||||
this.audioLanguage,
|
||||
this.subtitleLanguage,
|
||||
this.playlistItemID,
|
||||
});
|
||||
|
||||
/// Create a copy of this metadata with optional field overrides
|
||||
@@ -116,6 +118,7 @@ class PlexMetadata {
|
||||
List<PlexRole>? role,
|
||||
String? audioLanguage,
|
||||
String? subtitleLanguage,
|
||||
int? playlistItemID,
|
||||
}) {
|
||||
final copy = PlexMetadata(
|
||||
ratingKey: ratingKey ?? this.ratingKey,
|
||||
@@ -151,6 +154,7 @@ class PlexMetadata {
|
||||
role: role ?? this.role,
|
||||
audioLanguage: audioLanguage ?? this.audioLanguage,
|
||||
subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage,
|
||||
playlistItemID: playlistItemID ?? this.playlistItemID,
|
||||
);
|
||||
// Preserve clearLogo
|
||||
copy._clearLogo = _clearLogo;
|
||||
|
||||
@@ -42,6 +42,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
||||
.toList(),
|
||||
audioLanguage: json['audioLanguage'] as String?,
|
||||
subtitleLanguage: json['subtitleLanguage'] as String?,
|
||||
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
||||
@@ -79,4 +80,5 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
||||
'Role': instance.role,
|
||||
'audioLanguage': instance.audioLanguage,
|
||||
'subtitleLanguage': instance.subtitleLanguage,
|
||||
'playlistItemID': instance.playlistItemID,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'plex_playlist.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class PlexPlaylist {
|
||||
final String ratingKey;
|
||||
final String key;
|
||||
final String type; // "playlist"
|
||||
final String title;
|
||||
final String? summary;
|
||||
final bool smart;
|
||||
final String playlistType; // video, audio, photo
|
||||
final int? duration;
|
||||
final int? leafCount; // Number of items in playlist
|
||||
final String? composite; // Composite thumbnail image
|
||||
final int? addedAt;
|
||||
final int? updatedAt;
|
||||
final int? lastViewedAt;
|
||||
final int? viewCount;
|
||||
final String? content; // For smart playlists - generator URI
|
||||
final String? guid;
|
||||
final String? thumb;
|
||||
|
||||
PlexPlaylist({
|
||||
required this.ratingKey,
|
||||
required this.key,
|
||||
required this.type,
|
||||
required this.title,
|
||||
this.summary,
|
||||
required this.smart,
|
||||
required this.playlistType,
|
||||
this.duration,
|
||||
this.leafCount,
|
||||
this.composite,
|
||||
this.addedAt,
|
||||
this.updatedAt,
|
||||
this.lastViewedAt,
|
||||
this.viewCount,
|
||||
this.content,
|
||||
this.guid,
|
||||
this.thumb,
|
||||
});
|
||||
|
||||
/// Helper to get display image (composite or thumb)
|
||||
String? get displayImage => composite ?? thumb;
|
||||
|
||||
/// Helper to get formatted duration
|
||||
String? get formattedDuration {
|
||||
if (duration == null) return null;
|
||||
final hours = duration! ~/ 3600000;
|
||||
final minutes = (duration! % 3600000) ~/ 60000;
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
return '${minutes}m';
|
||||
}
|
||||
|
||||
/// Helper to determine if playlist is editable
|
||||
bool get isEditable => !smart;
|
||||
|
||||
factory PlexPlaylist.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexPlaylistFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexPlaylistToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_playlist.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexPlaylist _$PlexPlaylistFromJson(Map<String, dynamic> json) => PlexPlaylist(
|
||||
ratingKey: json['ratingKey'] as String,
|
||||
key: json['key'] as String,
|
||||
type: json['type'] as String,
|
||||
title: json['title'] as String,
|
||||
summary: json['summary'] as String?,
|
||||
smart: json['smart'] as bool,
|
||||
playlistType: json['playlistType'] as String,
|
||||
duration: (json['duration'] as num?)?.toInt(),
|
||||
leafCount: (json['leafCount'] as num?)?.toInt(),
|
||||
composite: json['composite'] as String?,
|
||||
addedAt: (json['addedAt'] as num?)?.toInt(),
|
||||
updatedAt: (json['updatedAt'] as num?)?.toInt(),
|
||||
lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(),
|
||||
viewCount: (json['viewCount'] as num?)?.toInt(),
|
||||
content: json['content'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) =>
|
||||
<String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': instance.key,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'summary': instance.summary,
|
||||
'smart': instance.smart,
|
||||
'playlistType': instance.playlistType,
|
||||
'duration': instance.duration,
|
||||
'leafCount': instance.leafCount,
|
||||
'composite': instance.composite,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastViewedAt': instance.lastViewedAt,
|
||||
'viewCount': instance.viewCount,
|
||||
'content': instance.content,
|
||||
'guid': instance.guid,
|
||||
'thumb': instance.thumb,
|
||||
};
|
||||
@@ -1,98 +1,126 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
|
||||
/// Manages shuffle playback state for TV shows and seasons.
|
||||
/// Playback mode types
|
||||
enum PlaybackMode {
|
||||
none, // No active playback queue
|
||||
sequential, // Normal episode-to-episode playback (uses Plex API)
|
||||
shufflePlay, // Shuffle play for shows/seasons
|
||||
playlist, // Playlist playback (ordered or shuffled)
|
||||
}
|
||||
|
||||
/// Manages playback state for TV shows, seasons, and playlists.
|
||||
/// This provider is session-only and does not persist across app restarts.
|
||||
class PlaybackStateProvider with ChangeNotifier {
|
||||
List<PlexMetadata> _shuffleQueue = [];
|
||||
String?
|
||||
_shuffleContextKey; // The show/season ratingKey for this shuffle session
|
||||
List<PlexMetadata> _queue = [];
|
||||
String? _contextKey; // The show/season/playlist ratingKey for this session
|
||||
int _currentIndex = 0;
|
||||
PlaybackMode _playbackMode = PlaybackMode.none;
|
||||
|
||||
/// Current playback mode
|
||||
PlaybackMode get playbackMode => _playbackMode;
|
||||
|
||||
/// Whether shuffle mode is currently active
|
||||
bool get isShuffleActive => _shuffleQueue.isNotEmpty;
|
||||
bool get isShuffleActive => _playbackMode == PlaybackMode.shufflePlay;
|
||||
|
||||
/// The context key (show or season ratingKey) for the current shuffle session
|
||||
String? get shuffleContextKey => _shuffleContextKey;
|
||||
/// Whether playlist mode is currently active
|
||||
bool get isPlaylistActive => _playbackMode == PlaybackMode.playlist;
|
||||
|
||||
/// Whether any queue-based playback is active
|
||||
bool get isQueueActive => _queue.isNotEmpty && _playbackMode != PlaybackMode.none;
|
||||
|
||||
/// The context key (show/season/playlist ratingKey) for the current session
|
||||
String? get shuffleContextKey => _contextKey;
|
||||
|
||||
/// Sets a new shuffle queue and starts shuffle mode
|
||||
void setShuffleQueue(List<PlexMetadata> episodes, String contextKey) {
|
||||
_shuffleQueue = List.from(episodes);
|
||||
_shuffleContextKey = contextKey;
|
||||
_queue = List.from(episodes);
|
||||
_contextKey = contextKey;
|
||||
_currentIndex = 0;
|
||||
_playbackMode = PlaybackMode.shufflePlay;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Gets the next episode in the shuffle queue.
|
||||
/// Returns null if queue is exhausted or current episode is not in queue.
|
||||
/// Sets a playback queue for playlist playback (ordered, not shuffled)
|
||||
void setPlaybackQueue(List<PlexMetadata> items, String contextKey) {
|
||||
_queue = List.from(items);
|
||||
_contextKey = contextKey;
|
||||
_currentIndex = 0;
|
||||
_playbackMode = PlaybackMode.playlist;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Gets the next item in the playback queue.
|
||||
/// Returns null if queue is exhausted or current item is not in queue.
|
||||
/// [loopQueue] - If true, restart from beginning when queue is exhausted
|
||||
PlexMetadata? getNextEpisode(
|
||||
String currentEpisodeKey, {
|
||||
String currentItemKey, {
|
||||
bool loopQueue = false,
|
||||
}) {
|
||||
if (_shuffleQueue.isEmpty) return null;
|
||||
if (_queue.isEmpty) return null;
|
||||
|
||||
// Find current episode in queue
|
||||
final currentIndex = _shuffleQueue.indexWhere(
|
||||
(ep) => ep.ratingKey == currentEpisodeKey,
|
||||
// Find current item in queue
|
||||
final currentIndex = _queue.indexWhere(
|
||||
(item) => item.ratingKey == currentItemKey,
|
||||
);
|
||||
|
||||
if (currentIndex == -1) {
|
||||
// Current episode not in queue, clear shuffle
|
||||
// Current item not in queue, clear queue
|
||||
clearShuffle();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there's a next episode
|
||||
if (currentIndex + 1 >= _shuffleQueue.length) {
|
||||
// Check if there's a next item
|
||||
if (currentIndex + 1 >= _queue.length) {
|
||||
// Queue exhausted
|
||||
if (loopQueue && _shuffleQueue.isNotEmpty) {
|
||||
if (loopQueue && _queue.isNotEmpty) {
|
||||
// Loop back to beginning
|
||||
_currentIndex = 0;
|
||||
return _shuffleQueue[_currentIndex];
|
||||
return _queue[_currentIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_currentIndex = currentIndex + 1;
|
||||
return _shuffleQueue[_currentIndex];
|
||||
return _queue[_currentIndex];
|
||||
}
|
||||
|
||||
/// Gets the previous episode in the shuffle queue.
|
||||
/// Returns null if at the beginning of the queue or current episode is not in queue.
|
||||
PlexMetadata? getPreviousEpisode(String currentEpisodeKey) {
|
||||
if (_shuffleQueue.isEmpty) return null;
|
||||
/// Gets the previous item in the playback queue.
|
||||
/// Returns null if at the beginning of the queue or current item is not in queue.
|
||||
PlexMetadata? getPreviousEpisode(String currentItemKey) {
|
||||
if (_queue.isEmpty) return null;
|
||||
|
||||
// Find current episode in queue
|
||||
final currentIndex = _shuffleQueue.indexWhere(
|
||||
(ep) => ep.ratingKey == currentEpisodeKey,
|
||||
// Find current item in queue
|
||||
final currentIndex = _queue.indexWhere(
|
||||
(item) => item.ratingKey == currentItemKey,
|
||||
);
|
||||
|
||||
if (currentIndex == -1) {
|
||||
// Current episode not in queue
|
||||
// Current item not in queue
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there's a previous episode
|
||||
// Check if there's a previous item
|
||||
if (currentIndex <= 0) {
|
||||
// At the beginning of queue
|
||||
return null;
|
||||
}
|
||||
|
||||
_currentIndex = currentIndex - 1;
|
||||
return _shuffleQueue[_currentIndex];
|
||||
return _queue[_currentIndex];
|
||||
}
|
||||
|
||||
/// Clears the shuffle queue and exits shuffle mode
|
||||
/// Clears the playback queue and exits queue mode
|
||||
void clearShuffle() {
|
||||
_shuffleQueue = [];
|
||||
_shuffleContextKey = null;
|
||||
_queue = [];
|
||||
_contextKey = null;
|
||||
_currentIndex = 0;
|
||||
_playbackMode = PlaybackMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Gets the total number of episodes in the current shuffle queue
|
||||
int get queueLength => _shuffleQueue.length;
|
||||
/// Gets the total number of items in the current playback queue
|
||||
int get queueLength => _queue.length;
|
||||
|
||||
/// Gets the current position in the queue (1-indexed)
|
||||
int get currentPosition => _currentIndex + 1;
|
||||
|
||||
@@ -21,6 +21,7 @@ import '../mixins/refreshable.dart';
|
||||
import '../mixins/item_updatable.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'playlists_screen.dart';
|
||||
|
||||
class LibrariesScreen extends StatefulWidget {
|
||||
const LibrariesScreen({super.key});
|
||||
@@ -1125,6 +1126,18 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
],
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const PlaylistsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: t.playlists.title,
|
||||
child: const Icon(Icons.playlist_play),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/playlist_item_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../mixins/item_updatable.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Screen to display the contents of a playlist
|
||||
class PlaylistDetailScreen extends StatefulWidget {
|
||||
final PlexPlaylist playlist;
|
||||
|
||||
const PlaylistDetailScreen({super.key, required this.playlist});
|
||||
|
||||
@override
|
||||
State<PlaylistDetailScreen> createState() => _PlaylistDetailScreenState();
|
||||
}
|
||||
|
||||
class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
||||
with Refreshable, ItemUpdatable {
|
||||
@override
|
||||
PlexClient get client => context.clientSafe;
|
||||
|
||||
List<PlexMetadata> _items = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPlaylistItems();
|
||||
}
|
||||
|
||||
Future<void> _loadPlaylistItems() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
final items = await client.getPlaylist(widget.playlist.ratingKey);
|
||||
|
||||
setState(() {
|
||||
_items = items;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
appLogger.d('Loaded ${items.length} items for playlist: ${widget.playlist.title}');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load playlist items', error: e);
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load playlist items: ${e.toString()}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deletePlaylist() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(t.playlists.deleteConfirm),
|
||||
content: Text(t.playlists.deleteMessage(name: widget.playlist.title)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(t.playlists.delete),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final success = await client.deletePlaylist(widget.playlist.ratingKey);
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.deleted)),
|
||||
);
|
||||
Navigator.pop(context); // Return to playlists screen
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.errorDeleting)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onReorder(int oldIndex, int newIndex) async {
|
||||
// Adjust newIndex if moving down in the list
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex--;
|
||||
}
|
||||
|
||||
// Can't reorder if indices are the same
|
||||
if (oldIndex == newIndex) return;
|
||||
|
||||
final movedItem = _items[oldIndex];
|
||||
|
||||
// Check if item has playlistItemID (required for reordering)
|
||||
if (movedItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot reorder: item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.errorReordering)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the "after" item ID
|
||||
// If moving to position 0, afterPlaylistItemId should be 0 (move to top)
|
||||
// Otherwise, use the playlistItemID of the item before the new position
|
||||
final int afterPlaylistItemId;
|
||||
if (newIndex == 0) {
|
||||
afterPlaylistItemId = 0; // Move to top
|
||||
} else {
|
||||
final afterItem = _items[newIndex - 1];
|
||||
if (afterItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot reorder: after item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.errorReordering)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
afterPlaylistItemId = afterItem.playlistItemID!;
|
||||
}
|
||||
|
||||
appLogger.d('Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)');
|
||||
|
||||
// Optimistically update UI
|
||||
setState(() {
|
||||
final item = _items.removeAt(oldIndex);
|
||||
_items.insert(newIndex, item);
|
||||
});
|
||||
|
||||
// Call API to persist the change
|
||||
final success = await client.movePlaylistItem(
|
||||
playlistId: widget.playlist.ratingKey,
|
||||
playlistItemId: movedItem.playlistItemID!,
|
||||
afterPlaylistItemId: afterPlaylistItemId,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
// Revert on failure
|
||||
appLogger.e('Failed to reorder playlist item, reverting UI');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
final item = _items.removeAt(newIndex);
|
||||
_items.insert(oldIndex, item);
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.errorReordering)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeItem(int index) async {
|
||||
final item = _items[index];
|
||||
|
||||
// Check if item has playlistItemID (required for removal)
|
||||
if (item.playlistItemID == null) {
|
||||
appLogger.e('Cannot remove: item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.errorRemoving)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d('Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist');
|
||||
|
||||
// Optimistically update UI
|
||||
setState(() {
|
||||
_items.removeAt(index);
|
||||
});
|
||||
|
||||
// Call API to persist the change
|
||||
final success = await client.removeFromPlaylist(
|
||||
playlistId: widget.playlist.ratingKey,
|
||||
playlistItemId: item.playlistItemID.toString(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.itemRemoved)),
|
||||
);
|
||||
} else {
|
||||
// Revert on failure
|
||||
appLogger.e('Failed to remove playlist item, reverting UI');
|
||||
setState(() {
|
||||
_items.insert(index, item);
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.errorRemoving)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
if (index != -1) {
|
||||
_items[index] = updatedMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
_loadPlaylistItems();
|
||||
}
|
||||
|
||||
Future<void> _playPlaylist() async {
|
||||
if (_items.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.emptyPlaylist)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
|
||||
// Set the playlist items as the playback queue (in order, not shuffled)
|
||||
playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey);
|
||||
|
||||
// Navigate to the first item
|
||||
if (mounted) {
|
||||
await navigateToVideoPlayer(context, metadata: _items.first);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _shufflePlayPlaylist() async {
|
||||
if (_items.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.emptyPlaylist)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
|
||||
// Shuffle the items
|
||||
final shuffledItems = List<PlexMetadata>.from(_items)..shuffle();
|
||||
|
||||
// Set the shuffled playlist items as the playback queue (playlist mode, not shuffle mode)
|
||||
playbackState.setPlaybackQueue(shuffledItems, widget.playlist.ratingKey);
|
||||
|
||||
// Navigate to the first shuffled item
|
||||
if (mounted) {
|
||||
await navigateToVideoPlayer(context, metadata: shuffledItems.first);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playFromItem(int index) async {
|
||||
if (_items.isEmpty || index < 0 || index >= _items.length) return;
|
||||
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
|
||||
// Set the full playlist as playback queue (in order)
|
||||
playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey);
|
||||
|
||||
// Start playing from the clicked item
|
||||
if (mounted) {
|
||||
await navigateToVideoPlayer(context, metadata: _items[index]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.playlist.title,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
if (widget.playlist.smart)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 12,
|
||||
color: Colors.blue[300],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
t.playlists.smartPlaylist,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.blue[300],
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
pinned: true,
|
||||
actions: [
|
||||
// Play button
|
||||
if (_items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
tooltip: t.discover.play,
|
||||
onPressed: _playPlaylist,
|
||||
),
|
||||
// Shuffle button
|
||||
if (_items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.shuffle),
|
||||
tooltip: t.playlists.shuffle,
|
||||
onPressed: _shufflePlayPlaylist,
|
||||
),
|
||||
// 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: _loadPlaylistItems,
|
||||
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: _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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final padding = 16.0;
|
||||
final availableWidth = screenWidth - padding;
|
||||
|
||||
if (screenWidth >= 900) {
|
||||
double divisor;
|
||||
double maxItemWidth;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 6.5;
|
||||
maxItemWidth = 280;
|
||||
break;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 8.0;
|
||||
maxItemWidth = 200;
|
||||
break;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 10.0;
|
||||
maxItemWidth = 160;
|
||||
break;
|
||||
}
|
||||
|
||||
return (availableWidth / divisor).clamp(120, maxItemWidth);
|
||||
} else if (screenWidth >= 600) {
|
||||
double divisor;
|
||||
double maxItemWidth;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 4.5;
|
||||
maxItemWidth = 220;
|
||||
break;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 5.5;
|
||||
maxItemWidth = 180;
|
||||
break;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 7.0;
|
||||
maxItemWidth = 140;
|
||||
break;
|
||||
}
|
||||
|
||||
return (availableWidth / divisor).clamp(100, maxItemWidth);
|
||||
} else {
|
||||
double divisor;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 2.2;
|
||||
break;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 2.8;
|
||||
break;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 3.5;
|
||||
break;
|
||||
}
|
||||
|
||||
return availableWidth / divisor;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'playlist_detail_screen.dart';
|
||||
|
||||
/// Screen to display all video playlists
|
||||
class PlaylistsScreen extends StatefulWidget {
|
||||
const PlaylistsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PlaylistsScreen> createState() => _PlaylistsScreenState();
|
||||
}
|
||||
|
||||
class _PlaylistsScreenState extends State<PlaylistsScreen> with Refreshable {
|
||||
PlexClient get client => context.clientSafe;
|
||||
|
||||
List<PlexPlaylist> _playlists = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
bool? _filterSmart;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPlaylists();
|
||||
}
|
||||
|
||||
Future<void> _loadPlaylists() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
final playlists = await client.getPlaylists(
|
||||
playlistType: 'video',
|
||||
smart: _filterSmart,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_playlists = playlists;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
appLogger.d('Loaded ${playlists.length} playlists');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load playlists', error: e);
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load playlists: ${e.toString()}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleSmartFilter() {
|
||||
setState(() {
|
||||
if (_filterSmart == null) {
|
||||
_filterSmart = true; // Show only smart
|
||||
} else if (_filterSmart == true) {
|
||||
_filterSmart = false; // Show only regular
|
||||
} else {
|
||||
_filterSmart = null; // Show all
|
||||
}
|
||||
});
|
||||
_loadPlaylists();
|
||||
}
|
||||
|
||||
String _getFilterLabel() {
|
||||
if (_filterSmart == null) return 'All';
|
||||
if (_filterSmart == true) return 'Smart';
|
||||
return 'Regular';
|
||||
}
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
_loadPlaylists();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Text(t.playlists.title),
|
||||
pinned: true,
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.filter_list),
|
||||
label: Text(_getFilterLabel()),
|
||||
onPressed: _toggleSmartFilter,
|
||||
),
|
||||
],
|
||||
),
|
||||
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: _loadPlaylists,
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_playlists.isEmpty && _isLoading)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_playlists.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(child: Text(t.playlists.noPlaylists)),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: _getMaxCrossAxisExtent(
|
||||
context,
|
||||
context.watch<SettingsProvider>().libraryDensity,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
return _PlaylistCard(
|
||||
playlist: _playlists[index],
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PlaylistDetailScreen(
|
||||
playlist: _playlists[index],
|
||||
),
|
||||
),
|
||||
).then((_) => _loadPlaylists()); // Refresh on return
|
||||
},
|
||||
onDeleted: _loadPlaylists,
|
||||
);
|
||||
}, childCount: _playlists.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final padding = 16.0;
|
||||
final availableWidth = screenWidth - padding;
|
||||
|
||||
if (screenWidth >= 900) {
|
||||
double divisor;
|
||||
double maxItemWidth;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 6.5;
|
||||
maxItemWidth = 280;
|
||||
break;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 8.0;
|
||||
maxItemWidth = 200;
|
||||
break;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 10.0;
|
||||
maxItemWidth = 160;
|
||||
break;
|
||||
}
|
||||
|
||||
return (availableWidth / divisor).clamp(120, maxItemWidth);
|
||||
} else if (screenWidth >= 600) {
|
||||
double divisor;
|
||||
double maxItemWidth;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 4.5;
|
||||
maxItemWidth = 220;
|
||||
break;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 5.5;
|
||||
maxItemWidth = 180;
|
||||
break;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 7.0;
|
||||
maxItemWidth = 140;
|
||||
break;
|
||||
}
|
||||
|
||||
return (availableWidth / divisor).clamp(100, maxItemWidth);
|
||||
} else {
|
||||
double divisor;
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
divisor = 2.2;
|
||||
break;
|
||||
case LibraryDensity.normal:
|
||||
divisor = 2.8;
|
||||
break;
|
||||
case LibraryDensity.compact:
|
||||
divisor = 3.5;
|
||||
break;
|
||||
}
|
||||
|
||||
return availableWidth / divisor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget to display a single playlist card
|
||||
class _PlaylistCard extends StatelessWidget {
|
||||
final PlexPlaylist playlist;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDeleted;
|
||||
|
||||
const _PlaylistCard({
|
||||
required this.playlist,
|
||||
required this.onTap,
|
||||
required this.onDeleted,
|
||||
});
|
||||
|
||||
Future<void> _showDeleteDialog(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(t.playlists.deleteConfirm),
|
||||
content: Text(t.playlists.deleteMessage(name: playlist.title)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(t.playlists.delete),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
final client = context.clientSafe;
|
||||
final success = await client.deletePlaylist(playlist.ratingKey);
|
||||
|
||||
if (context.mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.deleted)),
|
||||
);
|
||||
onDeleted();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.errorDeleting)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = context.clientSafe;
|
||||
final imageUrl = playlist.displayImage != null
|
||||
? client.getThumbnailUrl(playlist.displayImage!)
|
||||
: null;
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: const EdgeInsets.all(4),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: () => _showDeleteDialog(context),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Playlist image
|
||||
Expanded(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (imageUrl != null)
|
||||
Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return _buildPlaceholder();
|
||||
},
|
||||
)
|
||||
else
|
||||
_buildPlaceholder(),
|
||||
// Smart playlist indicator
|
||||
if (playlist.smart)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Playlist info
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
playlist.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.playlist_play,
|
||||
size: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
playlist.leafCount != null && playlist.leafCount! > 0
|
||||
? (playlist.leafCount == 1
|
||||
? t.playlists.oneItem
|
||||
: t.playlists.itemCount(count: playlist.leafCount!))
|
||||
: t.playlists.emptyPlaylist,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder() {
|
||||
return Container(
|
||||
color: Colors.grey[850],
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -284,10 +284,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
}
|
||||
|
||||
Future<void> _loadAdjacentEpisodes() async {
|
||||
if (widget.metadata.type.toLowerCase() != 'episode') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
@@ -299,8 +295,25 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
PlexMetadata? next;
|
||||
PlexMetadata? previous;
|
||||
|
||||
// Check if playlist mode is active (takes priority)
|
||||
if (playbackState.isPlaylistActive) {
|
||||
// For playlists, always use the queue regardless of item type
|
||||
// Playlists can contain both movies and episodes
|
||||
next = playbackState.getNextEpisode(
|
||||
widget.metadata.ratingKey,
|
||||
loopQueue: false, // Don't loop playlists by default
|
||||
);
|
||||
previous = playbackState.getPreviousEpisode(
|
||||
widget.metadata.ratingKey,
|
||||
);
|
||||
}
|
||||
// Check if shuffle mode is active
|
||||
if (playbackState.isShuffleActive) {
|
||||
else if (playbackState.isShuffleActive) {
|
||||
// Only works for episodes in shuffle mode
|
||||
if (widget.metadata.type.toLowerCase() != 'episode') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get settings
|
||||
final shuffleOrderNavigation = settingsProvider.shuffleOrderNavigation;
|
||||
final loopQueue = settingsProvider.shuffleLoopQueue;
|
||||
@@ -319,7 +332,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
next = await client.findAdjacentEpisode(widget.metadata, 1);
|
||||
previous = await client.findAdjacentEpisode(widget.metadata, -1);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
// Normal sequential playback
|
||||
else {
|
||||
// Only works for episodes in sequential mode
|
||||
if (widget.metadata.type.toLowerCase() != 'episode') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use normal sequential episode loading
|
||||
next = await client.findAdjacentEpisode(widget.metadata, 1);
|
||||
previous = await client.findAdjacentEpisode(widget.metadata, -1);
|
||||
@@ -1483,16 +1503,20 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
}
|
||||
});
|
||||
|
||||
// Enable/disable next/previous track controls based on content type
|
||||
// Enable/disable next/previous track controls based on content type and playback mode
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
|
||||
if (isEpisode) {
|
||||
// Enable next/previous track controls for episodes
|
||||
final isInPlaylist = playbackState.isPlaylistActive;
|
||||
|
||||
// Enable controls for episodes OR playlist items
|
||||
if (isEpisode || isInPlaylist) {
|
||||
// Enable next/previous track controls for episodes and playlist items
|
||||
await OsMediaControls.enableControls([
|
||||
MediaControl.next,
|
||||
MediaControl.previous,
|
||||
]);
|
||||
} else {
|
||||
// Disable next/previous track controls for movies
|
||||
// Disable next/previous track controls for standalone movies
|
||||
await OsMediaControls.disableControls([
|
||||
MediaControl.next,
|
||||
MediaControl.previous,
|
||||
|
||||
@@ -112,6 +112,18 @@ class ServerConnectionService {
|
||||
},
|
||||
);
|
||||
|
||||
// Fetch machine identifier and cache it in config
|
||||
try {
|
||||
final machineId = await client.getMachineIdentifier();
|
||||
if (machineId != null) {
|
||||
client.config = config.copyWith(machineIdentifier: machineId);
|
||||
appLogger.d('Cached machine identifier: $machineId');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to fetch machine identifier', error: e);
|
||||
// Continue without it - buildMetadataUri will fallback to fetching it
|
||||
}
|
||||
|
||||
// Verify server is accessible if requested
|
||||
if (verifyServer) {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
import '../screens/season_detail_screen.dart';
|
||||
import '../widgets/file_info_bottom_sheet.dart';
|
||||
@@ -143,6 +145,17 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
);
|
||||
}
|
||||
|
||||
// Add to Playlist (for episodes, movies, shows, and seasons)
|
||||
if (itemType == 'episode' || itemType == 'movie' || itemType == 'show' || itemType == 'season') {
|
||||
menuActions.add(
|
||||
_MenuAction(
|
||||
value: 'add_to_playlist',
|
||||
icon: Icons.playlist_add,
|
||||
label: t.playlists.addTo,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? selected;
|
||||
|
||||
if (useBottomSheet) {
|
||||
@@ -296,6 +309,10 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
await _showFileInfo(context);
|
||||
break;
|
||||
|
||||
case 'add_to_playlist':
|
||||
await _showAddToPlaylistDialog(context);
|
||||
break;
|
||||
|
||||
case 'shuffle_play':
|
||||
await handleShufflePlay(context, widget.metadata);
|
||||
break;
|
||||
@@ -412,6 +429,100 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show dialog to select playlist and add item
|
||||
Future<void> _showAddToPlaylistDialog(BuildContext context) async {
|
||||
final client = context.client;
|
||||
if (client == null) return;
|
||||
|
||||
try {
|
||||
final itemType = widget.metadata.type.toLowerCase();
|
||||
|
||||
// Load playlists
|
||||
final playlists = await client.getPlaylists(playlistType: 'video');
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Show dialog to select playlist or create new
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => _PlaylistSelectionDialog(
|
||||
playlists: playlists,
|
||||
),
|
||||
);
|
||||
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
// Build URI for the item (works for all types: movies, episodes, seasons, shows)
|
||||
// For seasons/shows, the Plex API should automatically expand to include all episodes
|
||||
final itemUri = await client.buildMetadataUri(widget.metadata.ratingKey);
|
||||
appLogger.d('Built URI for $itemType: $itemUri');
|
||||
|
||||
if (result == '_create_new') {
|
||||
// Create new playlist flow
|
||||
final playlistName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => _CreatePlaylistDialog(),
|
||||
);
|
||||
|
||||
if (playlistName == null || playlistName.isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create playlist with the item(s)
|
||||
appLogger.d('Creating playlist "$playlistName" with URI length: ${itemUri.length}');
|
||||
final newPlaylist = await client.createPlaylist(
|
||||
title: playlistName,
|
||||
uri: itemUri,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
if (newPlaylist != null) {
|
||||
appLogger.d('Successfully created playlist: ${newPlaylist.title}');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.created)),
|
||||
);
|
||||
} else {
|
||||
appLogger.e('Failed to create playlist - API returned null');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.errorCreating)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Add to existing playlist
|
||||
appLogger.d('Adding to playlist $result with URI: $itemUri');
|
||||
final success = await client.addToPlaylist(
|
||||
playlistId: result,
|
||||
uri: itemUri,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
if (success) {
|
||||
appLogger.d('Successfully added item(s) to playlist $result');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.itemAdded)),
|
||||
);
|
||||
} else {
|
||||
appLogger.e('Failed to add item(s) to playlist $result - API returned false');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.playlists.errorAdding)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${t.playlists.errorLoading}: ${e.toString()}'),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
@@ -424,3 +535,107 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog to select a playlist or create a new one
|
||||
class _PlaylistSelectionDialog extends StatelessWidget {
|
||||
final List<PlexPlaylist> playlists;
|
||||
|
||||
const _PlaylistSelectionDialog({required this.playlists});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(t.playlists.selectPlaylist),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: playlists.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
// Create new playlist option (always shown first)
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: Text(t.playlists.createNewPlaylist),
|
||||
onTap: () => Navigator.pop(context, '_create_new'),
|
||||
);
|
||||
}
|
||||
|
||||
final playlist = playlists[index - 1];
|
||||
return ListTile(
|
||||
leading: playlist.smart
|
||||
? const Icon(Icons.auto_awesome)
|
||||
: const Icon(Icons.playlist_play),
|
||||
title: Text(playlist.title),
|
||||
subtitle: playlist.leafCount != null
|
||||
? Text(playlist.leafCount == 1
|
||||
? t.playlists.oneItem
|
||||
: t.playlists.itemCount(count: playlist.leafCount!))
|
||||
: null,
|
||||
onTap: playlist.smart
|
||||
? null // Disable smart playlists
|
||||
: () => Navigator.pop(context, playlist.ratingKey),
|
||||
enabled: !playlist.smart,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog to create a new playlist
|
||||
class _CreatePlaylistDialog extends StatefulWidget {
|
||||
@override
|
||||
State<_CreatePlaylistDialog> createState() => _CreatePlaylistDialogState();
|
||||
}
|
||||
|
||||
class _CreatePlaylistDialogState extends State<_CreatePlaylistDialog> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(t.playlists.create),
|
||||
content: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.playlists.playlistName,
|
||||
hintText: t.playlists.enterPlaylistName,
|
||||
),
|
||||
onSubmitted: (value) {
|
||||
if (value.isNotEmpty) {
|
||||
Navigator.pop(context, value);
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (_controller.text.isNotEmpty) {
|
||||
Navigator.pop(context, _controller.text);
|
||||
}
|
||||
},
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Custom list item widget for playlist items
|
||||
/// Shows drag handle, poster, title/metadata, duration, and remove button
|
||||
class PlaylistItemCard extends StatelessWidget {
|
||||
final PlexMetadata item;
|
||||
final int index;
|
||||
final VoidCallback onRemove;
|
||||
final VoidCallback? onTap;
|
||||
final bool canReorder; // Whether drag handle should be shown
|
||||
|
||||
const PlaylistItemCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.index,
|
||||
required this.onRemove,
|
||||
this.onTap,
|
||||
this.canReorder = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Drag handle (if reorderable)
|
||||
if (canReorder)
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: Icon(
|
||||
Icons.drag_indicator,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Poster thumbnail
|
||||
_buildPosterImage(context),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Title and metadata
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
item.displayTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Subtitle (episode info or type)
|
||||
Text(
|
||||
_buildSubtitle(),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
// Progress indicator if partially watched
|
||||
if (item.viewOffset != null && item.duration != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: LinearProgressIndicator(
|
||||
value: item.viewOffset! / item.duration!,
|
||||
backgroundColor: Colors.grey[800],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Duration
|
||||
if (item.duration != null)
|
||||
Text(
|
||||
_formatDuration(item.duration!),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Remove button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: onRemove,
|
||||
tooltip: t.playlists.removeItem,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPosterImage(BuildContext context) {
|
||||
final posterUrl = item.posterThumb();
|
||||
if (posterUrl != null) {
|
||||
return Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
return _buildPlaceholder();
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(posterUrl),
|
||||
width: 60,
|
||||
height: 90,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => _buildPlaceholder(),
|
||||
errorWidget: (context, url, error) => _buildPlaceholder(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _buildPlaceholder();
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder() {
|
||||
return Container(
|
||||
width: 60,
|
||||
height: 90,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[850],
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Icon(Icons.movie, color: Colors.grey, size: 24),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildSubtitle() {
|
||||
final itemType = item.type.toLowerCase();
|
||||
|
||||
if (itemType == 'episode') {
|
||||
// For episodes, show "S#E# - Episode Title"
|
||||
final season = item.parentIndex;
|
||||
final episode = item.index;
|
||||
if (season != null && episode != null) {
|
||||
return 'S${season}E$episode${item.displaySubtitle != null ? ' - ${item.displaySubtitle}' : ''}';
|
||||
}
|
||||
return item.displaySubtitle ?? t.discover.tvShow;
|
||||
} else if (itemType == 'movie') {
|
||||
// For movies, show year
|
||||
return item.year?.toString() ?? t.discover.movie;
|
||||
}
|
||||
|
||||
// Default to type
|
||||
return item.type;
|
||||
}
|
||||
|
||||
String _formatDuration(int milliseconds) {
|
||||
final duration = Duration(milliseconds: milliseconds);
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
} else {
|
||||
return '${minutes}m';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- HotKey (0.2.1)
|
||||
- hotkey_manager_macos (0.0.1):
|
||||
@@ -36,6 +38,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
||||
- macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`)
|
||||
@@ -57,6 +60,8 @@ SPEC REPOS:
|
||||
- HotKey
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
connectivity_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
hotkey_manager_macos:
|
||||
@@ -89,6 +94,7 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
||||
|
||||
Reference in New Issue
Block a user