feat: playlists

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