diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b352e747..7f1d716c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,9 +1,12 @@ PODS: + - connectivity_plus (0.0.1): + - Flutter - Flutter (1.0.0) - media_kit_libs_ios_video (1.0.4): - Flutter - media_kit_video (0.0.1): - Flutter + - media_kit_libs_ios_video - os_media_controls (0.0.1): - Flutter - package_info_plus (0.4.5): @@ -25,6 +28,7 @@ PODS: - Flutter DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) @@ -38,6 +42,8 @@ DEPENDENCIES: - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" Flutter: :path: Flutter media_kit_libs_ios_video: @@ -62,9 +68,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 - media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 + media_kit_video: 6235abf1d299037d23692ad47119b89e05346b23 os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 03b3c3e7..0fc3e407 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -39,10 +39,6 @@ This app needs to connect to your Plex Media Server on your local network. UIApplicationSupportsIndirectInputEvents - UIBackgroundModes - - audio - UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index ebce3753..0b31a98a 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -10,9 +10,12 @@ 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'; import '../utils/app_logger.dart'; +import '../utils/log_redaction_manager.dart'; /// Result of testing a connection, including success status and latency class ConnectionTestResult { @@ -25,6 +28,8 @@ class ConnectionTestResult { class PlexClient { PlexConfig config; late final Dio _dio; + final EndpointFailoverManager? _endpointManager; + final Future Function(String newBaseUrl)? _onEndpointChanged; /// Custom response decoder that handles malformed UTF-8 gracefully static String _lenientUtf8Decoder( @@ -35,7 +40,18 @@ class PlexClient { return utf8.decode(responseBytes, allowMalformed: true); } - PlexClient(this.config) { + PlexClient( + this.config, { + List? prioritizedEndpoints, + Future Function(String newBaseUrl)? onEndpointChanged, + }) : _endpointManager = + (prioritizedEndpoints != null && prioritizedEndpoints.isNotEmpty) + ? EndpointFailoverManager(prioritizedEndpoints) + : null, + _onEndpointChanged = onEndpointChanged { + LogRedactionManager.registerServerUrl(config.baseUrl); + LogRedactionManager.registerToken(config.token); + _dio = Dio( BaseOptions( baseUrl: config.baseUrl, @@ -59,6 +75,16 @@ class PlexClient { responseHeader: false, ), ); + + if (_endpointManager != null) { + _dio.interceptors.add( + EndpointFailoverInterceptor( + dio: _dio, + endpointManager: _endpointManager, + onEndpointSwitch: _handleEndpointSwitch, + ), + ); + } } /// Update the token used by this client @@ -66,9 +92,29 @@ class PlexClient { // Update both the Dio headers and the config to ensure consistency _dio.options.headers['X-Plex-Token'] = newToken; config = config.copyWith(token: newToken); + LogRedactionManager.registerToken(newToken); appLogger.d('PlexClient token updated (headers and config)'); } + /// Update endpoint priority list and optionally hop to the new best endpoint. + Future updateEndpointPreferences( + List prioritizedEndpoints, { + bool switchToFirst = false, + }) async { + if (_endpointManager == null || prioritizedEndpoints.isEmpty) { + return; + } + + final targetBaseUrl = switchToFirst + ? prioritizedEndpoints.first + : config.baseUrl; + _endpointManager.reset(prioritizedEndpoints, currentBaseUrl: targetBaseUrl); + + if (switchToFirst && targetBaseUrl != config.baseUrl) { + await _handleEndpointSwitch(targetBaseUrl); + } + } + /// Test connection to server Future testConnection() async { try { @@ -257,6 +303,30 @@ class PlexClient { return _extractSingleMetadata(response); } + /// Get the server's machine identifier + Future getMachineIdentifier() async { + try { + final response = await _dio.get('/'); + final container = _getMediaContainer(response); + if (container == null) return null; + return container['machineIdentifier'] as String?; + } catch (e) { + appLogger.e('Failed to get machine identifier', error: e); + return null; + } + } + + /// Build a proper metadata URI for adding to playlists + /// Returns URI in format: server://{machineId}/com.plexapp.plugins.library/library/metadata/{ratingKey} + Future buildMetadataUri(String ratingKey) async { + // Use cached machine identifier from config if available + final machineId = config.machineIdentifier ?? await getMachineIdentifier(); + if (machineId == null) { + throw Exception('Could not get server machine identifier'); + } + return 'server://$machineId/com.plexapp.plugins.library/library/metadata/$ratingKey'; + } + /// Get metadata by rating key with images (includes clearLogo and OnDeck) Future> getMetadataWithImagesAndOnDeck( String ratingKey, @@ -302,6 +372,40 @@ class PlexClient { : null; } + /// Set per-media language preferences (audio and subtitle) + /// For TV shows, use grandparentRatingKey to set preference for the entire series + /// For movies, use the movie's ratingKey + Future setMetadataPreferences( + String ratingKey, { + String? audioLanguage, + String? subtitleLanguage, + }) async { + try { + final queryParams = {}; + if (audioLanguage != null) { + queryParams['audioLanguage'] = audioLanguage; + } + if (subtitleLanguage != null) { + queryParams['subtitleLanguage'] = subtitleLanguage; + } + + // If no preferences to set, return early + if (queryParams.isEmpty) { + return true; + } + + final response = await _dio.put( + '/library/metadata/$ratingKey/prefs', + queryParameters: queryParams, + ); + + return response.statusCode == 200; + } catch (e) { + appLogger.e('Failed to set metadata preferences', error: e); + return false; + } + } + /// Search across all libraries using the hub search endpoint /// Only returns movies and shows, filtering out seasons and episodes Future> search(String query, {int limit = 10}) async { @@ -1152,6 +1256,234 @@ class PlexClient { } } + /// Get all playlists + /// Filters by playlistType=video by default + /// Set smart to true/false to filter smart playlists, or null for all + Future> getPlaylists({ + String playlistType = 'video', + bool? smart, + }) async { + try { + final queryParams = {'playlistType': playlistType}; + if (smart != null) { + queryParams['smart'] = smart ? '1' : '0'; + } + + final response = await _dio.get( + '/playlists', + queryParameters: queryParams, + ); + final container = _getMediaContainer(response); + + if (container == null || container['Metadata'] == null) { + return []; + } + + final List metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return []; + } + + return metadata + .map((item) => PlexPlaylist.fromJson(item as Map)) + .toList(); + } catch (e) { + appLogger.e('Failed to get playlists: $e'); + return []; + } + } + + /// Get playlist metadata by playlist ID + /// Returns the playlist details (not the items) + Future getPlaylistMetadata(String playlistId) async { + try { + final response = await _dio.get('/playlists/$playlistId'); + final container = _getMediaContainer(response); + + if (container == null || container['Metadata'] == null) { + return null; + } + + final List metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return null; + } + + return PlexPlaylist.fromJson(metadata.first as Map); + } catch (e) { + appLogger.e('Failed to get playlist metadata: $e'); + return null; + } + } + + /// Create a new playlist + /// [title] - Name of the playlist + /// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234") + /// [playQueueId] - Optional play queue ID to create playlist from + Future createPlaylist({ + required String title, + String? uri, + int? playQueueId, + }) async { + try { + final queryParams = { + 'type': 'video', + 'title': title, + 'smart': '0', + }; + + if (uri != null) { + queryParams['uri'] = uri; + } + if (playQueueId != null) { + queryParams['playQueueID'] = playQueueId.toString(); + } + + final response = await _dio.post( + '/playlists', + queryParameters: queryParams, + ); + final container = _getMediaContainer(response); + + if (container == null || container['Metadata'] == null) { + return null; + } + + final List metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return null; + } + + return PlexPlaylist.fromJson(metadata.first as Map); + } catch (e) { + appLogger.e('Failed to create playlist: $e'); + return null; + } + } + + /// Delete a playlist + Future deletePlaylist(String playlistId) async { + try { + await _dio.delete('/playlists/$playlistId'); + return true; + } catch (e) { + appLogger.e('Failed to delete playlist: $e'); + return false; + } + } + + /// Add items to a playlist + /// [playlistId] - The playlist to add items to + /// [uri] - Comma-separated list of item URIs to add + Future addToPlaylist({ + required String playlistId, + required String uri, + }) async { + try { + appLogger.d( + 'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}', + ); + final response = await _dio.put( + '/playlists/$playlistId/items', + queryParameters: {'uri': uri}, + ); + appLogger.d('Add to playlist response status: ${response.statusCode}'); + return response.statusCode == 200; + } catch (e) { + appLogger.e('Failed to add to playlist', error: e); + return false; + } + } + + /// Remove an item from a playlist + /// [playlistId] - The playlist to remove from + /// [playlistItemId] - The playlist item ID to remove (from the item's playlistItemID field) + Future removeFromPlaylist({ + required String playlistId, + required String playlistItemId, + }) async { + try { + await _dio.delete('/playlists/$playlistId/items/$playlistItemId'); + return true; + } catch (e) { + appLogger.e('Failed to remove from playlist: $e'); + return false; + } + } + + /// Move a playlist item to a new position + /// Only works with non-smart playlists + /// [playlistId] - The playlist rating key + /// [playlistItemId] - The playlist item ID to move + /// [afterPlaylistItemId] - Move the item after this playlist item ID (0 = move to top) + Future movePlaylistItem({ + required String playlistId, + required int playlistItemId, + required int afterPlaylistItemId, + }) async { + try { + appLogger.d( + 'Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId', + ); + await _dio.put( + '/playlists/$playlistId/items/$playlistItemId/move', + queryParameters: {'after': afterPlaylistItemId}, + ); + appLogger.d('Successfully moved playlist item'); + return true; + } catch (e) { + appLogger.e('Failed to move playlist item', error: e); + return false; + } + } + + /// Clear all items from a playlist + Future clearPlaylist(String playlistId) async { + try { + await _dio.delete('/playlists/$playlistId/items'); + return true; + } catch (e) { + appLogger.e('Failed to clear playlist: $e'); + return false; + } + } + + /// Update playlist metadata (e.g., title, summary) + /// Uses the same metadata editing mechanism as other items + Future updatePlaylist({ + required String playlistId, + String? title, + String? summary, + }) async { + try { + final queryParams = { + 'type': 'playlist', + 'id': playlistId, + }; + + if (title != null) { + queryParams['title.value'] = title; + queryParams['title.locked'] = '1'; + } + if (summary != null) { + queryParams['summary.value'] = summary; + queryParams['summary.locked'] = '1'; + } + + await _dio.put( + '/library/metadata/$playlistId', + queryParameters: queryParams, + ); + return true; + } catch (e) { + appLogger.e('Failed to update playlist: $e'); + return false; + } + } + // ============================================================================ // Library Management Methods // ============================================================================ @@ -1175,4 +1507,19 @@ class PlexClient { Future analyzeLibrary(String sectionId) async { await _dio.get('/library/sections/$sectionId/analyze'); } + + Future _handleEndpointSwitch(String newBaseUrl) async { + if (config.baseUrl == newBaseUrl) { + return; + } + + appLogger.i('Applying Plex endpoint switch', error: newBaseUrl); + _dio.options.baseUrl = newBaseUrl; + config = config.copyWith(baseUrl: newBaseUrl); + LogRedactionManager.registerServerUrl(newBaseUrl); + + if (_onEndpointChanged != null) { + await _onEndpointChanged(newBaseUrl); + } + } } diff --git a/lib/config/plex_config.dart b/lib/config/plex_config.dart index 08675587..5d53b1a9 100644 --- a/lib/config/plex_config.dart +++ b/lib/config/plex_config.dart @@ -9,6 +9,7 @@ class PlexConfig { final String platform; final String? device; final bool acceptJson; + final String? machineIdentifier; PlexConfig({ required this.baseUrl, @@ -19,6 +20,7 @@ class PlexConfig { this.platform = 'Flutter', this.device, this.acceptJson = true, + this.machineIdentifier, }); static Future create({ @@ -29,6 +31,7 @@ class PlexConfig { String? platform, String? device, bool acceptJson = true, + String? machineIdentifier, }) async { final packageInfo = await PackageInfo.fromPlatform(); return PlexConfig( @@ -40,6 +43,7 @@ class PlexConfig { platform: platform ?? 'Flutter', device: device, acceptJson: acceptJson, + machineIdentifier: machineIdentifier, ); } @@ -70,6 +74,7 @@ class PlexConfig { String? platform, String? device, bool? acceptJson, + String? machineIdentifier, }) { return PlexConfig( baseUrl: baseUrl ?? this.baseUrl, @@ -80,6 +85,7 @@ class PlexConfig { platform: platform ?? this.platform, device: device ?? this.device, acceptJson: acceptJson ?? this.acceptJson, + machineIdentifier: machineIdentifier ?? this.machineIdentifier, ); } } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 6d055508..d95497f8 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -3,10 +3,10 @@ /// Original: lib/i18n /// To regenerate, run: `dart run slang` /// -/// Locales: 4 -/// Strings: 1256 (314 per locale) +/// Locales: 6 +/// Strings: 2130 (355 per locale) /// -/// Built on 2025-11-13 at 21:35 UTC +/// Built on 2025-11-15 at 11:48 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -26,9 +26,11 @@ const AppLocale _baseLocale = AppLocale.en; /// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check enum AppLocale with BaseAppLocale { en(languageCode: 'en', build: Translations.build), + de(languageCode: 'de', build: _StringsDe.build), it(languageCode: 'it', build: _StringsIt.build), nl(languageCode: 'nl', build: _StringsNl.build), - sv(languageCode: 'sv', build: _StringsSv.build); + sv(languageCode: 'sv', build: _StringsSv.build), + zh(languageCode: 'zh', build: _StringsZh.build); const AppLocale({required this.languageCode, this.scriptCode, this.countryCode, required this.build}); // ignore: unused_element @@ -177,6 +179,7 @@ class Translations implements BaseTranslations { late final _StringsLogsEn logs = _StringsLogsEn._(_root); late final _StringsLicensesEn licenses = _StringsLicensesEn._(_root); late final _StringsNavigationEn navigation = _StringsNavigationEn._(_root); + late final _StringsPlaylistsEn playlists = _StringsPlaylistsEn._(_root); } // Path: app @@ -321,6 +324,8 @@ class _StringsSettingsEn { String secondsUnit({required Object seconds}) => '${seconds} seconds'; String get defaultSleepTimer => 'Default Sleep Timer'; String minutesUnit({required Object minutes}) => '${minutes} minutes'; + String get rememberTrackSelections => 'Remember track selections per show/movie'; + String get rememberTrackSelectionsDescription => 'Automatically save audio and subtitle language preferences when you change tracks during playback'; String get unwatchedOnly => 'Unwatched Only'; String get unwatchedOnlyDescription => 'Only include unwatched episodes in shuffle queue'; String get shuffleOrderNavigation => 'Shuffle Order Navigation'; @@ -480,6 +485,7 @@ class _StringsUserStatusEn { String get admin => 'Admin'; String get restricted => 'Restricted'; String get protected => 'Protected'; + String get current => 'CURRENT'; } // Path: messages @@ -578,6 +584,10 @@ class _StringsDiscoverEn { String get pause => 'Pause'; String get overview => 'Overview'; String get cast => 'Cast'; + String get seasons => 'Seasons'; + String get studio => 'Studio'; + String get rating => 'Rating'; + String get watched => 'Watched'; String episodeCount({required Object count}) => '${count} episodes'; String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} watched'; String get movie => 'Movie'; @@ -736,6 +746,710 @@ class _StringsNavigationEn { String get settings => 'Settings'; } +// Path: playlists +class _StringsPlaylistsEn { + _StringsPlaylistsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'Playlists'; + String get noPlaylists => 'No playlists found'; + String get create => 'Create Playlist'; + String get newPlaylist => 'New Playlist'; + String get playlistName => 'Playlist Name'; + String get enterPlaylistName => 'Enter playlist name'; + String get edit => 'Edit Playlist'; + String get delete => 'Delete Playlist'; + String get addTo => 'Add to Playlist'; + String get addItems => 'Add Items'; + String get removeItem => 'Remove from Playlist'; + String get clearPlaylist => 'Clear Playlist'; + String get playAll => 'Play All'; + String get shuffle => 'Shuffle'; + String get smartPlaylist => 'Smart Playlist'; + String get regularPlaylist => 'Regular Playlist'; + String itemCount({required Object count}) => '${count} items'; + String get oneItem => '1 item'; + String get emptyPlaylist => 'This playlist is empty'; + String get deleteConfirm => 'Delete Playlist?'; + String deleteMessage({required Object name}) => 'Are you sure you want to delete "${name}"?'; + String get created => 'Playlist created'; + String get updated => 'Playlist updated'; + String get deleted => 'Playlist deleted'; + String get itemAdded => 'Added to playlist'; + String get itemRemoved => 'Removed from playlist'; + String get selectPlaylist => 'Select Playlist'; + String get createNewPlaylist => 'Create New Playlist'; + String get errorCreating => 'Failed to create playlist'; + String get errorDeleting => 'Failed to delete playlist'; + String get errorLoading => 'Failed to load playlists'; + String get errorAdding => 'Failed to add to playlist'; + String get errorReordering => 'Failed to reorder playlist item'; + String get errorRemoving => 'Failed to remove from playlist'; +} + +// Path: +class _StringsDe implements Translations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + _StringsDe.build({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = TranslationMetadata( + locale: AppLocale.de, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ) { + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + @override dynamic operator[](String key) => $meta.getTranslation(key); + + @override late final _StringsDe _root = this; // ignore: unused_field + + // Translations + @override late final _StringsAppDe app = _StringsAppDe._(_root); + @override late final _StringsAuthDe auth = _StringsAuthDe._(_root); + @override late final _StringsCommonDe common = _StringsCommonDe._(_root); + @override late final _StringsScreensDe screens = _StringsScreensDe._(_root); + @override late final _StringsUpdateDe update = _StringsUpdateDe._(_root); + @override late final _StringsSettingsDe settings = _StringsSettingsDe._(_root); + @override late final _StringsSearchDe search = _StringsSearchDe._(_root); + @override late final _StringsHotkeysDe hotkeys = _StringsHotkeysDe._(_root); + @override late final _StringsPinEntryDe pinEntry = _StringsPinEntryDe._(_root); + @override late final _StringsFileInfoDe fileInfo = _StringsFileInfoDe._(_root); + @override late final _StringsMediaMenuDe mediaMenu = _StringsMediaMenuDe._(_root); + @override late final _StringsTooltipsDe tooltips = _StringsTooltipsDe._(_root); + @override late final _StringsVideoControlsDe videoControls = _StringsVideoControlsDe._(_root); + @override late final _StringsUserStatusDe userStatus = _StringsUserStatusDe._(_root); + @override late final _StringsMessagesDe messages = _StringsMessagesDe._(_root); + @override late final _StringsProfileDe profile = _StringsProfileDe._(_root); + @override late final _StringsSubtitlingStylingDe subtitlingStyling = _StringsSubtitlingStylingDe._(_root); + @override late final _StringsDialogDe dialog = _StringsDialogDe._(_root); + @override late final _StringsDiscoverDe discover = _StringsDiscoverDe._(_root); + @override late final _StringsErrorsDe errors = _StringsErrorsDe._(_root); + @override late final _StringsLibrariesDe libraries = _StringsLibrariesDe._(_root); + @override late final _StringsAboutDe about = _StringsAboutDe._(_root); + @override late final _StringsServerSelectionDe serverSelection = _StringsServerSelectionDe._(_root); + @override late final _StringsHubDetailDe hubDetail = _StringsHubDetailDe._(_root); + @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 +class _StringsAppDe implements _StringsAppEn { + _StringsAppDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Plezy'; + @override String get loading => 'Lädt...'; +} + +// Path: auth +class _StringsAuthDe implements _StringsAuthEn { + _StringsAuthDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get signInWithPlex => 'Mit Plex anmelden'; + @override String get showQRCode => 'QR-Code anzeigen'; + @override String get cancel => 'Abbrechen'; + @override String get authenticate => 'Authentifizieren'; + @override String get retry => 'Erneut versuchen'; + @override String get debugEnterToken => 'Debug: Plex-Token eingeben'; + @override String get plexTokenLabel => 'Plex-Auth-Token'; + @override String get plexTokenHint => 'Plex.tv-Token eingeben'; + @override String get authenticationTimeout => 'Authentifizierung abgelaufen. Bitte erneut versuchen.'; + @override String get scanQRCodeInstruction => 'Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.'; + @override String get waitingForAuth => 'Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen.'; +} + +// Path: common +class _StringsCommonDe implements _StringsCommonEn { + _StringsCommonDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get cancel => 'Abbrechen'; + @override String get save => 'Speichern'; + @override String get close => 'Schließen'; + @override String get clear => 'Leeren'; + @override String get reset => 'Zurücksetzen'; + @override String get later => 'Später'; + @override String get submit => 'Senden'; + @override String get confirm => 'Bestätigen'; + @override String get retry => 'Erneut versuchen'; + @override String get playNow => 'Jetzt abspielen'; + @override String get logout => 'Abmelden'; + @override String get online => 'Online'; + @override String get offline => 'Offline'; + @override String get owned => 'Besitzer'; + @override String get shared => 'Geteilt'; + @override String get current => 'AKTUELL'; + @override String get unknown => 'Unbekannt'; + @override String get refresh => 'Aktualisieren'; + @override String get yes => 'Ja'; + @override String get no => 'Nein'; + @override String get server => 'Server'; +} + +// Path: screens +class _StringsScreensDe implements _StringsScreensEn { + _StringsScreensDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get licenses => 'Lizenzen'; + @override String get selectServer => 'Server auswählen'; + @override String get switchProfile => 'Profil wechseln'; + @override String get subtitleStyling => 'Untertitel-Stil'; + @override String get search => 'Suche'; + @override String get logs => 'Protokolle'; +} + +// Path: update +class _StringsUpdateDe implements _StringsUpdateEn { + _StringsUpdateDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get available => 'Update verfügbar'; + @override String versionAvailable({required Object version}) => 'Version ${version} ist verfügbar'; + @override String currentVersion({required Object version}) => 'Aktuell: ${version}'; + @override String get skipVersion => 'Diese Version überspringen'; + @override String get viewRelease => 'Release anzeigen'; + @override String get latestVersion => 'Aktuellste Version installiert'; + @override String get checkFailed => 'Fehler bei der Updateprüfung'; +} + +// Path: settings +class _StringsSettingsDe implements _StringsSettingsEn { + _StringsSettingsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Einstellungen'; + @override String get language => 'Sprache'; + @override String get theme => 'Design'; + @override String get appearance => 'Darstellung'; + @override String get videoPlayback => 'Videowiedergabe'; + @override String get shufflePlay => 'Zufallswiedergabe'; + @override String get advanced => 'Erweitert'; + @override String get useSeasonPostersDescription => 'Staffelposter statt Serienposter für Episoden anzeigen'; + @override String get showHeroSectionDescription => 'Bereich mit empfohlenen Inhalten auf der Startseite anzeigen'; + @override String get secondsLabel => 'Sekunden'; + @override String get minutesLabel => 'Minuten'; + @override String get secondsShort => 's'; + @override String get minutesShort => 'm'; + @override String durationHint({required Object min, required Object max}) => 'Dauer eingeben (${min}-${max})'; + @override String get systemTheme => 'System'; + @override String get systemThemeDescription => 'Systemeinstellungen folgen'; + @override String get lightTheme => 'Hell'; + @override String get darkTheme => 'Dunkel'; + @override String get libraryDensity => 'Mediathekdichte'; + @override String get compact => 'Kompakt'; + @override String get compactDescription => 'Kleinere Karten, mehr Elemente sichtbar'; + @override String get normal => 'Normal'; + @override String get normalDescription => 'Standardgröße'; + @override String get comfortable => 'Großzügig'; + @override String get comfortableDescription => 'Größere Karten, weniger Elemente sichtbar'; + @override String get viewMode => 'Ansichtsmodus'; + @override String get gridView => 'Raster'; + @override String get gridViewDescription => 'Elemente im Raster anzeigen'; + @override String get listView => 'Liste'; + @override String get listViewDescription => 'Elemente in Listenansicht anzeigen'; + @override String get useSeasonPosters => 'Staffelposter verwenden'; + @override String get showHeroSection => 'Hero-Bereich anzeigen'; + @override String get hardwareDecoding => 'Hardware-Decodierung'; + @override String get hardwareDecodingDescription => 'Hardwarebeschleunigung verwenden, sofern verfügbar'; + @override String get bufferSize => 'Puffergröße'; + @override String bufferSizeMB({required Object size}) => '${size}MB'; + @override String get subtitleStyling => 'Untertitel-Stil'; + @override String get subtitleStylingDescription => 'Aussehen von Untertiteln anpassen'; + @override String get smallSkipDuration => 'Kleine Sprungdauer'; + @override String get largeSkipDuration => 'Große Sprungdauer'; + @override String secondsUnit({required Object seconds}) => '${seconds} Sekunden'; + @override String get defaultSleepTimer => 'Standard-Sleep-Timer'; + @override String minutesUnit({required Object minutes}) => '${minutes} Minuten'; + @override String get rememberTrackSelections => 'Spurauswahl pro Serie/Film merken'; + @override String get rememberTrackSelectionsDescription => 'Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert'; + @override String get unwatchedOnly => 'Nur ungesehene'; + @override String get unwatchedOnlyDescription => 'Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen'; + @override String get shuffleOrderNavigation => 'Navigation der Shuffle-Reihenfolge'; + @override String get shuffleOrderNavigationDescription => 'Weiter/Zurück folgt der zufälligen Reihenfolge'; + @override String get loopShuffleQueue => 'Shuffle-Warteschlange wiederholen'; + @override String get loopShuffleQueueDescription => 'Warteschlange neu starten, wenn das Ende erreicht ist'; + @override String get videoPlayerControls => 'Videoplayer-Steuerung'; + @override String get keyboardShortcuts => 'Tastenkürzel'; + @override String get keyboardShortcutsDescription => 'Tastenkürzel anpassen'; + @override String get debugLogging => 'Debug-Protokollierung'; + @override String get debugLoggingDescription => 'Detaillierte Protokolle zur Fehleranalyse aktivieren'; + @override String get viewLogs => 'Protokolle anzeigen'; + @override String get viewLogsDescription => 'App-Protokolle anzeigen'; + @override String get clearCache => 'Cache löschen'; + @override String get clearCacheDescription => 'Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.'; + @override String get clearCacheSuccess => 'Cache erfolgreich gelöscht'; + @override String get resetSettings => 'Einstellungen zurücksetzen'; + @override String get resetSettingsDescription => 'Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.'; + @override String get resetSettingsSuccess => 'Einstellungen erfolgreich zurückgesetzt'; + @override String get shortcutsReset => 'Tastenkürzel auf Standard zurückgesetzt'; + @override String get about => 'Über'; + @override String get aboutDescription => 'App-Informationen und Lizenzen'; + @override String get updates => 'Updates'; + @override String get updateAvailable => 'Update verfügbar'; + @override String get checkForUpdates => 'Nach Updates suchen'; + @override String get validationErrorEnterNumber => 'Bitte eine gültige Zahl eingeben'; + @override String validationErrorDuration({required Object min, required Object max, required Object unit}) => 'Dauer muss zwischen ${min} und ${max} ${unit} liegen'; + @override String shortcutAlreadyAssigned({required Object action}) => 'Tastenkürzel bereits zugewiesen an ${action}'; + @override String shortcutUpdated({required Object action}) => 'Tastenkürzel aktualisiert für ${action}'; +} + +// Path: search +class _StringsSearchDe implements _StringsSearchEn { + _StringsSearchDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get hint => 'Filme, Serien, Musik suchen...'; + @override String get tryDifferentTerm => 'Anderen Suchbegriff versuchen'; + @override String get searchYourMedia => 'In den eigenen Medien suchen'; + @override String get enterTitleActorOrKeyword => 'Titel, Schauspieler oder Stichwort eingeben'; +} + +// Path: hotkeys +class _StringsHotkeysDe implements _StringsHotkeysEn { + _StringsHotkeysDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String setShortcutFor({required Object actionName}) => 'Tastenkürzel festlegen für ${actionName}'; + @override String get clearShortcut => 'Kürzel löschen'; +} + +// Path: pinEntry +class _StringsPinEntryDe implements _StringsPinEntryEn { + _StringsPinEntryDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get enterPin => 'PIN eingeben'; + @override String get showPin => 'PIN anzeigen'; + @override String get hidePin => 'PIN verbergen'; +} + +// Path: fileInfo +class _StringsFileInfoDe implements _StringsFileInfoEn { + _StringsFileInfoDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Dateiinfo'; + @override String get video => 'Video'; + @override String get audio => 'Audio'; + @override String get file => 'Datei'; + @override String get advanced => 'Erweitert'; + @override String get codec => 'Codec'; + @override String get resolution => 'Auflösung'; + @override String get bitrate => 'Bitrate'; + @override String get frameRate => 'Bildrate'; + @override String get aspectRatio => 'Seitenverhältnis'; + @override String get profile => 'Profil'; + @override String get bitDepth => 'Farbtiefe'; + @override String get colorSpace => 'Farbraum'; + @override String get colorRange => 'Farbbereich'; + @override String get colorPrimaries => 'Primärfarben'; + @override String get chromaSubsampling => 'Chroma-Subsampling'; + @override String get channels => 'Kanäle'; + @override String get path => 'Pfad'; + @override String get size => 'Größe'; + @override String get container => 'Container'; + @override String get duration => 'Dauer'; + @override String get optimizedForStreaming => 'Für Streaming optimiert'; + @override String get has64bitOffsets => '64-Bit-Offsets'; +} + +// Path: mediaMenu +class _StringsMediaMenuDe implements _StringsMediaMenuEn { + _StringsMediaMenuDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get markAsWatched => 'Als gesehen markieren'; + @override String get markAsUnwatched => 'Als ungesehen markieren'; + @override String get removeFromContinueWatching => 'Aus ‚Weiterschauen‘ entfernen'; + @override String get goToSeries => 'Zur Serie'; + @override String get goToSeason => 'Zur Staffel'; + @override String get shufflePlay => 'Zufallswiedergabe'; + @override String get fileInfo => 'Dateiinfo'; +} + +// Path: tooltips +class _StringsTooltipsDe implements _StringsTooltipsEn { + _StringsTooltipsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get shufflePlay => 'Zufallswiedergabe'; + @override String get markAsWatched => 'Als gesehen markieren'; + @override String get markAsUnwatched => 'Als ungesehen markieren'; +} + +// Path: videoControls +class _StringsVideoControlsDe implements _StringsVideoControlsEn { + _StringsVideoControlsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get audioLabel => 'Audio'; + @override String get subtitlesLabel => 'Untertitel'; + @override String get resetToZero => 'Auf 0 ms zurücksetzen'; + @override String addTime({required Object amount, required Object unit}) => '+${amount}${unit}'; + @override String minusTime({required Object amount, required Object unit}) => '-${amount}${unit}'; + @override String playsLater({required Object label}) => '${label} spielt später'; + @override String playsEarlier({required Object label}) => '${label} spielt früher'; + @override String get noOffset => 'Kein Offset'; + @override String get letterbox => 'Letterbox'; + @override String get fillScreen => 'Bild füllen'; + @override String get stretch => 'Strecken'; + @override String get lockRotation => 'Rotation sperren'; + @override String get unlockRotation => 'Rotation entsperren'; +} + +// Path: userStatus +class _StringsUserStatusDe implements _StringsUserStatusEn { + _StringsUserStatusDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get admin => 'Eigentümer'; + @override String get restricted => 'Eingeschränkt'; + @override String get protected => 'Geschützt'; + @override String get current => 'AKTUELL'; +} + +// Path: messages +class _StringsMessagesDe implements _StringsMessagesEn { + _StringsMessagesDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get markedAsWatched => 'Als gesehen markiert'; + @override String get markedAsUnwatched => 'Als ungesehen markiert'; + @override String get removedFromContinueWatching => 'Aus ‚Weiterschauen‘ entfernt'; + @override String errorLoading({required Object error}) => 'Fehler: ${error}'; + @override String get fileInfoNotAvailable => 'Dateiinfo nicht verfügbar'; + @override String errorLoadingFileInfo({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}'; + @override String get errorLoadingSeries => 'Fehler beim Laden der Serie'; + @override String get errorLoadingSeason => 'Fehler beim Laden der Staffel'; + @override String get musicNotSupported => 'Musikwiedergabe wird noch nicht unterstützt'; + @override String get logsCleared => 'Protokolle gelöscht'; + @override String get logsCopied => 'Protokolle in Zwischenablage kopiert'; + @override String get noLogsAvailable => 'Keine Protokolle verfügbar'; + @override String libraryScanning({required Object title}) => 'Scanne „${title}“...'; + @override String libraryScanStarted({required Object title}) => 'Mediathekscan gestartet für „${title}“'; + @override String libraryScanFailed({required Object error}) => 'Fehler beim Scannen der Mediathek: ${error}'; + @override String metadataRefreshing({required Object title}) => 'Metadaten werden aktualisiert für „${title}“...'; + @override String metadataRefreshStarted({required Object title}) => 'Metadaten-Aktualisierung gestartet für „${title}“'; + @override String metadataRefreshFailed({required Object error}) => 'Metadaten konnten nicht aktualisiert werden: ${error}'; + @override String get noPlexToken => 'Kein Plex-Token gefunden. Bitte erneut anmelden.'; + @override String get logoutConfirm => 'Abmeldung wirklich durchführen?'; + @override String get noSeasonsFound => 'Keine Staffeln gefunden'; + @override String get noEpisodesFound => 'Keine Episoden in der ersten Staffel gefunden'; + @override String get noEpisodesFoundGeneral => 'Keine Episoden gefunden'; + @override String get noResultsFound => 'Keine Ergebnisse gefunden'; + @override String sleepTimerSet({required Object label}) => 'Sleep-Timer gesetzt auf ${label}'; + @override String failedToSwitchProfile({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; +} + +// Path: profile +class _StringsProfileDe implements _StringsProfileEn { + _StringsProfileDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get noUsersAvailable => 'Keine Benutzer verfügbar'; +} + +// Path: subtitlingStyling +class _StringsSubtitlingStylingDe implements _StringsSubtitlingStylingEn { + _StringsSubtitlingStylingDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get stylingOptions => 'Stiloptionen'; + @override String get fontSize => 'Schriftgröße'; + @override String get textColor => 'Textfarbe'; + @override String get borderSize => 'Rahmengröße'; + @override String get borderColor => 'Rahmenfarbe'; + @override String get backgroundOpacity => 'Hintergrunddeckkraft'; + @override String get backgroundColor => 'Hintergrundfarbe'; +} + +// Path: dialog +class _StringsDialogDe implements _StringsDialogEn { + _StringsDialogDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get confirmAction => 'Aktion bestätigen'; + @override String get areYouSure => 'Aktion wirklich ausführen?'; + @override String get cancel => 'Abbrechen'; + @override String get playNow => 'Jetzt abspielen'; +} + +// Path: discover +class _StringsDiscoverDe implements _StringsDiscoverEn { + _StringsDiscoverDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Entdecken'; + @override String get switchProfile => 'Profil wechseln'; + @override String get switchServer => 'Server wechseln'; + @override String get logout => 'Abmelden'; + @override String get noContentAvailable => 'Kein Inhalt verfügbar'; + @override String get addMediaToLibraries => 'Medien zur Mediathek hinzufügen'; + @override String get continueWatching => 'Weiterschauen'; + @override String get recentlyAdded => 'Kürzlich hinzugefügt'; + @override String get play => 'Abspielen'; + @override String get resume => 'Fortsetzen'; + @override String playEpisode({required Object season, required Object episode}) => 'S${season}, E${episode} - Abspielen'; + @override String resumeEpisode({required Object season, required Object episode}) => 'S${season}, E${episode} - Fortsetzen'; + @override String get pause => 'Pause'; + @override String get overview => 'Übersicht'; + @override String get cast => 'Besetzung'; + @override String get seasons => 'Staffeln'; + @override String get studio => 'Studio'; + @override String get rating => 'Altersfreigabe'; + @override String get watched => 'Gesehen'; + @override String episodeCount({required Object count}) => '${count} Episoden'; + @override String watchedProgress({required Object watched, required Object total}) => '${watched} von ${total} gesehen'; + @override String get movie => 'Film'; + @override String get tvShow => 'Serie'; + @override String minutesLeft({required Object minutes}) => '${minutes} Min übrig'; +} + +// Path: errors +class _StringsErrorsDe implements _StringsErrorsEn { + _StringsErrorsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String searchFailed({required Object error}) => 'Suche fehlgeschlagen: ${error}'; + @override String connectionTimeout({required Object context}) => 'Zeitüberschreitung beim Laden von ${context}'; + @override String get connectionFailed => 'Verbindung zum Plex-Server fehlgeschlagen'; + @override String failedToLoad({required Object context, required Object error}) => 'Fehler beim Laden von ${context}: ${error}'; + @override String get noClientAvailable => 'Kein Client verfügbar'; + @override String authenticationFailed({required Object error}) => 'Authentifizierung fehlgeschlagen: ${error}'; + @override String get couldNotLaunchUrl => 'Auth-URL konnte nicht geöffnet werden'; + @override String get pleaseEnterToken => 'Bitte Token eingeben'; + @override String get invalidToken => 'Ungültiges Token'; + @override String failedToVerifyToken({required Object error}) => 'Token-Verifizierung fehlgeschlagen: ${error}'; + @override String failedToSwitchProfile({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + @override String get connectionFailedGeneric => 'Verbindung fehlgeschlagen'; +} + +// Path: libraries +class _StringsLibrariesDe implements _StringsLibrariesEn { + _StringsLibrariesDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Mediatheken'; + @override String get scanLibraryFiles => 'Mediatheksdateien scannen'; + @override String get scanLibrary => 'Mediathek scannen'; + @override String get analyze => 'Analysieren'; + @override String get analyzeLibrary => 'Mediathek analysieren'; + @override String get refreshMetadata => 'Metadaten aktualisieren'; + @override String get emptyTrash => 'Papierkorb leeren'; + @override String emptyingTrash({required Object title}) => 'Papierkorb für „${title}“ wird geleert...'; + @override String trashEmptied({required Object title}) => 'Papierkorb für „${title}“ geleert'; + @override String failedToEmptyTrash({required Object error}) => 'Papierkorb konnte nicht geleert werden: ${error}'; + @override String analyzing({required Object title}) => 'Analysiere „${title}“...'; + @override String analysisStarted({required Object title}) => 'Analyse gestartet für „${title}“'; + @override String failedToAnalyze({required Object error}) => 'Analyse der Mediathek fehlgeschlagen: ${error}'; + @override String get noLibrariesFound => 'Keine Mediatheken gefunden'; + @override String get thisLibraryIsEmpty => 'Diese Mediathek ist leer'; + @override String get all => 'Alle'; + @override String get clearAll => 'Alle löschen'; + @override String scanLibraryConfirm({required Object title}) => '„${title}“ wirklich scannen?'; + @override String analyzeLibraryConfirm({required Object title}) => '„${title}“ wirklich analysieren?'; + @override String refreshMetadataConfirm({required Object title}) => 'Metadaten für „${title}“ wirklich aktualisieren?'; + @override String emptyTrashConfirm({required Object title}) => 'Papierkorb für „${title}“ wirklich leeren?'; + @override String get manageLibraries => 'Mediatheken verwalten'; + @override String get sort => 'Sortieren'; + @override String get sortBy => 'Sortieren nach'; + @override String get filters => 'Filter'; + @override String loadingLibraryWithCount({required Object count}) => 'Lade Mediathek... (${count} Elemente geladen)'; + @override String get confirmActionMessage => 'Aktion wirklich durchführen?'; + @override String get showLibrary => 'Mediathek anzeigen'; + @override String get hideLibrary => 'Mediathek ausblenden'; + @override String get libraryOptions => 'Mediatheksoptionen'; +} + +// Path: about +class _StringsAboutDe implements _StringsAboutEn { + _StringsAboutDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Über'; + @override String get openSourceLicenses => 'Open-Source-Lizenzen'; + @override String versionLabel({required Object version}) => 'Version ${version}'; + @override String get appDescription => 'Ein schöner Plex-Client für Flutter'; + @override String get viewLicensesDescription => 'Lizenzen von Drittanbieter-Bibliotheken anzeigen'; +} + +// Path: serverSelection +class _StringsServerSelectionDe implements _StringsServerSelectionEn { + _StringsServerSelectionDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get connectingToServer => 'Verbinde mit Server...'; + @override String get serverDebugCopied => 'Server-Debugdaten kopiert'; + @override String get copyDebugData => 'Debugdaten kopieren'; + @override String get noServersFound => 'Keine Server gefunden'; + @override String malformedServerData({required Object count}) => '${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.'; + @override String get incompleteServerInfo => 'Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.'; + @override String get incompleteConnectionInfo => 'Serververbindungsinformationen unvollständig. Bitte erneut versuchen.'; + @override String malformedServerInfo({required Object message}) => 'Serverinformationen fehlerhaft: ${message}'; + @override String get networkConnectionFailed => 'Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.'; + @override String get authenticationFailed => 'Authentifizierung fehlgeschlagen. Bitte erneut anmelden.'; + @override String get plexServiceUnavailable => 'Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.'; + @override String failedToLoadServers({required Object error}) => 'Server konnten nicht geladen werden: ${error}'; +} + +// Path: hubDetail +class _StringsHubDetailDe implements _StringsHubDetailEn { + _StringsHubDetailDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Titel'; + @override String get releaseYear => 'Erscheinungsjahr'; + @override String get dateAdded => 'Hinzugefügt am'; + @override String get rating => 'Bewertung'; + @override String get noItemsFound => 'Keine Elemente gefunden'; +} + +// Path: logs +class _StringsLogsDe implements _StringsLogsEn { + _StringsLogsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Protokolle'; + @override String get clearLogs => 'Protokolle löschen'; + @override String get copyLogs => 'Protokolle kopieren'; + @override String get exportLogs => 'Protokolle exportieren'; + @override String get noLogsToShow => 'Keine Protokolle zum Anzeigen'; + @override String get error => 'Fehler:'; + @override String get stackTrace => 'Stacktrace:'; +} + +// Path: licenses +class _StringsLicensesDe implements _StringsLicensesEn { + _StringsLicensesDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get relatedPackages => 'Verwandte Pakete'; + @override String get license => 'Lizenz'; + @override String licenseNumber({required Object number}) => 'Lizenz ${number}'; + @override String licensesCount({required Object count}) => '${count} Lizenzen'; +} + +// Path: navigation +class _StringsNavigationDe implements _StringsNavigationEn { + _StringsNavigationDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get home => 'Start'; + @override String get search => 'Suche'; + @override String get libraries => 'Mediatheken'; + @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 => 'Wiedergabelisten'; + @override String get noPlaylists => 'Keine Wiedergabelisten gefunden'; + @override String get create => 'Wiedergabeliste erstellen'; + @override String get newPlaylist => 'Neue Wiedergabeliste'; + @override String get playlistName => 'Name der Wiedergabeliste'; + @override String get enterPlaylistName => 'Name der Wiedergabeliste eingeben'; + @override String get edit => 'Wiedergabeliste bearbeiten'; + @override String get delete => 'Wiedergabeliste löschen'; + @override String get addTo => 'Zur Wiedergabeliste hinzufügen'; + @override String get addItems => 'Elemente hinzufügen'; + @override String get removeItem => 'Aus Wiedergabeliste entfernen'; + @override String get clearPlaylist => 'Wiedergabeliste leeren'; + @override String get playAll => 'Alle abspielen'; + @override String get shuffle => 'Zufällig'; + @override String get smartPlaylist => 'Intelligente Wiedergabeliste'; + @override String get regularPlaylist => 'Normale Wiedergabeliste'; + @override String itemCount({required Object count}) => '${count} Elemente'; + @override String get oneItem => '1 Element'; + @override String get emptyPlaylist => 'Diese Wiedergabeliste ist leer'; + @override String get deleteConfirm => 'Wiedergabeliste löschen?'; + @override String deleteMessage({required Object name}) => 'Soll "${name}" wirklich gelöscht werden?'; + @override String get created => 'Wiedergabeliste erstellt'; + @override String get updated => 'Wiedergabeliste aktualisiert'; + @override String get deleted => 'Wiedergabeliste gelöscht'; + @override String get itemAdded => 'Zur Wiedergabeliste hinzugefügt'; + @override String get itemRemoved => 'Aus Wiedergabeliste entfernt'; + @override String get selectPlaylist => 'Wiedergabeliste auswählen'; + @override String get createNewPlaylist => 'Neue Wiedergabeliste erstellen'; + @override String get errorCreating => 'Wiedergabeliste konnte nicht erstellt werden'; + @override String get errorDeleting => 'Wiedergabeliste konnte nicht gelöscht werden'; + @override String get errorLoading => 'Wiedergabelisten konnten nicht geladen werden'; + @override String get errorAdding => 'Konnte nicht zur Wiedergabeliste hinzugefügt werden'; + @override String get errorReordering => 'Element der Wiedergabeliste konnte nicht neu geordnet werden'; + @override String get errorRemoving => 'Konnte nicht aus der Wiedergabeliste entfernt werden'; +} + // Path: class _StringsIt implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -787,6 +1501,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 @@ -931,6 +1646,8 @@ class _StringsSettingsIt implements _StringsSettingsEn { @override String secondsUnit({required Object seconds}) => '${seconds} secondi'; @override String get defaultSleepTimer => 'Timer spegnimento predefinito'; @override String minutesUnit({required Object minutes}) => '${minutes} minuti'; + @override String get rememberTrackSelections => 'Ricorda selezioni tracce per serie/film'; + @override String get rememberTrackSelectionsDescription => 'Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione'; @override String get unwatchedOnly => 'Solo non guardati'; @override String get unwatchedOnlyDescription => 'Includi solo gli episodi non guardati nella coda di riproduzione casuale'; @override String get shuffleOrderNavigation => 'Navigazione in ordine casuale'; @@ -1090,6 +1807,7 @@ class _StringsUserStatusIt implements _StringsUserStatusEn { @override String get admin => 'Admin'; @override String get restricted => 'Limitato'; @override String get protected => 'Protetto'; + @override String get current => 'ATTUALE'; } // Path: messages @@ -1188,6 +1906,10 @@ class _StringsDiscoverIt implements _StringsDiscoverEn { @override String get pause => 'Pausa'; @override String get overview => 'Panoramica'; @override String get cast => 'Cast'; + @override String get seasons => 'Stagioni'; + @override String get studio => 'Studio'; + @override String get rating => 'Classificazione'; + @override String get watched => 'Guardato'; @override String episodeCount({required Object count}) => '${count} episodi'; @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} guardati'; @override String get movie => 'Film'; @@ -1346,6 +2068,49 @@ class _StringsNavigationIt implements _StringsNavigationEn { @override String get settings => 'Impostazioni'; } +// Path: playlists +class _StringsPlaylistsIt implements _StringsPlaylistsEn { + _StringsPlaylistsIt._(this._root); + + @override final _StringsIt _root; // ignore: unused_field + + // Translations + @override String get title => 'Playlist'; + @override String get noPlaylists => 'Nessuna playlist trovata'; + @override String get create => 'Crea playlist'; + @override String get newPlaylist => 'Nuova playlist'; + @override String get playlistName => 'Nome playlist'; + @override String get enterPlaylistName => 'Inserisci nome playlist'; + @override String get edit => 'Modifica playlist'; + @override String get delete => 'Elimina playlist'; + @override String get addTo => 'Aggiungi a playlist'; + @override String get addItems => 'Aggiungi elementi'; + @override String get removeItem => 'Rimuovi da playlist'; + @override String get clearPlaylist => 'Svuota playlist'; + @override String get playAll => 'Riproduci tutto'; + @override String get shuffle => 'Casuale'; + @override String get smartPlaylist => 'Playlist intelligente'; + @override String get regularPlaylist => 'Playlist normale'; + @override String itemCount({required Object count}) => '${count} elementi'; + @override String get oneItem => '1 elemento'; + @override String get emptyPlaylist => 'Questa playlist è vuota'; + @override String get deleteConfirm => 'Eliminare playlist?'; + @override String deleteMessage({required Object name}) => 'Sei sicuro di voler eliminare "${name}"?'; + @override String get created => 'Playlist creata'; + @override String get updated => 'Playlist aggiornata'; + @override String get deleted => 'Playlist eliminata'; + @override String get itemAdded => 'Aggiunto alla playlist'; + @override String get itemRemoved => 'Rimosso dalla playlist'; + @override String get selectPlaylist => 'Seleziona playlist'; + @override String get createNewPlaylist => 'Crea nuova playlist'; + @override String get errorCreating => 'Errore durante la creazione della playlist'; + @override String get errorDeleting => 'Errore durante l\'eliminazione della playlist'; + @override String get errorLoading => 'Errore durante il caricamento delle playlist'; + @override String get errorAdding => 'Errore durante l\'aggiunta alla playlist'; + @override String get errorReordering => 'Errore durante il riordino dell\'elemento della playlist'; + @override String get errorRemoving => 'Errore durante la rimozione dalla playlist'; +} + // Path: class _StringsNl implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -1397,6 +2162,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 @@ -1541,6 +2307,8 @@ class _StringsSettingsNl implements _StringsSettingsEn { @override String secondsUnit({required Object seconds}) => '${seconds} seconden'; @override String get defaultSleepTimer => 'Standaard slaap timer'; @override String minutesUnit({required Object minutes}) => 'bij ${minutes} minuten'; + @override String get rememberTrackSelections => 'Onthoud track selecties per serie/film'; + @override String get rememberTrackSelectionsDescription => 'Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen'; @override String get unwatchedOnly => 'Alleen ongekeken'; @override String get unwatchedOnlyDescription => 'Alleen ongekeken afleveringen opnemen in willekeurige wachtrij'; @override String get shuffleOrderNavigation => 'Willekeurige volgorde navigatie'; @@ -1700,6 +2468,7 @@ class _StringsUserStatusNl implements _StringsUserStatusEn { @override String get admin => 'Beheerder'; @override String get restricted => 'Beperkt'; @override String get protected => 'Beschermd'; + @override String get current => 'HUIDIG'; } // Path: messages @@ -1798,6 +2567,10 @@ class _StringsDiscoverNl implements _StringsDiscoverEn { @override String get pause => 'Pauzeren'; @override String get overview => 'Overzicht'; @override String get cast => 'Cast'; + @override String get seasons => 'Seizoenen'; + @override String get studio => 'Studio'; + @override String get rating => 'Leeftijd'; + @override String get watched => 'Bekeken'; @override String episodeCount({required Object count}) => '${count} afleveringen'; @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} gekeken'; @override String get movie => 'Film'; @@ -1956,6 +2729,49 @@ class _StringsNavigationNl implements _StringsNavigationEn { @override String get settings => 'Instellingen'; } +// Path: playlists +class _StringsPlaylistsNl implements _StringsPlaylistsEn { + _StringsPlaylistsNl._(this._root); + + @override final _StringsNl _root; // ignore: unused_field + + // Translations + @override String get title => 'Afspeellijsten'; + @override String get noPlaylists => 'Geen afspeellijsten gevonden'; + @override String get create => 'Afspeellijst maken'; + @override String get newPlaylist => 'Nieuwe afspeellijst'; + @override String get playlistName => 'Naam afspeellijst'; + @override String get enterPlaylistName => 'Voer naam afspeellijst in'; + @override String get edit => 'Afspeellijst bewerken'; + @override String get delete => 'Afspeellijst verwijderen'; + @override String get addTo => 'Toevoegen aan afspeellijst'; + @override String get addItems => 'Items toevoegen'; + @override String get removeItem => 'Verwijderen uit afspeellijst'; + @override String get clearPlaylist => 'Afspeellijst wissen'; + @override String get playAll => 'Alles afspelen'; + @override String get shuffle => 'Shuffle'; + @override String get smartPlaylist => 'Slimme afspeellijst'; + @override String get regularPlaylist => 'Normale afspeellijst'; + @override String itemCount({required Object count}) => '${count} items'; + @override String get oneItem => '1 item'; + @override String get emptyPlaylist => 'Deze afspeellijst is leeg'; + @override String get deleteConfirm => 'Afspeellijst verwijderen?'; + @override String deleteMessage({required Object name}) => 'Weet je zeker dat je "${name}" wilt verwijderen?'; + @override String get created => 'Afspeellijst gemaakt'; + @override String get updated => 'Afspeellijst bijgewerkt'; + @override String get deleted => 'Afspeellijst verwijderd'; + @override String get itemAdded => 'Toegevoegd aan afspeellijst'; + @override String get itemRemoved => 'Verwijderd uit afspeellijst'; + @override String get selectPlaylist => 'Selecteer afspeellijst'; + @override String get createNewPlaylist => 'Nieuwe afspeellijst maken'; + @override String get errorCreating => 'Fout bij maken afspeellijst'; + @override String get errorDeleting => 'Fout bij verwijderen afspeellijst'; + @override String get errorLoading => 'Fout bij laden afspeellijsten'; + @override String get errorAdding => 'Fout bij toevoegen aan afspeellijst'; + @override String get errorReordering => 'Fout bij herschikken van afspeellijstitem'; + @override String get errorRemoving => 'Fout bij verwijderen uit afspeellijst'; +} + // Path: class _StringsSv implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -2007,6 +2823,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 @@ -2151,6 +2968,8 @@ class _StringsSettingsSv implements _StringsSettingsEn { @override String secondsUnit({required Object seconds}) => '${seconds} sekunder'; @override String get defaultSleepTimer => 'Standard sovtimer'; @override String minutesUnit({required Object minutes}) => '${minutes} minuter'; + @override String get rememberTrackSelections => 'Kom ihåg spårval per serie/film'; + @override String get rememberTrackSelectionsDescription => 'Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning'; @override String get unwatchedOnly => 'Endast osedda'; @override String get unwatchedOnlyDescription => 'Inkludera endast osedda avsnitt i blandningskön'; @override String get shuffleOrderNavigation => 'Blandningsordning-navigation'; @@ -2310,6 +3129,7 @@ class _StringsUserStatusSv implements _StringsUserStatusEn { @override String get admin => 'Admin'; @override String get restricted => 'Begränsad'; @override String get protected => 'Skyddad'; + @override String get current => 'NUVARANDE'; } // Path: messages @@ -2408,6 +3228,10 @@ class _StringsDiscoverSv implements _StringsDiscoverEn { @override String get pause => 'Pausa'; @override String get overview => 'Översikt'; @override String get cast => 'Rollbesättning'; + @override String get seasons => 'Säsonger'; + @override String get studio => 'Studio'; + @override String get rating => 'Åldersgräns'; + @override String get watched => 'Tittad'; @override String episodeCount({required Object count}) => '${count} avsnitt'; @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} sedda'; @override String get movie => 'Film'; @@ -2566,6 +3390,710 @@ class _StringsNavigationSv implements _StringsNavigationEn { @override String get settings => 'Inställningar'; } +// Path: playlists +class _StringsPlaylistsSv implements _StringsPlaylistsEn { + _StringsPlaylistsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Spellistor'; + @override String get noPlaylists => 'Inga spellistor hittades'; + @override String get create => 'Skapa spellista'; + @override String get newPlaylist => 'Ny spellista'; + @override String get playlistName => 'Spellistans namn'; + @override String get enterPlaylistName => 'Ange spellistans namn'; + @override String get edit => 'Redigera spellista'; + @override String get delete => 'Ta bort spellista'; + @override String get addTo => 'Lägg till i spellista'; + @override String get addItems => 'Lägg till objekt'; + @override String get removeItem => 'Ta bort från spellista'; + @override String get clearPlaylist => 'Rensa spellista'; + @override String get playAll => 'Spela alla'; + @override String get shuffle => 'Blanda'; + @override String get smartPlaylist => 'Smart spellista'; + @override String get regularPlaylist => 'Vanlig spellista'; + @override String itemCount({required Object count}) => '${count} objekt'; + @override String get oneItem => '1 objekt'; + @override String get emptyPlaylist => 'Denna spellista är tom'; + @override String get deleteConfirm => 'Ta bort spellista?'; + @override String deleteMessage({required Object name}) => 'Är du säker på att du vill ta bort "${name}"?'; + @override String get created => 'Spellista skapad'; + @override String get updated => 'Spellista uppdaterad'; + @override String get deleted => 'Spellista borttagen'; + @override String get itemAdded => 'Tillagd i spellista'; + @override String get itemRemoved => 'Borttagen från spellista'; + @override String get selectPlaylist => 'Välj spellista'; + @override String get createNewPlaylist => 'Skapa ny spellista'; + @override String get errorCreating => 'Det gick inte att skapa spellista'; + @override String get errorDeleting => 'Det gick inte att ta bort spellista'; + @override String get errorLoading => 'Det gick inte att ladda spellistor'; + @override String get errorAdding => 'Det gick inte att lägga till i spellista'; + @override String get errorReordering => 'Det gick inte att omordna spellisteobjekt'; + @override String get errorRemoving => 'Det gick inte att ta bort från spellista'; +} + +// Path: +class _StringsZh implements Translations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + _StringsZh.build({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = TranslationMetadata( + locale: AppLocale.zh, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ) { + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + @override dynamic operator[](String key) => $meta.getTranslation(key); + + @override late final _StringsZh _root = this; // ignore: unused_field + + // Translations + @override late final _StringsAppZh app = _StringsAppZh._(_root); + @override late final _StringsAuthZh auth = _StringsAuthZh._(_root); + @override late final _StringsCommonZh common = _StringsCommonZh._(_root); + @override late final _StringsScreensZh screens = _StringsScreensZh._(_root); + @override late final _StringsUpdateZh update = _StringsUpdateZh._(_root); + @override late final _StringsSettingsZh settings = _StringsSettingsZh._(_root); + @override late final _StringsSearchZh search = _StringsSearchZh._(_root); + @override late final _StringsHotkeysZh hotkeys = _StringsHotkeysZh._(_root); + @override late final _StringsPinEntryZh pinEntry = _StringsPinEntryZh._(_root); + @override late final _StringsFileInfoZh fileInfo = _StringsFileInfoZh._(_root); + @override late final _StringsMediaMenuZh mediaMenu = _StringsMediaMenuZh._(_root); + @override late final _StringsTooltipsZh tooltips = _StringsTooltipsZh._(_root); + @override late final _StringsVideoControlsZh videoControls = _StringsVideoControlsZh._(_root); + @override late final _StringsUserStatusZh userStatus = _StringsUserStatusZh._(_root); + @override late final _StringsMessagesZh messages = _StringsMessagesZh._(_root); + @override late final _StringsProfileZh profile = _StringsProfileZh._(_root); + @override late final _StringsSubtitlingStylingZh subtitlingStyling = _StringsSubtitlingStylingZh._(_root); + @override late final _StringsDialogZh dialog = _StringsDialogZh._(_root); + @override late final _StringsDiscoverZh discover = _StringsDiscoverZh._(_root); + @override late final _StringsErrorsZh errors = _StringsErrorsZh._(_root); + @override late final _StringsLibrariesZh libraries = _StringsLibrariesZh._(_root); + @override late final _StringsAboutZh about = _StringsAboutZh._(_root); + @override late final _StringsServerSelectionZh serverSelection = _StringsServerSelectionZh._(_root); + @override late final _StringsHubDetailZh hubDetail = _StringsHubDetailZh._(_root); + @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 +class _StringsAppZh implements _StringsAppEn { + _StringsAppZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => 'Plezy'; + @override String get loading => '加载中...'; +} + +// Path: auth +class _StringsAuthZh implements _StringsAuthEn { + _StringsAuthZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get signInWithPlex => '使用 Plex 登录'; + @override String get showQRCode => '显示二维码'; + @override String get cancel => '取消'; + @override String get authenticate => '验证'; + @override String get retry => '重试'; + @override String get debugEnterToken => '调试:输入 Plex Token'; + @override String get plexTokenLabel => 'Plex 授权令牌 (Auth Token)'; + @override String get plexTokenHint => '输入你的 Plex.tv 令牌'; + @override String get authenticationTimeout => '验证超时。请重试。'; + @override String get scanQRCodeInstruction => '请使用已登录 Plex 的设备扫描此二维码进行验证。'; + @override String get waitingForAuth => '等待验证中...\n请在你的浏览器中完成登录。'; +} + +// Path: common +class _StringsCommonZh implements _StringsCommonEn { + _StringsCommonZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get cancel => '取消'; + @override String get save => '保存'; + @override String get close => '关闭'; + @override String get clear => '清除'; + @override String get reset => '重置'; + @override String get later => '稍后'; + @override String get submit => '提交'; + @override String get confirm => '确认'; + @override String get retry => '重试'; + @override String get playNow => '立即播放'; + @override String get logout => '登出'; + @override String get online => '在线'; + @override String get offline => '离线'; + @override String get owned => '拥有'; + @override String get shared => '共享'; + @override String get current => '当前'; + @override String get unknown => '未知'; + @override String get refresh => '刷新'; + @override String get yes => '是'; + @override String get no => '否'; + @override String get server => '服务器'; +} + +// Path: screens +class _StringsScreensZh implements _StringsScreensEn { + _StringsScreensZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get licenses => '许可证'; + @override String get selectServer => '选择服务器'; + @override String get switchProfile => '切换配置文件'; + @override String get subtitleStyling => '字幕样式'; + @override String get search => '搜索'; + @override String get logs => '日志'; +} + +// Path: update +class _StringsUpdateZh implements _StringsUpdateEn { + _StringsUpdateZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get available => '有可用更新'; + @override String versionAvailable({required Object version}) => '版本 ${version} 已发布'; + @override String currentVersion({required Object version}) => '当前版本: ${version}'; + @override String get skipVersion => '跳过此版本'; + @override String get viewRelease => '查看发布详情'; + @override String get latestVersion => '已安装的版本是可用的最新版本'; + @override String get checkFailed => '无法检查更新'; +} + +// Path: settings +class _StringsSettingsZh implements _StringsSettingsEn { + _StringsSettingsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '设置'; + @override String get language => '语言'; + @override String get theme => '主题'; + @override String get appearance => '外观'; + @override String get videoPlayback => '视频播放'; + @override String get shufflePlay => '随机播放'; + @override String get advanced => '高级'; + @override String get useSeasonPostersDescription => '为剧集显示季海报而非剧集海报'; + @override String get showHeroSectionDescription => '在主屏幕上显示精选内容轮播区'; + @override String get secondsLabel => '秒'; + @override String get minutesLabel => '分钟'; + @override String get secondsShort => 's'; + @override String get minutesShort => 'm'; + @override String durationHint({required Object min, required Object max}) => '输入时长 (${min}-${max})'; + @override String get systemTheme => '系统'; + @override String get systemThemeDescription => '跟随系统设置'; + @override String get lightTheme => '浅色'; + @override String get darkTheme => '深色'; + @override String get libraryDensity => '媒体库密度'; + @override String get compact => '紧凑'; + @override String get compactDescription => '卡片更小,显示更多项目'; + @override String get normal => '标准'; + @override String get normalDescription => '默认尺寸'; + @override String get comfortable => '舒适'; + @override String get comfortableDescription => '卡片更大,显示更少项目'; + @override String get viewMode => '视图模式'; + @override String get gridView => '网格视图'; + @override String get gridViewDescription => '以网格布局显示项目'; + @override String get listView => '列表视图'; + @override String get listViewDescription => '以列表布局显示项目'; + @override String get useSeasonPosters => '使用季海报'; + @override String get showHeroSection => '显示主要精选区'; + @override String get hardwareDecoding => '硬件解码'; + @override String get hardwareDecodingDescription => '如果可用,使用硬件加速'; + @override String get bufferSize => '缓冲区大小'; + @override String bufferSizeMB({required Object size}) => '${size}MB'; + @override String get subtitleStyling => '字幕样式'; + @override String get subtitleStylingDescription => '调整字幕外观'; + @override String get smallSkipDuration => '短跳过时长'; + @override String get largeSkipDuration => '长跳过时长'; + @override String secondsUnit({required Object seconds}) => '${seconds} 秒'; + @override String get defaultSleepTimer => '默认睡眠定时器'; + @override String minutesUnit({required Object minutes}) => '${minutes} 分钟'; + @override String get rememberTrackSelections => '记住每个剧集/电影的音轨选择'; + @override String get rememberTrackSelectionsDescription => '在播放过程中更改音轨时自动保存音频和字幕语言偏好'; + @override String get unwatchedOnly => '仅未观看'; + @override String get unwatchedOnlyDescription => '随机队列中仅包含未观看的剧集'; + @override String get shuffleOrderNavigation => '随机顺序导航'; + @override String get shuffleOrderNavigationDescription => '下一集/上一集按钮遵循随机播放顺序'; + @override String get loopShuffleQueue => '循环随机队列'; + @override String get loopShuffleQueueDescription => '在队列结束时重新开始'; + @override String get videoPlayerControls => '视频播放器控制'; + @override String get keyboardShortcuts => '键盘快捷键'; + @override String get keyboardShortcutsDescription => '自定义键盘快捷键'; + @override String get debugLogging => '调试日志'; + @override String get debugLoggingDescription => '启用详细日志记录以便故障排除'; + @override String get viewLogs => '查看日志'; + @override String get viewLogsDescription => '查看应用程序日志'; + @override String get clearCache => '清除缓存'; + @override String get clearCacheDescription => '这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。'; + @override String get clearCacheSuccess => '缓存清除成功'; + @override String get resetSettings => '重置设置'; + @override String get resetSettingsDescription => '这会将所有设置重置为其默认值。此操作无法撤销。'; + @override String get resetSettingsSuccess => '设置重置成功'; + @override String get shortcutsReset => '快捷键已重置为默认值'; + @override String get about => '关于'; + @override String get aboutDescription => '应用程序信息和许可证'; + @override String get updates => '更新'; + @override String get updateAvailable => '有可用更新'; + @override String get checkForUpdates => '检查更新'; + @override String get validationErrorEnterNumber => '请输入一个有效的数字'; + @override String validationErrorDuration({required Object min, required Object max, required Object unit}) => '时长必须介于 ${min} 和 ${max} ${unit} 之间'; + @override String shortcutAlreadyAssigned({required Object action}) => '快捷键已被分配给 ${action}'; + @override String shortcutUpdated({required Object action}) => '快捷键已为 ${action} 更新'; +} + +// Path: search +class _StringsSearchZh implements _StringsSearchEn { + _StringsSearchZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get hint => '搜索电影、系列、音乐...'; + @override String get tryDifferentTerm => '尝试不同的搜索词'; + @override String get searchYourMedia => '搜索媒体'; + @override String get enterTitleActorOrKeyword => '输入标题、演员或关键词'; +} + +// Path: hotkeys +class _StringsHotkeysZh implements _StringsHotkeysEn { + _StringsHotkeysZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String setShortcutFor({required Object actionName}) => '为 ${actionName} 设置快捷键'; + @override String get clearShortcut => '清除快捷键'; +} + +// Path: pinEntry +class _StringsPinEntryZh implements _StringsPinEntryEn { + _StringsPinEntryZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get enterPin => '输入 PIN'; + @override String get showPin => '显示 PIN'; + @override String get hidePin => '隐藏 PIN'; +} + +// Path: fileInfo +class _StringsFileInfoZh implements _StringsFileInfoEn { + _StringsFileInfoZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '文件信息'; + @override String get video => '视频'; + @override String get audio => '音频'; + @override String get file => '文件'; + @override String get advanced => '高级'; + @override String get codec => '编解码器'; + @override String get resolution => '分辨率'; + @override String get bitrate => '比特率'; + @override String get frameRate => '帧率'; + @override String get aspectRatio => '宽高比'; + @override String get profile => '配置文件'; + @override String get bitDepth => '位深度'; + @override String get colorSpace => '色彩空间'; + @override String get colorRange => '色彩范围'; + @override String get colorPrimaries => '颜色原色'; + @override String get chromaSubsampling => '色度子采样'; + @override String get channels => '声道'; + @override String get path => '路径'; + @override String get size => '大小'; + @override String get container => '容器'; + @override String get duration => '时长'; + @override String get optimizedForStreaming => '已优化用于流媒体'; + @override String get has64bitOffsets => '64位偏移量'; +} + +// Path: mediaMenu +class _StringsMediaMenuZh implements _StringsMediaMenuEn { + _StringsMediaMenuZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get markAsWatched => '标记为已观看'; + @override String get markAsUnwatched => '标记为未观看'; + @override String get removeFromContinueWatching => '从继续观看中移除'; + @override String get goToSeries => '转到系列'; + @override String get goToSeason => '转到季'; + @override String get shufflePlay => '随机播放'; + @override String get fileInfo => '文件信息'; +} + +// Path: tooltips +class _StringsTooltipsZh implements _StringsTooltipsEn { + _StringsTooltipsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get shufflePlay => '随机播放'; + @override String get markAsWatched => '标记为已观看'; + @override String get markAsUnwatched => '标记为未观看'; +} + +// Path: videoControls +class _StringsVideoControlsZh implements _StringsVideoControlsEn { + _StringsVideoControlsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get audioLabel => '音频'; + @override String get subtitlesLabel => '字幕'; + @override String get resetToZero => '重置为 0ms'; + @override String addTime({required Object amount, required Object unit}) => '+${amount}${unit}'; + @override String minusTime({required Object amount, required Object unit}) => '-${amount}${unit}'; + @override String playsLater({required Object label}) => '${label} 播放较晚'; + @override String playsEarlier({required Object label}) => '${label} 播放较早'; + @override String get noOffset => '无偏移'; + @override String get letterbox => '信箱模式(Letterbox)'; + @override String get fillScreen => '填充屏幕'; + @override String get stretch => '拉伸'; + @override String get lockRotation => '锁定旋转'; + @override String get unlockRotation => '解锁旋转'; +} + +// Path: userStatus +class _StringsUserStatusZh implements _StringsUserStatusEn { + _StringsUserStatusZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get admin => '管理员'; + @override String get restricted => '受限'; + @override String get protected => '受保护'; + @override String get current => '当前'; +} + +// Path: messages +class _StringsMessagesZh implements _StringsMessagesEn { + _StringsMessagesZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get markedAsWatched => '已标记为已观看'; + @override String get markedAsUnwatched => '已标记为未观看'; + @override String get removedFromContinueWatching => '已从继续观看中移除'; + @override String errorLoading({required Object error}) => '错误: ${error}'; + @override String get fileInfoNotAvailable => '文件信息不可用'; + @override String errorLoadingFileInfo({required Object error}) => '加载文件信息时出错: ${error}'; + @override String get errorLoadingSeries => '加载系列时出错'; + @override String get errorLoadingSeason => '加载季时出错'; + @override String get musicNotSupported => '尚不支持播放音乐'; + @override String get logsCleared => '日志已清除'; + @override String get logsCopied => '日志已复制到剪贴板'; + @override String get noLogsAvailable => '没有可用日志'; + @override String libraryScanning({required Object title}) => '正在扫描 “${title}”...'; + @override String libraryScanStarted({required Object title}) => '已开始扫描 “${title}” 媒体库'; + @override String libraryScanFailed({required Object error}) => '无法扫描媒体库: ${error}'; + @override String metadataRefreshing({required Object title}) => '正在刷新 “${title}” 的元数据...'; + @override String metadataRefreshStarted({required Object title}) => '已开始刷新 “${title}” 的元数据'; + @override String metadataRefreshFailed({required Object error}) => '无法刷新元数据: ${error}'; + @override String get noPlexToken => '未找到 Plex 令牌。请重新登录。'; + @override String get logoutConfirm => '你确定要登出吗?'; + @override String get noSeasonsFound => '未找到季'; + @override String get noEpisodesFound => '在第一季中未找到剧集'; + @override String get noEpisodesFoundGeneral => '未找到剧集'; + @override String get noResultsFound => '未找到结果'; + @override String sleepTimerSet({required Object label}) => '睡眠定时器已设置为 ${label}'; + @override String failedToSwitchProfile({required Object displayName}) => '无法切换到 ${displayName}'; +} + +// Path: profile +class _StringsProfileZh implements _StringsProfileEn { + _StringsProfileZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get noUsersAvailable => '没有可用用户'; +} + +// Path: subtitlingStyling +class _StringsSubtitlingStylingZh implements _StringsSubtitlingStylingEn { + _StringsSubtitlingStylingZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get stylingOptions => '样式选项'; + @override String get fontSize => '字号'; + @override String get textColor => '文本颜色'; + @override String get borderSize => '边框大小'; + @override String get borderColor => '边框颜色'; + @override String get backgroundOpacity => '背景不透明度'; + @override String get backgroundColor => '背景颜色'; +} + +// Path: dialog +class _StringsDialogZh implements _StringsDialogEn { + _StringsDialogZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get confirmAction => '确认操作'; + @override String get areYouSure => '确定要执行此操作吗?'; + @override String get cancel => '取消'; + @override String get playNow => '立即播放'; +} + +// Path: discover +class _StringsDiscoverZh implements _StringsDiscoverEn { + _StringsDiscoverZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '发现'; + @override String get switchProfile => '切换配置文件'; + @override String get switchServer => '切换服务器'; + @override String get logout => '登出'; + @override String get noContentAvailable => '没有可用内容'; + @override String get addMediaToLibraries => '请向你的媒体库添加一些媒体'; + @override String get continueWatching => '继续观看'; + @override String get recentlyAdded => '最近添加'; + @override String get play => '播放'; + @override String get resume => '继续'; + @override String playEpisode({required Object season, required Object episode}) => '播放 S${season}, E${episode}'; + @override String resumeEpisode({required Object season, required Object episode}) => '继续 S${season}, E${episode}'; + @override String get pause => '暂停'; + @override String get overview => '概述'; + @override String get cast => '演员表'; + @override String get seasons => '季数'; + @override String get studio => '制作公司'; + @override String get rating => '年龄分级'; + @override String get watched => '已观看'; + @override String episodeCount({required Object count}) => '${count} 集'; + @override String watchedProgress({required Object watched, required Object total}) => '已观看 ${watched}/${total} 集'; + @override String get movie => '电影'; + @override String get tvShow => '电视剧'; + @override String minutesLeft({required Object minutes}) => '剩余 ${minutes} 分钟'; +} + +// Path: errors +class _StringsErrorsZh implements _StringsErrorsEn { + _StringsErrorsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String searchFailed({required Object error}) => '搜索失败: ${error}'; + @override String connectionTimeout({required Object context}) => '加载 ${context} 时连接超时'; + @override String get connectionFailed => '无法连接到 Plex 服务器'; + @override String failedToLoad({required Object context, required Object error}) => '无法加载 ${context}: ${error}'; + @override String get noClientAvailable => '没有可用客户端'; + @override String authenticationFailed({required Object error}) => '验证失败: ${error}'; + @override String get couldNotLaunchUrl => '无法打开授权 URL'; + @override String get pleaseEnterToken => '请输入一个令牌'; + @override String get invalidToken => '令牌无效'; + @override String failedToVerifyToken({required Object error}) => '无法验证令牌: ${error}'; + @override String failedToSwitchProfile({required Object displayName}) => '无法切换到 ${displayName}'; + @override String get connectionFailedGeneric => '连接失败'; +} + +// Path: libraries +class _StringsLibrariesZh implements _StringsLibrariesEn { + _StringsLibrariesZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '媒体库'; + @override String get scanLibraryFiles => '扫描媒体库文件'; + @override String get scanLibrary => '扫描媒体库'; + @override String get analyze => '分析'; + @override String get analyzeLibrary => '分析媒体库'; + @override String get refreshMetadata => '刷新元数据'; + @override String get emptyTrash => '清空回收站'; + @override String emptyingTrash({required Object title}) => '正在清空 “${title}” 的回收站...'; + @override String trashEmptied({required Object title}) => '已清空 “${title}” 的回收站'; + @override String failedToEmptyTrash({required Object error}) => '无法清空回收站: ${error}'; + @override String analyzing({required Object title}) => '正在分析 “${title}”...'; + @override String analysisStarted({required Object title}) => '已开始分析 “${title}”'; + @override String failedToAnalyze({required Object error}) => '无法分析媒体库: ${error}'; + @override String get noLibrariesFound => '未找到媒体库'; + @override String get thisLibraryIsEmpty => '此媒体库为空'; + @override String get all => '全部'; + @override String get clearAll => '全部清除'; + @override String scanLibraryConfirm({required Object title}) => '确定要扫描 “${title}” 吗?'; + @override String analyzeLibraryConfirm({required Object title}) => '确定要分析 “${title}” 吗?'; + @override String refreshMetadataConfirm({required Object title}) => '确定要刷新 “${title}” 的元数据吗?'; + @override String emptyTrashConfirm({required Object title}) => '确定要清空 “${title}” 的回收站吗?'; + @override String get manageLibraries => '管理媒体库'; + @override String get sort => '排序'; + @override String get sortBy => '排序依据'; + @override String get filters => '筛选器'; + @override String loadingLibraryWithCount({required Object count}) => '正在加载媒体库... (已加载 ${count} 个项目)'; + @override String get confirmActionMessage => '确定要执行此操作吗?'; + @override String get showLibrary => '显示媒体库'; + @override String get hideLibrary => '隐藏媒体库'; + @override String get libraryOptions => '媒体库选项'; +} + +// Path: about +class _StringsAboutZh implements _StringsAboutEn { + _StringsAboutZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '关于'; + @override String get openSourceLicenses => '开源许可证'; + @override String versionLabel({required Object version}) => '版本 ${version}'; + @override String get appDescription => '一款精美的 Flutter Plex 客户端'; + @override String get viewLicensesDescription => '查看第三方库的许可证'; +} + +// Path: serverSelection +class _StringsServerSelectionZh implements _StringsServerSelectionEn { + _StringsServerSelectionZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get connectingToServer => '正在连接服务器...'; + @override String get serverDebugCopied => '服务器调试数据已复制到剪贴板'; + @override String get copyDebugData => '复制调试数据'; + @override String get noServersFound => '未找到服务器'; + @override String malformedServerData({required Object count}) => '找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。'; + @override String get incompleteServerInfo => '某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。'; + @override String get incompleteConnectionInfo => '服务器连接信息不完整。请重试。'; + @override String malformedServerInfo({required Object message}) => '服务器信息格式错误: ${message}'; + @override String get networkConnectionFailed => '网络连接失败。请检查你的网络连接并重试。'; + @override String get authenticationFailed => '验证失败。请重新登录。'; + @override String get plexServiceUnavailable => 'Plex 服务不可用。请稍后重试。'; + @override String failedToLoadServers({required Object error}) => '无法加载服务器: ${error}'; +} + +// Path: hubDetail +class _StringsHubDetailZh implements _StringsHubDetailEn { + _StringsHubDetailZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '标题'; + @override String get releaseYear => '发行年份'; + @override String get dateAdded => '添加日期'; + @override String get rating => '评分'; + @override String get noItemsFound => '未找到项目'; +} + +// Path: logs +class _StringsLogsZh implements _StringsLogsEn { + _StringsLogsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '日志'; + @override String get clearLogs => '清除日志'; + @override String get copyLogs => '复制日志'; + @override String get exportLogs => '导出日志'; + @override String get noLogsToShow => '没有可显示的日志'; + @override String get error => '错误:'; + @override String get stackTrace => '堆栈跟踪 (Stack Trace):'; +} + +// Path: licenses +class _StringsLicensesZh implements _StringsLicensesEn { + _StringsLicensesZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get relatedPackages => '相关软件包'; + @override String get license => '许可证'; + @override String licenseNumber({required Object number}) => '许可证 ${number}'; + @override String licensesCount({required Object count}) => '${count} 个许可证'; +} + +// Path: navigation +class _StringsNavigationZh implements _StringsNavigationEn { + _StringsNavigationZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get home => '主页'; + @override String get search => '搜索'; + @override String get libraries => '媒体库'; + @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. @@ -2662,6 +4190,8 @@ extension on Translations { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} seconds'; case 'settings.defaultSleepTimer': return 'Default Sleep Timer'; case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} minutes'; + case 'settings.rememberTrackSelections': return 'Remember track selections per show/movie'; + case 'settings.rememberTrackSelectionsDescription': return 'Automatically save audio and subtitle language preferences when you change tracks during playback'; case 'settings.unwatchedOnly': return 'Unwatched Only'; case 'settings.unwatchedOnlyDescription': return 'Only include unwatched episodes in shuffle queue'; case 'settings.shuffleOrderNavigation': return 'Shuffle Order Navigation'; @@ -2749,6 +4279,7 @@ extension on Translations { case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Restricted'; case 'userStatus.protected': return 'Protected'; + case 'userStatus.current': return 'CURRENT'; case 'messages.markedAsWatched': return 'Marked as watched'; case 'messages.markedAsUnwatched': return 'Marked as unwatched'; case 'messages.removedFromContinueWatching': return 'Removed from Continue Watching'; @@ -2802,6 +4333,10 @@ extension on Translations { case 'discover.pause': return 'Pause'; case 'discover.overview': return 'Overview'; case 'discover.cast': return 'Cast'; + case 'discover.seasons': return 'Seasons'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Rating'; + case 'discover.watched': return 'Watched'; case 'discover.episodeCount': return ({required Object count}) => '${count} episodes'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} watched'; case 'discover.movie': return 'Movie'; @@ -2886,6 +4421,403 @@ 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; + } + } +} + +extension on _StringsDe { + dynamic _flatMapFunction(String path) { + switch (path) { + case 'app.title': return 'Plezy'; + case 'app.loading': return 'Lädt...'; + case 'auth.signInWithPlex': return 'Mit Plex anmelden'; + case 'auth.showQRCode': return 'QR-Code anzeigen'; + case 'auth.cancel': return 'Abbrechen'; + case 'auth.authenticate': return 'Authentifizieren'; + case 'auth.retry': return 'Erneut versuchen'; + case 'auth.debugEnterToken': return 'Debug: Plex-Token eingeben'; + case 'auth.plexTokenLabel': return 'Plex-Auth-Token'; + case 'auth.plexTokenHint': return 'Plex.tv-Token eingeben'; + case 'auth.authenticationTimeout': return 'Authentifizierung abgelaufen. Bitte erneut versuchen.'; + case 'auth.scanQRCodeInstruction': return 'Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.'; + case 'auth.waitingForAuth': return 'Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen.'; + case 'common.cancel': return 'Abbrechen'; + case 'common.save': return 'Speichern'; + case 'common.close': return 'Schließen'; + case 'common.clear': return 'Leeren'; + case 'common.reset': return 'Zurücksetzen'; + case 'common.later': return 'Später'; + case 'common.submit': return 'Senden'; + case 'common.confirm': return 'Bestätigen'; + case 'common.retry': return 'Erneut versuchen'; + case 'common.playNow': return 'Jetzt abspielen'; + case 'common.logout': return 'Abmelden'; + case 'common.online': return 'Online'; + case 'common.offline': return 'Offline'; + case 'common.owned': return 'Besitzer'; + case 'common.shared': return 'Geteilt'; + case 'common.current': return 'AKTUELL'; + case 'common.unknown': return 'Unbekannt'; + case 'common.refresh': return 'Aktualisieren'; + case 'common.yes': return 'Ja'; + case 'common.no': return 'Nein'; + case 'common.server': return 'Server'; + case 'screens.licenses': return 'Lizenzen'; + case 'screens.selectServer': return 'Server auswählen'; + case 'screens.switchProfile': return 'Profil wechseln'; + case 'screens.subtitleStyling': return 'Untertitel-Stil'; + case 'screens.search': return 'Suche'; + case 'screens.logs': return 'Protokolle'; + case 'update.available': return 'Update verfügbar'; + case 'update.versionAvailable': return ({required Object version}) => 'Version ${version} ist verfügbar'; + case 'update.currentVersion': return ({required Object version}) => 'Aktuell: ${version}'; + case 'update.skipVersion': return 'Diese Version überspringen'; + case 'update.viewRelease': return 'Release anzeigen'; + case 'update.latestVersion': return 'Aktuellste Version installiert'; + case 'update.checkFailed': return 'Fehler bei der Updateprüfung'; + case 'settings.title': return 'Einstellungen'; + case 'settings.language': return 'Sprache'; + case 'settings.theme': return 'Design'; + case 'settings.appearance': return 'Darstellung'; + case 'settings.videoPlayback': return 'Videowiedergabe'; + case 'settings.shufflePlay': return 'Zufallswiedergabe'; + case 'settings.advanced': return 'Erweitert'; + case 'settings.useSeasonPostersDescription': return 'Staffelposter statt Serienposter für Episoden anzeigen'; + case 'settings.showHeroSectionDescription': return 'Bereich mit empfohlenen Inhalten auf der Startseite anzeigen'; + case 'settings.secondsLabel': return 'Sekunden'; + case 'settings.minutesLabel': return 'Minuten'; + case 'settings.secondsShort': return 's'; + case 'settings.minutesShort': return 'm'; + case 'settings.durationHint': return ({required Object min, required Object max}) => 'Dauer eingeben (${min}-${max})'; + case 'settings.systemTheme': return 'System'; + case 'settings.systemThemeDescription': return 'Systemeinstellungen folgen'; + case 'settings.lightTheme': return 'Hell'; + case 'settings.darkTheme': return 'Dunkel'; + case 'settings.libraryDensity': return 'Mediathekdichte'; + case 'settings.compact': return 'Kompakt'; + case 'settings.compactDescription': return 'Kleinere Karten, mehr Elemente sichtbar'; + case 'settings.normal': return 'Normal'; + case 'settings.normalDescription': return 'Standardgröße'; + case 'settings.comfortable': return 'Großzügig'; + case 'settings.comfortableDescription': return 'Größere Karten, weniger Elemente sichtbar'; + case 'settings.viewMode': return 'Ansichtsmodus'; + case 'settings.gridView': return 'Raster'; + case 'settings.gridViewDescription': return 'Elemente im Raster anzeigen'; + case 'settings.listView': return 'Liste'; + case 'settings.listViewDescription': return 'Elemente in Listenansicht anzeigen'; + case 'settings.useSeasonPosters': return 'Staffelposter verwenden'; + case 'settings.showHeroSection': return 'Hero-Bereich anzeigen'; + case 'settings.hardwareDecoding': return 'Hardware-Decodierung'; + case 'settings.hardwareDecodingDescription': return 'Hardwarebeschleunigung verwenden, sofern verfügbar'; + case 'settings.bufferSize': return 'Puffergröße'; + case 'settings.bufferSizeMB': return ({required Object size}) => '${size}MB'; + case 'settings.subtitleStyling': return 'Untertitel-Stil'; + case 'settings.subtitleStylingDescription': return 'Aussehen von Untertiteln anpassen'; + case 'settings.smallSkipDuration': return 'Kleine Sprungdauer'; + case 'settings.largeSkipDuration': return 'Große Sprungdauer'; + case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} Sekunden'; + case 'settings.defaultSleepTimer': return 'Standard-Sleep-Timer'; + case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} Minuten'; + case 'settings.rememberTrackSelections': return 'Spurauswahl pro Serie/Film merken'; + case 'settings.rememberTrackSelectionsDescription': return 'Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert'; + case 'settings.unwatchedOnly': return 'Nur ungesehene'; + case 'settings.unwatchedOnlyDescription': return 'Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen'; + case 'settings.shuffleOrderNavigation': return 'Navigation der Shuffle-Reihenfolge'; + case 'settings.shuffleOrderNavigationDescription': return 'Weiter/Zurück folgt der zufälligen Reihenfolge'; + case 'settings.loopShuffleQueue': return 'Shuffle-Warteschlange wiederholen'; + case 'settings.loopShuffleQueueDescription': return 'Warteschlange neu starten, wenn das Ende erreicht ist'; + case 'settings.videoPlayerControls': return 'Videoplayer-Steuerung'; + case 'settings.keyboardShortcuts': return 'Tastenkürzel'; + case 'settings.keyboardShortcutsDescription': return 'Tastenkürzel anpassen'; + case 'settings.debugLogging': return 'Debug-Protokollierung'; + case 'settings.debugLoggingDescription': return 'Detaillierte Protokolle zur Fehleranalyse aktivieren'; + case 'settings.viewLogs': return 'Protokolle anzeigen'; + case 'settings.viewLogsDescription': return 'App-Protokolle anzeigen'; + case 'settings.clearCache': return 'Cache löschen'; + case 'settings.clearCacheDescription': return 'Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.'; + case 'settings.clearCacheSuccess': return 'Cache erfolgreich gelöscht'; + case 'settings.resetSettings': return 'Einstellungen zurücksetzen'; + case 'settings.resetSettingsDescription': return 'Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.'; + case 'settings.resetSettingsSuccess': return 'Einstellungen erfolgreich zurückgesetzt'; + case 'settings.shortcutsReset': return 'Tastenkürzel auf Standard zurückgesetzt'; + case 'settings.about': return 'Über'; + case 'settings.aboutDescription': return 'App-Informationen und Lizenzen'; + case 'settings.updates': return 'Updates'; + case 'settings.updateAvailable': return 'Update verfügbar'; + case 'settings.checkForUpdates': return 'Nach Updates suchen'; + case 'settings.validationErrorEnterNumber': return 'Bitte eine gültige Zahl eingeben'; + case 'settings.validationErrorDuration': return ({required Object min, required Object max, required Object unit}) => 'Dauer muss zwischen ${min} und ${max} ${unit} liegen'; + case 'settings.shortcutAlreadyAssigned': return ({required Object action}) => 'Tastenkürzel bereits zugewiesen an ${action}'; + case 'settings.shortcutUpdated': return ({required Object action}) => 'Tastenkürzel aktualisiert für ${action}'; + case 'search.hint': return 'Filme, Serien, Musik suchen...'; + case 'search.tryDifferentTerm': return 'Anderen Suchbegriff versuchen'; + case 'search.searchYourMedia': return 'In den eigenen Medien suchen'; + case 'search.enterTitleActorOrKeyword': return 'Titel, Schauspieler oder Stichwort eingeben'; + case 'hotkeys.setShortcutFor': return ({required Object actionName}) => 'Tastenkürzel festlegen für ${actionName}'; + case 'hotkeys.clearShortcut': return 'Kürzel löschen'; + case 'pinEntry.enterPin': return 'PIN eingeben'; + case 'pinEntry.showPin': return 'PIN anzeigen'; + case 'pinEntry.hidePin': return 'PIN verbergen'; + case 'fileInfo.title': return 'Dateiinfo'; + case 'fileInfo.video': return 'Video'; + case 'fileInfo.audio': return 'Audio'; + case 'fileInfo.file': return 'Datei'; + case 'fileInfo.advanced': return 'Erweitert'; + case 'fileInfo.codec': return 'Codec'; + case 'fileInfo.resolution': return 'Auflösung'; + case 'fileInfo.bitrate': return 'Bitrate'; + case 'fileInfo.frameRate': return 'Bildrate'; + case 'fileInfo.aspectRatio': return 'Seitenverhältnis'; + case 'fileInfo.profile': return 'Profil'; + case 'fileInfo.bitDepth': return 'Farbtiefe'; + case 'fileInfo.colorSpace': return 'Farbraum'; + case 'fileInfo.colorRange': return 'Farbbereich'; + case 'fileInfo.colorPrimaries': return 'Primärfarben'; + case 'fileInfo.chromaSubsampling': return 'Chroma-Subsampling'; + case 'fileInfo.channels': return 'Kanäle'; + case 'fileInfo.path': return 'Pfad'; + case 'fileInfo.size': return 'Größe'; + case 'fileInfo.container': return 'Container'; + case 'fileInfo.duration': return 'Dauer'; + case 'fileInfo.optimizedForStreaming': return 'Für Streaming optimiert'; + case 'fileInfo.has64bitOffsets': return '64-Bit-Offsets'; + case 'mediaMenu.markAsWatched': return 'Als gesehen markieren'; + case 'mediaMenu.markAsUnwatched': return 'Als ungesehen markieren'; + case 'mediaMenu.removeFromContinueWatching': return 'Aus ‚Weiterschauen‘ entfernen'; + case 'mediaMenu.goToSeries': return 'Zur Serie'; + case 'mediaMenu.goToSeason': return 'Zur Staffel'; + case 'mediaMenu.shufflePlay': return 'Zufallswiedergabe'; + case 'mediaMenu.fileInfo': return 'Dateiinfo'; + case 'tooltips.shufflePlay': return 'Zufallswiedergabe'; + case 'tooltips.markAsWatched': return 'Als gesehen markieren'; + case 'tooltips.markAsUnwatched': return 'Als ungesehen markieren'; + case 'videoControls.audioLabel': return 'Audio'; + case 'videoControls.subtitlesLabel': return 'Untertitel'; + case 'videoControls.resetToZero': return 'Auf 0 ms zurücksetzen'; + case 'videoControls.addTime': return ({required Object amount, required Object unit}) => '+${amount}${unit}'; + case 'videoControls.minusTime': return ({required Object amount, required Object unit}) => '-${amount}${unit}'; + case 'videoControls.playsLater': return ({required Object label}) => '${label} spielt später'; + case 'videoControls.playsEarlier': return ({required Object label}) => '${label} spielt früher'; + case 'videoControls.noOffset': return 'Kein Offset'; + case 'videoControls.letterbox': return 'Letterbox'; + case 'videoControls.fillScreen': return 'Bild füllen'; + case 'videoControls.stretch': return 'Strecken'; + case 'videoControls.lockRotation': return 'Rotation sperren'; + case 'videoControls.unlockRotation': return 'Rotation entsperren'; + case 'userStatus.admin': return 'Eigentümer'; + case 'userStatus.restricted': return 'Eingeschränkt'; + case 'userStatus.protected': return 'Geschützt'; + case 'userStatus.current': return 'AKTUELL'; + case 'messages.markedAsWatched': return 'Als gesehen markiert'; + case 'messages.markedAsUnwatched': return 'Als ungesehen markiert'; + case 'messages.removedFromContinueWatching': return 'Aus ‚Weiterschauen‘ entfernt'; + case 'messages.errorLoading': return ({required Object error}) => 'Fehler: ${error}'; + case 'messages.fileInfoNotAvailable': return 'Dateiinfo nicht verfügbar'; + case 'messages.errorLoadingFileInfo': return ({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}'; + case 'messages.errorLoadingSeries': return 'Fehler beim Laden der Serie'; + case 'messages.errorLoadingSeason': return 'Fehler beim Laden der Staffel'; + case 'messages.musicNotSupported': return 'Musikwiedergabe wird noch nicht unterstützt'; + case 'messages.logsCleared': return 'Protokolle gelöscht'; + case 'messages.logsCopied': return 'Protokolle in Zwischenablage kopiert'; + case 'messages.noLogsAvailable': return 'Keine Protokolle verfügbar'; + case 'messages.libraryScanning': return ({required Object title}) => 'Scanne „${title}“...'; + case 'messages.libraryScanStarted': return ({required Object title}) => 'Mediathekscan gestartet für „${title}“'; + case 'messages.libraryScanFailed': return ({required Object error}) => 'Fehler beim Scannen der Mediathek: ${error}'; + case 'messages.metadataRefreshing': return ({required Object title}) => 'Metadaten werden aktualisiert für „${title}“...'; + case 'messages.metadataRefreshStarted': return ({required Object title}) => 'Metadaten-Aktualisierung gestartet für „${title}“'; + case 'messages.metadataRefreshFailed': return ({required Object error}) => 'Metadaten konnten nicht aktualisiert werden: ${error}'; + case 'messages.noPlexToken': return 'Kein Plex-Token gefunden. Bitte erneut anmelden.'; + case 'messages.logoutConfirm': return 'Abmeldung wirklich durchführen?'; + case 'messages.noSeasonsFound': return 'Keine Staffeln gefunden'; + case 'messages.noEpisodesFound': return 'Keine Episoden in der ersten Staffel gefunden'; + case 'messages.noEpisodesFoundGeneral': return 'Keine Episoden gefunden'; + case 'messages.noResultsFound': return 'Keine Ergebnisse gefunden'; + case 'messages.sleepTimerSet': return ({required Object label}) => 'Sleep-Timer gesetzt auf ${label}'; + case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + case 'profile.noUsersAvailable': return 'Keine Benutzer verfügbar'; + case 'subtitlingStyling.stylingOptions': return 'Stiloptionen'; + case 'subtitlingStyling.fontSize': return 'Schriftgröße'; + case 'subtitlingStyling.textColor': return 'Textfarbe'; + case 'subtitlingStyling.borderSize': return 'Rahmengröße'; + case 'subtitlingStyling.borderColor': return 'Rahmenfarbe'; + case 'subtitlingStyling.backgroundOpacity': return 'Hintergrunddeckkraft'; + case 'subtitlingStyling.backgroundColor': return 'Hintergrundfarbe'; + case 'dialog.confirmAction': return 'Aktion bestätigen'; + case 'dialog.areYouSure': return 'Aktion wirklich ausführen?'; + case 'dialog.cancel': return 'Abbrechen'; + case 'dialog.playNow': return 'Jetzt abspielen'; + case 'discover.title': return 'Entdecken'; + case 'discover.switchProfile': return 'Profil wechseln'; + case 'discover.switchServer': return 'Server wechseln'; + case 'discover.logout': return 'Abmelden'; + case 'discover.noContentAvailable': return 'Kein Inhalt verfügbar'; + case 'discover.addMediaToLibraries': return 'Medien zur Mediathek hinzufügen'; + case 'discover.continueWatching': return 'Weiterschauen'; + case 'discover.recentlyAdded': return 'Kürzlich hinzugefügt'; + case 'discover.play': return 'Abspielen'; + case 'discover.resume': return 'Fortsetzen'; + case 'discover.playEpisode': return ({required Object season, required Object episode}) => 'S${season}, E${episode} - Abspielen'; + case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => 'S${season}, E${episode} - Fortsetzen'; + case 'discover.pause': return 'Pause'; + case 'discover.overview': return 'Übersicht'; + case 'discover.cast': return 'Besetzung'; + case 'discover.seasons': return 'Staffeln'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Altersfreigabe'; + case 'discover.watched': return 'Gesehen'; + case 'discover.episodeCount': return ({required Object count}) => '${count} Episoden'; + case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched} von ${total} gesehen'; + case 'discover.movie': return 'Film'; + case 'discover.tvShow': return 'Serie'; + case 'discover.minutesLeft': return ({required Object minutes}) => '${minutes} Min übrig'; + case 'errors.searchFailed': return ({required Object error}) => 'Suche fehlgeschlagen: ${error}'; + case 'errors.connectionTimeout': return ({required Object context}) => 'Zeitüberschreitung beim Laden von ${context}'; + case 'errors.connectionFailed': return 'Verbindung zum Plex-Server fehlgeschlagen'; + case 'errors.failedToLoad': return ({required Object context, required Object error}) => 'Fehler beim Laden von ${context}: ${error}'; + case 'errors.noClientAvailable': return 'Kein Client verfügbar'; + case 'errors.authenticationFailed': return ({required Object error}) => 'Authentifizierung fehlgeschlagen: ${error}'; + case 'errors.couldNotLaunchUrl': return 'Auth-URL konnte nicht geöffnet werden'; + case 'errors.pleaseEnterToken': return 'Bitte Token eingeben'; + case 'errors.invalidToken': return 'Ungültiges Token'; + case 'errors.failedToVerifyToken': return ({required Object error}) => 'Token-Verifizierung fehlgeschlagen: ${error}'; + case 'errors.failedToSwitchProfile': return ({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + case 'errors.connectionFailedGeneric': return 'Verbindung fehlgeschlagen'; + case 'libraries.title': return 'Mediatheken'; + case 'libraries.scanLibraryFiles': return 'Mediatheksdateien scannen'; + case 'libraries.scanLibrary': return 'Mediathek scannen'; + case 'libraries.analyze': return 'Analysieren'; + case 'libraries.analyzeLibrary': return 'Mediathek analysieren'; + case 'libraries.refreshMetadata': return 'Metadaten aktualisieren'; + case 'libraries.emptyTrash': return 'Papierkorb leeren'; + case 'libraries.emptyingTrash': return ({required Object title}) => 'Papierkorb für „${title}“ wird geleert...'; + case 'libraries.trashEmptied': return ({required Object title}) => 'Papierkorb für „${title}“ geleert'; + case 'libraries.failedToEmptyTrash': return ({required Object error}) => 'Papierkorb konnte nicht geleert werden: ${error}'; + case 'libraries.analyzing': return ({required Object title}) => 'Analysiere „${title}“...'; + case 'libraries.analysisStarted': return ({required Object title}) => 'Analyse gestartet für „${title}“'; + case 'libraries.failedToAnalyze': return ({required Object error}) => 'Analyse der Mediathek fehlgeschlagen: ${error}'; + case 'libraries.noLibrariesFound': return 'Keine Mediatheken gefunden'; + case 'libraries.thisLibraryIsEmpty': return 'Diese Mediathek ist leer'; + case 'libraries.all': return 'Alle'; + case 'libraries.clearAll': return 'Alle löschen'; + case 'libraries.scanLibraryConfirm': return ({required Object title}) => '„${title}“ wirklich scannen?'; + case 'libraries.analyzeLibraryConfirm': return ({required Object title}) => '„${title}“ wirklich analysieren?'; + case 'libraries.refreshMetadataConfirm': return ({required Object title}) => 'Metadaten für „${title}“ wirklich aktualisieren?'; + case 'libraries.emptyTrashConfirm': return ({required Object title}) => 'Papierkorb für „${title}“ wirklich leeren?'; + case 'libraries.manageLibraries': return 'Mediatheken verwalten'; + case 'libraries.sort': return 'Sortieren'; + case 'libraries.sortBy': return 'Sortieren nach'; + case 'libraries.filters': return 'Filter'; + case 'libraries.loadingLibraryWithCount': return ({required Object count}) => 'Lade Mediathek... (${count} Elemente geladen)'; + case 'libraries.confirmActionMessage': return 'Aktion wirklich durchführen?'; + case 'libraries.showLibrary': return 'Mediathek anzeigen'; + case 'libraries.hideLibrary': return 'Mediathek ausblenden'; + case 'libraries.libraryOptions': return 'Mediatheksoptionen'; + case 'about.title': return 'Über'; + case 'about.openSourceLicenses': return 'Open-Source-Lizenzen'; + case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; + case 'about.appDescription': return 'Ein schöner Plex-Client für Flutter'; + case 'about.viewLicensesDescription': return 'Lizenzen von Drittanbieter-Bibliotheken anzeigen'; + case 'serverSelection.connectingToServer': return 'Verbinde mit Server...'; + case 'serverSelection.serverDebugCopied': return 'Server-Debugdaten kopiert'; + case 'serverSelection.copyDebugData': return 'Debugdaten kopieren'; + case 'serverSelection.noServersFound': return 'Keine Server gefunden'; + case 'serverSelection.malformedServerData': return ({required Object count}) => '${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.'; + case 'serverSelection.incompleteServerInfo': return 'Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.'; + case 'serverSelection.incompleteConnectionInfo': return 'Serververbindungsinformationen unvollständig. Bitte erneut versuchen.'; + case 'serverSelection.malformedServerInfo': return ({required Object message}) => 'Serverinformationen fehlerhaft: ${message}'; + case 'serverSelection.networkConnectionFailed': return 'Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.'; + case 'serverSelection.authenticationFailed': return 'Authentifizierung fehlgeschlagen. Bitte erneut anmelden.'; + case 'serverSelection.plexServiceUnavailable': return 'Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.'; + case 'serverSelection.failedToLoadServers': return ({required Object error}) => 'Server konnten nicht geladen werden: ${error}'; + case 'hubDetail.title': return 'Titel'; + case 'hubDetail.releaseYear': return 'Erscheinungsjahr'; + case 'hubDetail.dateAdded': return 'Hinzugefügt am'; + case 'hubDetail.rating': return 'Bewertung'; + case 'hubDetail.noItemsFound': return 'Keine Elemente gefunden'; + case 'logs.title': return 'Protokolle'; + case 'logs.clearLogs': return 'Protokolle löschen'; + case 'logs.copyLogs': return 'Protokolle kopieren'; + case 'logs.exportLogs': return 'Protokolle exportieren'; + case 'logs.noLogsToShow': return 'Keine Protokolle zum Anzeigen'; + case 'logs.error': return 'Fehler:'; + case 'logs.stackTrace': return 'Stacktrace:'; + case 'licenses.relatedPackages': return 'Verwandte Pakete'; + case 'licenses.license': return 'Lizenz'; + case 'licenses.licenseNumber': return ({required Object number}) => 'Lizenz ${number}'; + case 'licenses.licensesCount': return ({required Object count}) => '${count} Lizenzen'; + case 'navigation.home': return 'Start'; + case 'navigation.search': return 'Suche'; + case 'navigation.libraries': return 'Mediatheken'; + case 'navigation.settings': return 'Einstellungen'; + case 'playlists.title': return 'Wiedergabelisten'; + case 'playlists.noPlaylists': return 'Keine Wiedergabelisten gefunden'; + case 'playlists.create': return 'Wiedergabeliste erstellen'; + case 'playlists.newPlaylist': return 'Neue Wiedergabeliste'; + case 'playlists.playlistName': return 'Name der Wiedergabeliste'; + case 'playlists.enterPlaylistName': return 'Name der Wiedergabeliste eingeben'; + case 'playlists.edit': return 'Wiedergabeliste bearbeiten'; + case 'playlists.delete': return 'Wiedergabeliste löschen'; + case 'playlists.addTo': return 'Zur Wiedergabeliste hinzufügen'; + case 'playlists.addItems': return 'Elemente hinzufügen'; + case 'playlists.removeItem': return 'Aus Wiedergabeliste entfernen'; + case 'playlists.clearPlaylist': return 'Wiedergabeliste leeren'; + case 'playlists.playAll': return 'Alle abspielen'; + case 'playlists.shuffle': return 'Zufällig'; + case 'playlists.smartPlaylist': return 'Intelligente Wiedergabeliste'; + case 'playlists.regularPlaylist': return 'Normale Wiedergabeliste'; + case 'playlists.itemCount': return ({required Object count}) => '${count} Elemente'; + case 'playlists.oneItem': return '1 Element'; + case 'playlists.emptyPlaylist': return 'Diese Wiedergabeliste ist leer'; + case 'playlists.deleteConfirm': return 'Wiedergabeliste löschen?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Soll "${name}" wirklich gelöscht werden?'; + case 'playlists.created': return 'Wiedergabeliste erstellt'; + case 'playlists.updated': return 'Wiedergabeliste aktualisiert'; + case 'playlists.deleted': return 'Wiedergabeliste gelöscht'; + case 'playlists.itemAdded': return 'Zur Wiedergabeliste hinzugefügt'; + case 'playlists.itemRemoved': return 'Aus Wiedergabeliste entfernt'; + case 'playlists.selectPlaylist': return 'Wiedergabeliste auswählen'; + case 'playlists.createNewPlaylist': return 'Neue Wiedergabeliste erstellen'; + case 'playlists.errorCreating': return 'Wiedergabeliste konnte nicht erstellt werden'; + case 'playlists.errorDeleting': return 'Wiedergabeliste konnte nicht gelöscht werden'; + case 'playlists.errorLoading': return 'Wiedergabelisten konnten nicht geladen werden'; + case 'playlists.errorAdding': return 'Konnte nicht zur Wiedergabeliste hinzugefügt werden'; + case 'playlists.errorReordering': return 'Element der Wiedergabeliste konnte nicht neu geordnet werden'; + case 'playlists.errorRemoving': return 'Konnte nicht aus der Wiedergabeliste entfernt werden'; default: return null; } } @@ -2984,6 +4916,8 @@ extension on _StringsIt { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} secondi'; case 'settings.defaultSleepTimer': return 'Timer spegnimento predefinito'; case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} minuti'; + case 'settings.rememberTrackSelections': return 'Ricorda selezioni tracce per serie/film'; + case 'settings.rememberTrackSelectionsDescription': return 'Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione'; case 'settings.unwatchedOnly': return 'Solo non guardati'; case 'settings.unwatchedOnlyDescription': return 'Includi solo gli episodi non guardati nella coda di riproduzione casuale'; case 'settings.shuffleOrderNavigation': return 'Navigazione in ordine casuale'; @@ -3071,6 +5005,7 @@ extension on _StringsIt { case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Limitato'; case 'userStatus.protected': return 'Protetto'; + case 'userStatus.current': return 'ATTUALE'; case 'messages.markedAsWatched': return 'Segna come visto'; case 'messages.markedAsUnwatched': return 'Segna come non visto'; case 'messages.removedFromContinueWatching': return 'Rimosso da Continua a guardare'; @@ -3124,6 +5059,10 @@ extension on _StringsIt { case 'discover.pause': return 'Pausa'; case 'discover.overview': return 'Panoramica'; case 'discover.cast': return 'Cast'; + case 'discover.seasons': return 'Stagioni'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Classificazione'; + case 'discover.watched': return 'Guardato'; case 'discover.episodeCount': return ({required Object count}) => '${count} episodi'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} guardati'; case 'discover.movie': return 'Film'; @@ -3208,6 +5147,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; } } @@ -3306,6 +5279,8 @@ extension on _StringsNl { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} seconden'; case 'settings.defaultSleepTimer': return 'Standaard slaap timer'; case 'settings.minutesUnit': return ({required Object minutes}) => 'bij ${minutes} minuten'; + case 'settings.rememberTrackSelections': return 'Onthoud track selecties per serie/film'; + case 'settings.rememberTrackSelectionsDescription': return 'Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen'; case 'settings.unwatchedOnly': return 'Alleen ongekeken'; case 'settings.unwatchedOnlyDescription': return 'Alleen ongekeken afleveringen opnemen in willekeurige wachtrij'; case 'settings.shuffleOrderNavigation': return 'Willekeurige volgorde navigatie'; @@ -3393,6 +5368,7 @@ extension on _StringsNl { case 'userStatus.admin': return 'Beheerder'; case 'userStatus.restricted': return 'Beperkt'; case 'userStatus.protected': return 'Beschermd'; + case 'userStatus.current': return 'HUIDIG'; case 'messages.markedAsWatched': return 'Gemarkeerd als gekeken'; case 'messages.markedAsUnwatched': return 'Gemarkeerd als ongekeken'; case 'messages.removedFromContinueWatching': return 'Verwijderd uit Doorgaan met kijken'; @@ -3446,6 +5422,10 @@ extension on _StringsNl { case 'discover.pause': return 'Pauzeren'; case 'discover.overview': return 'Overzicht'; case 'discover.cast': return 'Cast'; + case 'discover.seasons': return 'Seizoenen'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Leeftijd'; + case 'discover.watched': return 'Bekeken'; case 'discover.episodeCount': return ({required Object count}) => '${count} afleveringen'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} gekeken'; case 'discover.movie': return 'Film'; @@ -3530,6 +5510,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; } } @@ -3628,6 +5642,8 @@ extension on _StringsSv { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} sekunder'; case 'settings.defaultSleepTimer': return 'Standard sovtimer'; case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} minuter'; + case 'settings.rememberTrackSelections': return 'Kom ihåg spårval per serie/film'; + case 'settings.rememberTrackSelectionsDescription': return 'Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning'; case 'settings.unwatchedOnly': return 'Endast osedda'; case 'settings.unwatchedOnlyDescription': return 'Inkludera endast osedda avsnitt i blandningskön'; case 'settings.shuffleOrderNavigation': return 'Blandningsordning-navigation'; @@ -3715,6 +5731,7 @@ extension on _StringsSv { case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Begränsad'; case 'userStatus.protected': return 'Skyddad'; + case 'userStatus.current': return 'NUVARANDE'; case 'messages.markedAsWatched': return 'Markerad som sedd'; case 'messages.markedAsUnwatched': return 'Markerad som osedd'; case 'messages.removedFromContinueWatching': return 'Borttagen från Fortsätt titta'; @@ -3768,6 +5785,10 @@ extension on _StringsSv { case 'discover.pause': return 'Pausa'; case 'discover.overview': return 'Översikt'; case 'discover.cast': return 'Rollbesättning'; + case 'discover.seasons': return 'Säsonger'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Åldersgräns'; + case 'discover.watched': return 'Tittad'; case 'discover.episodeCount': return ({required Object count}) => '${count} avsnitt'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} sedda'; case 'discover.movie': return 'Film'; @@ -3852,6 +5873,403 @@ 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; + } + } +} + +extension on _StringsZh { + dynamic _flatMapFunction(String path) { + switch (path) { + case 'app.title': return 'Plezy'; + case 'app.loading': return '加载中...'; + case 'auth.signInWithPlex': return '使用 Plex 登录'; + case 'auth.showQRCode': return '显示二维码'; + case 'auth.cancel': return '取消'; + case 'auth.authenticate': return '验证'; + case 'auth.retry': return '重试'; + case 'auth.debugEnterToken': return '调试:输入 Plex Token'; + case 'auth.plexTokenLabel': return 'Plex 授权令牌 (Auth Token)'; + case 'auth.plexTokenHint': return '输入你的 Plex.tv 令牌'; + case 'auth.authenticationTimeout': return '验证超时。请重试。'; + case 'auth.scanQRCodeInstruction': return '请使用已登录 Plex 的设备扫描此二维码进行验证。'; + case 'auth.waitingForAuth': return '等待验证中...\n请在你的浏览器中完成登录。'; + case 'common.cancel': return '取消'; + case 'common.save': return '保存'; + case 'common.close': return '关闭'; + case 'common.clear': return '清除'; + case 'common.reset': return '重置'; + case 'common.later': return '稍后'; + case 'common.submit': return '提交'; + case 'common.confirm': return '确认'; + case 'common.retry': return '重试'; + case 'common.playNow': return '立即播放'; + case 'common.logout': return '登出'; + case 'common.online': return '在线'; + case 'common.offline': return '离线'; + case 'common.owned': return '拥有'; + case 'common.shared': return '共享'; + case 'common.current': return '当前'; + case 'common.unknown': return '未知'; + case 'common.refresh': return '刷新'; + case 'common.yes': return '是'; + case 'common.no': return '否'; + case 'common.server': return '服务器'; + case 'screens.licenses': return '许可证'; + case 'screens.selectServer': return '选择服务器'; + case 'screens.switchProfile': return '切换配置文件'; + case 'screens.subtitleStyling': return '字幕样式'; + case 'screens.search': return '搜索'; + case 'screens.logs': return '日志'; + case 'update.available': return '有可用更新'; + case 'update.versionAvailable': return ({required Object version}) => '版本 ${version} 已发布'; + case 'update.currentVersion': return ({required Object version}) => '当前版本: ${version}'; + case 'update.skipVersion': return '跳过此版本'; + case 'update.viewRelease': return '查看发布详情'; + case 'update.latestVersion': return '已安装的版本是可用的最新版本'; + case 'update.checkFailed': return '无法检查更新'; + case 'settings.title': return '设置'; + case 'settings.language': return '语言'; + case 'settings.theme': return '主题'; + case 'settings.appearance': return '外观'; + case 'settings.videoPlayback': return '视频播放'; + case 'settings.shufflePlay': return '随机播放'; + case 'settings.advanced': return '高级'; + case 'settings.useSeasonPostersDescription': return '为剧集显示季海报而非剧集海报'; + case 'settings.showHeroSectionDescription': return '在主屏幕上显示精选内容轮播区'; + case 'settings.secondsLabel': return '秒'; + case 'settings.minutesLabel': return '分钟'; + case 'settings.secondsShort': return 's'; + case 'settings.minutesShort': return 'm'; + case 'settings.durationHint': return ({required Object min, required Object max}) => '输入时长 (${min}-${max})'; + case 'settings.systemTheme': return '系统'; + case 'settings.systemThemeDescription': return '跟随系统设置'; + case 'settings.lightTheme': return '浅色'; + case 'settings.darkTheme': return '深色'; + case 'settings.libraryDensity': return '媒体库密度'; + case 'settings.compact': return '紧凑'; + case 'settings.compactDescription': return '卡片更小,显示更多项目'; + case 'settings.normal': return '标准'; + case 'settings.normalDescription': return '默认尺寸'; + case 'settings.comfortable': return '舒适'; + case 'settings.comfortableDescription': return '卡片更大,显示更少项目'; + case 'settings.viewMode': return '视图模式'; + case 'settings.gridView': return '网格视图'; + case 'settings.gridViewDescription': return '以网格布局显示项目'; + case 'settings.listView': return '列表视图'; + case 'settings.listViewDescription': return '以列表布局显示项目'; + case 'settings.useSeasonPosters': return '使用季海报'; + case 'settings.showHeroSection': return '显示主要精选区'; + case 'settings.hardwareDecoding': return '硬件解码'; + case 'settings.hardwareDecodingDescription': return '如果可用,使用硬件加速'; + case 'settings.bufferSize': return '缓冲区大小'; + case 'settings.bufferSizeMB': return ({required Object size}) => '${size}MB'; + case 'settings.subtitleStyling': return '字幕样式'; + case 'settings.subtitleStylingDescription': return '调整字幕外观'; + case 'settings.smallSkipDuration': return '短跳过时长'; + case 'settings.largeSkipDuration': return '长跳过时长'; + case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} 秒'; + case 'settings.defaultSleepTimer': return '默认睡眠定时器'; + case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} 分钟'; + case 'settings.rememberTrackSelections': return '记住每个剧集/电影的音轨选择'; + case 'settings.rememberTrackSelectionsDescription': return '在播放过程中更改音轨时自动保存音频和字幕语言偏好'; + case 'settings.unwatchedOnly': return '仅未观看'; + case 'settings.unwatchedOnlyDescription': return '随机队列中仅包含未观看的剧集'; + case 'settings.shuffleOrderNavigation': return '随机顺序导航'; + case 'settings.shuffleOrderNavigationDescription': return '下一集/上一集按钮遵循随机播放顺序'; + case 'settings.loopShuffleQueue': return '循环随机队列'; + case 'settings.loopShuffleQueueDescription': return '在队列结束时重新开始'; + case 'settings.videoPlayerControls': return '视频播放器控制'; + case 'settings.keyboardShortcuts': return '键盘快捷键'; + case 'settings.keyboardShortcutsDescription': return '自定义键盘快捷键'; + case 'settings.debugLogging': return '调试日志'; + case 'settings.debugLoggingDescription': return '启用详细日志记录以便故障排除'; + case 'settings.viewLogs': return '查看日志'; + case 'settings.viewLogsDescription': return '查看应用程序日志'; + case 'settings.clearCache': return '清除缓存'; + case 'settings.clearCacheDescription': return '这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。'; + case 'settings.clearCacheSuccess': return '缓存清除成功'; + case 'settings.resetSettings': return '重置设置'; + case 'settings.resetSettingsDescription': return '这会将所有设置重置为其默认值。此操作无法撤销。'; + case 'settings.resetSettingsSuccess': return '设置重置成功'; + case 'settings.shortcutsReset': return '快捷键已重置为默认值'; + case 'settings.about': return '关于'; + case 'settings.aboutDescription': return '应用程序信息和许可证'; + case 'settings.updates': return '更新'; + case 'settings.updateAvailable': return '有可用更新'; + case 'settings.checkForUpdates': return '检查更新'; + case 'settings.validationErrorEnterNumber': return '请输入一个有效的数字'; + case 'settings.validationErrorDuration': return ({required Object min, required Object max, required Object unit}) => '时长必须介于 ${min} 和 ${max} ${unit} 之间'; + case 'settings.shortcutAlreadyAssigned': return ({required Object action}) => '快捷键已被分配给 ${action}'; + case 'settings.shortcutUpdated': return ({required Object action}) => '快捷键已为 ${action} 更新'; + case 'search.hint': return '搜索电影、系列、音乐...'; + case 'search.tryDifferentTerm': return '尝试不同的搜索词'; + case 'search.searchYourMedia': return '搜索媒体'; + case 'search.enterTitleActorOrKeyword': return '输入标题、演员或关键词'; + case 'hotkeys.setShortcutFor': return ({required Object actionName}) => '为 ${actionName} 设置快捷键'; + case 'hotkeys.clearShortcut': return '清除快捷键'; + case 'pinEntry.enterPin': return '输入 PIN'; + case 'pinEntry.showPin': return '显示 PIN'; + case 'pinEntry.hidePin': return '隐藏 PIN'; + case 'fileInfo.title': return '文件信息'; + case 'fileInfo.video': return '视频'; + case 'fileInfo.audio': return '音频'; + case 'fileInfo.file': return '文件'; + case 'fileInfo.advanced': return '高级'; + case 'fileInfo.codec': return '编解码器'; + case 'fileInfo.resolution': return '分辨率'; + case 'fileInfo.bitrate': return '比特率'; + case 'fileInfo.frameRate': return '帧率'; + case 'fileInfo.aspectRatio': return '宽高比'; + case 'fileInfo.profile': return '配置文件'; + case 'fileInfo.bitDepth': return '位深度'; + case 'fileInfo.colorSpace': return '色彩空间'; + case 'fileInfo.colorRange': return '色彩范围'; + case 'fileInfo.colorPrimaries': return '颜色原色'; + case 'fileInfo.chromaSubsampling': return '色度子采样'; + case 'fileInfo.channels': return '声道'; + case 'fileInfo.path': return '路径'; + case 'fileInfo.size': return '大小'; + case 'fileInfo.container': return '容器'; + case 'fileInfo.duration': return '时长'; + case 'fileInfo.optimizedForStreaming': return '已优化用于流媒体'; + case 'fileInfo.has64bitOffsets': return '64位偏移量'; + case 'mediaMenu.markAsWatched': return '标记为已观看'; + case 'mediaMenu.markAsUnwatched': return '标记为未观看'; + case 'mediaMenu.removeFromContinueWatching': return '从继续观看中移除'; + case 'mediaMenu.goToSeries': return '转到系列'; + case 'mediaMenu.goToSeason': return '转到季'; + case 'mediaMenu.shufflePlay': return '随机播放'; + case 'mediaMenu.fileInfo': return '文件信息'; + case 'tooltips.shufflePlay': return '随机播放'; + case 'tooltips.markAsWatched': return '标记为已观看'; + case 'tooltips.markAsUnwatched': return '标记为未观看'; + case 'videoControls.audioLabel': return '音频'; + case 'videoControls.subtitlesLabel': return '字幕'; + case 'videoControls.resetToZero': return '重置为 0ms'; + case 'videoControls.addTime': return ({required Object amount, required Object unit}) => '+${amount}${unit}'; + case 'videoControls.minusTime': return ({required Object amount, required Object unit}) => '-${amount}${unit}'; + case 'videoControls.playsLater': return ({required Object label}) => '${label} 播放较晚'; + case 'videoControls.playsEarlier': return ({required Object label}) => '${label} 播放较早'; + case 'videoControls.noOffset': return '无偏移'; + case 'videoControls.letterbox': return '信箱模式(Letterbox)'; + case 'videoControls.fillScreen': return '填充屏幕'; + case 'videoControls.stretch': return '拉伸'; + case 'videoControls.lockRotation': return '锁定旋转'; + case 'videoControls.unlockRotation': return '解锁旋转'; + case 'userStatus.admin': return '管理员'; + case 'userStatus.restricted': return '受限'; + case 'userStatus.protected': return '受保护'; + case 'userStatus.current': return '当前'; + case 'messages.markedAsWatched': return '已标记为已观看'; + case 'messages.markedAsUnwatched': return '已标记为未观看'; + case 'messages.removedFromContinueWatching': return '已从继续观看中移除'; + case 'messages.errorLoading': return ({required Object error}) => '错误: ${error}'; + case 'messages.fileInfoNotAvailable': return '文件信息不可用'; + case 'messages.errorLoadingFileInfo': return ({required Object error}) => '加载文件信息时出错: ${error}'; + case 'messages.errorLoadingSeries': return '加载系列时出错'; + case 'messages.errorLoadingSeason': return '加载季时出错'; + case 'messages.musicNotSupported': return '尚不支持播放音乐'; + case 'messages.logsCleared': return '日志已清除'; + case 'messages.logsCopied': return '日志已复制到剪贴板'; + case 'messages.noLogsAvailable': return '没有可用日志'; + case 'messages.libraryScanning': return ({required Object title}) => '正在扫描 “${title}”...'; + case 'messages.libraryScanStarted': return ({required Object title}) => '已开始扫描 “${title}” 媒体库'; + case 'messages.libraryScanFailed': return ({required Object error}) => '无法扫描媒体库: ${error}'; + case 'messages.metadataRefreshing': return ({required Object title}) => '正在刷新 “${title}” 的元数据...'; + case 'messages.metadataRefreshStarted': return ({required Object title}) => '已开始刷新 “${title}” 的元数据'; + case 'messages.metadataRefreshFailed': return ({required Object error}) => '无法刷新元数据: ${error}'; + case 'messages.noPlexToken': return '未找到 Plex 令牌。请重新登录。'; + case 'messages.logoutConfirm': return '你确定要登出吗?'; + case 'messages.noSeasonsFound': return '未找到季'; + case 'messages.noEpisodesFound': return '在第一季中未找到剧集'; + case 'messages.noEpisodesFoundGeneral': return '未找到剧集'; + case 'messages.noResultsFound': return '未找到结果'; + case 'messages.sleepTimerSet': return ({required Object label}) => '睡眠定时器已设置为 ${label}'; + case 'messages.failedToSwitchProfile': return ({required Object displayName}) => '无法切换到 ${displayName}'; + case 'profile.noUsersAvailable': return '没有可用用户'; + case 'subtitlingStyling.stylingOptions': return '样式选项'; + case 'subtitlingStyling.fontSize': return '字号'; + case 'subtitlingStyling.textColor': return '文本颜色'; + case 'subtitlingStyling.borderSize': return '边框大小'; + case 'subtitlingStyling.borderColor': return '边框颜色'; + case 'subtitlingStyling.backgroundOpacity': return '背景不透明度'; + case 'subtitlingStyling.backgroundColor': return '背景颜色'; + case 'dialog.confirmAction': return '确认操作'; + case 'dialog.areYouSure': return '确定要执行此操作吗?'; + case 'dialog.cancel': return '取消'; + case 'dialog.playNow': return '立即播放'; + case 'discover.title': return '发现'; + case 'discover.switchProfile': return '切换配置文件'; + case 'discover.switchServer': return '切换服务器'; + case 'discover.logout': return '登出'; + case 'discover.noContentAvailable': return '没有可用内容'; + case 'discover.addMediaToLibraries': return '请向你的媒体库添加一些媒体'; + case 'discover.continueWatching': return '继续观看'; + case 'discover.recentlyAdded': return '最近添加'; + case 'discover.play': return '播放'; + case 'discover.resume': return '继续'; + case 'discover.playEpisode': return ({required Object season, required Object episode}) => '播放 S${season}, E${episode}'; + case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => '继续 S${season}, E${episode}'; + case 'discover.pause': return '暂停'; + case 'discover.overview': return '概述'; + case 'discover.cast': return '演员表'; + case 'discover.seasons': return '季数'; + case 'discover.studio': return '制作公司'; + case 'discover.rating': return '年龄分级'; + case 'discover.watched': return '已观看'; + case 'discover.episodeCount': return ({required Object count}) => '${count} 集'; + case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '已观看 ${watched}/${total} 集'; + case 'discover.movie': return '电影'; + case 'discover.tvShow': return '电视剧'; + case 'discover.minutesLeft': return ({required Object minutes}) => '剩余 ${minutes} 分钟'; + case 'errors.searchFailed': return ({required Object error}) => '搜索失败: ${error}'; + case 'errors.connectionTimeout': return ({required Object context}) => '加载 ${context} 时连接超时'; + case 'errors.connectionFailed': return '无法连接到 Plex 服务器'; + case 'errors.failedToLoad': return ({required Object context, required Object error}) => '无法加载 ${context}: ${error}'; + case 'errors.noClientAvailable': return '没有可用客户端'; + case 'errors.authenticationFailed': return ({required Object error}) => '验证失败: ${error}'; + case 'errors.couldNotLaunchUrl': return '无法打开授权 URL'; + case 'errors.pleaseEnterToken': return '请输入一个令牌'; + case 'errors.invalidToken': return '令牌无效'; + case 'errors.failedToVerifyToken': return ({required Object error}) => '无法验证令牌: ${error}'; + case 'errors.failedToSwitchProfile': return ({required Object displayName}) => '无法切换到 ${displayName}'; + case 'errors.connectionFailedGeneric': return '连接失败'; + case 'libraries.title': return '媒体库'; + case 'libraries.scanLibraryFiles': return '扫描媒体库文件'; + case 'libraries.scanLibrary': return '扫描媒体库'; + case 'libraries.analyze': return '分析'; + case 'libraries.analyzeLibrary': return '分析媒体库'; + case 'libraries.refreshMetadata': return '刷新元数据'; + case 'libraries.emptyTrash': return '清空回收站'; + case 'libraries.emptyingTrash': return ({required Object title}) => '正在清空 “${title}” 的回收站...'; + case 'libraries.trashEmptied': return ({required Object title}) => '已清空 “${title}” 的回收站'; + case 'libraries.failedToEmptyTrash': return ({required Object error}) => '无法清空回收站: ${error}'; + case 'libraries.analyzing': return ({required Object title}) => '正在分析 “${title}”...'; + case 'libraries.analysisStarted': return ({required Object title}) => '已开始分析 “${title}”'; + case 'libraries.failedToAnalyze': return ({required Object error}) => '无法分析媒体库: ${error}'; + case 'libraries.noLibrariesFound': return '未找到媒体库'; + case 'libraries.thisLibraryIsEmpty': return '此媒体库为空'; + case 'libraries.all': return '全部'; + case 'libraries.clearAll': return '全部清除'; + case 'libraries.scanLibraryConfirm': return ({required Object title}) => '确定要扫描 “${title}” 吗?'; + case 'libraries.analyzeLibraryConfirm': return ({required Object title}) => '确定要分析 “${title}” 吗?'; + case 'libraries.refreshMetadataConfirm': return ({required Object title}) => '确定要刷新 “${title}” 的元数据吗?'; + case 'libraries.emptyTrashConfirm': return ({required Object title}) => '确定要清空 “${title}” 的回收站吗?'; + case 'libraries.manageLibraries': return '管理媒体库'; + case 'libraries.sort': return '排序'; + case 'libraries.sortBy': return '排序依据'; + case 'libraries.filters': return '筛选器'; + case 'libraries.loadingLibraryWithCount': return ({required Object count}) => '正在加载媒体库... (已加载 ${count} 个项目)'; + case 'libraries.confirmActionMessage': return '确定要执行此操作吗?'; + case 'libraries.showLibrary': return '显示媒体库'; + case 'libraries.hideLibrary': return '隐藏媒体库'; + case 'libraries.libraryOptions': return '媒体库选项'; + case 'about.title': return '关于'; + case 'about.openSourceLicenses': return '开源许可证'; + case 'about.versionLabel': return ({required Object version}) => '版本 ${version}'; + case 'about.appDescription': return '一款精美的 Flutter Plex 客户端'; + case 'about.viewLicensesDescription': return '查看第三方库的许可证'; + case 'serverSelection.connectingToServer': return '正在连接服务器...'; + case 'serverSelection.serverDebugCopied': return '服务器调试数据已复制到剪贴板'; + case 'serverSelection.copyDebugData': return '复制调试数据'; + case 'serverSelection.noServersFound': return '未找到服务器'; + case 'serverSelection.malformedServerData': return ({required Object count}) => '找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。'; + case 'serverSelection.incompleteServerInfo': return '某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。'; + case 'serverSelection.incompleteConnectionInfo': return '服务器连接信息不完整。请重试。'; + case 'serverSelection.malformedServerInfo': return ({required Object message}) => '服务器信息格式错误: ${message}'; + case 'serverSelection.networkConnectionFailed': return '网络连接失败。请检查你的网络连接并重试。'; + case 'serverSelection.authenticationFailed': return '验证失败。请重新登录。'; + case 'serverSelection.plexServiceUnavailable': return 'Plex 服务不可用。请稍后重试。'; + case 'serverSelection.failedToLoadServers': return ({required Object error}) => '无法加载服务器: ${error}'; + case 'hubDetail.title': return '标题'; + case 'hubDetail.releaseYear': return '发行年份'; + case 'hubDetail.dateAdded': return '添加日期'; + case 'hubDetail.rating': return '评分'; + case 'hubDetail.noItemsFound': return '未找到项目'; + case 'logs.title': return '日志'; + case 'logs.clearLogs': return '清除日志'; + case 'logs.copyLogs': return '复制日志'; + case 'logs.exportLogs': return '导出日志'; + case 'logs.noLogsToShow': return '没有可显示的日志'; + case 'logs.error': return '错误:'; + case 'logs.stackTrace': return '堆栈跟踪 (Stack Trace):'; + case 'licenses.relatedPackages': return '相关软件包'; + case 'licenses.license': return '许可证'; + case 'licenses.licenseNumber': return ({required Object number}) => '许可证 ${number}'; + case 'licenses.licensesCount': return ({required Object count}) => '${count} 个许可证'; + case 'navigation.home': return '主页'; + case 'navigation.search': return '搜索'; + case 'navigation.libraries': return '媒体库'; + case 'navigation.settings': return '设置'; + case 'playlists.title': return '播放列表'; + case 'playlists.noPlaylists': return '未找到播放列表'; + case 'playlists.create': return '创建播放列表'; + case 'playlists.newPlaylist': return '新播放列表'; + case 'playlists.playlistName': return '播放列表名称'; + case 'playlists.enterPlaylistName': return '输入播放列表名称'; + case 'playlists.edit': return '编辑播放列表'; + case 'playlists.delete': return '删除播放列表'; + case 'playlists.addTo': return '添加到播放列表'; + case 'playlists.addItems': return '添加项目'; + case 'playlists.removeItem': return '从播放列表中移除'; + case 'playlists.clearPlaylist': return '清空播放列表'; + case 'playlists.playAll': return '全部播放'; + case 'playlists.shuffle': return '随机播放'; + case 'playlists.smartPlaylist': return '智能播放列表'; + case 'playlists.regularPlaylist': return '普通播放列表'; + case 'playlists.itemCount': return ({required Object count}) => '${count} 个项目'; + case 'playlists.oneItem': return '1 个项目'; + case 'playlists.emptyPlaylist': return '此播放列表为空'; + case 'playlists.deleteConfirm': return '删除播放列表?'; + case 'playlists.deleteMessage': return ({required Object name}) => '确定要删除 "${name}" 吗?'; + case 'playlists.created': return '播放列表已创建'; + case 'playlists.updated': return '播放列表已更新'; + case 'playlists.deleted': return '播放列表已删除'; + case 'playlists.itemAdded': return '已添加到播放列表'; + case 'playlists.itemRemoved': return '已从播放列表中移除'; + case 'playlists.selectPlaylist': return '选择播放列表'; + case 'playlists.createNewPlaylist': return '创建新播放列表'; + case 'playlists.errorCreating': return '创建播放列表失败'; + case 'playlists.errorDeleting': return '删除播放列表失败'; + case 'playlists.errorLoading': return '加载播放列表失败'; + case 'playlists.errorAdding': return '添加到播放列表失败'; + case 'playlists.errorReordering': return '重新排序播放列表项目失败'; + case 'playlists.errorRemoving': return '从播放列表中移除失败'; default: return null; } } diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 9a5182fa..e57bc0fe 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} seconds", "defaultSleepTimer": "Default Sleep Timer", "minutesUnit": "${minutes} minutes", + "rememberTrackSelections": "Remember track selections per show/movie", + "rememberTrackSelectionsDescription": "Automatically save audio and subtitle language preferences when you change tracks during playback", "unwatchedOnly": "Unwatched Only", "unwatchedOnlyDescription": "Only include unwatched episodes in shuffle queue", "shuffleOrderNavigation": "Shuffle Order Navigation", @@ -202,7 +204,8 @@ "userStatus": { "admin": "Admin", "restricted": "Restricted", - "protected": "Protected" + "protected": "Protected", + "current": "CURRENT" }, "messages": { "markedAsWatched": "Marked as watched", @@ -266,6 +269,10 @@ "pause": "Pause", "overview": "Overview", "cast": "Cast", + "seasons": "Seasons", + "studio": "Studio", + "rating": "Rating", + "watched": "Watched", "episodeCount": "${count} episodes", "watchedProgress": "${watched}/${total} watched", "movie": "Movie", @@ -366,5 +373,41 @@ "search": "Search", "libraries": "Libraries", "settings": "Settings" + }, + "playlists": { + "title": "Playlists", + "noPlaylists": "No playlists found", + "create": "Create Playlist", + "newPlaylist": "New Playlist", + "playlistName": "Playlist Name", + "enterPlaylistName": "Enter playlist name", + "edit": "Edit Playlist", + "delete": "Delete Playlist", + "addTo": "Add to Playlist", + "addItems": "Add Items", + "removeItem": "Remove from Playlist", + "clearPlaylist": "Clear Playlist", + "playAll": "Play All", + "shuffle": "Shuffle", + "smartPlaylist": "Smart Playlist", + "regularPlaylist": "Regular Playlist", + "itemCount": "${count} items", + "oneItem": "1 item", + "emptyPlaylist": "This playlist is empty", + "deleteConfirm": "Delete Playlist?", + "deleteMessage": "Are you sure you want to delete \"${name}\"?", + "created": "Playlist created", + "updated": "Playlist updated", + "deleted": "Playlist deleted", + "itemAdded": "Added to playlist", + "itemRemoved": "Removed from playlist", + "selectPlaylist": "Select Playlist", + "createNewPlaylist": "Create New Playlist", + "errorCreating": "Failed to create playlist", + "errorDeleting": "Failed to delete playlist", + "errorLoading": "Failed to load playlists", + "errorAdding": "Failed to add to playlist", + "errorReordering": "Failed to reorder playlist item", + "errorRemoving": "Failed to remove from playlist" } } diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json new file mode 100644 index 00000000..e78c5411 --- /dev/null +++ b/lib/i18n/strings_de.i18n.json @@ -0,0 +1,413 @@ +{ + "app": { + "title": "Plezy", + "loading": "Lädt..." + }, + "auth": { + "signInWithPlex": "Mit Plex anmelden", + "showQRCode": "QR-Code anzeigen", + "cancel": "Abbrechen", + "authenticate": "Authentifizieren", + "retry": "Erneut versuchen", + "debugEnterToken": "Debug: Plex-Token eingeben", + "plexTokenLabel": "Plex-Auth-Token", + "plexTokenHint": "Plex.tv-Token eingeben", + "authenticationTimeout": "Authentifizierung abgelaufen. Bitte erneut versuchen.", + "scanQRCodeInstruction": "Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.", + "waitingForAuth": "Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen." + }, + "common": { + "cancel": "Abbrechen", + "save": "Speichern", + "close": "Schließen", + "clear": "Leeren", + "reset": "Zurücksetzen", + "later": "Später", + "submit": "Senden", + "confirm": "Bestätigen", + "retry": "Erneut versuchen", + "playNow": "Jetzt abspielen", + "logout": "Abmelden", + "online": "Online", + "offline": "Offline", + "owned": "Besitzer", + "shared": "Geteilt", + "current": "AKTUELL", + "unknown": "Unbekannt", + "refresh": "Aktualisieren", + "yes": "Ja", + "no": "Nein", + "server": "Server" + }, + "screens": { + "licenses": "Lizenzen", + "selectServer": "Server auswählen", + "switchProfile": "Profil wechseln", + "subtitleStyling": "Untertitel-Stil", + "search": "Suche", + "logs": "Protokolle" + }, + "update": { + "available": "Update verfügbar", + "versionAvailable": "Version ${version} ist verfügbar", + "currentVersion": "Aktuell: ${version}", + "skipVersion": "Diese Version überspringen", + "viewRelease": "Release anzeigen", + "latestVersion": "Aktuellste Version installiert", + "checkFailed": "Fehler bei der Updateprüfung" + }, + "settings": { + "title": "Einstellungen", + "language": "Sprache", + "theme": "Design", + "appearance": "Darstellung", + "videoPlayback": "Videowiedergabe", + "shufflePlay": "Zufallswiedergabe", + "advanced": "Erweitert", + "useSeasonPostersDescription": "Staffelposter statt Serienposter für Episoden anzeigen", + "showHeroSectionDescription": "Bereich mit empfohlenen Inhalten auf der Startseite anzeigen", + "secondsLabel": "Sekunden", + "minutesLabel": "Minuten", + "secondsShort": "s", + "minutesShort": "m", + "durationHint": "Dauer eingeben (${min}-${max})", + "systemTheme": "System", + "systemThemeDescription": "Systemeinstellungen folgen", + "lightTheme": "Hell", + "darkTheme": "Dunkel", + "libraryDensity": "Mediathekdichte", + "compact": "Kompakt", + "compactDescription": "Kleinere Karten, mehr Elemente sichtbar", + "normal": "Normal", + "normalDescription": "Standardgröße", + "comfortable": "Großzügig", + "comfortableDescription": "Größere Karten, weniger Elemente sichtbar", + "viewMode": "Ansichtsmodus", + "gridView": "Raster", + "gridViewDescription": "Elemente im Raster anzeigen", + "listView": "Liste", + "listViewDescription": "Elemente in Listenansicht anzeigen", + "useSeasonPosters": "Staffelposter verwenden", + "showHeroSection": "Hero-Bereich anzeigen", + "hardwareDecoding": "Hardware-Decodierung", + "hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, sofern verfügbar", + "bufferSize": "Puffergröße", + "bufferSizeMB": "${size}MB", + "subtitleStyling": "Untertitel-Stil", + "subtitleStylingDescription": "Aussehen von Untertiteln anpassen", + "smallSkipDuration": "Kleine Sprungdauer", + "largeSkipDuration": "Große Sprungdauer", + "secondsUnit": "${seconds} Sekunden", + "defaultSleepTimer": "Standard-Sleep-Timer", + "minutesUnit": "${minutes} Minuten", + "rememberTrackSelections": "Spurauswahl pro Serie/Film merken", + "rememberTrackSelectionsDescription": "Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert", + "unwatchedOnly": "Nur ungesehene", + "unwatchedOnlyDescription": "Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen", + "shuffleOrderNavigation": "Navigation der Shuffle-Reihenfolge", + "shuffleOrderNavigationDescription": "Weiter/Zurück folgt der zufälligen Reihenfolge", + "loopShuffleQueue": "Shuffle-Warteschlange wiederholen", + "loopShuffleQueueDescription": "Warteschlange neu starten, wenn das Ende erreicht ist", + "videoPlayerControls": "Videoplayer-Steuerung", + "keyboardShortcuts": "Tastenkürzel", + "keyboardShortcutsDescription": "Tastenkürzel anpassen", + "debugLogging": "Debug-Protokollierung", + "debugLoggingDescription": "Detaillierte Protokolle zur Fehleranalyse aktivieren", + "viewLogs": "Protokolle anzeigen", + "viewLogsDescription": "App-Protokolle anzeigen", + "clearCache": "Cache löschen", + "clearCacheDescription": "Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.", + "clearCacheSuccess": "Cache erfolgreich gelöscht", + "resetSettings": "Einstellungen zurücksetzen", + "resetSettingsDescription": "Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.", + "resetSettingsSuccess": "Einstellungen erfolgreich zurückgesetzt", + "shortcutsReset": "Tastenkürzel auf Standard zurückgesetzt", + "about": "Über", + "aboutDescription": "App-Informationen und Lizenzen", + "updates": "Updates", + "updateAvailable": "Update verfügbar", + "checkForUpdates": "Nach Updates suchen", + "validationErrorEnterNumber": "Bitte eine gültige Zahl eingeben", + "validationErrorDuration": "Dauer muss zwischen ${min} und ${max} ${unit} liegen", + "shortcutAlreadyAssigned": "Tastenkürzel bereits zugewiesen an ${action}", + "shortcutUpdated": "Tastenkürzel aktualisiert für ${action}" + }, + "search": { + "hint": "Filme, Serien, Musik suchen...", + "tryDifferentTerm": "Anderen Suchbegriff versuchen", + "searchYourMedia": "In den eigenen Medien suchen", + "enterTitleActorOrKeyword": "Titel, Schauspieler oder Stichwort eingeben" + }, + "hotkeys": { + "setShortcutFor": "Tastenkürzel festlegen für ${actionName}", + "clearShortcut": "Kürzel löschen" + }, + "pinEntry": { + "enterPin": "PIN eingeben", + "showPin": "PIN anzeigen", + "hidePin": "PIN verbergen" + }, + "fileInfo": { + "title": "Dateiinfo", + "video": "Video", + "audio": "Audio", + "file": "Datei", + "advanced": "Erweitert", + "codec": "Codec", + "resolution": "Auflösung", + "bitrate": "Bitrate", + "frameRate": "Bildrate", + "aspectRatio": "Seitenverhältnis", + "profile": "Profil", + "bitDepth": "Farbtiefe", + "colorSpace": "Farbraum", + "colorRange": "Farbbereich", + "colorPrimaries": "Primärfarben", + "chromaSubsampling": "Chroma-Subsampling", + "channels": "Kanäle", + "path": "Pfad", + "size": "Größe", + "container": "Container", + "duration": "Dauer", + "optimizedForStreaming": "Für Streaming optimiert", + "has64bitOffsets": "64-Bit-Offsets" + }, + "mediaMenu": { + "markAsWatched": "Als gesehen markieren", + "markAsUnwatched": "Als ungesehen markieren", + "removeFromContinueWatching": "Aus ‚Weiterschauen‘ entfernen", + "goToSeries": "Zur Serie", + "goToSeason": "Zur Staffel", + "shufflePlay": "Zufallswiedergabe", + "fileInfo": "Dateiinfo" + }, + "tooltips": { + "shufflePlay": "Zufallswiedergabe", + "markAsWatched": "Als gesehen markieren", + "markAsUnwatched": "Als ungesehen markieren" + }, + "videoControls": { + "audioLabel": "Audio", + "subtitlesLabel": "Untertitel", + "resetToZero": "Auf 0 ms zurücksetzen", + "addTime": "+${amount}${unit}", + "minusTime": "-${amount}${unit}", + "playsLater": "${label} spielt später", + "playsEarlier": "${label} spielt früher", + "noOffset": "Kein Offset", + "letterbox": "Letterbox", + "fillScreen": "Bild füllen", + "stretch": "Strecken", + "lockRotation": "Rotation sperren", + "unlockRotation": "Rotation entsperren" + }, + "userStatus": { + "admin": "Eigentümer", + "restricted": "Eingeschränkt", + "protected": "Geschützt", + "current": "AKTUELL" + }, + "messages": { + "markedAsWatched": "Als gesehen markiert", + "markedAsUnwatched": "Als ungesehen markiert", + "removedFromContinueWatching": "Aus ‚Weiterschauen‘ entfernt", + "errorLoading": "Fehler: ${error}", + "fileInfoNotAvailable": "Dateiinfo nicht verfügbar", + "errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}", + "errorLoadingSeries": "Fehler beim Laden der Serie", + "errorLoadingSeason": "Fehler beim Laden der Staffel", + "musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt", + "logsCleared": "Protokolle gelöscht", + "logsCopied": "Protokolle in Zwischenablage kopiert", + "noLogsAvailable": "Keine Protokolle verfügbar", + "libraryScanning": "Scanne „${title}“...", + "libraryScanStarted": "Mediathekscan gestartet für „${title}“", + "libraryScanFailed": "Fehler beim Scannen der Mediathek: ${error}", + "metadataRefreshing": "Metadaten werden aktualisiert für „${title}“...", + "metadataRefreshStarted": "Metadaten-Aktualisierung gestartet für „${title}“", + "metadataRefreshFailed": "Metadaten konnten nicht aktualisiert werden: ${error}", + "noPlexToken": "Kein Plex-Token gefunden. Bitte erneut anmelden.", + "logoutConfirm": "Abmeldung wirklich durchführen?", + "noSeasonsFound": "Keine Staffeln gefunden", + "noEpisodesFound": "Keine Episoden in der ersten Staffel gefunden", + "noEpisodesFoundGeneral": "Keine Episoden gefunden", + "noResultsFound": "Keine Ergebnisse gefunden", + "sleepTimerSet": "Sleep-Timer gesetzt auf ${label}", + "failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen" + }, + "profile": { + "noUsersAvailable": "Keine Benutzer verfügbar" + }, + "subtitlingStyling": { + "stylingOptions": "Stiloptionen", + "fontSize": "Schriftgröße", + "textColor": "Textfarbe", + "borderSize": "Rahmengröße", + "borderColor": "Rahmenfarbe", + "backgroundOpacity": "Hintergrunddeckkraft", + "backgroundColor": "Hintergrundfarbe" + }, + "dialog": { + "confirmAction": "Aktion bestätigen", + "areYouSure": "Aktion wirklich ausführen?", + "cancel": "Abbrechen", + "playNow": "Jetzt abspielen" + }, + "discover": { + "title": "Entdecken", + "switchProfile": "Profil wechseln", + "switchServer": "Server wechseln", + "logout": "Abmelden", + "noContentAvailable": "Kein Inhalt verfügbar", + "addMediaToLibraries": "Medien zur Mediathek hinzufügen", + "continueWatching": "Weiterschauen", + "recentlyAdded": "Kürzlich hinzugefügt", + "play": "Abspielen", + "resume": "Fortsetzen", + "playEpisode": "S${season}, E${episode} - Abspielen", + "resumeEpisode": "S${season}, E${episode} - Fortsetzen", + "pause": "Pause", + "overview": "Übersicht", + "cast": "Besetzung", + "seasons": "Staffeln", + "studio": "Studio", + "rating": "Altersfreigabe", + "watched": "Gesehen", + "episodeCount": "${count} Episoden", + "watchedProgress": "${watched} von ${total} gesehen", + "movie": "Film", + "tvShow": "Serie", + "minutesLeft": "${minutes} Min übrig" + }, + "errors": { + "searchFailed": "Suche fehlgeschlagen: ${error}", + "connectionTimeout": "Zeitüberschreitung beim Laden von ${context}", + "connectionFailed": "Verbindung zum Plex-Server fehlgeschlagen", + "failedToLoad": "Fehler beim Laden von ${context}: ${error}", + "noClientAvailable": "Kein Client verfügbar", + "authenticationFailed": "Authentifizierung fehlgeschlagen: ${error}", + "couldNotLaunchUrl": "Auth-URL konnte nicht geöffnet werden", + "pleaseEnterToken": "Bitte Token eingeben", + "invalidToken": "Ungültiges Token", + "failedToVerifyToken": "Token-Verifizierung fehlgeschlagen: ${error}", + "failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen", + "connectionFailedGeneric": "Verbindung fehlgeschlagen" + }, + "libraries": { + "title": "Mediatheken", + "scanLibraryFiles": "Mediatheksdateien scannen", + "scanLibrary": "Mediathek scannen", + "analyze": "Analysieren", + "analyzeLibrary": "Mediathek analysieren", + "refreshMetadata": "Metadaten aktualisieren", + "emptyTrash": "Papierkorb leeren", + "emptyingTrash": "Papierkorb für „${title}“ wird geleert...", + "trashEmptied": "Papierkorb für „${title}“ geleert", + "failedToEmptyTrash": "Papierkorb konnte nicht geleert werden: ${error}", + "analyzing": "Analysiere „${title}“...", + "analysisStarted": "Analyse gestartet für „${title}“", + "failedToAnalyze": "Analyse der Mediathek fehlgeschlagen: ${error}", + "noLibrariesFound": "Keine Mediatheken gefunden", + "thisLibraryIsEmpty": "Diese Mediathek ist leer", + "all": "Alle", + "clearAll": "Alle löschen", + "scanLibraryConfirm": "„${title}“ wirklich scannen?", + "analyzeLibraryConfirm": "„${title}“ wirklich analysieren?", + "refreshMetadataConfirm": "Metadaten für „${title}“ wirklich aktualisieren?", + "emptyTrashConfirm": "Papierkorb für „${title}“ wirklich leeren?", + "manageLibraries": "Mediatheken verwalten", + "sort": "Sortieren", + "sortBy": "Sortieren nach", + "filters": "Filter", + "loadingLibraryWithCount": "Lade Mediathek... (${count} Elemente geladen)", + "confirmActionMessage": "Aktion wirklich durchführen?", + "showLibrary": "Mediathek anzeigen", + "hideLibrary": "Mediathek ausblenden", + "libraryOptions": "Mediatheksoptionen" + }, + "about": { + "title": "Über", + "openSourceLicenses": "Open-Source-Lizenzen", + "versionLabel": "Version ${version}", + "appDescription": "Ein schöner Plex-Client für Flutter", + "viewLicensesDescription": "Lizenzen von Drittanbieter-Bibliotheken anzeigen" + }, + "serverSelection": { + "connectingToServer": "Verbinde mit Server...", + "serverDebugCopied": "Server-Debugdaten kopiert", + "copyDebugData": "Debugdaten kopieren", + "noServersFound": "Keine Server gefunden", + "malformedServerData": "${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.", + "incompleteServerInfo": "Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.", + "incompleteConnectionInfo": "Serververbindungsinformationen unvollständig. Bitte erneut versuchen.", + "malformedServerInfo": "Serverinformationen fehlerhaft: ${message}", + "networkConnectionFailed": "Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.", + "authenticationFailed": "Authentifizierung fehlgeschlagen. Bitte erneut anmelden.", + "plexServiceUnavailable": "Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.", + "failedToLoadServers": "Server konnten nicht geladen werden: ${error}" + }, + "hubDetail": { + "title": "Titel", + "releaseYear": "Erscheinungsjahr", + "dateAdded": "Hinzugefügt am", + "rating": "Bewertung", + "noItemsFound": "Keine Elemente gefunden" + }, + "logs": { + "title": "Protokolle", + "clearLogs": "Protokolle löschen", + "copyLogs": "Protokolle kopieren", + "exportLogs": "Protokolle exportieren", + "noLogsToShow": "Keine Protokolle zum Anzeigen", + "error": "Fehler:", + "stackTrace": "Stacktrace:" + }, + "licenses": { + "relatedPackages": "Verwandte Pakete", + "license": "Lizenz", + "licenseNumber": "Lizenz ${number}", + "licensesCount": "${count} Lizenzen" + }, + "navigation": { + "home": "Start", + "search": "Suche", + "libraries": "Mediatheken", + "settings": "Einstellungen" + }, + "playlists": { + "title": "Wiedergabelisten", + "noPlaylists": "Keine Wiedergabelisten gefunden", + "create": "Wiedergabeliste erstellen", + "newPlaylist": "Neue Wiedergabeliste", + "playlistName": "Name der Wiedergabeliste", + "enterPlaylistName": "Name der Wiedergabeliste eingeben", + "edit": "Wiedergabeliste bearbeiten", + "delete": "Wiedergabeliste löschen", + "addTo": "Zur Wiedergabeliste hinzufügen", + "addItems": "Elemente hinzufügen", + "removeItem": "Aus Wiedergabeliste entfernen", + "clearPlaylist": "Wiedergabeliste leeren", + "playAll": "Alle abspielen", + "shuffle": "Zufällig", + "smartPlaylist": "Intelligente Wiedergabeliste", + "regularPlaylist": "Normale Wiedergabeliste", + "itemCount": "${count} Elemente", + "oneItem": "1 Element", + "emptyPlaylist": "Diese Wiedergabeliste ist leer", + "deleteConfirm": "Wiedergabeliste löschen?", + "deleteMessage": "Soll \"${name}\" wirklich gelöscht werden?", + "created": "Wiedergabeliste erstellt", + "updated": "Wiedergabeliste aktualisiert", + "deleted": "Wiedergabeliste gelöscht", + "itemAdded": "Zur Wiedergabeliste hinzugefügt", + "itemRemoved": "Aus Wiedergabeliste entfernt", + "selectPlaylist": "Wiedergabeliste auswählen", + "createNewPlaylist": "Neue Wiedergabeliste erstellen", + "errorCreating": "Wiedergabeliste konnte nicht erstellt werden", + "errorDeleting": "Wiedergabeliste konnte nicht gelöscht werden", + "errorLoading": "Wiedergabelisten konnten nicht geladen werden", + "errorAdding": "Konnte nicht zur Wiedergabeliste hinzugefügt werden", + "errorReordering": "Element der Wiedergabeliste konnte nicht neu geordnet werden", + "errorRemoving": "Konnte nicht aus der Wiedergabeliste entfernt werden" + } +} diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index efe57079..04a05e14 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} secondi", "defaultSleepTimer": "Timer spegnimento predefinito", "minutesUnit": "${minutes} minuti", + "rememberTrackSelections": "Ricorda selezioni tracce per serie/film", + "rememberTrackSelectionsDescription": "Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione", "unwatchedOnly": "Solo non guardati", "unwatchedOnlyDescription": "Includi solo gli episodi non guardati nella coda di riproduzione casuale", "shuffleOrderNavigation": "Navigazione in ordine casuale", @@ -202,7 +204,8 @@ "userStatus": { "admin": "Admin", "restricted": "Limitato", - "protected": "Protetto" + "protected": "Protetto", + "current": "ATTUALE" }, "messages": { "markedAsWatched": "Segna come visto", @@ -266,6 +269,10 @@ "pause": "Pausa", "overview": "Panoramica", "cast": "Cast", + "seasons": "Stagioni", + "studio": "Studio", + "rating": "Classificazione", + "watched": "Guardato", "episodeCount": "${count} episodi", "watchedProgress": "${watched}/${total} guardati", "movie": "Film", @@ -366,5 +373,41 @@ "search": "Cerca", "libraries": "Librerie", "settings": "Impostazioni" + }, + "playlists": { + "title": "Playlist", + "noPlaylists": "Nessuna playlist trovata", + "create": "Crea playlist", + "newPlaylist": "Nuova playlist", + "playlistName": "Nome playlist", + "enterPlaylistName": "Inserisci nome playlist", + "edit": "Modifica playlist", + "delete": "Elimina playlist", + "addTo": "Aggiungi a playlist", + "addItems": "Aggiungi elementi", + "removeItem": "Rimuovi da playlist", + "clearPlaylist": "Svuota playlist", + "playAll": "Riproduci tutto", + "shuffle": "Casuale", + "smartPlaylist": "Playlist intelligente", + "regularPlaylist": "Playlist normale", + "itemCount": "${count} elementi", + "oneItem": "1 elemento", + "emptyPlaylist": "Questa playlist è vuota", + "deleteConfirm": "Eliminare playlist?", + "deleteMessage": "Sei sicuro di voler eliminare \"${name}\"?", + "created": "Playlist creata", + "updated": "Playlist aggiornata", + "deleted": "Playlist eliminata", + "itemAdded": "Aggiunto alla playlist", + "itemRemoved": "Rimosso dalla playlist", + "selectPlaylist": "Seleziona playlist", + "createNewPlaylist": "Crea nuova playlist", + "errorCreating": "Errore durante la creazione della playlist", + "errorDeleting": "Errore durante l'eliminazione della playlist", + "errorLoading": "Errore durante il caricamento delle playlist", + "errorAdding": "Errore durante l'aggiunta alla playlist", + "errorReordering": "Errore durante il riordino dell'elemento della playlist", + "errorRemoving": "Errore durante la rimozione dalla playlist" } } diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index 68f2084a..bced748e 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} seconden", "defaultSleepTimer": "Standaard slaap timer", "minutesUnit": "bij ${minutes} minuten", + "rememberTrackSelections": "Onthoud track selecties per serie/film", + "rememberTrackSelectionsDescription": "Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen", "unwatchedOnly": "Alleen ongekeken", "unwatchedOnlyDescription": "Alleen ongekeken afleveringen opnemen in willekeurige wachtrij", "shuffleOrderNavigation": "Willekeurige volgorde navigatie", @@ -202,7 +204,8 @@ "userStatus": { "admin": "Beheerder", "restricted": "Beperkt", - "protected": "Beschermd" + "protected": "Beschermd", + "current": "HUIDIG" }, "messages": { "markedAsWatched": "Gemarkeerd als gekeken", @@ -266,6 +269,10 @@ "pause": "Pauzeren", "overview": "Overzicht", "cast": "Cast", + "seasons": "Seizoenen", + "studio": "Studio", + "rating": "Leeftijd", + "watched": "Bekeken", "episodeCount": "${count} afleveringen", "watchedProgress": "${watched}/${total} gekeken", "movie": "Film", @@ -366,5 +373,41 @@ "search": "Zoeken", "libraries": "Bibliotheken", "settings": "Instellingen" + }, + "playlists": { + "title": "Afspeellijsten", + "noPlaylists": "Geen afspeellijsten gevonden", + "create": "Afspeellijst maken", + "newPlaylist": "Nieuwe afspeellijst", + "playlistName": "Naam afspeellijst", + "enterPlaylistName": "Voer naam afspeellijst in", + "edit": "Afspeellijst bewerken", + "delete": "Afspeellijst verwijderen", + "addTo": "Toevoegen aan afspeellijst", + "addItems": "Items toevoegen", + "removeItem": "Verwijderen uit afspeellijst", + "clearPlaylist": "Afspeellijst wissen", + "playAll": "Alles afspelen", + "shuffle": "Shuffle", + "smartPlaylist": "Slimme afspeellijst", + "regularPlaylist": "Normale afspeellijst", + "itemCount": "${count} items", + "oneItem": "1 item", + "emptyPlaylist": "Deze afspeellijst is leeg", + "deleteConfirm": "Afspeellijst verwijderen?", + "deleteMessage": "Weet je zeker dat je \"${name}\" wilt verwijderen?", + "created": "Afspeellijst gemaakt", + "updated": "Afspeellijst bijgewerkt", + "deleted": "Afspeellijst verwijderd", + "itemAdded": "Toegevoegd aan afspeellijst", + "itemRemoved": "Verwijderd uit afspeellijst", + "selectPlaylist": "Selecteer afspeellijst", + "createNewPlaylist": "Nieuwe afspeellijst maken", + "errorCreating": "Fout bij maken afspeellijst", + "errorDeleting": "Fout bij verwijderen afspeellijst", + "errorLoading": "Fout bij laden afspeellijsten", + "errorAdding": "Fout bij toevoegen aan afspeellijst", + "errorReordering": "Fout bij herschikken van afspeellijstitem", + "errorRemoving": "Fout bij verwijderen uit afspeellijst" } } diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index c81bc3fc..b97094d9 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} sekunder", "defaultSleepTimer": "Standard sovtimer", "minutesUnit": "${minutes} minuter", + "rememberTrackSelections": "Kom ihåg spårval per serie/film", + "rememberTrackSelectionsDescription": "Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning", "unwatchedOnly": "Endast osedda", "unwatchedOnlyDescription": "Inkludera endast osedda avsnitt i blandningskön", "shuffleOrderNavigation": "Blandningsordning-navigation", @@ -202,7 +204,8 @@ "userStatus": { "admin": "Admin", "restricted": "Begränsad", - "protected": "Skyddad" + "protected": "Skyddad", + "current": "NUVARANDE" }, "messages": { "markedAsWatched": "Markerad som sedd", @@ -266,6 +269,10 @@ "pause": "Pausa", "overview": "Översikt", "cast": "Rollbesättning", + "seasons": "Säsonger", + "studio": "Studio", + "rating": "Åldersgräns", + "watched": "Tittad", "episodeCount": "${count} avsnitt", "watchedProgress": "${watched}/${total} sedda", "movie": "Film", @@ -366,5 +373,41 @@ "search": "Sök", "libraries": "Bibliotek", "settings": "Inställningar" + }, + "playlists": { + "title": "Spellistor", + "noPlaylists": "Inga spellistor hittades", + "create": "Skapa spellista", + "newPlaylist": "Ny spellista", + "playlistName": "Spellistans namn", + "enterPlaylistName": "Ange spellistans namn", + "edit": "Redigera spellista", + "delete": "Ta bort spellista", + "addTo": "Lägg till i spellista", + "addItems": "Lägg till objekt", + "removeItem": "Ta bort från spellista", + "clearPlaylist": "Rensa spellista", + "playAll": "Spela alla", + "shuffle": "Blanda", + "smartPlaylist": "Smart spellista", + "regularPlaylist": "Vanlig spellista", + "itemCount": "${count} objekt", + "oneItem": "1 objekt", + "emptyPlaylist": "Denna spellista är tom", + "deleteConfirm": "Ta bort spellista?", + "deleteMessage": "Är du säker på att du vill ta bort \"${name}\"?", + "created": "Spellista skapad", + "updated": "Spellista uppdaterad", + "deleted": "Spellista borttagen", + "itemAdded": "Tillagd i spellista", + "itemRemoved": "Borttagen från spellista", + "selectPlaylist": "Välj spellista", + "createNewPlaylist": "Skapa ny spellista", + "errorCreating": "Det gick inte att skapa spellista", + "errorDeleting": "Det gick inte att ta bort spellista", + "errorLoading": "Det gick inte att ladda spellistor", + "errorAdding": "Det gick inte att lägga till i spellista", + "errorReordering": "Det gick inte att omordna spellisteobjekt", + "errorRemoving": "Det gick inte att ta bort från spellista" } } diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json new file mode 100644 index 00000000..8daa0990 --- /dev/null +++ b/lib/i18n/strings_zh.i18n.json @@ -0,0 +1,413 @@ +{ + "app": { + "title": "Plezy", + "loading": "加载中..." + }, + "auth": { + "signInWithPlex": "使用 Plex 登录", + "showQRCode": "显示二维码", + "cancel": "取消", + "authenticate": "验证", + "retry": "重试", + "debugEnterToken": "调试:输入 Plex Token", + "plexTokenLabel": "Plex 授权令牌 (Auth Token)", + "plexTokenHint": "输入你的 Plex.tv 令牌", + "authenticationTimeout": "验证超时。请重试。", + "scanQRCodeInstruction": "请使用已登录 Plex 的设备扫描此二维码进行验证。", + "waitingForAuth": "等待验证中...\n请在你的浏览器中完成登录。" + }, + "common": { + "cancel": "取消", + "save": "保存", + "close": "关闭", + "clear": "清除", + "reset": "重置", + "later": "稍后", + "submit": "提交", + "confirm": "确认", + "retry": "重试", + "playNow": "立即播放", + "logout": "登出", + "online": "在线", + "offline": "离线", + "owned": "拥有", + "shared": "共享", + "current": "当前", + "unknown": "未知", + "refresh": "刷新", + "yes": "是", + "no": "否", + "server": "服务器" + }, + "screens": { + "licenses": "许可证", + "selectServer": "选择服务器", + "switchProfile": "切换配置文件", + "subtitleStyling": "字幕样式", + "search": "搜索", + "logs": "日志" + }, + "update": { + "available": "有可用更新", + "versionAvailable": "版本 ${version} 已发布", + "currentVersion": "当前版本: ${version}", + "skipVersion": "跳过此版本", + "viewRelease": "查看发布详情", + "latestVersion": "已安装的版本是可用的最新版本", + "checkFailed": "无法检查更新" + }, + "settings": { + "title": "设置", + "language": "语言", + "theme": "主题", + "appearance": "外观", + "videoPlayback": "视频播放", + "shufflePlay": "随机播放", + "advanced": "高级", + "useSeasonPostersDescription": "为剧集显示季海报而非剧集海报", + "showHeroSectionDescription": "在主屏幕上显示精选内容轮播区", + "secondsLabel": "秒", + "minutesLabel": "分钟", + "secondsShort": "s", + "minutesShort": "m", + "durationHint": "输入时长 (${min}-${max})", + "systemTheme": "系统", + "systemThemeDescription": "跟随系统设置", + "lightTheme": "浅色", + "darkTheme": "深色", + "libraryDensity": "媒体库密度", + "compact": "紧凑", + "compactDescription": "卡片更小,显示更多项目", + "normal": "标准", + "normalDescription": "默认尺寸", + "comfortable": "舒适", + "comfortableDescription": "卡片更大,显示更少项目", + "viewMode": "视图模式", + "gridView": "网格视图", + "gridViewDescription": "以网格布局显示项目", + "listView": "列表视图", + "listViewDescription": "以列表布局显示项目", + "useSeasonPosters": "使用季海报", + "showHeroSection": "显示主要精选区", + "hardwareDecoding": "硬件解码", + "hardwareDecodingDescription": "如果可用,使用硬件加速", + "bufferSize": "缓冲区大小", + "bufferSizeMB": "${size}MB", + "subtitleStyling": "字幕样式", + "subtitleStylingDescription": "调整字幕外观", + "smallSkipDuration": "短跳过时长", + "largeSkipDuration": "长跳过时长", + "secondsUnit": "${seconds} 秒", + "defaultSleepTimer": "默认睡眠定时器", + "minutesUnit": "${minutes} 分钟", + "rememberTrackSelections": "记住每个剧集/电影的音轨选择", + "rememberTrackSelectionsDescription": "在播放过程中更改音轨时自动保存音频和字幕语言偏好", + "unwatchedOnly": "仅未观看", + "unwatchedOnlyDescription": "随机队列中仅包含未观看的剧集", + "shuffleOrderNavigation": "随机顺序导航", + "shuffleOrderNavigationDescription": "下一集/上一集按钮遵循随机播放顺序", + "loopShuffleQueue": "循环随机队列", + "loopShuffleQueueDescription": "在队列结束时重新开始", + "videoPlayerControls": "视频播放器控制", + "keyboardShortcuts": "键盘快捷键", + "keyboardShortcutsDescription": "自定义键盘快捷键", + "debugLogging": "调试日志", + "debugLoggingDescription": "启用详细日志记录以便故障排除", + "viewLogs": "查看日志", + "viewLogsDescription": "查看应用程序日志", + "clearCache": "清除缓存", + "clearCacheDescription": "这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。", + "clearCacheSuccess": "缓存清除成功", + "resetSettings": "重置设置", + "resetSettingsDescription": "这会将所有设置重置为其默认值。此操作无法撤销。", + "resetSettingsSuccess": "设置重置成功", + "shortcutsReset": "快捷键已重置为默认值", + "about": "关于", + "aboutDescription": "应用程序信息和许可证", + "updates": "更新", + "updateAvailable": "有可用更新", + "checkForUpdates": "检查更新", + "validationErrorEnterNumber": "请输入一个有效的数字", + "validationErrorDuration": "时长必须介于 ${min} 和 ${max} ${unit} 之间", + "shortcutAlreadyAssigned": "快捷键已被分配给 ${action}", + "shortcutUpdated": "快捷键已为 ${action} 更新" + }, + "search": { + "hint": "搜索电影、系列、音乐...", + "tryDifferentTerm": "尝试不同的搜索词", + "searchYourMedia": "搜索媒体", + "enterTitleActorOrKeyword": "输入标题、演员或关键词" + }, + "hotkeys": { + "setShortcutFor": "为 ${actionName} 设置快捷键", + "clearShortcut": "清除快捷键" + }, + "pinEntry": { + "enterPin": "输入 PIN", + "showPin": "显示 PIN", + "hidePin": "隐藏 PIN" + }, + "fileInfo": { + "title": "文件信息", + "video": "视频", + "audio": "音频", + "file": "文件", + "advanced": "高级", + "codec": "编解码器", + "resolution": "分辨率", + "bitrate": "比特率", + "frameRate": "帧率", + "aspectRatio": "宽高比", + "profile": "配置文件", + "bitDepth": "位深度", + "colorSpace": "色彩空间", + "colorRange": "色彩范围", + "colorPrimaries": "颜色原色", + "chromaSubsampling": "色度子采样", + "channels": "声道", + "path": "路径", + "size": "大小", + "container": "容器", + "duration": "时长", + "optimizedForStreaming": "已优化用于流媒体", + "has64bitOffsets": "64位偏移量" + }, + "mediaMenu": { + "markAsWatched": "标记为已观看", + "markAsUnwatched": "标记为未观看", + "removeFromContinueWatching": "从继续观看中移除", + "goToSeries": "转到系列", + "goToSeason": "转到季", + "shufflePlay": "随机播放", + "fileInfo": "文件信息" + }, + "tooltips": { + "shufflePlay": "随机播放", + "markAsWatched": "标记为已观看", + "markAsUnwatched": "标记为未观看" + }, + "videoControls": { + "audioLabel": "音频", + "subtitlesLabel": "字幕", + "resetToZero": "重置为 0ms", + "addTime": "+${amount}${unit}", + "minusTime": "-${amount}${unit}", + "playsLater": "${label} 播放较晚", + "playsEarlier": "${label} 播放较早", + "noOffset": "无偏移", + "letterbox": "信箱模式(Letterbox)", + "fillScreen": "填充屏幕", + "stretch": "拉伸", + "lockRotation": "锁定旋转", + "unlockRotation": "解锁旋转" + }, + "userStatus": { + "admin": "管理员", + "restricted": "受限", + "protected": "受保护", + "current": "当前" + }, + "messages": { + "markedAsWatched": "已标记为已观看", + "markedAsUnwatched": "已标记为未观看", + "removedFromContinueWatching": "已从继续观看中移除", + "errorLoading": "错误: ${error}", + "fileInfoNotAvailable": "文件信息不可用", + "errorLoadingFileInfo": "加载文件信息时出错: ${error}", + "errorLoadingSeries": "加载系列时出错", + "errorLoadingSeason": "加载季时出错", + "musicNotSupported": "尚不支持播放音乐", + "logsCleared": "日志已清除", + "logsCopied": "日志已复制到剪贴板", + "noLogsAvailable": "没有可用日志", + "libraryScanning": "正在扫描 “${title}”...", + "libraryScanStarted": "已开始扫描 “${title}” 媒体库", + "libraryScanFailed": "无法扫描媒体库: ${error}", + "metadataRefreshing": "正在刷新 “${title}” 的元数据...", + "metadataRefreshStarted": "已开始刷新 “${title}” 的元数据", + "metadataRefreshFailed": "无法刷新元数据: ${error}", + "noPlexToken": "未找到 Plex 令牌。请重新登录。", + "logoutConfirm": "你确定要登出吗?", + "noSeasonsFound": "未找到季", + "noEpisodesFound": "在第一季中未找到剧集", + "noEpisodesFoundGeneral": "未找到剧集", + "noResultsFound": "未找到结果", + "sleepTimerSet": "睡眠定时器已设置为 ${label}", + "failedToSwitchProfile": "无法切换到 ${displayName}" + }, + "profile": { + "noUsersAvailable": "没有可用用户" + }, + "subtitlingStyling": { + "stylingOptions": "样式选项", + "fontSize": "字号", + "textColor": "文本颜色", + "borderSize": "边框大小", + "borderColor": "边框颜色", + "backgroundOpacity": "背景不透明度", + "backgroundColor": "背景颜色" + }, + "dialog": { + "confirmAction": "确认操作", + "areYouSure": "确定要执行此操作吗?", + "cancel": "取消", + "playNow": "立即播放" + }, + "discover": { + "title": "发现", + "switchProfile": "切换配置文件", + "switchServer": "切换服务器", + "logout": "登出", + "noContentAvailable": "没有可用内容", + "addMediaToLibraries": "请向你的媒体库添加一些媒体", + "continueWatching": "继续观看", + "recentlyAdded": "最近添加", + "play": "播放", + "resume": "继续", + "playEpisode": "播放 S${season}, E${episode}", + "resumeEpisode": "继续 S${season}, E${episode}", + "pause": "暂停", + "overview": "概述", + "cast": "演员表", + "seasons": "季数", + "studio": "制作公司", + "rating": "年龄分级", + "watched": "已观看", + "episodeCount": "${count} 集", + "watchedProgress": "已观看 ${watched}/${total} 集", + "movie": "电影", + "tvShow": "电视剧", + "minutesLeft": "剩余 ${minutes} 分钟" + }, + "errors": { + "searchFailed": "搜索失败: ${error}", + "connectionTimeout": "加载 ${context} 时连接超时", + "connectionFailed": "无法连接到 Plex 服务器", + "failedToLoad": "无法加载 ${context}: ${error}", + "noClientAvailable": "没有可用客户端", + "authenticationFailed": "验证失败: ${error}", + "couldNotLaunchUrl": "无法打开授权 URL", + "pleaseEnterToken": "请输入一个令牌", + "invalidToken": "令牌无效", + "failedToVerifyToken": "无法验证令牌: ${error}", + "failedToSwitchProfile": "无法切换到 ${displayName}", + "connectionFailedGeneric": "连接失败" + }, + "libraries": { + "title": "媒体库", + "scanLibraryFiles": "扫描媒体库文件", + "scanLibrary": "扫描媒体库", + "analyze": "分析", + "analyzeLibrary": "分析媒体库", + "refreshMetadata": "刷新元数据", + "emptyTrash": "清空回收站", + "emptyingTrash": "正在清空 “${title}” 的回收站...", + "trashEmptied": "已清空 “${title}” 的回收站", + "failedToEmptyTrash": "无法清空回收站: ${error}", + "analyzing": "正在分析 “${title}”...", + "analysisStarted": "已开始分析 “${title}”", + "failedToAnalyze": "无法分析媒体库: ${error}", + "noLibrariesFound": "未找到媒体库", + "thisLibraryIsEmpty": "此媒体库为空", + "all": "全部", + "clearAll": "全部清除", + "scanLibraryConfirm": "确定要扫描 “${title}” 吗?", + "analyzeLibraryConfirm": "确定要分析 “${title}” 吗?", + "refreshMetadataConfirm": "确定要刷新 “${title}” 的元数据吗?", + "emptyTrashConfirm": "确定要清空 “${title}” 的回收站吗?", + "manageLibraries": "管理媒体库", + "sort": "排序", + "sortBy": "排序依据", + "filters": "筛选器", + "loadingLibraryWithCount": "正在加载媒体库... (已加载 ${count} 个项目)", + "confirmActionMessage": "确定要执行此操作吗?", + "showLibrary": "显示媒体库", + "hideLibrary": "隐藏媒体库", + "libraryOptions": "媒体库选项" + }, + "about": { + "title": "关于", + "openSourceLicenses": "开源许可证", + "versionLabel": "版本 ${version}", + "appDescription": "一款精美的 Flutter Plex 客户端", + "viewLicensesDescription": "查看第三方库的许可证" + }, + "serverSelection": { + "connectingToServer": "正在连接服务器...", + "serverDebugCopied": "服务器调试数据已复制到剪贴板", + "copyDebugData": "复制调试数据", + "noServersFound": "未找到服务器", + "malformedServerData": "找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。", + "incompleteServerInfo": "某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。", + "incompleteConnectionInfo": "服务器连接信息不完整。请重试。", + "malformedServerInfo": "服务器信息格式错误: ${message}", + "networkConnectionFailed": "网络连接失败。请检查你的网络连接并重试。", + "authenticationFailed": "验证失败。请重新登录。", + "plexServiceUnavailable": "Plex 服务不可用。请稍后重试。", + "failedToLoadServers": "无法加载服务器: ${error}" + }, + "hubDetail": { + "title": "标题", + "releaseYear": "发行年份", + "dateAdded": "添加日期", + "rating": "评分", + "noItemsFound": "未找到项目" + }, + "logs": { + "title": "日志", + "clearLogs": "清除日志", + "copyLogs": "复制日志", + "exportLogs": "导出日志", + "noLogsToShow": "没有可显示的日志", + "error": "错误:", + "stackTrace": "堆栈跟踪 (Stack Trace):" + }, + "licenses": { + "relatedPackages": "相关软件包", + "license": "许可证", + "licenseNumber": "许可证 ${number}", + "licensesCount": "${count} 个许可证" + }, + "navigation": { + "home": "主页", + "search": "搜索", + "libraries": "媒体库", + "settings": "设置" + }, + "playlists": { + "title": "播放列表", + "noPlaylists": "未找到播放列表", + "create": "创建播放列表", + "newPlaylist": "新播放列表", + "playlistName": "播放列表名称", + "enterPlaylistName": "输入播放列表名称", + "edit": "编辑播放列表", + "delete": "删除播放列表", + "addTo": "添加到播放列表", + "addItems": "添加项目", + "removeItem": "从播放列表中移除", + "clearPlaylist": "清空播放列表", + "playAll": "全部播放", + "shuffle": "随机播放", + "smartPlaylist": "智能播放列表", + "regularPlaylist": "普通播放列表", + "itemCount": "${count} 个项目", + "oneItem": "1 个项目", + "emptyPlaylist": "此播放列表为空", + "deleteConfirm": "删除播放列表?", + "deleteMessage": "确定要删除 \"${name}\" 吗?", + "created": "播放列表已创建", + "updated": "播放列表已更新", + "deleted": "播放列表已删除", + "itemAdded": "已添加到播放列表", + "itemRemoved": "已从播放列表中移除", + "selectPlaylist": "选择播放列表", + "createNewPlaylist": "创建新播放列表", + "errorCreating": "创建播放列表失败", + "errorDeleting": "删除播放列表失败", + "errorLoading": "加载播放列表失败", + "errorAdding": "添加到播放列表失败", + "errorReordering": "重新排序播放列表项目失败", + "errorRemoving": "从播放列表中移除失败" + } +} diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 17f360f0..52233ed1 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -38,6 +38,9 @@ class PlexMetadata { final int? viewedLeafCount; // Number of watched episodes in a series/season @JsonKey(name: 'Role') final List? role; // Cast members + final String? audioLanguage; // Per-media preferred audio language + final String? subtitleLanguage; // Per-media preferred subtitle language + final int? playlistItemID; // Playlist item ID (for dumb playlists only) // Transient field for clear logo (extracted from Image array) String? _clearLogo; @@ -75,6 +78,9 @@ class PlexMetadata { this.leafCount, this.viewedLeafCount, this.role, + this.audioLanguage, + this.subtitleLanguage, + this.playlistItemID, }); /// Create a copy of this metadata with optional field overrides @@ -110,6 +116,9 @@ class PlexMetadata { int? leafCount, int? viewedLeafCount, List? role, + String? audioLanguage, + String? subtitleLanguage, + int? playlistItemID, }) { final copy = PlexMetadata( ratingKey: ratingKey ?? this.ratingKey, @@ -143,6 +152,9 @@ class PlexMetadata { leafCount: leafCount ?? this.leafCount, viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, role: role ?? this.role, + audioLanguage: audioLanguage ?? this.audioLanguage, + subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, + playlistItemID: playlistItemID ?? this.playlistItemID, ); // Preserve clearLogo copy._clearLogo = _clearLogo; diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index 5cf19850..a9f262ea 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -40,6 +40,9 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( role: (json['Role'] as List?) ?.map((e) => PlexRole.fromJson(e as Map)) .toList(), + audioLanguage: json['audioLanguage'] as String?, + subtitleLanguage: json['subtitleLanguage'] as String?, + playlistItemID: (json['playlistItemID'] as num?)?.toInt(), ); Map _$PlexMetadataToJson(PlexMetadata instance) => @@ -75,4 +78,7 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 'leafCount': instance.leafCount, 'viewedLeafCount': instance.viewedLeafCount, 'Role': instance.role, + 'audioLanguage': instance.audioLanguage, + 'subtitleLanguage': instance.subtitleLanguage, + 'playlistItemID': instance.playlistItemID, }; diff --git a/lib/models/plex_playlist.dart b/lib/models/plex_playlist.dart new file mode 100644 index 00000000..d47944f7 --- /dev/null +++ b/lib/models/plex_playlist.dart @@ -0,0 +1,66 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'plex_playlist.g.dart'; + +@JsonSerializable() +class PlexPlaylist { + final String ratingKey; + final String key; + final String type; // "playlist" + final String title; + final String? summary; + final bool smart; + final String playlistType; // video, audio, photo + final int? duration; + final int? leafCount; // Number of items in playlist + final String? composite; // Composite thumbnail image + final int? addedAt; + final int? updatedAt; + final int? lastViewedAt; + final int? viewCount; + final String? content; // For smart playlists - generator URI + final String? guid; + final String? thumb; + + PlexPlaylist({ + required this.ratingKey, + required this.key, + required this.type, + required this.title, + this.summary, + required this.smart, + required this.playlistType, + this.duration, + this.leafCount, + this.composite, + this.addedAt, + this.updatedAt, + this.lastViewedAt, + this.viewCount, + this.content, + this.guid, + this.thumb, + }); + + /// Helper to get display image (composite or thumb) + String? get displayImage => composite ?? thumb; + + /// Helper to get formatted duration + String? get formattedDuration { + if (duration == null) return null; + final hours = duration! ~/ 3600000; + final minutes = (duration! % 3600000) ~/ 60000; + if (hours > 0) { + return '${hours}h ${minutes}m'; + } + return '${minutes}m'; + } + + /// Helper to determine if playlist is editable + bool get isEditable => !smart; + + factory PlexPlaylist.fromJson(Map json) => + _$PlexPlaylistFromJson(json); + + Map toJson() => _$PlexPlaylistToJson(this); +} diff --git a/lib/models/plex_playlist.g.dart b/lib/models/plex_playlist.g.dart new file mode 100644 index 00000000..96647865 --- /dev/null +++ b/lib/models/plex_playlist.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_playlist.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexPlaylist _$PlexPlaylistFromJson(Map json) => PlexPlaylist( + ratingKey: json['ratingKey'] as String, + key: json['key'] as String, + type: json['type'] as String, + title: json['title'] as String, + summary: json['summary'] as String?, + smart: json['smart'] as bool, + playlistType: json['playlistType'] as String, + duration: (json['duration'] as num?)?.toInt(), + leafCount: (json['leafCount'] as num?)?.toInt(), + composite: json['composite'] as String?, + addedAt: (json['addedAt'] as num?)?.toInt(), + updatedAt: (json['updatedAt'] as num?)?.toInt(), + lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(), + viewCount: (json['viewCount'] as num?)?.toInt(), + content: json['content'] as String?, + guid: json['guid'] as String?, + thumb: json['thumb'] as String?, +); + +Map _$PlexPlaylistToJson(PlexPlaylist instance) => + { + 'ratingKey': instance.ratingKey, + 'key': instance.key, + 'type': instance.type, + 'title': instance.title, + 'summary': instance.summary, + 'smart': instance.smart, + 'playlistType': instance.playlistType, + 'duration': instance.duration, + 'leafCount': instance.leafCount, + 'composite': instance.composite, + 'addedAt': instance.addedAt, + 'updatedAt': instance.updatedAt, + 'lastViewedAt': instance.lastViewedAt, + 'viewCount': instance.viewCount, + 'content': instance.content, + 'guid': instance.guid, + 'thumb': instance.thumb, + }; diff --git a/lib/network/endpoint_failover_interceptor.dart b/lib/network/endpoint_failover_interceptor.dart new file mode 100644 index 00000000..e0ecd011 --- /dev/null +++ b/lib/network/endpoint_failover_interceptor.dart @@ -0,0 +1,174 @@ +import 'package:dio/dio.dart'; + +import '../utils/app_logger.dart'; + +/// Maintains the list of endpoints we can cycle through when one fails. +class EndpointFailoverManager { + EndpointFailoverManager(List urls) { + _setEndpoints(urls); + } + + late List _endpoints; + int _currentIndex = 0; + + List get endpoints => List.unmodifiable(_endpoints); + + String get current => _endpoints[_currentIndex]; + + bool get hasFallback => _currentIndex < _endpoints.length - 1; + + /// Move to the next endpoint, returning its URL or null if exhausted. + String? moveToNext() { + if (!hasFallback) return null; + _currentIndex++; + return _endpoints[_currentIndex]; + } + + /// Replace the endpoint list and optionally set the active endpoint. + void reset(List urls, {String? currentBaseUrl}) { + _setEndpoints(urls); + if (currentBaseUrl != null) { + final index = _endpoints.indexOf(currentBaseUrl); + _currentIndex = index >= 0 ? index : 0; + } else { + _currentIndex = 0; + } + } + + void _setEndpoints(List urls) { + final sanitized = []; + final seen = {}; + for (final url in urls) { + if (url.isEmpty || seen.contains(url)) continue; + seen.add(url); + sanitized.add(url); + } + if (sanitized.isEmpty) { + throw ArgumentError('At least one endpoint is required'); + } + _endpoints = sanitized; + _currentIndex = _currentIndex.clamp(0, _endpoints.length - 1); + } +} + +/// Dio interceptor that retries failed requests on the next available endpoint. +class EndpointFailoverInterceptor extends Interceptor { + EndpointFailoverInterceptor({ + required Dio dio, + required this.endpointManager, + required Future Function(String newBaseUrl) onEndpointSwitch, + }) : _dio = dio, + _onEndpointSwitch = onEndpointSwitch; + + final Dio _dio; + final EndpointFailoverManager endpointManager; + final Future Function(String newBaseUrl) _onEndpointSwitch; + bool _isSwitching = false; + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + if (_isSwitching || + !_shouldAttemptFailover(err) || + !endpointManager.hasFallback) { + handler.next(err); + return; + } + + final failedEndpoint = endpointManager.current; + appLogger.w( + 'Endpoint request failed, evaluating failover', + error: { + 'endpoint': failedEndpoint, + 'type': err.type.name, + 'statusCode': err.response?.statusCode, + }, + stackTrace: err.stackTrace, + ); + + final nextBaseUrl = endpointManager.moveToNext(); + if (nextBaseUrl == null) { + appLogger.w( + 'Endpoint failure but no fallback endpoints remain', + error: {'failedEndpoint': failedEndpoint}, + ); + handler.next(err); + return; + } + + _isSwitching = true; + try { + appLogger.i( + 'Switching Plex endpoint after request failure', + error: { + 'from': failedEndpoint, + 'to': nextBaseUrl, + 'path': err.requestOptions.path, + }, + ); + await _onEndpointSwitch(nextBaseUrl); + final response = await _retryRequest(err.requestOptions); + appLogger.i( + 'Endpoint failover retry succeeded', + error: {'newEndpoint': nextBaseUrl}, + ); + handler.resolve(response); + } on DioException catch (dioError) { + appLogger.w( + 'Endpoint failover retry failed', + error: { + 'newEndpoint': nextBaseUrl, + 'type': dioError.type.name, + 'statusCode': dioError.response?.statusCode, + }, + stackTrace: dioError.stackTrace, + ); + handler.next(dioError); + } catch (_) { + handler.next(err); + } finally { + _isSwitching = false; + } + } + + bool _shouldAttemptFailover(DioException error) { + if (error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.sendTimeout || + error.type == DioExceptionType.connectionError) { + return true; + } + + if (error.type == DioExceptionType.badResponse) { + final statusCode = error.response?.statusCode ?? 0; + return statusCode >= 500; + } + + return false; + } + + Future> _retryRequest(RequestOptions requestOptions) { + final options = Options( + method: requestOptions.method, + headers: requestOptions.headers, + responseType: requestOptions.responseType, + contentType: requestOptions.contentType, + followRedirects: requestOptions.followRedirects, + receiveDataWhenStatusError: requestOptions.receiveDataWhenStatusError, + validateStatus: requestOptions.validateStatus, + sendTimeout: requestOptions.sendTimeout, + receiveTimeout: requestOptions.receiveTimeout, + extra: requestOptions.extra, + listFormat: requestOptions.listFormat, + ); + + return _dio.request( + requestOptions.path, + data: requestOptions.data, + queryParameters: requestOptions.queryParameters, + options: options, + cancelToken: requestOptions.cancelToken, + onSendProgress: requestOptions.onSendProgress, + onReceiveProgress: requestOptions.onReceiveProgress, + ); + } +} diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 721345af..b994af6c 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -1,98 +1,127 @@ import 'package:flutter/foundation.dart'; import '../models/plex_metadata.dart'; -/// Manages shuffle playback state for TV shows and seasons. +/// Playback mode types +enum PlaybackMode { + none, // No active playback queue + sequential, // Normal episode-to-episode playback (uses Plex API) + shufflePlay, // Shuffle play for shows/seasons + playlist, // Playlist playback (ordered or shuffled) +} + +/// Manages playback state for TV shows, seasons, and playlists. /// This provider is session-only and does not persist across app restarts. class PlaybackStateProvider with ChangeNotifier { - List _shuffleQueue = []; - String? - _shuffleContextKey; // The show/season ratingKey for this shuffle session + List _queue = []; + String? _contextKey; // The show/season/playlist ratingKey for this session int _currentIndex = 0; + PlaybackMode _playbackMode = PlaybackMode.none; + + /// Current playback mode + PlaybackMode get playbackMode => _playbackMode; /// Whether shuffle mode is currently active - bool get isShuffleActive => _shuffleQueue.isNotEmpty; + bool get isShuffleActive => _playbackMode == PlaybackMode.shufflePlay; - /// The context key (show or season ratingKey) for the current shuffle session - String? get shuffleContextKey => _shuffleContextKey; + /// Whether playlist mode is currently active + bool get isPlaylistActive => _playbackMode == PlaybackMode.playlist; + + /// Whether any queue-based playback is active + bool get isQueueActive => + _queue.isNotEmpty && _playbackMode != PlaybackMode.none; + + /// The context key (show/season/playlist ratingKey) for the current session + String? get shuffleContextKey => _contextKey; /// Sets a new shuffle queue and starts shuffle mode void setShuffleQueue(List episodes, String contextKey) { - _shuffleQueue = List.from(episodes); - _shuffleContextKey = contextKey; + _queue = List.from(episodes); + _contextKey = contextKey; _currentIndex = 0; + _playbackMode = PlaybackMode.shufflePlay; notifyListeners(); } - /// Gets the next episode in the shuffle queue. - /// Returns null if queue is exhausted or current episode is not in queue. + /// Sets a playback queue for playlist playback (ordered, not shuffled) + void setPlaybackQueue(List items, String contextKey) { + _queue = List.from(items); + _contextKey = contextKey; + _currentIndex = 0; + _playbackMode = PlaybackMode.playlist; + notifyListeners(); + } + + /// Gets the next item in the playback queue. + /// Returns null if queue is exhausted or current item is not in queue. /// [loopQueue] - If true, restart from beginning when queue is exhausted PlexMetadata? getNextEpisode( - String currentEpisodeKey, { + String currentItemKey, { bool loopQueue = false, }) { - if (_shuffleQueue.isEmpty) return null; + if (_queue.isEmpty) return null; - // Find current episode in queue - final currentIndex = _shuffleQueue.indexWhere( - (ep) => ep.ratingKey == currentEpisodeKey, + // Find current item in queue + final currentIndex = _queue.indexWhere( + (item) => item.ratingKey == currentItemKey, ); if (currentIndex == -1) { - // Current episode not in queue, clear shuffle + // Current item not in queue, clear queue clearShuffle(); return null; } - // Check if there's a next episode - if (currentIndex + 1 >= _shuffleQueue.length) { + // Check if there's a next item + if (currentIndex + 1 >= _queue.length) { // Queue exhausted - if (loopQueue && _shuffleQueue.isNotEmpty) { + if (loopQueue && _queue.isNotEmpty) { // Loop back to beginning _currentIndex = 0; - return _shuffleQueue[_currentIndex]; + return _queue[_currentIndex]; } return null; } _currentIndex = currentIndex + 1; - return _shuffleQueue[_currentIndex]; + return _queue[_currentIndex]; } - /// Gets the previous episode in the shuffle queue. - /// Returns null if at the beginning of the queue or current episode is not in queue. - PlexMetadata? getPreviousEpisode(String currentEpisodeKey) { - if (_shuffleQueue.isEmpty) return null; + /// Gets the previous item in the playback queue. + /// Returns null if at the beginning of the queue or current item is not in queue. + PlexMetadata? getPreviousEpisode(String currentItemKey) { + if (_queue.isEmpty) return null; - // Find current episode in queue - final currentIndex = _shuffleQueue.indexWhere( - (ep) => ep.ratingKey == currentEpisodeKey, + // Find current item in queue + final currentIndex = _queue.indexWhere( + (item) => item.ratingKey == currentItemKey, ); if (currentIndex == -1) { - // Current episode not in queue + // Current item not in queue return null; } - // Check if there's a previous episode + // Check if there's a previous item if (currentIndex <= 0) { // At the beginning of queue return null; } _currentIndex = currentIndex - 1; - return _shuffleQueue[_currentIndex]; + return _queue[_currentIndex]; } - /// Clears the shuffle queue and exits shuffle mode + /// Clears the playback queue and exits queue mode void clearShuffle() { - _shuffleQueue = []; - _shuffleContextKey = null; + _queue = []; + _contextKey = null; _currentIndex = 0; + _playbackMode = PlaybackMode.none; notifyListeners(); } - /// Gets the total number of episodes in the current shuffle queue - int get queueLength => _shuffleQueue.length; + /// Gets the total number of items in the current playback queue + int get queueLength => _queue.length; /// Gets the current position in the queue (1-indexed) int get currentPosition => _currentIndex + 1; diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index b7a65fd2..ff02bde8 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -21,6 +21,7 @@ import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; +import 'playlists_screen.dart'; class LibrariesScreen extends StatefulWidget { const LibrariesScreen({super.key}); @@ -1125,6 +1126,16 @@ class _LibrariesScreenState extends State ], ], ), + floatingActionButton: FloatingActionButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const PlaylistsScreen()), + ); + }, + tooltip: t.playlists.title, + child: const Icon(Icons.playlist_play), + ), ); } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 9831062e..0b1afff6 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -768,7 +768,7 @@ class _MediaDetailScreenState extends State { // Seasons (for TV shows) if (isShow) ...[ Text( - 'Seasons', + t.discover.seasons, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), @@ -930,12 +930,12 @@ class _MediaDetailScreenState extends State { // Additional info if (metadata.studio != null) ...[ - _buildInfoRow('Studio', metadata.studio!), + _buildInfoRow(t.discover.studio, metadata.studio!), const SizedBox(height: 12), ], if (metadata.contentRating != null) ...[ _buildInfoRow( - 'Rating', + t.discover.rating, formatContentRating(metadata.contentRating!), ), const SizedBox(height: 12), diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart new file mode 100644 index 00000000..bcd25c25 --- /dev/null +++ b/lib/screens/playlist_detail_screen.dart @@ -0,0 +1,520 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; +import '../models/plex_playlist.dart'; +import '../models/plex_metadata.dart'; +import '../providers/settings_provider.dart'; +import '../providers/playback_state_provider.dart'; +import '../services/settings_service.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; +import '../utils/video_player_navigation.dart'; +import '../widgets/media_card.dart'; +import '../widgets/playlist_item_card.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../mixins/refreshable.dart'; +import '../mixins/item_updatable.dart'; +import '../i18n/strings.g.dart'; + +/// Screen to display the contents of a playlist +class PlaylistDetailScreen extends StatefulWidget { + final PlexPlaylist playlist; + + const PlaylistDetailScreen({super.key, required this.playlist}); + + @override + State createState() => _PlaylistDetailScreenState(); +} + +class _PlaylistDetailScreenState extends State + with Refreshable, ItemUpdatable { + @override + PlexClient get client => context.clientSafe; + + List _items = []; + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadPlaylistItems(); + } + + Future _loadPlaylistItems() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final items = await client.getPlaylist(widget.playlist.ratingKey); + + setState(() { + _items = items; + _isLoading = false; + }); + + appLogger.d( + 'Loaded ${items.length} items for playlist: ${widget.playlist.title}', + ); + } catch (e) { + appLogger.e('Failed to load playlist items', error: e); + setState(() { + _errorMessage = 'Failed to load playlist items: ${e.toString()}'; + _isLoading = false; + }); + } + } + + Future _deletePlaylist() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.playlists.deleteConfirm), + content: Text(t.playlists.deleteMessage(name: widget.playlist.title)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(t.playlists.delete), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final success = await client.deletePlaylist(widget.playlist.ratingKey); + + if (mounted) { + if (success) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.deleted))); + Navigator.pop(context); // Return to playlists screen + } else { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting))); + } + } + } + } + + Future _onReorder(int oldIndex, int newIndex) async { + // Adjust newIndex if moving down in the list + if (newIndex > oldIndex) { + newIndex--; + } + + // Can't reorder if indices are the same + if (oldIndex == newIndex) return; + + final movedItem = _items[oldIndex]; + + // Check if item has playlistItemID (required for reordering) + if (movedItem.playlistItemID == null) { + appLogger.e('Cannot reorder: item missing playlistItemID'); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering))); + } + return; + } + + // Determine the "after" item ID + // If moving to position 0, afterPlaylistItemId should be 0 (move to top) + // Otherwise, use the playlistItemID of the item before the new position + final int afterPlaylistItemId; + if (newIndex == 0) { + afterPlaylistItemId = 0; // Move to top + } else { + final afterItem = _items[newIndex - 1]; + if (afterItem.playlistItemID == null) { + appLogger.e('Cannot reorder: after item missing playlistItemID'); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering))); + } + return; + } + afterPlaylistItemId = afterItem.playlistItemID!; + } + + appLogger.d( + 'Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)', + ); + + // Optimistically update UI + setState(() { + final item = _items.removeAt(oldIndex); + _items.insert(newIndex, item); + }); + + // Call API to persist the change + final success = await client.movePlaylistItem( + playlistId: widget.playlist.ratingKey, + playlistItemId: movedItem.playlistItemID!, + afterPlaylistItemId: afterPlaylistItemId, + ); + + if (!success) { + // Revert on failure + appLogger.e('Failed to reorder playlist item, reverting UI'); + if (mounted) { + setState(() { + final item = _items.removeAt(newIndex); + _items.insert(oldIndex, item); + }); + + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering))); + } + } + } + + Future _removeItem(int index) async { + final item = _items[index]; + + // Check if item has playlistItemID (required for removal) + if (item.playlistItemID == null) { + appLogger.e('Cannot remove: item missing playlistItemID'); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving))); + } + return; + } + + appLogger.d( + 'Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist', + ); + + // Optimistically update UI + setState(() { + _items.removeAt(index); + }); + + // Call API to persist the change + final success = await client.removeFromPlaylist( + playlistId: widget.playlist.ratingKey, + playlistItemId: item.playlistItemID.toString(), + ); + + if (mounted) { + if (success) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.itemRemoved))); + } else { + // Revert on failure + appLogger.e('Failed to remove playlist item, reverting UI'); + setState(() { + _items.insert(index, item); + }); + + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving))); + } + } + } + + @override + void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + final index = _items.indexWhere((item) => item.ratingKey == ratingKey); + if (index != -1) { + _items[index] = updatedMetadata; + } + } + + @override + void refresh() { + _loadPlaylistItems(); + } + + Future _playPlaylist() async { + if (_items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist))); + } + return; + } + + final playbackState = context.read(); + + // Set the playlist items as the playback queue (in order, not shuffled) + playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey); + + // Navigate to the first item + if (mounted) { + await navigateToVideoPlayer(context, metadata: _items.first); + } + } + + Future _shufflePlayPlaylist() async { + if (_items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist))); + } + return; + } + + final playbackState = context.read(); + + // Shuffle the items + final shuffledItems = List.from(_items)..shuffle(); + + // Set the shuffled playlist items as the playback queue (playlist mode, not shuffle mode) + playbackState.setPlaybackQueue(shuffledItems, widget.playlist.ratingKey); + + // Navigate to the first shuffled item + if (mounted) { + await navigateToVideoPlayer(context, metadata: shuffledItems.first); + } + } + + Future _playFromItem(int index) async { + if (_items.isEmpty || index < 0 || index >= _items.length) return; + + final playbackState = context.read(); + + // Set the full playlist as playback queue (in order) + playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey); + + // Start playing from the clicked item + if (mounted) { + await navigateToVideoPlayer(context, metadata: _items[index]); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + CustomAppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.playlist.title, + style: const TextStyle(fontSize: 16), + ), + if (widget.playlist.smart) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.auto_awesome, + size: 12, + color: Colors.blue[300], + ), + const SizedBox(width: 4), + Text( + t.playlists.smartPlaylist, + style: TextStyle( + fontSize: 11, + color: Colors.blue[300], + fontWeight: FontWeight.normal, + ), + ), + ], + ), + ], + ), + pinned: true, + actions: [ + // Play button + if (_items.isNotEmpty) + IconButton( + icon: const Icon(Icons.play_arrow), + tooltip: t.discover.play, + onPressed: _playPlaylist, + ), + // Shuffle button + if (_items.isNotEmpty) + IconButton( + icon: const Icon(Icons.shuffle), + tooltip: t.playlists.shuffle, + onPressed: _shufflePlayPlaylist, + ), + // Delete button for non-smart playlists + if (!widget.playlist.smart) + IconButton( + icon: const Icon(Icons.delete), + tooltip: t.playlists.delete, + onPressed: _deletePlaylist, + color: Colors.red, + ), + ], + ), + if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPlaylistItems, + child: Text(t.common.retry), + ), + ], + ), + ), + ) + else if (_items.isEmpty && _isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_items.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.playlist_play, + size: 64, + color: Colors.grey, + ), + const SizedBox(height: 16), + Text( + t.playlists.emptyPlaylist, + style: const TextStyle(fontSize: 16, color: Colors.grey), + ), + ], + ), + ), + ) + else if (widget.playlist.smart) + // Smart playlists: Use grid view (cannot be reordered) + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + delegate: SliverChildBuilderDelegate((context, index) { + return MediaCard(item: _items[index], onRefresh: updateItem); + }, childCount: _items.length), + ), + ) + else + // Regular playlists: Use reorderable list view + SliverReorderableList( + itemBuilder: (context, index) { + final item = _items[index]; + return PlaylistItemCard( + key: ValueKey(item.playlistItemID ?? item.ratingKey), + item: item, + index: index, + onRemove: () => _removeItem(index), + onTap: () => _playFromItem(index), + canReorder: !widget.playlist.smart, + ); + }, + itemCount: _items.length, + onReorder: _onReorder, + ), + ], + ), + ); + } + + double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { + final screenWidth = MediaQuery.of(context).size.width; + final padding = 16.0; + final availableWidth = screenWidth - padding; + + if (screenWidth >= 900) { + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(120, maxItemWidth); + } else if (screenWidth >= 600) { + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 4.5; + maxItemWidth = 220; + break; + case LibraryDensity.normal: + divisor = 5.5; + maxItemWidth = 180; + break; + case LibraryDensity.compact: + divisor = 7.0; + maxItemWidth = 140; + break; + } + + return (availableWidth / divisor).clamp(100, maxItemWidth); + } else { + double divisor; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 2.2; + break; + case LibraryDensity.normal: + divisor = 2.8; + break; + case LibraryDensity.compact: + divisor = 3.5; + break; + } + + return availableWidth / divisor; + } + } +} diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart new file mode 100644 index 00000000..16166ef8 --- /dev/null +++ b/lib/screens/playlists_screen.dart @@ -0,0 +1,398 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; +import '../models/plex_playlist.dart'; +import '../providers/settings_provider.dart'; +import '../services/settings_service.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../mixins/refreshable.dart'; +import '../i18n/strings.g.dart'; +import 'playlist_detail_screen.dart'; + +/// Screen to display all video playlists +class PlaylistsScreen extends StatefulWidget { + const PlaylistsScreen({super.key}); + + @override + State createState() => _PlaylistsScreenState(); +} + +class _PlaylistsScreenState extends State with Refreshable { + PlexClient get client => context.clientSafe; + + List _playlists = []; + bool _isLoading = false; + String? _errorMessage; + bool? _filterSmart; + + @override + void initState() { + super.initState(); + _loadPlaylists(); + } + + Future _loadPlaylists() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final playlists = await client.getPlaylists( + playlistType: 'video', + smart: _filterSmart, + ); + + setState(() { + _playlists = playlists; + _isLoading = false; + }); + + appLogger.d('Loaded ${playlists.length} playlists'); + } catch (e) { + appLogger.e('Failed to load playlists', error: e); + setState(() { + _errorMessage = 'Failed to load playlists: ${e.toString()}'; + _isLoading = false; + }); + } + } + + void _toggleSmartFilter() { + setState(() { + if (_filterSmart == null) { + _filterSmart = true; // Show only smart + } else if (_filterSmart == true) { + _filterSmart = false; // Show only regular + } else { + _filterSmart = null; // Show all + } + }); + _loadPlaylists(); + } + + String _getFilterLabel() { + if (_filterSmart == null) return 'All'; + if (_filterSmart == true) return 'Smart'; + return 'Regular'; + } + + @override + void refresh() { + _loadPlaylists(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + CustomAppBar( + title: Text(t.playlists.title), + pinned: true, + actions: [ + TextButton.icon( + icon: const Icon(Icons.filter_list), + label: Text(_getFilterLabel()), + onPressed: _toggleSmartFilter, + ), + ], + ), + if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPlaylists, + child: Text(t.common.retry), + ), + ], + ), + ), + ) + else if (_playlists.isEmpty && _isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_playlists.isEmpty) + SliverFillRemaining( + child: Center(child: Text(t.playlists.noPlaylists)), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + delegate: SliverChildBuilderDelegate((context, index) { + return _PlaylistCard( + playlist: _playlists[index], + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + PlaylistDetailScreen(playlist: _playlists[index]), + ), + ).then((_) => _loadPlaylists()); // Refresh on return + }, + onDeleted: _loadPlaylists, + ); + }, childCount: _playlists.length), + ), + ), + ], + ), + ); + } + + double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { + final screenWidth = MediaQuery.of(context).size.width; + final padding = 16.0; + final availableWidth = screenWidth - padding; + + if (screenWidth >= 900) { + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(120, maxItemWidth); + } else if (screenWidth >= 600) { + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 4.5; + maxItemWidth = 220; + break; + case LibraryDensity.normal: + divisor = 5.5; + maxItemWidth = 180; + break; + case LibraryDensity.compact: + divisor = 7.0; + maxItemWidth = 140; + break; + } + + return (availableWidth / divisor).clamp(100, maxItemWidth); + } else { + double divisor; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 2.2; + break; + case LibraryDensity.normal: + divisor = 2.8; + break; + case LibraryDensity.compact: + divisor = 3.5; + break; + } + + return availableWidth / divisor; + } + } +} + +/// Widget to display a single playlist card +class _PlaylistCard extends StatelessWidget { + final PlexPlaylist playlist; + final VoidCallback onTap; + final VoidCallback onDeleted; + + const _PlaylistCard({ + required this.playlist, + required this.onTap, + required this.onDeleted, + }); + + Future _showDeleteDialog(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.playlists.deleteConfirm), + content: Text(t.playlists.deleteMessage(name: playlist.title)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(t.playlists.delete), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + final client = context.clientSafe; + final success = await client.deletePlaylist(playlist.ratingKey); + + if (context.mounted) { + if (success) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.deleted))); + onDeleted(); + } else { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting))); + } + } + } + } + + @override + Widget build(BuildContext context) { + final client = context.clientSafe; + final imageUrl = playlist.displayImage != null + ? client.getThumbnailUrl(playlist.displayImage!) + : null; + + return Card( + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.all(4), + child: InkWell( + onTap: onTap, + onLongPress: () => _showDeleteDialog(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Playlist image + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + if (imageUrl != null) + Image.network( + imageUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildPlaceholder(); + }, + ) + else + _buildPlaceholder(), + // Smart playlist indicator + if (playlist.smart) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.9), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon( + Icons.auto_awesome, + size: 12, + color: Colors.white, + ), + ), + ), + ], + ), + ), + // Playlist info + Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + playlist.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.playlist_play, + size: 14, + color: Colors.grey[600], + ), + const SizedBox(width: 4), + Text( + playlist.leafCount != null && playlist.leafCount! > 0 + ? (playlist.leafCount == 1 + ? t.playlists.oneItem + : t.playlists.itemCount( + count: playlist.leafCount!, + )) + : t.playlists.emptyPlaylist, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildPlaceholder() { + return Container( + color: Colors.grey[850], + child: const Center( + child: Icon(Icons.playlist_play, size: 48, color: Colors.grey), + ), + ); + } +} diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 24995170..137816d0 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -350,7 +350,7 @@ class _SeasonDetailScreenState extends State ), ), Text( - 'Watched ✓', + '${t.discover.watched} ✓', style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: tokens(context).textMuted, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 20b08546..88cc672b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -33,6 +33,7 @@ class _SettingsScreenState extends State { int _seekTimeSmall = 10; int _seekTimeLarge = 30; int _sleepTimerDuration = 30; + bool _rememberTrackSelections = true; // Update checking state bool _isCheckingForUpdate = false; @@ -55,6 +56,7 @@ class _SettingsScreenState extends State { _seekTimeSmall = _settingsService.getSeekTimeSmall(); _seekTimeLarge = _settingsService.getSeekTimeLarge(); _sleepTimerDuration = _settingsService.getSleepTimerDuration(); + _rememberTrackSelections = _settingsService.getRememberTrackSelections(); _isLoading = false; }); } @@ -264,6 +266,18 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _showSleepTimerDurationDialog(), ), + SwitchListTile( + secondary: const Icon(Icons.bookmark), + title: Text(t.settings.rememberTrackSelections), + subtitle: Text(t.settings.rememberTrackSelectionsDescription), + value: _rememberTrackSelections, + onChanged: (value) async { + setState(() { + _rememberTrackSelections = value; + }); + await _settingsService.setRememberTrackSelections(value); + }, + ), ], ), ); @@ -871,6 +885,10 @@ class _SettingsScreenState extends State { return 'Italiano'; case AppLocale.nl: return 'Nederlands'; + case AppLocale.de: + return 'Deutsch'; + case AppLocale.zh: + return '中文'; } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 7d5a5826..53fe04ad 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -7,6 +7,7 @@ import 'package:media_kit_video/media_kit_video.dart'; import 'package:os_media_controls/os_media_controls.dart'; import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; @@ -43,7 +44,8 @@ class VideoPlayerScreen extends StatefulWidget { State createState() => VideoPlayerScreenState(); } -class VideoPlayerScreenState extends State { +class VideoPlayerScreenState extends State + with WidgetsBindingObserver { Player? player; VideoController? controller; bool _isPlayerInitialized = false; @@ -68,6 +70,11 @@ class VideoPlayerScreenState extends State { int _boxFitMode = 0; bool _isPinching = false; // Track if a pinch gesture is occurring + // Video cropping state for fill screen mode + Size? _playerSize; + Size? _videoSize; + Timer? _resizeDebounceTimer; + @override void initState() { super.initState(); @@ -85,6 +92,9 @@ class VideoPlayerScreenState extends State { appLogger.d('Preferred subtitle track: $subtitleDesc'); } + // Register app lifecycle observer + WidgetsBinding.instance.addObserver(this); + // Initialize player asynchronously with buffer size from settings _initializePlayer(); } @@ -108,6 +118,40 @@ class VideoPlayerScreenState extends State { appLogger.w('Failed to determine device type', error: e); _isPhone = false; // Default to tablet/desktop (all orientations) } + + // Update video filter when dependencies change (orientation, screen size, etc.) + WidgetsBinding.instance.addPostFrameCallback((_) { + _debouncedUpdateVideoFilter(); + }); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + switch (state) { + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + // Clear media controls when app goes to background or screen locks + // (we don't support background playback) + OsMediaControls.clear(); + appLogger.d( + 'Media controls cleared due to app lifecycle state: $state', + ); + break; + case AppLifecycleState.resumed: + // Restore media controls when app is resumed + if (_isPlayerInitialized && mounted) { + _updateMediaMetadata(); + _updateMediaControlsPlaybackState(); + appLogger.d('Media controls restored on app resume'); + } + break; + case AppLifecycleState.detached: + case AppLifecycleState.hidden: + // No action needed for these states + break; + } } Future _initializePlayer() async { @@ -137,6 +181,7 @@ class VideoPlayerScreenState extends State { 'sub-border-color': settingsService.getSubtitleBorderColor(), 'sub-back-color': '#${(settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt().toRadixString(16).padLeft(2, '0').toUpperCase()}${settingsService.getSubtitleBackgroundColor().replaceFirst('#', '')}', + 'sub-ass-override': 'no', }, ), ); @@ -241,10 +286,6 @@ class VideoPlayerScreenState extends State { } Future _loadAdjacentEpisodes() async { - if (widget.metadata.type.toLowerCase() != 'episode') { - return; - } - try { final clientProvider = context.plexClient; final client = clientProvider.client; @@ -256,8 +297,23 @@ class VideoPlayerScreenState extends State { PlexMetadata? next; PlexMetadata? previous; + // Check if playlist mode is active (takes priority) + if (playbackState.isPlaylistActive) { + // For playlists, always use the queue regardless of item type + // Playlists can contain both movies and episodes + next = playbackState.getNextEpisode( + widget.metadata.ratingKey, + loopQueue: false, // Don't loop playlists by default + ); + previous = playbackState.getPreviousEpisode(widget.metadata.ratingKey); + } // Check if shuffle mode is active - if (playbackState.isShuffleActive) { + else if (playbackState.isShuffleActive) { + // Only works for episodes in shuffle mode + if (widget.metadata.type.toLowerCase() != 'episode') { + return; + } + // Get settings final shuffleOrderNavigation = settingsProvider.shuffleOrderNavigation; final loopQueue = settingsProvider.shuffleLoopQueue; @@ -276,7 +332,14 @@ class VideoPlayerScreenState extends State { next = await client.findAdjacentEpisode(widget.metadata, 1); previous = await client.findAdjacentEpisode(widget.metadata, -1); } - } else { + } + // Normal sequential playback + else { + // Only works for episodes in sequential mode + if (widget.metadata.type.toLowerCase() != 'episode') { + return; + } + // Use normal sequential episode loading next = await client.findAdjacentEpisode(widget.metadata, 1); previous = await client.findAdjacentEpisode(widget.metadata, -1); @@ -316,6 +379,8 @@ class VideoPlayerScreenState extends State { setState(() { _availableVersions = playbackData.availableVersions; }); + // Update video filter once dimensions are available + _updateVideoFilter(); } // Build list of external subtitle tracks for media_kit @@ -440,6 +505,7 @@ class VideoPlayerScreenState extends State { setState(() { _boxFitMode = (_boxFitMode + 1) % 3; }); + _updateVideoFilter(); } /// Toggle between contain and cover modes only (for pinch gesture) @@ -447,6 +513,7 @@ class VideoPlayerScreenState extends State { setState(() { _boxFitMode = _boxFitMode == 0 ? 1 : 0; }); + _updateVideoFilter(); } /// Get current BoxFit based on mode @@ -463,11 +530,186 @@ class VideoPlayerScreenState extends State { } } + /// Calculate crop parameters to match what BoxFit.cover will show + Map? _calculateCropParameters() { + if (_boxFitMode != 1 || _playerSize == null || _videoSize == null) { + return null; + } + + final playerAspectRatio = _playerSize!.width / _playerSize!.height; + final videoAspectRatio = _videoSize!.width / _videoSize!.height; + + // No cropping needed if aspect ratios are very similar + if ((playerAspectRatio - videoAspectRatio).abs() < 0.01) { + return null; + } + + int cropWidth, cropHeight, cropX, cropY; + + // BoxFit.cover scales the video to fill the container, cropping the excess + // We need to crop the video to match what will actually be visible + if (videoAspectRatio > playerAspectRatio) { + // Video is wider than player - BoxFit.cover will crop horizontally + // Scale video height to match player height, then crop the width + final scale = _playerSize!.height / _videoSize!.height; + + cropHeight = _videoSize!.height.toInt(); + cropWidth = (_playerSize!.width / scale).toInt(); + cropX = ((_videoSize!.width - cropWidth) / 2).toInt(); + cropY = 0; + } else { + // Video is taller than player - BoxFit.cover will crop vertically + // Scale video width to match player width, then crop the height + final scale = _playerSize!.width / _videoSize!.width; + + cropWidth = _videoSize!.width.toInt(); + cropHeight = (_playerSize!.height / scale).toInt(); + cropX = 0; + cropY = ((_videoSize!.height - cropHeight) / 2).toInt(); + } + + // Calculate subtitle margins to prevent text subtitles from appearing in cropped areas + // MPV subtitle coordinates use a normalized system where video height = 720 + const double subCoordinateHeight = 720.0; + const double baseMarginY = + 40.0; // Base margin from bottom edge (subtitle coordinates) + const double baseMarginX = + 20.0; // Base margin from sides (subtitle coordinates) + + double subMarginX = baseMarginX; + double subMarginY = baseMarginY; + double subScale = 1.0; // Default scale + + if (videoAspectRatio > playerAspectRatio) { + // Horizontal crop - need additional horizontal margins and scaling + // Convert pixel margin to subtitle coordinate system + final subCoordinateWidth = subCoordinateHeight * videoAspectRatio; + final cropMarginX = (cropX / _videoSize!.width) * subCoordinateWidth; + + // Calculate scale factor first + subScale = cropWidth / _videoSize!.width; + + // Apply margin accounting for scaling (scaled margins are effectively larger) + subMarginX = (baseMarginX + cropMarginX) / subScale; + } else { + // Vertical crop - need additional vertical margins and scaling + // Convert pixel margin to subtitle coordinate system + final cropMarginY = (cropY / _videoSize!.height) * subCoordinateHeight; + + // Calculate scale factor first + subScale = cropHeight / _videoSize!.height; + + // Apply margin accounting for scaling (scaled margins are effectively larger) + subMarginY = (baseMarginY + cropMarginY) / subScale; + } + + return { + 'width': cropWidth, + 'height': cropHeight, + 'x': cropX, + 'y': cropY, + 'subMarginX': subMarginX.round(), + 'subMarginY': subMarginY.round(), + 'subScale': subScale, + }; + } + + /// Get video dimensions from the currently selected media version + Size? _getCurrentVideoSize() { + if (_availableVersions.isEmpty || + widget.selectedMediaIndex >= _availableVersions.length) { + return null; + } + + final currentVersion = _availableVersions[widget.selectedMediaIndex]; + if (currentVersion.width != null && currentVersion.height != null) { + return Size( + currentVersion.width!.toDouble(), + currentVersion.height!.toDouble(), + ); + } + + return null; + } + + /// Update the video filter based on current crop mode + void _updateVideoFilter() async { + if (player == null) return; + + try { + final nativePlayer = player!.platform as dynamic; + + if (_boxFitMode == 1) { + // Fill screen mode - apply crop filter + _videoSize = _getCurrentVideoSize(); + final cropParams = _calculateCropParameters(); + + if (cropParams != null) { + final cropFilter = + 'crop=${cropParams['width']}:${cropParams['height']}:${cropParams['x']}:${cropParams['y']}'; + appLogger.d( + 'Applying video filter: $cropFilter (player: $_playerSize, video: $_videoSize)', + ); + + // Apply crop filter + await nativePlayer.setProperty('vf', cropFilter); + + // Apply subtitle margins and scaling to compensate for crop zoom + final subMarginX = cropParams['subMarginX']!; + final subMarginY = cropParams['subMarginY']!; + final subScale = cropParams['subScale']!; + + appLogger.d( + 'Applying subtitle properties - margins: x=$subMarginX, y=$subMarginY, scale=$subScale', + ); + + await nativePlayer.setProperty('sub-margin-x', subMarginX.toString()); + await nativePlayer.setProperty('sub-margin-y', subMarginY.toString()); + await nativePlayer.setProperty('sub-scale', subScale.toString()); + } else { + // Clear filter but apply base margins if no cropping needed + appLogger.d( + 'Clearing video filter - aspect ratios similar, applying base margins (player: $_playerSize, video: $_videoSize)', + ); + await nativePlayer.setProperty('vf', ''); + await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin + await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin + await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale + } + } else { + // Other modes - clear video filter but apply base margins + appLogger.d( + 'Clearing video filter, applying base margins - BoxFit mode $_boxFitMode', + ); + await nativePlayer.setProperty('vf', ''); + await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin + await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin + await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale + } + } catch (e) { + appLogger.w('Failed to update video filter', error: e); + } + } + + /// Debounced version of _updateVideoFilter for resize events + void _debouncedUpdateVideoFilter() { + _resizeDebounceTimer?.cancel(); + _resizeDebounceTimer = Timer(const Duration(milliseconds: 50), () { + _updateVideoFilter(); + }); + } + @override void dispose() { + // Unregister app lifecycle observer + WidgetsBinding.instance.removeObserver(this); + // Stop progress tracking _progressTimer?.cancel(); + // Cancel debounce timer + _resizeDebounceTimer?.cancel(); + // Cancel stream subscriptions _playingSubscription?.cancel(); _completedSubscription?.cancel(); @@ -482,6 +724,19 @@ class VideoPlayerScreenState extends State { // Send final stopped state _sendProgress('stopped'); + // Clear video filter and reset subtitle margins before disposing player + try { + if (player != null) { + final nativePlayer = player!.platform as dynamic; + nativePlayer.setProperty('vf', ''); + nativePlayer.setProperty('sub-margin-x', '0'); + nativePlayer.setProperty('sub-margin-y', '0'); + nativePlayer.setProperty('sub-scale', '1.0'); + } + } catch (e) { + // Ignore errors during cleanup + } + // Restore system UI and orientation preferences (skip if navigating to another video) if (!_isReplacingWithVideo) { OrientationHelper.restoreSystemUI(); @@ -873,6 +1128,34 @@ class VideoPlayerScreenState extends State { return title.contains('forced'); } + /// Checks if a track language matches a preferred language + /// + /// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes + /// Also handles bibliographic variants and region codes (e.g., "en-US") + bool _languageMatches(String? trackLanguage, String? preferredLanguage) { + if (trackLanguage == null || preferredLanguage == null) { + return false; + } + + final track = trackLanguage.toLowerCase(); + final preferred = preferredLanguage.toLowerCase(); + + // Direct match + if (track == preferred) return true; + + // Extract base language codes (handle region codes like "en-US") + final trackBase = track.split('-').first; + final preferredBase = preferred.split('-').first; + + if (trackBase == preferredBase) return true; + + // Get all variations of the preferred language (e.g., "en" → ["en", "eng"]) + final variations = LanguageCodes.getVariations(preferredBase); + + // Check if track's base code matches any variation + return variations.contains(trackBase); + } + void _waitForTracksAndApply() async { // Helper function to process tracks Future processTracks(Tracks tracks) async { @@ -902,7 +1185,7 @@ class VideoPlayerScreenState extends State { ); } - // Select audio track with priority: preferred > user profile > default > first + // Select audio track with priority: preferred > per-media > user profile > default > first appLogger.d('Audio track selection'); if (realAudioTracks.isNotEmpty) { AudioTrack? trackToSelect; @@ -926,20 +1209,46 @@ class VideoPlayerScreenState extends State { appLogger.d('Priority 1: No preferred track from navigation'); } - // Priority 2: If no preferred track matched, try user profile preferences + // Priority 2: If no preferred track matched, try per-media language preference + if (trackToSelect == null && widget.metadata.audioLanguage != null) { + appLogger.d( + 'Priority 2: Checking per-media audio language preference', + ); + appLogger.d( + ' Per-media audio language: ${widget.metadata.audioLanguage}', + ); + trackToSelect = realAudioTracks.firstWhere( + (track) => + _languageMatches(track.language, widget.metadata.audioLanguage), + orElse: () => realAudioTracks.first, + ); + if (_languageMatches( + trackToSelect.language, + widget.metadata.audioLanguage, + )) { + appLogger.d(' Matched per-media audio language preference'); + } else { + appLogger.d(' No match found for per-media audio language'); + trackToSelect = null; + } + } else if (trackToSelect == null) { + appLogger.d('Priority 2: No per-media audio language preference'); + } + + // Priority 3: If no preferred track matched, try user profile preferences if (trackToSelect == null && profileSettings != null) { - appLogger.d('Priority 2: Checking user profile preferences'); + appLogger.d('Priority 3: Checking user profile preferences'); trackToSelect = _findAudioTrackByProfile( realAudioTracks, profileSettings, ); } else if (trackToSelect == null) { - appLogger.d('Priority 2: No user profile available'); + appLogger.d('Priority 3: No user profile available'); } - // Priority 3: If no match, use default or first track + // Priority 4: If no match, use default or first track if (trackToSelect == null) { - appLogger.d('Priority 3: Using default or first available track'); + appLogger.d('Priority 4: Using default or first available track'); trackToSelect = realAudioTracks.firstWhere( (t) => t.isDefault == true, orElse: () => realAudioTracks.first, @@ -958,7 +1267,7 @@ class VideoPlayerScreenState extends State { appLogger.d('No audio tracks available'); } - // Select subtitle track with priority: preferred > user profile > default > off + // Select subtitle track with priority: preferred > per-media > user profile > default > off appLogger.d('Subtitle track selection'); SubtitleTrack? subtitleToSelect; @@ -987,11 +1296,47 @@ class VideoPlayerScreenState extends State { appLogger.d('Priority 1: No preferred track from navigation'); } - // Priority 2: If no preferred match, apply user profile preferences + // Priority 2: If no preferred match, try per-media language preference + if (subtitleToSelect == null && + widget.metadata.subtitleLanguage != null) { + appLogger.d( + 'Priority 2: Checking per-media subtitle language preference', + ); + appLogger.d( + ' Per-media subtitle language: ${widget.metadata.subtitleLanguage}', + ); + // Check if subtitle should be disabled + if (widget.metadata.subtitleLanguage == 'none' || + widget.metadata.subtitleLanguage!.isEmpty) { + appLogger.d(' Per-media preference: Subtitles OFF'); + subtitleToSelect = SubtitleTrack.no(); + } else if (realSubtitleTracks.isNotEmpty) { + final matchedTrack = realSubtitleTracks.firstWhere( + (track) => _languageMatches( + track.language, + widget.metadata.subtitleLanguage, + ), + orElse: () => realSubtitleTracks.first, + ); + if (_languageMatches( + matchedTrack.language, + widget.metadata.subtitleLanguage, + )) { + subtitleToSelect = matchedTrack; + appLogger.d(' Matched per-media subtitle language preference'); + } else { + appLogger.d(' No match found for per-media subtitle language'); + } + } + } else if (subtitleToSelect == null) { + appLogger.d('Priority 2: No per-media subtitle language preference'); + } + + // Priority 3: If no preferred match, apply user profile preferences if (subtitleToSelect == null && profileSettings != null && realSubtitleTracks.isNotEmpty) { - appLogger.d('Priority 2: Checking user profile preferences'); + appLogger.d('Priority 3: Checking user profile preferences'); // Get the currently selected audio track final currentAudioTrack = realAudioTracks.firstWhere( (t) => t.id == player!.state.track.audio.id, @@ -1003,12 +1348,12 @@ class VideoPlayerScreenState extends State { selectedAudioTrack: currentAudioTrack, ); } else if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) { - appLogger.d('Priority 2: No user profile available'); + appLogger.d('Priority 3: No user profile available'); } - // Priority 3: If no profile match, check for default subtitle + // Priority 4: If no profile match, check for default subtitle if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) { - appLogger.d('Priority 3: Checking for default subtitle track'); + appLogger.d('Priority 4: Checking for default subtitle track'); final defaultTrackIndex = realSubtitleTracks.indexWhere( (t) => t.isDefault == true, ); @@ -1024,7 +1369,7 @@ class VideoPlayerScreenState extends State { // If still no subtitle selected, turn off if (subtitleToSelect == null) { - appLogger.d('Priority 4: No subtitle selected - Subtitles OFF'); + appLogger.d('Priority 5: No subtitle selected - Subtitles OFF'); subtitleToSelect = SubtitleTrack.no(); } @@ -1158,16 +1503,20 @@ class VideoPlayerScreenState extends State { } }); - // Enable/disable next/previous track controls based on content type + // Enable/disable next/previous track controls based on content type and playback mode + final playbackState = context.read(); final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; - if (isEpisode) { - // Enable next/previous track controls for episodes + final isInPlaylist = playbackState.isPlaylistActive; + + // Enable controls for episodes OR playlist items + if (isEpisode || isInPlaylist) { + // Enable next/previous track controls for episodes and playlist items await OsMediaControls.enableControls([ MediaControl.next, MediaControl.previous, ]); } else { - // Disable next/previous track controls for movies + // Disable next/previous track controls for standalone movies await OsMediaControls.disableControls([ MediaControl.next, MediaControl.previous, @@ -1285,6 +1634,94 @@ class VideoPlayerScreenState extends State { await _navigateToEpisode(_previousEpisode!); } + /// Handle audio track changes from the user - save as per-media preference if enabled + Future _onAudioTrackChanged(AudioTrack track) async { + final settings = await SettingsService.getInstance(); + + // Only save if remember track selections is enabled + if (!settings.getRememberTrackSelections()) { + return; + } + + // Extract language code from the track + final languageCode = track.language; + if (languageCode == null || languageCode.isEmpty) { + appLogger.d('Audio track has no language code, not saving preference'); + return; + } + + // Determine which ratingKey to use + // For TV shows: use grandparentRatingKey (series level) + // For movies: use ratingKey (movie level) + final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; + final targetRatingKey = isEpisode + ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) + : widget.metadata.ratingKey; + + appLogger.i( + 'Saving audio language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)', + ); + + try { + if (!mounted) return; + final client = context.read(); + await client.setMetadataPreferences( + targetRatingKey, + audioLanguage: languageCode, + ); + appLogger.d('Successfully saved audio language preference'); + } catch (e) { + appLogger.e('Failed to save audio language preference', error: e); + } + } + + /// Handle subtitle track changes from the user - save as per-media preference if enabled + Future _onSubtitleTrackChanged(SubtitleTrack track) async { + final settings = await SettingsService.getInstance(); + + // Only save if remember track selections is enabled + if (!settings.getRememberTrackSelections()) { + return; + } + + // Handle "Off" selection + String? languageCode; + if (track.id == 'no') { + languageCode = 'none'; + appLogger.i('User turned subtitles off, saving preference'); + } else { + languageCode = track.language; + if (languageCode == null || languageCode.isEmpty) { + appLogger.d( + 'Subtitle track has no language code, not saving preference', + ); + return; + } + } + + // Determine which ratingKey to use + final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; + final targetRatingKey = isEpisode + ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) + : widget.metadata.ratingKey; + + appLogger.i( + 'Saving subtitle language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)', + ); + + try { + if (!mounted) return; + final client = context.read(); + await client.setMetadataPreferences( + targetRatingKey, + subtitleLanguage: languageCode, + ); + appLogger.d('Successfully saved subtitle language preference'); + } catch (e) { + appLogger.e('Failed to save subtitle language preference', error: e); + } + } + /// Set flag to skip orientation restoration when replacing with another video void setReplacingWithVideo() { _isReplacingWithVideo = true; @@ -1378,19 +1815,48 @@ class VideoPlayerScreenState extends State { children: [ // Video player Center( - child: Video( - controller: controller!, - fit: _getCurrentBoxFit, - controls: (state) => plexVideoControlsBuilder( - player!, - widget.metadata, - onNext: _nextEpisode != null ? _playNext : null, - onPrevious: _previousEpisode != null ? _playPrevious : null, - availableVersions: _availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - boxFitMode: _boxFitMode, - onCycleBoxFitMode: _cycleBoxFitMode, - ), + child: LayoutBuilder( + builder: (context, constraints) { + // Update player size when layout changes + final newSize = Size( + constraints.maxWidth, + constraints.maxHeight, + ); + + // Check if size actually changed to avoid unnecessary updates + if (_playerSize == null || + (_playerSize!.width - newSize.width).abs() > 0.1 || + (_playerSize!.height - newSize.height).abs() > 0.1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _playerSize = newSize; + }); + // Use debounced update for resize events + _debouncedUpdateVideoFilter(); + } + }); + } + + return Video( + controller: controller!, + fit: _getCurrentBoxFit, + controls: (state) => plexVideoControlsBuilder( + player!, + widget.metadata, + onNext: _nextEpisode != null ? _playNext : null, + onPrevious: _previousEpisode != null + ? _playPrevious + : null, + availableVersions: _availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, + boxFitMode: _boxFitMode, + onCycleBoxFitMode: _cycleBoxFitMode, + onAudioTrackChanged: _onAudioTrackChanged, + onSubtitleTrackChanged: _onSubtitleTrackChanged, + ), + ); + }, ), ), // Play Next Dialog diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 7c6ec57c..f649a9ee 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -6,6 +6,7 @@ import '../client/plex_client.dart'; import '../models/plex_user_profile.dart'; import '../models/plex_home.dart'; import '../models/user_switch_response.dart'; +import '../utils/app_logger.dart'; class PlexAuthService { static const String _appName = 'Plezy'; @@ -240,8 +241,14 @@ class _ConnectionCandidate { final PlexConnection connection; final String url; final bool isPlexDirectUri; + final bool isHttps; - _ConnectionCandidate(this.connection, this.url, this.isPlexDirectUri); + _ConnectionCandidate( + this.connection, + this.url, + this.isPlexDirectUri, + this.isHttps, + ); } /// Represents a Plex Media Server @@ -391,74 +398,111 @@ class PlexServer { /// Priority: local > remote > relay, then HTTPS > HTTP, then lowest latency /// Tests both plex.direct URI and direct IP for each connection /// HTTPS connections are tested first, with HTTP as fallback - Stream findBestWorkingConnection() async* { - if (connections.isEmpty) return; + Stream findBestWorkingConnection({ + String? preferredUri, + }) async* { + if (connections.isEmpty) { + appLogger.w('No connections available for server discovery'); + return; + } - // Create candidates: test both uri and directUrl for each connection - // Separate HTTPS and HTTP candidates to prioritize HTTPS first - final httpsCandidates = <_ConnectionCandidate>[]; - final httpCandidates = <_ConnectionCandidate>[]; + const preferredTimeout = Duration(seconds: 2); + const raceTimeout = Duration(seconds: 4); - for (final connection in connections) { - final uriCandidate = _ConnectionCandidate( - connection, - connection.uri, - true, - ); - final directCandidate = _ConnectionCandidate( - connection, - connection.directUrl, - false, - ); + final candidates = _buildPrioritizedCandidates(); + if (candidates.isEmpty) { + appLogger.w('No connection candidates generated for server discovery'); + return; + } - if (connection.protocol == 'https') { - httpsCandidates.add(uriCandidate); - httpsCandidates.add(directCandidate); - } else { - httpCandidates.add(uriCandidate); - httpCandidates.add(directCandidate); + final totalCandidates = candidates.length; + appLogger.d( + 'Starting server connection discovery', + error: {'preferred': preferredUri, 'candidateCount': totalCandidates}, + ); + + _ConnectionCandidate? firstCandidate; + + // Fast-path: if we have a cached working URI, probe it with a short timeout + if (preferredUri != null) { + final cachedCandidate = _candidateForUrl(preferredUri); + if (cachedCandidate != null) { + appLogger.d( + 'Testing cached endpoint before running full race', + error: {'uri': preferredUri}, + ); + final result = await PlexClient.testConnectionWithLatency( + cachedCandidate.url, + accessToken, + timeout: preferredTimeout, + ); + + if (result.success) { + appLogger.i( + 'Cached endpoint succeeded, using immediately', + error: {'uri': preferredUri}, + ); + firstCandidate = cachedCandidate; + } else { + appLogger.w( + 'Cached endpoint failed, falling back to candidate race', + error: {'uri': preferredUri}, + ); + } } } - // Combine candidates with HTTPS first, then HTTP - final candidates = [...httpsCandidates, ...httpCandidates]; - - // Phase 1: Race to find first working connection - final completer = Completer<_ConnectionCandidate?>(); - _ConnectionCandidate? firstCandidate; - int completedTests = 0; - - // Start testing all candidates simultaneously - for (final candidate in candidates) { - PlexClient.testConnectionWithLatency(candidate.url, accessToken).then(( - result, - ) { - completedTests++; - - // If this is the first successful connection, emit it immediately - if (result.success && !completer.isCompleted) { - completer.complete(candidate); - } - - // If all tests complete without success, complete with null - if (completedTests == candidates.length && !completer.isCompleted) { - completer.complete(null); - } - }); - } - - // Wait for and emit the first successful connection - firstCandidate = await completer.future; + // If no cached candidate or it failed, race candidates to find first success if (firstCandidate == null) { - return; // No working connections found + final completer = Completer<_ConnectionCandidate?>(); + int completedTests = 0; + + appLogger.d( + 'Running connection race to find first working endpoint', + error: {'candidateCount': totalCandidates}, + ); + + for (final candidate in candidates) { + PlexClient.testConnectionWithLatency( + candidate.url, + accessToken, + timeout: raceTimeout, + ).then((result) { + completedTests++; + + if (result.success && !completer.isCompleted) { + completer.complete(candidate); + } + + if (completedTests == candidates.length && !completer.isCompleted) { + completer.complete(null); + } + }); + } + + firstCandidate = await completer.future; + if (firstCandidate == null) { + appLogger.e('No working server connections after race'); + return; // No working connections found + } + appLogger.i( + 'Connection race found first working endpoint', + error: { + 'uri': firstCandidate.url, + 'type': firstCandidate.connection.displayType, + }, + ); } - // Update the connection object to use the working URL final firstConnection = _updateConnectionUrl( firstCandidate.connection, firstCandidate.url, ); yield firstConnection; + appLogger.d( + 'Emitted first working connection, continuing latency tests in background', + error: {'uri': firstConnection.uri}, + ); // Phase 2: Continue testing in background to find best connection // Test each candidate 2-3 times and average the latency @@ -480,20 +524,39 @@ class PlexServer { // If no connections succeeded, we're done if (candidateResults.isEmpty) { + appLogger.w('Latency sweep found no additional working endpoints'); return; } + appLogger.d( + 'Completed latency sweep for server connections', + error: {'successfulCandidates': candidateResults.length}, + ); + // Find the best connection considering priority, latency, and URL type final bestCandidate = _selectBestCandidateWithLatency(candidateResults); // Emit the best connection if it's different from the first one if (bestCandidate != null) { + final upgradedCandidate = + await _upgradeCandidateToHttpsIfPossible(bestCandidate) ?? + bestCandidate; + final bestConnection = _updateConnectionUrl( - bestCandidate.connection, - bestCandidate.url, + upgradedCandidate.connection, + upgradedCandidate.url, ); if (bestConnection.uri != firstConnection.uri) { + appLogger.i( + 'Latency sweep selected better endpoint', + error: {'uri': bestConnection.uri}, + ); yield bestConnection; + } else { + appLogger.d( + 'Latency sweep confirmed initial endpoint is optimal', + error: {'uri': bestConnection.uri}, + ); } } } @@ -517,6 +580,230 @@ class PlexServer { ); } + _ConnectionCandidate? _candidateForUrl(String url) { + for (final connection in connections) { + final httpUrl = connection.httpDirectUrl; + if (httpUrl == url) { + return _ConnectionCandidate(connection, httpUrl, false, false); + } + + final uri = connection.uri; + if (uri == url) { + final isHttps = uri.startsWith('https://'); + final parsedHost = Uri.tryParse(uri)?.host ?? ''; + final isPlexDirect = parsedHost.toLowerCase().contains('plex.direct'); + return _ConnectionCandidate(connection, uri, isPlexDirect, isHttps); + } + } + return null; + } + + List<_ConnectionCandidate> _buildPrioritizedCandidates({ + Set? excludeUrls, + }) { + final seen = {}; + if (excludeUrls != null) { + seen.addAll(excludeUrls); + } + + final httpsLocal = <_ConnectionCandidate>[]; + final httpsRemote = <_ConnectionCandidate>[]; + final httpsRelay = <_ConnectionCandidate>[]; + final httpLocal = <_ConnectionCandidate>[]; + final httpRemote = <_ConnectionCandidate>[]; + final httpRelay = <_ConnectionCandidate>[]; + + List<_ConnectionCandidate> bucketFor( + PlexConnection connection, + bool isHttps, + ) { + if (isHttps) { + if (connection.relay) return httpsRelay; + if (connection.local) return httpsLocal; + return httpsRemote; + } else { + if (connection.relay) return httpRelay; + if (connection.local) return httpLocal; + return httpRemote; + } + } + + void addCandidate( + PlexConnection connection, + String url, + bool isPlexDirectUri, + bool isHttps, + ) { + if (url.isEmpty || seen.contains(url)) { + return; + } + seen.add(url); + bucketFor( + connection, + isHttps, + ).add(_ConnectionCandidate(connection, url, isPlexDirectUri, isHttps)); + } + + for (final connection in connections) { + addCandidate(connection, connection.httpDirectUrl, false, false); + } + + return [ + ...httpsLocal, + ...httpsRemote, + ...httpsRelay, + ...httpLocal, + ...httpRemote, + ...httpRelay, + ]; + } + + List prioritizedEndpointUrls({String? preferredFirst}) { + final urls = []; + final exclude = {}; + + if (preferredFirst != null && preferredFirst.isNotEmpty) { + urls.add(preferredFirst); + exclude.add(preferredFirst); + } + + final candidates = _buildPrioritizedCandidates(excludeUrls: exclude); + urls.addAll(candidates.map((candidate) => candidate.url)); + return urls; + } + + Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible( + _ConnectionCandidate candidate, + ) async { + final currentUrl = candidate.url; + if (currentUrl.startsWith('https://')) { + return null; + } + + late final String httpsUrl; + bool resultingIsPlexDirect = candidate.isPlexDirectUri; + + if (candidate.isPlexDirectUri) { + if (!currentUrl.startsWith('http://')) { + return null; + } + httpsUrl = currentUrl.replaceFirst('http://', 'https://'); + } else { + // Raw IP endpoints can't present HTTPS certificates—prefer their plex.direct alias. + final plexDirectUri = candidate.connection.uri; + if (plexDirectUri.isEmpty) { + return null; + } + + if (plexDirectUri.startsWith('https://')) { + httpsUrl = plexDirectUri; + } else if (plexDirectUri.startsWith('http://')) { + httpsUrl = plexDirectUri.replaceFirst('http://', 'https://'); + } else { + return null; + } + + final upgradedHost = Uri.tryParse(httpsUrl)?.host; + if (upgradedHost == null || + !upgradedHost.toLowerCase().endsWith('.plex.direct')) { + appLogger.d( + 'Skipping HTTPS upgrade for raw IP candidate: no plex.direct alias available', + error: {'candidate': currentUrl, 'target': httpsUrl}, + ); + return null; + } + resultingIsPlexDirect = true; + } + + if (httpsUrl == currentUrl) { + return null; + } + + appLogger.d( + 'Attempting HTTPS upgrade for candidate endpoint', + error: {'from': currentUrl, 'to': httpsUrl}, + ); + + final result = await PlexClient.testConnectionWithLatency( + httpsUrl, + accessToken, + timeout: const Duration(seconds: 4), + ); + + if (!result.success) { + appLogger.w( + 'HTTPS upgrade failed, staying on HTTP candidate', + error: {'url': currentUrl}, + ); + return null; + } + + appLogger.i( + 'HTTPS upgrade succeeded for candidate endpoint', + error: {'httpsUrl': httpsUrl}, + ); + + final httpsConnection = PlexConnection( + protocol: 'https', + address: candidate.connection.address, + port: candidate.connection.port, + uri: httpsUrl, + local: candidate.connection.local, + relay: candidate.connection.relay, + ipv6: candidate.connection.ipv6, + ); + + return _ConnectionCandidate( + httpsConnection, + httpsUrl, + resultingIsPlexDirect, + true, + ); + } + + Future upgradeConnectionToHttps( + PlexConnection current, + ) async { + if (current.uri.startsWith('https://')) { + return current; + } + + final baseConnection = _findMatchingBaseConnection(current); + if (baseConnection == null) { + return null; + } + + final candidate = _ConnectionCandidate( + baseConnection, + current.uri, + current.uri.contains('.plex.direct'), + current.uri.startsWith('https://'), + ); + final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible( + candidate, + ); + if (upgradedCandidate == null) { + return null; + } + return _updateConnectionUrl( + upgradedCandidate.connection, + upgradedCandidate.url, + ); + } + + PlexConnection? _findMatchingBaseConnection(PlexConnection connection) { + for (final base in connections) { + final sameAddress = base.address == connection.address; + final samePort = base.port == connection.port; + final sameLocal = base.local == connection.local; + final sameRelay = base.relay == connection.relay; + if (sameAddress && samePort && sameLocal && sameRelay) { + return base; + } + } + return null; + } + /// Select the best candidate considering priority, latency, and URL type preference _ConnectionCandidate? _selectBestCandidateWithLatency( Map<_ConnectionCandidate, ConnectionTestResult> results, @@ -550,8 +837,8 @@ class PlexServer { if (latencyCompare != 0) return latencyCompare; // If latencies are equal, prefer HTTPS over HTTP - final aIsHttps = a.key.connection.protocol == 'https'; - final bIsHttps = b.key.connection.protocol == 'https'; + final aIsHttps = a.key.isHttps; + final bIsHttps = b.key.isHttps; if (aIsHttps && !bIsHttps) return -1; if (!aIsHttps && bIsHttps) return 1; @@ -641,6 +928,13 @@ class PlexConnection { /// This bypasses plex.direct DNS and connects directly to the IP String get directUrl => '$protocol://$address:$port'; + /// Always return an HTTP URL that points directly at the IP/port combo. + String get httpDirectUrl { + final needsBrackets = address.contains(':') && !address.startsWith('['); + final safeAddress = needsBrackets ? '[$address]' : address; + return 'http://$safeAddress:$port'; + } + String get displayType { if (relay) return 'Relay'; if (local) return 'Local'; diff --git a/lib/services/server_connection_service.dart b/lib/services/server_connection_service.dart index 32569b25..5f7d23f1 100644 --- a/lib/services/server_connection_service.dart +++ b/lib/services/server_connection_service.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; + import 'plex_auth_service.dart'; import 'storage_service.dart'; import '../client/plex_client.dart'; @@ -19,6 +23,12 @@ class ServerConnectionResult { /// Service for handling optimized server connections /// Implements fast-first connection with background optimization class ServerConnectionService { + static StreamSubscription>? + _connectivitySubscription; + static Future? _activeOptimization; + static PlexServer? _activeServer; + static PlexClient? _activeClient; + /// Connect to a Plex server with optimized connection testing /// /// Returns immediately with first working connection, then continues @@ -39,86 +49,125 @@ class ServerConnectionService { bool fetchUserProfile = false, void Function(String message)? onProgress, }) async { - PlexConnection? firstConnection; final storage = await StorageService.getInstance(); + final connectionStream = server + .findBestWorkingConnection() + .asBroadcastStream(); + PlexClient? client; + + final optimizationSubscription = connectionStream + .skip(1) + .listen( + (connection) async { + await _handleOptimizedConnection( + connection: connection, + storage: storage, + server: server, + client: client, + reason: 'initial_latency_sweep', + ); + }, + onError: (error, stackTrace) { + appLogger.w( + 'Background connection optimization error', + error: error, + stackTrace: stackTrace, + ); + }, + ); + try { - // Listen to the connection stream for progressive connection testing - await for (final connection in server.findBestWorkingConnection()) { - if (firstConnection == null) { - // First emission - use this connection immediately - firstConnection = connection; + final connection = await connectionStream.first; - if (onProgress != null) { - onProgress('Connected to ${connection.displayType} endpoint'); - } + if (onProgress != null) { + onProgress('Connected to ${connection.displayType} endpoint'); + } - // Save server information to storage - await storage.saveServerData(server.toJson()); - await storage.saveServerUrl(connection.uri); - await storage.saveServerAccessToken(server.accessToken); + // Save server information to storage + await storage.saveServerData(server.toJson()); + await storage.saveServerUrl(connection.uri); + await storage.saveServerAccessToken(server.accessToken); - // Save plex token if provided - if (plexToken != null) { - await storage.savePlexToken(plexToken); - } + // Save plex token if provided + if (plexToken != null) { + await storage.savePlexToken(plexToken); + } - // Create client with working connection - final config = await PlexConfig.create( - baseUrl: connection.uri, - token: server.accessToken, - clientIdentifier: clientIdentifier, + // Create client with working connection + final prioritizedEndpoints = server.prioritizedEndpointUrls( + preferredFirst: connection.uri, + ); + final config = await PlexConfig.create( + baseUrl: connection.uri, + token: server.accessToken, + clientIdentifier: clientIdentifier, + ); + client = PlexClient( + config, + prioritizedEndpoints: prioritizedEndpoints, + onEndpointChanged: (newUrl) async { + await storage.saveServerUrl(newUrl); + appLogger.i( + 'Updated stored server URL after failover', + error: newUrl, ); - final client = PlexClient(config); + }, + ); - // Verify server is accessible if requested - if (verifyServer) { - try { - await client.getServerIdentity(); - } catch (e) { - appLogger.w('Server identity verification failed', error: e); - await storage.clearCredentials(); - return ServerConnectionResult( - error: 'Server is not accessible: $e', - ); - } - } + // 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 + } - // Fetch user profile if requested - PlexUserProfile? userProfile; - if (fetchUserProfile && plexToken != null) { - userProfile = await _fetchUserProfile(plexToken); - } - - // Return success result - // Note: Stream continues in background to find better connection - return ServerConnectionResult( - client: client, - userProfile: userProfile, - ); - } else { - // Second emission - better connection found - // Update stored connection seamlessly for future app launches - await storage.saveServerUrl(connection.uri); - appLogger.d( - 'Switched to better connection: ${connection.displayType} (${connection.uri})', - ); + // Verify server is accessible if requested + if (verifyServer) { + try { + await client.getServerIdentity(); + } catch (e) { + await optimizationSubscription.cancel(); + appLogger.w('Server identity verification failed', error: e); + await storage.clearCredentials(); + return ServerConnectionResult(error: 'Server is not accessible: $e'); } } - // Handle case where no connections were found - if (firstConnection == null) { - return ServerConnectionResult( - error: 'No working connections found for this server', - ); + // Fetch user profile if requested + PlexUserProfile? userProfile; + if (fetchUserProfile && plexToken != null) { + userProfile = await _fetchUserProfile(plexToken); } - // Should never reach here due to return in the stream loop + // Return success result while optimization continues in background + _activeServer = server; + _activeClient = client; + _startConnectivityMonitoring(server); + + return ServerConnectionResult(client: client, userProfile: userProfile); + } on StateError catch (e, stackTrace) { + await optimizationSubscription.cancel(); + appLogger.e( + 'No working connections found for this server', + error: e, + stackTrace: stackTrace, + ); return ServerConnectionResult( - error: 'Unexpected error in connection flow', + error: 'No working connections found for this server', + ); + } catch (e, stackTrace) { + await optimizationSubscription.cancel(); + appLogger.e( + 'Error connecting to server', + error: e, + stackTrace: stackTrace, ); - } catch (e) { - appLogger.e('Error connecting to server', error: e); return ServerConnectionResult(error: 'Connection failed: $e'); } } @@ -148,4 +197,150 @@ class ServerConnectionService { return null; } } + + static void _startConnectivityMonitoring(PlexServer server) { + _connectivitySubscription?.cancel(); + final connectivity = Connectivity(); + _connectivitySubscription = connectivity.onConnectivityChanged.listen( + (results) { + final status = results.isNotEmpty + ? results.first + : ConnectivityResult.none; + if (status == ConnectivityResult.none) { + appLogger.w( + 'Connectivity lost, pausing optimization until network returns', + ); + return; + } + appLogger.d( + 'Connectivity change detected, triggering endpoint optimization', + error: { + 'status': status.name, + 'interfaces': results.map((r) => r.name).toList(), + }, + ); + _activeServer = server; + _triggerReoptimization(reason: 'connectivity:${status.name}'); + }, + onError: (error, stackTrace) { + appLogger.w( + 'Connectivity listener error', + error: error, + stackTrace: stackTrace, + ); + }, + ); + } + + static void _triggerReoptimization({required String reason}) { + if (_activeServer == null) { + appLogger.d( + 'Optimization trigger ignored because there is no active server', + error: {'reason': reason}, + ); + return; + } + + if (_activeOptimization != null) { + appLogger.d( + 'Optimization already running, skipping new trigger', + error: {'reason': reason}, + ); + return; + } + + _activeOptimization = + _runOptimization( + server: _activeServer!, + client: _activeClient, + reason: reason, + ).whenComplete(() { + _activeOptimization = null; + }); + } + + static Future _runOptimization({ + required PlexServer server, + required PlexClient? client, + required String reason, + }) async { + final storage = await StorageService.getInstance(); + try { + appLogger.d( + 'Starting background connection optimization run', + error: {'reason': reason}, + ); + await for (final connection in server.findBestWorkingConnection()) { + await _handleOptimizedConnection( + connection: connection, + storage: storage, + server: server, + client: client, + reason: reason, + ); + } + } catch (e, stackTrace) { + appLogger.w( + 'Background connection optimization failed', + error: e, + stackTrace: stackTrace, + ); + } + } + + static Future _handleOptimizedConnection({ + required PlexConnection connection, + required StorageService storage, + required PlexServer server, + required PlexClient? client, + required String reason, + }) async { + final previousUrl = storage.getServerUrl(); + final isNewEndpoint = previousUrl != connection.uri; + + await storage.saveServerUrl(connection.uri); + appLogger.d( + 'Evaluated optimized endpoint candidate', + error: { + 'uri': connection.uri, + 'displayType': connection.displayType, + 'reason': reason, + 'isNewEndpoint': isNewEndpoint, + }, + ); + + if (client != null) { + final prioritizedEndpoints = server.prioritizedEndpointUrls( + preferredFirst: connection.uri, + ); + await client.updateEndpointPreferences( + prioritizedEndpoints, + switchToFirst: isNewEndpoint, + ); + if (isNewEndpoint) { + appLogger.i( + 'Active client switched to optimized endpoint', + error: {'uri': connection.uri, 'reason': reason}, + ); + } + } else if (isNewEndpoint) { + appLogger.i( + 'Stored optimized endpoint for future sessions', + error: {'uri': connection.uri, 'reason': reason}, + ); + } + + if (isNewEndpoint && !connection.uri.startsWith('https://')) { + final upgraded = await server.upgradeConnectionToHttps(connection); + if (upgraded != null && upgraded.uri != connection.uri) { + await _handleOptimizedConnection( + connection: upgraded, + storage: storage, + server: server, + client: client, + reason: '$reason:https-upgrade', + ); + } + } + } } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 8c745318..8dc02100 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -43,6 +43,7 @@ class SettingsService { static const String _keyShuffleOrderNavigation = 'shuffle_order_navigation'; static const String _keyShuffleLoopQueue = 'shuffle_loop_queue'; static const String _keyAppLocale = 'app_locale'; + static const String _keyRememberTrackSelections = 'remember_track_selections'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -828,6 +829,17 @@ class SettingsService { return _prefs.getBool(_keyShuffleLoopQueue) ?? false; // Default: false } + // Track Selection Settings + + /// Remember Track Selections - Save per-media audio/subtitle language preferences + Future setRememberTrackSelections(bool enabled) async { + await _prefs.setBool(_keyRememberTrackSelections, enabled); + } + + bool getRememberTrackSelections() { + return _prefs.getBool(_keyRememberTrackSelections) ?? true; // Default: true + } + // Reset all settings to defaults Future resetAllSettings() async { await Future.wait([ @@ -860,6 +872,7 @@ class SettingsService { _prefs.remove(_keyShuffleOrderNavigation), _prefs.remove(_keyShuffleLoopQueue), _prefs.remove(_keyAppLocale), + _prefs.remove(_keyRememberTrackSelections), ]); } @@ -891,6 +904,7 @@ class SettingsService { 'keyboardHotkeys': hotkeys.map( (key, value) => MapEntry(key, _serializeHotKey(value)), ), + 'rememberTrackSelections': getRememberTrackSelections(), }; } } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 1b1c9349..4c16d670 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -1,6 +1,9 @@ import 'dart:convert'; + import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/log_redaction_manager.dart'; + class StorageService { static const String _keyServerUrl = 'server_url'; static const String _keyToken = 'token'; @@ -32,11 +35,16 @@ class StorageService { Future _init() async { _prefs = await SharedPreferences.getInstance(); + // Seed known values so logs can redact immediately on startup. + LogRedactionManager.registerServerUrl(getServerUrl()); + LogRedactionManager.registerToken(getToken()); + LogRedactionManager.registerToken(getPlexToken()); } // Server URL Future saveServerUrl(String url) async { await _prefs.setString(_keyServerUrl, url); + LogRedactionManager.registerServerUrl(url); } String? getServerUrl() { @@ -46,6 +54,7 @@ class StorageService { // Server Access Token Future saveToken(String token) async { await _prefs.setString(_keyToken, token); + LogRedactionManager.registerToken(token); } String? getToken() { @@ -64,6 +73,7 @@ class StorageService { // Plex.tv Token (for API access) Future savePlexToken(String token) async { await _prefs.setString(_keyPlexToken, token); + LogRedactionManager.registerToken(token); } String? getPlexToken() { @@ -127,6 +137,7 @@ class StorageService { _prefs.remove(_keyHomeUsersCache), _prefs.remove(_keyHomeUsersCacheExpiry), ]); + LogRedactionManager.clearTrackedValues(); } // Get all credentials as a map diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index d852abef..11c7da2b 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -1,18 +1,43 @@ import '../models/plex_media_info.dart'; +import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; +import '../utils/language_codes.dart'; /// Service for selecting audio and subtitle tracks based on user preferences class TrackSelectionService { /// Selects the best audio track based on user preferences /// + /// Priority order: + /// 1. Per-media preferred audio language (from metadata.audioLanguage) + /// 2. Profile-wide language preferences (if auto-select is enabled) + /// 3. Plex's selected track (if auto-select is disabled) + /// 4. First track + /// /// Returns the selected audio track, or null if no suitable track is found static PlexAudioTrack? selectAudioTrack( List tracks, - PlexUserProfile profile, - ) { + PlexUserProfile profile, { + PlexMetadata? metadata, + }) { if (tracks.isEmpty) return null; - // If auto-select is disabled, use Plex's selected track + // Priority 1: Check for per-media audio language preference + if (metadata?.audioLanguage != null) { + final perMediaTrack = tracks.firstWhere( + (track) => + _matchesLanguage(track.languageCode, metadata!.audioLanguage), + orElse: () => tracks.first, + ); + // Only use it if we actually found a matching track + if (_matchesLanguage( + perMediaTrack.languageCode, + metadata!.audioLanguage, + )) { + return perMediaTrack; + } + } + + // Priority 2: If auto-select is disabled, use Plex's selected track if (!profile.autoSelectAudio) { return tracks.firstWhere( (track) => track.selected, @@ -20,7 +45,7 @@ class TrackSelectionService { ); } - // Build list of preferred language codes + // Priority 3: Use profile-wide language preferences final preferredLanguages = []; if (profile.defaultAudioLanguage != null) { preferredLanguages.add(profile.defaultAudioLanguage!); @@ -52,14 +77,44 @@ class TrackSelectionService { /// Selects the best subtitle track based on user preferences /// + /// Priority order: + /// 1. Per-media preferred subtitle language (from metadata.subtitleLanguage) + /// 2. Profile-wide subtitle preferences (based on auto-select mode) + /// 3. Disabled (null) + /// /// Returns the selected subtitle track, or null if subtitles should be disabled static PlexSubtitleTrack? selectSubtitleTrack( List tracks, PlexUserProfile profile, - PlexAudioTrack? selectedAudioTrack, - ) { + PlexAudioTrack? selectedAudioTrack, { + PlexMetadata? metadata, + }) { if (tracks.isEmpty) return null; + // Priority 1: Check for per-media subtitle language preference + if (metadata?.subtitleLanguage != null && + metadata!.subtitleLanguage!.isNotEmpty) { + // Check if subtitle should be disabled (empty string or "none") + if (metadata.subtitleLanguage == 'none' || + metadata.subtitleLanguage == '') { + return null; + } + + final perMediaTrack = tracks.firstWhere( + (track) => + _matchesLanguage(track.languageCode, metadata.subtitleLanguage), + orElse: () => tracks.first, + ); + // Only use it if we actually found a matching track + if (_matchesLanguage( + perMediaTrack.languageCode, + metadata.subtitleLanguage, + )) { + return perMediaTrack; + } + } + + // Priority 2: Use profile-wide subtitle preferences // Mode 0: Manually selected - return null to disable subtitles if (profile.autoSelectSubtitle == 0) { return null; @@ -217,6 +272,7 @@ class TrackSelectionService { /// Checks if a language code matches a preferred language /// /// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes + /// Also handles bibliographic variants and region codes (e.g., "en-US") static bool _matchesLanguage( String? trackLanguage, String? preferredLanguage, @@ -231,49 +287,16 @@ class TrackSelectionService { // Direct match if (track == preferred) return true; - // Handle common 2-letter to 3-letter mappings - final languageMap = { - 'en': 'eng', - 'es': 'spa', - 'fr': 'fra', - 'de': 'deu', - 'it': 'ita', - 'pt': 'por', - 'ja': 'jpn', - 'ko': 'kor', - 'zh': 'zho', - 'ru': 'rus', - 'ar': 'ara', - 'hi': 'hin', - 'nl': 'nld', - 'pl': 'pol', - 'tr': 'tur', - 'sv': 'swe', - 'no': 'nor', - 'da': 'dan', - 'fi': 'fin', - 'cs': 'ces', - 'hu': 'hun', - 'ro': 'ron', - 'th': 'tha', - 'vi': 'vie', - 'id': 'ind', - 'uk': 'ukr', - 'el': 'ell', - 'he': 'heb', - }; + // Extract base language codes (handle region codes like "en-US") + final trackBase = track.split('-').first; + final preferredBase = preferred.split('-').first; - // Try mapping preferred to 3-letter and compare - if (languageMap[preferred] == track) return true; + if (trackBase == preferredBase) return true; - // Try mapping track to 3-letter and compare with preferred 3-letter - if (languageMap[track] == preferred) return true; + // Get all variations of the preferred language (e.g., "en" → ["en", "eng"]) + final variations = LanguageCodes.getVariations(preferredBase); - // Try reverse mapping (3-letter to 2-letter) - final reverseMap = languageMap.map((k, v) => MapEntry(v, k)); - if (reverseMap[preferred] == track) return true; - if (reverseMap[track] == preferred) return true; - - return false; + // Check if track's base code matches any variation + return variations.contains(trackBase); } } diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index 07c39cb6..9450fed4 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -1,72 +1,22 @@ import 'package:logger/logger.dart'; -/// Redacts sensitive information from log messages +import 'log_redaction_manager.dart'; + +/// Redacts sensitive information from log messages based on known values. String _redactSensitiveData(String message) { - String redacted = message; + var redacted = LogRedactionManager.redact(message); - // Redact Plex tokens (alphanumeric strings typically 20+ characters) - // Pattern: X-Plex-Token=... or token=... or accessToken=... or similar + // Fallbacks for sensitive fields we cannot track ahead of time. redacted = redacted.replaceAllMapped( - RegExp(r'([Tt]oken[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false), + RegExp(r'([Aa]uthorization[=:]\s*)([^\s,]+)'), (match) => '${match.group(1)}[REDACTED]', ); - // Redact authorization headers redacted = redacted.replaceAllMapped( - RegExp( - r'([Aa]uthorization[=:]\s*)([A-Za-z0-9_\-\.]+)', - caseSensitive: false, - ), + RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)'), (match) => '${match.group(1)}[REDACTED]', ); - // Redact API keys - redacted = redacted.replaceAllMapped( - RegExp(r'([Aa]pi[Kk]ey[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false), - (match) => '${match.group(1)}[REDACTED]', - ); - - // Redact passwords - redacted = redacted.replaceAllMapped( - RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)', caseSensitive: false), - (match) => '${match.group(1)}[REDACTED]', - ); - - // Redact full URLs with tokens in query parameters - redacted = redacted.replaceAllMapped( - RegExp( - r'(https?://[^\s]*[?&])([Xx]-[Pp]lex-[Tt]oken|token)=([A-Za-z0-9_-]+)', - ), - (match) => '${match.group(1)}${match.group(2)}=[REDACTED]', - ); - - // Redact IP addresses in dot notation (e.g., 192.168.1.100) - redacted = redacted.replaceAllMapped( - RegExp(r'\b(\d{1,3}\.)(\d{1,3}\.)(\d{1,3}\.)(\d{1,3})\b'), - (match) => '${match.group(1)}***.***.${match.group(4)}', - ); - - // Redact IP addresses in dash notation (e.g., 192-168-1-11) - redacted = redacted.replaceAllMapped( - RegExp(r'\b(\d{1,3}-)(\d{1,3}-)(\d{1,3}-)(\d{1,3})\b'), - (match) => '${match.group(1)}***-***-${match.group(4)}', - ); - - // Redact standalone token-like strings (20+ alphanumeric characters) - // Only if they appear in common token contexts - redacted = redacted.replaceAllMapped(RegExp(r'\b([A-Za-z0-9_-]{20,})\b'), ( - match, - ) { - final token = match.group(1)!; - // Only redact if it looks like a token (mixed case or contains hyphens/underscores) - if (token.contains(RegExp(r'[A-Z]')) && token.contains(RegExp(r'[a-z]')) || - token.contains('_') || - token.contains('-')) { - return '[REDACTED_TOKEN]'; - } - return token; - }); - return redacted; } diff --git a/lib/utils/log_redaction_manager.dart b/lib/utils/log_redaction_manager.dart new file mode 100644 index 00000000..15437c9e --- /dev/null +++ b/lib/utils/log_redaction_manager.dart @@ -0,0 +1,144 @@ +class LogRedactionManager { + static final Set _tokens = {}; + static final Set _urls = {}; + static final Set _customValues = {}; + static final RegExp _ipv4Pattern = RegExp( + r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b', + ); + static final RegExp _ipv4HostPattern = RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$'); + + /// Register a server access token or Plex.tv token for redaction. + static void registerToken(String? token) { + final normalized = _normalize(token); + if (normalized == null) return; + + _tokens.add(normalized); + + // Tokens often appear URL encoded in query params. + final encoded = Uri.encodeQueryComponent(normalized); + if (encoded != normalized) { + _tokens.add(encoded); + } + } + + /// Register the server/base URL currently in use. + static void registerServerUrl(String? url) { + final normalized = _normalize(url); + if (normalized == null) return; + + final uri = Uri.tryParse(normalized); + final host = uri?.host; + if (host != null && host.isNotEmpty && _isIpv4Like(host)) { + // Do not register full IP-based URLs; regex redaction handles them. + return; + } + + if (host == null && _isIpv4Like(normalized)) { + return; + } + + final strippedSlash = normalized.endsWith('/') + ? normalized.substring(0, normalized.length - 1) + : normalized; + + if (strippedSlash.isNotEmpty) { + _urls.add(strippedSlash); + _urls.add('$strippedSlash/'); // Include trailing slash variant. + } + + // Capture origin and host-level strings as well to cover most cases. + if (uri != null && uri.host.isNotEmpty) { + final origin = + '${uri.scheme.isEmpty ? 'https' : uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; + _urls.add(origin); + if (origin.endsWith('/')) { + _urls.add(origin.substring(0, origin.length - 1)); + } + } + } + + /// Register other sensitive values that need redaction. + static void registerCustomValue(String? value) { + final normalized = _normalize(value); + if (normalized == null) return; + _customValues.add(normalized); + } + + /// Reset any tracked sensitive values (e.g., on logout). + static void clearTrackedValues() { + _tokens.clear(); + _urls.clear(); + _customValues.clear(); + } + + /// Redact known sensitive values from the provided message. + static String redact(String message) { + var redacted = message; + + redacted = redacted.replaceAllMapped( + _ipv4Pattern, + (match) => _maskIpv4(match.group(1)!, match.group(2)!, match.group(5)!), + ); + + for (final url in _urls) { + redacted = redacted.replaceAll(url, _maskUrlPreview(url)); + } + + for (final token in _tokens) { + redacted = redacted.replaceAll(token, '[REDACTED_TOKEN]'); + } + + for (final custom in _customValues) { + redacted = redacted.replaceAll(custom, '[REDACTED]'); + } + + return redacted; + } + + static String? _normalize(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + return trimmed; + } + + static bool _isIpv4Like(String value) { + return _ipv4HostPattern.hasMatch(value); + } + + static String _maskIpv4(String first, String separator, String last) { + return '$first$separator' + 'x$separator' + 'x$separator' + '$last'; + } + + static String _maskUrlPreview(String url) { + const startPreviewLength = 12; + const endPreviewLength = 8; + + if (url.isEmpty) { + return '[REDACTED_URL]'; + } + + if (url.length <= 4) { + return '[REDACTED_URL]'; + } + + final startLength = url.length <= startPreviewLength + ? (url.length / 2).ceil() + : startPreviewLength; + final remainingForEnd = url.length - startLength; + final endLength = remainingForEnd <= endPreviewLength + ? remainingForEnd + : endPreviewLength; + + final start = url.substring(0, startLength); + if (endLength <= 0) { + return '$start...[REDACTED_URL]'; + } + + final end = url.substring(url.length - endLength); + return '$start...[REDACTED_URL]...$end'; + } +} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index a186469d..66c72186 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,7 +1,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_playlist.dart'; import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../widgets/file_info_bottom_sheet.dart'; @@ -143,6 +145,20 @@ class _MediaContextMenuState extends State { ); } + // Add to Playlist (for episodes, movies, shows, and seasons) + if (itemType == 'episode' || + itemType == 'movie' || + itemType == 'show' || + itemType == 'season') { + menuActions.add( + _MenuAction( + value: 'add_to_playlist', + icon: Icons.playlist_add, + label: t.playlists.addTo, + ), + ); + } + String? selected; if (useBottomSheet) { @@ -296,6 +312,10 @@ class _MediaContextMenuState extends State { await _showFileInfo(context); break; + case 'add_to_playlist': + await _showAddToPlaylistDialog(context); + break; + case 'shuffle_play': await handleShufflePlay(context, widget.metadata); break; @@ -412,6 +432,106 @@ class _MediaContextMenuState extends State { } } + /// Show dialog to select playlist and add item + Future _showAddToPlaylistDialog(BuildContext context) async { + final client = context.client; + if (client == null) return; + + try { + final itemType = widget.metadata.type.toLowerCase(); + + // Load playlists + final playlists = await client.getPlaylists(playlistType: 'video'); + + if (!context.mounted) return; + + // Show dialog to select playlist or create new + final result = await showDialog( + context: context, + builder: (context) => _PlaylistSelectionDialog(playlists: playlists), + ); + + if (result == null || !context.mounted) return; + + // Build URI for the item (works for all types: movies, episodes, seasons, shows) + // For seasons/shows, the Plex API should automatically expand to include all episodes + final itemUri = await client.buildMetadataUri(widget.metadata.ratingKey); + appLogger.d('Built URI for $itemType: $itemUri'); + + if (result == '_create_new') { + // Create new playlist flow + final playlistName = await showDialog( + context: context, + builder: (context) => _CreatePlaylistDialog(), + ); + + if (playlistName == null || playlistName.isEmpty || !context.mounted) { + return; + } + + // Create playlist with the item(s) + appLogger.d( + 'Creating playlist "$playlistName" with URI length: ${itemUri.length}', + ); + final newPlaylist = await client.createPlaylist( + title: playlistName, + uri: itemUri, + ); + + if (context.mounted) { + if (newPlaylist != null) { + appLogger.d('Successfully created playlist: ${newPlaylist.title}'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.created))); + } else { + appLogger.e('Failed to create playlist - API returned null'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorCreating))); + } + } + } else { + // Add to existing playlist + appLogger.d('Adding to playlist $result with URI: $itemUri'); + final success = await client.addToPlaylist( + playlistId: result, + uri: itemUri, + ); + + if (context.mounted) { + if (success) { + appLogger.d('Successfully added item(s) to playlist $result'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.itemAdded))); + } else { + appLogger.e( + 'Failed to add item(s) to playlist $result - API returned false', + ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorAdding))); + } + } + } + } catch (e, stackTrace) { + appLogger.e( + 'Error in add to playlist flow', + error: e, + stackTrace: stackTrace, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${t.playlists.errorLoading}: ${e.toString()}'), + duration: const Duration(seconds: 5), + ), + ); + } + } + } + @override Widget build(BuildContext context) { return GestureDetector( @@ -424,3 +544,109 @@ class _MediaContextMenuState extends State { ); } } + +/// Dialog to select a playlist or create a new one +class _PlaylistSelectionDialog extends StatelessWidget { + final List playlists; + + const _PlaylistSelectionDialog({required this.playlists}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(t.playlists.selectPlaylist), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: playlists.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + // Create new playlist option (always shown first) + return ListTile( + leading: const Icon(Icons.add), + title: Text(t.playlists.createNewPlaylist), + onTap: () => Navigator.pop(context, '_create_new'), + ); + } + + final playlist = playlists[index - 1]; + return ListTile( + leading: playlist.smart + ? const Icon(Icons.auto_awesome) + : const Icon(Icons.playlist_play), + title: Text(playlist.title), + subtitle: playlist.leafCount != null + ? Text( + playlist.leafCount == 1 + ? t.playlists.oneItem + : t.playlists.itemCount(count: playlist.leafCount!), + ) + : null, + onTap: playlist.smart + ? null // Disable smart playlists + : () => Navigator.pop(context, playlist.ratingKey), + enabled: !playlist.smart, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + ], + ); + } +} + +/// Dialog to create a new playlist +class _CreatePlaylistDialog extends StatefulWidget { + @override + State<_CreatePlaylistDialog> createState() => _CreatePlaylistDialogState(); +} + +class _CreatePlaylistDialogState extends State<_CreatePlaylistDialog> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(t.playlists.create), + content: TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + labelText: t.playlists.playlistName, + hintText: t.playlists.enterPlaylistName, + ), + onSubmitted: (value) { + if (value.isNotEmpty) { + Navigator.pop(context, value); + } + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () { + if (_controller.text.isNotEmpty) { + Navigator.pop(context, _controller.text); + } + }, + child: Text(t.common.save), + ), + ], + ); + } +} diff --git a/lib/widgets/playlist_item_card.dart b/lib/widgets/playlist_item_card.dart new file mode 100644 index 00000000..234ae989 --- /dev/null +++ b/lib/widgets/playlist_item_card.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../models/plex_metadata.dart'; +import '../providers/plex_client_provider.dart'; +import '../i18n/strings.g.dart'; + +/// Custom list item widget for playlist items +/// Shows drag handle, poster, title/metadata, duration, and remove button +class PlaylistItemCard extends StatelessWidget { + final PlexMetadata item; + final int index; + final VoidCallback onRemove; + final VoidCallback? onTap; + final bool canReorder; // Whether drag handle should be shown + + const PlaylistItemCard({ + super.key, + required this.item, + required this.index, + required this.onRemove, + this.onTap, + this.canReorder = true, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + // Drag handle (if reorderable) + if (canReorder) + ReorderableDragStartListener( + index: index, + child: const Padding( + padding: EdgeInsets.only(right: 12), + child: Icon(Icons.drag_indicator, color: Colors.grey), + ), + ), + + // Poster thumbnail + _buildPosterImage(context), + + const SizedBox(width: 12), + + // Title and metadata + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Title + Text( + item.displayTitle, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 4), + + // Subtitle (episode info or type) + Text( + _buildSubtitle(), + style: TextStyle(fontSize: 13, color: Colors.grey[400]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + // Progress indicator if partially watched + if (item.viewOffset != null && item.duration != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: LinearProgressIndicator( + value: item.viewOffset! / item.duration!, + backgroundColor: Colors.grey[800], + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + minHeight: 3, + ), + ), + ], + ), + ), + + const SizedBox(width: 12), + + // Duration + if (item.duration != null) + Text( + _formatDuration(item.duration!), + style: TextStyle(fontSize: 13, color: Colors.grey[400]), + ), + + const SizedBox(width: 8), + + // Remove button + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: onRemove, + tooltip: t.playlists.removeItem, + color: Colors.grey[400], + ), + ], + ), + ), + ), + ); + } + + Widget _buildPosterImage(BuildContext context) { + final posterUrl = item.posterThumb(); + if (posterUrl != null) { + return Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return _buildPlaceholder(); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: client.getThumbnailUrl(posterUrl), + width: 60, + height: 90, + fit: BoxFit.cover, + placeholder: (context, url) => _buildPlaceholder(), + errorWidget: (context, url, error) => _buildPlaceholder(), + ), + ); + }, + ); + } + return _buildPlaceholder(); + } + + Widget _buildPlaceholder() { + return Container( + width: 60, + height: 90, + decoration: BoxDecoration( + color: Colors.grey[850], + borderRadius: BorderRadius.circular(6), + ), + child: const Icon(Icons.movie, color: Colors.grey, size: 24), + ); + } + + String _buildSubtitle() { + final itemType = item.type.toLowerCase(); + + if (itemType == 'episode') { + // For episodes, show "S#E# - Episode Title" + final season = item.parentIndex; + final episode = item.index; + if (season != null && episode != null) { + return 'S${season}E$episode${item.displaySubtitle != null ? ' - ${item.displaySubtitle}' : ''}'; + } + return item.displaySubtitle ?? t.discover.tvShow; + } else if (itemType == 'movie') { + // For movies, show year + return item.year?.toString() ?? t.discover.movie; + } + + // Default to type + return item.type; + } + + String _formatDuration(int milliseconds) { + final duration = Duration(milliseconds: milliseconds); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m'; + } else { + return '${minutes}m'; + } + } +} diff --git a/lib/widgets/profile_list_tile.dart b/lib/widgets/profile_list_tile.dart index ca806763..9712653f 100644 --- a/lib/widgets/profile_list_tile.dart +++ b/lib/widgets/profile_list_tile.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import '../i18n/strings.g.dart'; import '../models/plex_home_user.dart'; import 'user_avatar_widget.dart'; +enum UserAttribute { admin, restricted, protected } + class ProfileListTile extends StatelessWidget { final PlexHomeUser user; final VoidCallback onTap; @@ -34,7 +37,7 @@ class ProfileListTile extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: Text( - 'CURRENT', + t.userStatus.current, style: TextStyle( fontSize: 10, color: theme.colorScheme.onPrimary, @@ -55,21 +58,21 @@ class ProfileListTile extends StatelessWidget { List _buildUserAttributes(ThemeData theme) { final attributes = []; - final labels = []; + final List userAttributes = []; if (user.isAdminUser) { - labels.add('Admin'); + userAttributes.add(UserAttribute.admin); } if (user.isRestrictedUser && !user.isAdminUser) { - labels.add('Restricted'); + userAttributes.add(UserAttribute.restricted); } if (user.requiresPassword) { - labels.add('Protected'); + userAttributes.add(UserAttribute.protected); } - for (int i = 0; i < labels.length; i++) { + for (int i = 0; i < userAttributes.length; i++) { if (i > 0) { attributes.addAll([ const SizedBox(width: 8), @@ -84,12 +87,14 @@ class ProfileListTile extends StatelessWidget { ]); } + final attribute = userAttributes[i]; + attributes.add( Text( - labels[i], + _getAttributeLabel(attribute), style: TextStyle( fontSize: 12, - color: _getAttributeColor(labels[i], theme), + color: _getAttributeColor(attribute, theme), fontWeight: FontWeight.w500, ), ), @@ -99,16 +104,25 @@ class ProfileListTile extends StatelessWidget { return attributes; } - Color _getAttributeColor(String attribute, ThemeData theme) { + String _getAttributeLabel(UserAttribute attribute) { switch (attribute) { - case 'Admin': + case UserAttribute.admin: + return t.userStatus.admin; + case UserAttribute.restricted: + return t.userStatus.restricted; + case UserAttribute.protected: + return t.userStatus.protected; + } + } + + Color _getAttributeColor(UserAttribute attribute, ThemeData theme) { + switch (attribute) { + case UserAttribute.admin: return theme.colorScheme.primary; - case 'Restricted': + case UserAttribute.restricted: return theme.colorScheme.warning ?? Colors.orange; - case 'Protected': + case UserAttribute.protected: return theme.colorScheme.secondary; - default: - return theme.colorScheme.onSurface; } } } diff --git a/lib/widgets/video_controls/sheets/audio_track_sheet.dart b/lib/widgets/video_controls/sheets/audio_track_sheet.dart index ba7cd991..40354f17 100644 --- a/lib/widgets/video_controls/sheets/audio_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_track_sheet.dart @@ -5,8 +5,9 @@ import '../../../i18n/strings.g.dart'; /// Bottom sheet for selecting audio tracks class AudioTrackSheet extends StatelessWidget { final Player player; + final Function(AudioTrack)? onTrackChanged; - const AudioTrackSheet({super.key, required this.player}); + const AudioTrackSheet({super.key, required this.player, this.onTrackChanged}); static BoxConstraints getBottomSheetConstraints(BuildContext context) { final size = MediaQuery.of(context).size; @@ -19,13 +20,18 @@ class AudioTrackSheet extends StatelessWidget { ); } - static void show(BuildContext context, Player player) { + static void show( + BuildContext context, + Player player, { + Function(AudioTrack)? onTrackChanged, + }) { showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: getBottomSheetConstraints(context), - builder: (context) => AudioTrackSheet(player: player), + builder: (context) => + AudioTrackSheet(player: player, onTrackChanged: onTrackChanged), ); } @@ -130,6 +136,7 @@ class AudioTrackSheet extends StatelessWidget { : null, onTap: () { player.setAudioTrack(audioTrack); + onTrackChanged?.call(audioTrack); Navigator.pop(context); }, ); diff --git a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart index ab3858b7..4f375627 100644 --- a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart @@ -5,8 +5,13 @@ import '../../../i18n/strings.g.dart'; /// Bottom sheet for selecting subtitle tracks class SubtitleTrackSheet extends StatelessWidget { final Player player; + final Function(SubtitleTrack)? onTrackChanged; - const SubtitleTrackSheet({super.key, required this.player}); + const SubtitleTrackSheet({ + super.key, + required this.player, + this.onTrackChanged, + }); static BoxConstraints getBottomSheetConstraints(BuildContext context) { final size = MediaQuery.of(context).size; @@ -19,13 +24,18 @@ class SubtitleTrackSheet extends StatelessWidget { ); } - static void show(BuildContext context, Player player) { + static void show( + BuildContext context, + Player player, { + Function(SubtitleTrack)? onTrackChanged, + }) { showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: getBottomSheetConstraints(context), - builder: (context) => SubtitleTrackSheet(player: player), + builder: (context) => + SubtitleTrackSheet(player: player, onTrackChanged: onTrackChanged), ); } @@ -113,6 +123,7 @@ class SubtitleTrackSheet extends StatelessWidget { : null, onTap: () { player.setSubtitleTrack(SubtitleTrack.no()); + onTrackChanged?.call(SubtitleTrack.no()); Navigator.pop(context); }, ); @@ -167,6 +178,7 @@ class SubtitleTrackSheet extends StatelessWidget { : null, onTap: () { player.setSubtitleTrack(subtitle); + onTrackChanged?.call(subtitle); Navigator.pop(context); }, ); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index c3bc93b4..fde60db0 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -38,6 +38,8 @@ Widget plexVideoControlsBuilder( int? selectedMediaIndex, int boxFitMode = 0, VoidCallback? onCycleBoxFitMode, + Function(AudioTrack)? onAudioTrackChanged, + Function(SubtitleTrack)? onSubtitleTrackChanged, }) { return PlexVideoControls( player: player, @@ -48,6 +50,8 @@ Widget plexVideoControlsBuilder( selectedMediaIndex: selectedMediaIndex ?? 0, boxFitMode: boxFitMode, onCycleBoxFitMode: onCycleBoxFitMode, + onAudioTrackChanged: onAudioTrackChanged, + onSubtitleTrackChanged: onSubtitleTrackChanged, ); } @@ -60,6 +64,8 @@ class PlexVideoControls extends StatefulWidget { final int selectedMediaIndex; final int boxFitMode; final VoidCallback? onCycleBoxFitMode; + final Function(AudioTrack)? onAudioTrackChanged; + final Function(SubtitleTrack)? onSubtitleTrackChanged; const PlexVideoControls({ super.key, @@ -71,6 +77,8 @@ class PlexVideoControls extends StatefulWidget { this.selectedMediaIndex = 0, this.boxFitMode = 0, this.onCycleBoxFitMode, + this.onAudioTrackChanged, + this.onSubtitleTrackChanged, }); @override @@ -464,13 +472,20 @@ class _PlexVideoControlsState extends State if (_hasMultipleAudioTracks(tracks)) VideoControlButton( icon: Icons.audiotrack, - onPressed: () => AudioTrackSheet.show(context, widget.player), + onPressed: () => AudioTrackSheet.show( + context, + widget.player, + onTrackChanged: widget.onAudioTrackChanged, + ), ), if (_hasSubtitles(tracks)) VideoControlButton( icon: Icons.subtitles, - onPressed: () => - SubtitleTrackSheet.show(context, widget.player), + onPressed: () => SubtitleTrackSheet.show( + context, + widget.player, + onTrackChanged: widget.onSubtitleTrackChanged, + ), ), if (_chapters.isNotEmpty) VideoControlButton( diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index aa156acf..a6e9b89c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import connectivity_plus import hotkey_manager_macos import macos_window_utils import media_kit_libs_macos_video @@ -21,6 +22,7 @@ import wakelock_plus import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin")) MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 528496e2..c77fe217 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - connectivity_plus (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - HotKey (0.2.1) - hotkey_manager_macos (0.0.1): @@ -10,6 +12,7 @@ PODS: - FlutterMacOS - media_kit_video (0.0.1): - FlutterMacOS + - media_kit_libs_macos_video - os_media_controls (0.0.1): - FlutterMacOS - package_info_plus (0.0.1): @@ -35,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`) @@ -56,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: @@ -88,12 +94,13 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: + connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d - media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 - media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + media_kit_libs_macos_video: 69caff44badac986515a602bae1eb7f988a29f93 + media_kit_video: 71dcfc00fc7d2e7dbe43e60b5eb505f15ab6a88e os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed package_info_plus: f0052d280d17aa382b932f399edf32507174e870 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 diff --git a/pubspec.lock b/pubspec.lock index d49b164d..e818babd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,6 +185,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" convert: dependency: transitive description: @@ -540,26 +556,26 @@ packages: dependency: "direct main" description: path: media_kit - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" media_kit_libs_android_video: - dependency: transitive + dependency: "direct overridden" description: path: "libs/android/media_kit_libs_android_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.3.9" media_kit_libs_ios_video: - dependency: transitive + dependency: "direct overridden" description: path: "libs/ios/media_kit_libs_ios_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.1.4" @@ -567,17 +583,17 @@ packages: dependency: transitive description: path: "libs/linux/media_kit_libs_linux" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" media_kit_libs_macos_video: - dependency: transitive + dependency: "direct overridden" description: path: "libs/macos/media_kit_libs_macos_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.1.5" @@ -585,8 +601,8 @@ packages: dependency: "direct main" description: path: "libs/universal/media_kit_libs_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.0.7" @@ -594,27 +610,28 @@ packages: dependency: transitive description: path: "libs/windows/media_kit_libs_windows_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.0.12" media_kit_video: dependency: "direct main" description: - name: media_kit_video - sha256: "813858c3fe84eb46679eb698695f60665e2bfbef757766fac4d2e683f926e15a" - url: "https://pub.dev" - source: hosted + path: media_kit_video + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + url: "https://github.com/edde746/media-kit" + source: git version: "1.3.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -631,6 +648,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" octo_image: dependency: transitive description: @@ -643,8 +668,8 @@ packages: dependency: "direct main" description: path: "." - ref: "53d803e1c228eb7eab9ca642119d404c21f4a52f" - resolved-ref: "53d803e1c228eb7eab9ca642119d404c21f4a52f" + ref: a196b022dcbcc3433bae9233be179f02062327e7 + resolved-ref: a196b022dcbcc3433bae9233be179f02062327e7 url: "https://github.com/edde746/os-media-controls" source: git version: "0.0.2" @@ -1097,10 +1122,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6f952caa..d6ae92be 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,22 +28,43 @@ dependencies: qr_flutter: ^4.1.0 slang: ^3.31.2 slang_flutter: ^3.31.0 + connectivity_plus: ^6.0.5 os_media_controls: git: url: https://github.com/edde746/os-media-controls - ref: 53d803e1c228eb7eab9ca642119d404c21f4a52f + ref: a196b022dcbcc3433bae9233be179f02062327e7 dependency_overrides: media_kit: git: url: https://github.com/edde746/media-kit - ref: 9782771486c0356b48c2e31e47365d1ed0b7fcb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: media_kit + media_kit_video: + git: + url: https://github.com/edde746/media-kit + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 + path: media_kit_video media_kit_libs_video: git: url: https://github.com/edde746/media-kit - ref: 9782771486c0356b48c2e31e47365d1ed0b7fcb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: libs/universal/media_kit_libs_video + media_kit_libs_macos_video: + git: + url: https://github.com/edde746/media-kit + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 + path: libs/macos/media_kit_libs_macos_video + media_kit_libs_ios_video: + git: + url: https://github.com/edde746/media-kit + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 + path: libs/ios/media_kit_libs_ios_video + media_kit_libs_android_video: + git: + url: https://github.com/edde746/media-kit + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 + path: libs/android/media_kit_libs_android_video dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 4e0884f2..e59aa9c2 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -16,6 +17,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); HotkeyManagerWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index adfebedf..0c647b45 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus hotkey_manager_windows media_kit_libs_windows_video media_kit_video