diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index b01e62f3..0b31a98a 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -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? prioritizedEndpoints, Future 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 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 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> 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> getPlaylists({ + String playlistType = 'video', + bool? smart, + }) async { + try { + final queryParams = {'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 metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return []; + } + + return metadata + .map((item) => PlexPlaylist.fromJson(item as Map)) + .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 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 metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return null; + } + + return PlexPlaylist.fromJson(metadata.first as Map); + } 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 createPlaylist({ + required String title, + String? uri, + int? playQueueId, + }) async { + try { + final queryParams = { + '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 metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return null; + } + + return PlexPlaylist.fromJson(metadata.first as Map); + } catch (e) { + appLogger.e('Failed to create playlist: $e'); + return null; + } + } + + /// Delete a playlist + Future 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 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 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 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 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 updatePlaylist({ + required String playlistId, + String? title, + String? summary, + }) async { + try { + final queryParams = { + '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 // ============================================================================ diff --git a/lib/config/plex_config.dart b/lib/config/plex_config.dart index 08675587..5d53b1a9 100644 --- a/lib/config/plex_config.dart +++ b/lib/config/plex_config.dart @@ -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 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, ); } } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 61e360cf..dbf4b3d0 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 { 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: 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: 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: 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: 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: 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; } } diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 2d250cbd..9712e83b 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -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" } } diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index 176effea..fa5c662c 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -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" } } diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index b206b80d..8672f423 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -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" } } diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index ec1e906f..04640b3f 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -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" } } diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index e118824d..54f72356 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -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" } } diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 34eae804..705ae2ee 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -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": "从播放列表中移除失败" } } diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 4a667cf8..52233ed1 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -40,6 +40,7 @@ class PlexMetadata { final List? 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? 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; diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index 9d6de3a1..a9f262ea 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -42,6 +42,7 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( .toList(), audioLanguage: json['audioLanguage'] as String?, subtitleLanguage: json['subtitleLanguage'] as String?, + playlistItemID: (json['playlistItemID'] as num?)?.toInt(), ); Map _$PlexMetadataToJson(PlexMetadata instance) => @@ -79,4 +80,5 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 'Role': instance.role, 'audioLanguage': instance.audioLanguage, 'subtitleLanguage': instance.subtitleLanguage, + 'playlistItemID': instance.playlistItemID, }; diff --git a/lib/models/plex_playlist.dart b/lib/models/plex_playlist.dart new file mode 100644 index 00000000..d47944f7 --- /dev/null +++ b/lib/models/plex_playlist.dart @@ -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 json) => + _$PlexPlaylistFromJson(json); + + Map toJson() => _$PlexPlaylistToJson(this); +} diff --git a/lib/models/plex_playlist.g.dart b/lib/models/plex_playlist.g.dart new file mode 100644 index 00000000..96647865 --- /dev/null +++ b/lib/models/plex_playlist.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_playlist.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexPlaylist _$PlexPlaylistFromJson(Map 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 _$PlexPlaylistToJson(PlexPlaylist instance) => + { + '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, + }; diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 721345af..c13d7c3f 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -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 _shuffleQueue = []; - String? - _shuffleContextKey; // The show/season ratingKey for this shuffle session + List _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 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 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; diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index b7a65fd2..cf5a912e 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -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 ], ], ), + floatingActionButton: FloatingActionButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PlaylistsScreen(), + ), + ); + }, + tooltip: t.playlists.title, + child: const Icon(Icons.playlist_play), + ), ); } diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart new file mode 100644 index 00000000..c96af4ec --- /dev/null +++ b/lib/screens/playlist_detail_screen.dart @@ -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 createState() => _PlaylistDetailScreenState(); +} + +class _PlaylistDetailScreenState extends State + with Refreshable, ItemUpdatable { + @override + PlexClient get client => context.clientSafe; + + List _items = []; + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadPlaylistItems(); + } + + Future _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 _deletePlaylist() async { + final confirmed = await showDialog( + 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 _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 _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 _playPlaylist() async { + if (_items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.emptyPlaylist)), + ); + } + return; + } + + final playbackState = context.read(); + + // 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 _shufflePlayPlaylist() async { + if (_items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.emptyPlaylist)), + ); + } + return; + } + + final playbackState = context.read(); + + // Shuffle the items + final shuffledItems = List.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 _playFromItem(int index) async { + if (_items.isEmpty || index < 0 || index >= _items.length) return; + + final playbackState = context.read(); + + // 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().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; + } + } +} diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart new file mode 100644 index 00000000..82fad9f0 --- /dev/null +++ b/lib/screens/playlists_screen.dart @@ -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 createState() => _PlaylistsScreenState(); +} + +class _PlaylistsScreenState extends State with Refreshable { + PlexClient get client => context.clientSafe; + + List _playlists = []; + bool _isLoading = false; + String? _errorMessage; + bool? _filterSmart; + + @override + void initState() { + super.initState(); + _loadPlaylists(); + } + + Future _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().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 _showDeleteDialog(BuildContext context) async { + final confirmed = await showDialog( + 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, + ), + ), + ); + } +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 6f40aed7..06098ccb 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -284,10 +284,6 @@ class VideoPlayerScreenState extends State } Future _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 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 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 } }); - // 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(); 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, diff --git a/lib/services/server_connection_service.dart b/lib/services/server_connection_service.dart index 5a3dbea2..c760039c 100644 --- a/lib/services/server_connection_service.dart +++ b/lib/services/server_connection_service.dart @@ -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 { diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index a186469d..68e6c39b 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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 { ); } + // 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 { 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 { } } + /// Show dialog to select playlist and add item + Future _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( + 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( + 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 { ); } } + +/// Dialog to select a playlist or create a new one +class _PlaylistSelectionDialog extends StatelessWidget { + final List 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), + ), + ], + ); + } +} diff --git a/lib/widgets/playlist_item_card.dart b/lib/widgets/playlist_item_card.dart new file mode 100644 index 00000000..0cbb404b --- /dev/null +++ b/lib/widgets/playlist_item_card.dart @@ -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( + 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( + 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'; + } + } +} diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 3035b5b1..c77fe217 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -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