From 7fbd320fa75f5e645e63fae70f8674ed78fec5e6 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 9 Nov 2025 16:52:00 +0100 Subject: [PATCH 1/9] fix: full refresh on profile switch --- lib/screens/discover_screen.dart | 9 ++++++++- lib/screens/libraries_screen.dart | 12 +++++++++++- lib/screens/main_screen.dart | 18 +++++++++--------- lib/screens/search_screen.dart | 14 ++++++++++++++ 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index b55d3b50..86e68736 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -273,7 +273,7 @@ class _DiscoverScreenState extends State } } - // Public method to refresh content + // Public method to refresh content (for normal navigation) @override void refresh() { appLogger.d('DiscoverScreen.refresh() called'); @@ -281,6 +281,13 @@ class _DiscoverScreenState extends State _refreshContinueWatching(); } + // Public method to fully reload all content (for profile switches) + void fullRefresh() { + appLogger.d('DiscoverScreen.fullRefresh() called - reloading all content'); + // Reload all content including Recently Added and content hubs + _loadContent(); + } + /// Get icon for hub based on its title IconData _getHubIcon(String title) { final lowerTitle = title.toLowerCase(); diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 4feba0d3..55e34cd6 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -503,12 +503,22 @@ class _LibrariesScreenState extends State } } - // Public method to refresh content + // Public method to refresh content (for normal navigation) @override void refresh() { _loadLibraries(); } + // Public method to fully reload all content (for profile switches) + void fullRefresh() { + appLogger.d('LibrariesScreen.fullRefresh() called - reloading all content'); + // Reload libraries and clear any selected library/filters + _selectedLibraryKey = null; + _selectedFilters.clear(); + _items.clear(); + _loadLibraries(); + } + Future _toggleLibraryVisibility(PlexLibrary library) async { final hiddenLibrariesProvider = Provider.of( context, diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index e7da3156..6532a4e6 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -93,22 +93,22 @@ class _MainScreenState extends State with RouteAware { void _invalidateAllScreens() { appLogger.d('Invalidating all screen data due to profile switch'); - // Refresh discover screen + // Full refresh discover screen (reload all content for new profile) final discoverState = _discoverKey.currentState; - if (discoverState != null && discoverState is Refreshable) { - (discoverState as Refreshable).refresh(); + if (discoverState != null) { + (discoverState as dynamic).fullRefresh(); } - // Refresh libraries screen + // Full refresh libraries screen (clear filters and reload for new profile) final librariesState = _librariesKey.currentState; - if (librariesState != null && librariesState is Refreshable) { - (librariesState as Refreshable).refresh(); + if (librariesState != null) { + (librariesState as dynamic).fullRefresh(); } - // Refresh search screen + // Full refresh search screen (clear search for new profile) final searchState = _searchKey.currentState; - if (searchState != null && searchState is Refreshable) { - (searchState as Refreshable).refresh(); + if (searchState != null) { + (searchState as dynamic).fullRefresh(); } } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 39c26769..e98a154e 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -6,6 +6,7 @@ import '../models/plex_metadata.dart'; import '../services/settings_service.dart'; import '../providers/settings_provider.dart'; import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../mixins/refreshable.dart'; @@ -120,6 +121,19 @@ class _SearchScreenState extends State } } + // Public method to fully reload all content (for profile switches) + void fullRefresh() { + appLogger.d('SearchScreen.fullRefresh() called - clearing search and reloading'); + // Clear search results and search text for new profile + _searchController.clear(); + setState(() { + _searchResults.clear(); + _isSearching = false; + _hasSearched = false; + _lastSearchedQuery = ''; + }); + } + @override void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { final index = _searchResults.indexWhere( From a607904a358f64ac1f725d763eb10005e80af8a0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 9 Nov 2025 21:24:44 +0100 Subject: [PATCH 2/9] fix: better server selector errors --- lib/screens/server_selection_screen.dart | 90 ++++++++++++++-- lib/services/plex_auth_service.dart | 125 ++++++++++++++++++++--- 2 files changed, 190 insertions(+), 25 deletions(-) diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart index c42ac967..ca10e951 100644 --- a/lib/screens/server_selection_screen.dart +++ b/lib/screens/server_selection_screen.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; import '../services/server_connection_service.dart'; @@ -27,6 +29,7 @@ class _ServerSelectionScreenState extends State { bool _isLoading = true; String? _errorMessage; String? _currentServerUrl; + List>? _debugServerData; @override void initState() { @@ -52,12 +55,58 @@ class _ServerSelectionScreenState extends State { setState(() { _servers = servers; _isLoading = false; + _debugServerData = null; // Clear any previous debug data }); } catch (e) { setState(() { - _errorMessage = 'Failed to load servers: $e'; + _errorMessage = _getErrorMessage(e); _isLoading = false; + // Store debug data if it's a parsing exception + if (e is ServerParsingException) { + _debugServerData = e.invalidServerData; + } else { + _debugServerData = null; + } }); + appLogger.e('Failed to load servers', error: e); + } + } + + String _getErrorMessage(dynamic error) { + if (error is ServerParsingException) { + return 'Found ${error.invalidServerData.length} server(s) with malformed data. No valid servers available.'; + } else if (error is FormatException) { + // Handle JSON parsing errors with more user-friendly messages + if (error.message.contains('Invalid server data')) { + return 'Some servers have incomplete information and were skipped. Please check your Plex.tv account.'; + } else if (error.message.contains('Invalid connection data')) { + return 'Server connection information is incomplete. Please try again.'; + } + return 'Server information is malformed: ${error.message}'; + } else if (error.toString().contains('SocketException') || + error.toString().contains('TimeoutException')) { + return 'Network connection failed. Please check your internet connection and try again.'; + } else if (error.toString().contains('401') || + error.toString().contains('Unauthorized')) { + return 'Authentication failed. Please sign in again.'; + } else if (error.toString().contains('404') || + error.toString().contains('Not Found')) { + return 'Plex service unavailable. Please try again later.'; + } + + return 'Failed to load servers: ${error.toString()}'; + } + + Future _copyDebugDataToClipboard() async { + if (_debugServerData == null) return; + + final jsonString = const JsonEncoder.withIndent(' ').convert(_debugServerData); + await Clipboard.setData(ClipboardData(text: jsonString)); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Server debug data copied to clipboard')), + ); } } @@ -199,18 +248,41 @@ class _ServerSelectionScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - _errorMessage!, - style: TextStyle( - color: Theme.of(context).colorScheme.error, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + _errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadServers, - child: const Text('Retry'), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: _loadServers, + child: const Text('Retry'), + ), + if (_debugServerData != null) ...[ + const SizedBox(width: 16), + OutlinedButton.icon( + onPressed: _copyDebugDataToClipboard, + icon: const Icon(Icons.copy), + label: const Text('Copy Debug Data'), + ), + ], + ], ), + if (_debugServerData != null) ...[ + const SizedBox(height: 12), + Text( + 'Debug data available for ${_debugServerData!.length} server(s)', + style: Theme.of(context).textTheme.bodySmall, + ), + ], ], ), ) diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 24d9d07a..54b5ec4a 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -143,10 +143,30 @@ class PlexAuthService { final List resources = response.data as List; // Filter for server resources and map to PlexServer objects - return resources - .where((r) => r['provides'] == 'server') - .map((r) => PlexServer.fromJson(r as Map)) - .toList(); + final servers = []; + final invalidServers = >[]; + + for (final resource in resources.where((r) => r['provides'] == 'server')) { + try { + final server = PlexServer.fromJson(resource as Map); + servers.add(server); + } catch (e) { + // Collect invalid servers for debugging + invalidServers.add(resource as Map); + continue; + } + } + + // If we have invalid servers but some valid ones, that's okay + // If we have no valid servers but some invalid ones, throw with debug info + if (servers.isEmpty && invalidServers.isNotEmpty) { + throw ServerParsingException( + 'No valid servers found. All ${invalidServers.length} server(s) have malformed data.', + invalidServers, + ); + } + + return servers; } /// Get user information @@ -249,20 +269,35 @@ class PlexServer { }); factory PlexServer.fromJson(Map json) { + // Validate required fields first + if (!_isValidServerJson(json)) { + throw FormatException('Invalid server data: missing required fields (name, clientIdentifier, accessToken, or connections)'); + } + final List connectionsJson = json['connections'] as List; final connections = []; // Parse connections and generate HTTP fallbacks for HTTPS connections for (final c in connectionsJson) { - final connection = PlexConnection.fromJson(c as Map); - connections.add(connection); + try { + final connection = PlexConnection.fromJson(c as Map); + connections.add(connection); - // Generate HTTP fallback for HTTPS connections - if (connection.protocol == 'https') { - connections.add(connection.toHttpFallback()); + // Generate HTTP fallback for HTTPS connections + if (connection.protocol == 'https') { + connections.add(connection.toHttpFallback()); + } + } catch (e) { + // Skip invalid connections rather than failing the entire server + continue; } } + // If no valid connections were parsed, this server is unusable + if (connections.isEmpty) { + throw FormatException('Server has no valid connections'); + } + DateTime? lastSeenAt; if (json['lastSeenAt'] != null) { try { @@ -273,9 +308,9 @@ class PlexServer { } return PlexServer( - name: json['name'] as String, - clientIdentifier: json['clientIdentifier'] as String, - accessToken: json['accessToken'] as String, + name: json['name'] as String, // Safe because validated above + clientIdentifier: json['clientIdentifier'] as String, // Safe because validated above + accessToken: json['accessToken'] as String, // Safe because validated above connections: connections, owned: json['owned'] as bool? ?? false, product: json['product'] as String?, @@ -285,6 +320,27 @@ class PlexServer { ); } + /// Validates that server JSON contains all required fields with correct types + static bool _isValidServerJson(Map json) { + // Check for required string fields + if (json['name'] is! String || (json['name'] as String).isEmpty) { + return false; + } + if (json['clientIdentifier'] is! String || (json['clientIdentifier'] as String).isEmpty) { + return false; + } + if (json['accessToken'] is! String || (json['accessToken'] as String).isEmpty) { + return false; + } + + // Check for connections array + if (json['connections'] is! List || (json['connections'] as List).isEmpty) { + return false; + } + + return true; + } + Map toJson() { return { 'name': name, @@ -516,17 +572,43 @@ class PlexConnection { }); factory PlexConnection.fromJson(Map json) { + // Validate required fields + if (!_isValidConnectionJson(json)) { + throw FormatException('Invalid connection data: missing required fields (protocol, address, port, or uri)'); + } + return PlexConnection( - protocol: json['protocol'] as String, - address: json['address'] as String, - port: json['port'] as int, - uri: json['uri'] as String, + protocol: json['protocol'] as String, // Safe because validated above + address: json['address'] as String, // Safe because validated above + port: json['port'] as int, // Safe because validated above + uri: json['uri'] as String, // Safe because validated above local: json['local'] as bool? ?? false, relay: json['relay'] as bool? ?? false, ipv6: json['IPv6'] as bool? ?? false, ); } + /// Validates that connection JSON contains all required fields with correct types + static bool _isValidConnectionJson(Map json) { + // Check for required string fields + if (json['protocol'] is! String || (json['protocol'] as String).isEmpty) { + return false; + } + if (json['address'] is! String || (json['address'] as String).isEmpty) { + return false; + } + if (json['uri'] is! String || (json['uri'] as String).isEmpty) { + return false; + } + + // Check for required port (integer) + if (json['port'] is! int) { + return false; + } + + return true; + } + Map toJson() { return { 'protocol': protocol, @@ -565,3 +647,14 @@ class PlexConnection { ); } } + +/// Custom exception for server parsing errors that includes debug data +class ServerParsingException implements Exception { + final String message; + final List> invalidServerData; + + ServerParsingException(this.message, this.invalidServerData); + + @override + String toString() => message; +} From 995f0d006d5060aaf0e56bf1a9c2d84ff2232d09 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:02:34 +0100 Subject: [PATCH 3/9] feat: i18n --- lib/i18n/strings.g.dart | 1972 +++++++++++++++++ lib/i18n/strings.i18n.json | 365 +++ lib/i18n/strings_sv.i18n.json | 365 +++ lib/main.dart | 47 +- lib/screens/about_screen.dart | 15 +- lib/screens/auth_screen.dart | 39 +- lib/screens/discover_screen.dart | 53 +- lib/screens/hub_detail_screen.dart | 19 +- lib/screens/libraries_screen.dart | 149 +- lib/screens/licenses_screen.dart | 11 +- lib/screens/logs_screen.dart | 21 +- lib/screens/main_screen.dart | 27 +- lib/screens/media_detail_screen.dart | 46 +- lib/screens/profile_switch_screen.dart | 7 +- lib/screens/search_screen.dart | 11 +- lib/screens/season_detail_screen.dart | 3 +- lib/screens/server_selection_screen.dart | 35 +- lib/screens/settings_screen.dart | 378 ++-- lib/screens/subtitle_styling_screen.dart | 23 +- lib/screens/video_player_screen.dart | 9 +- lib/services/settings_service.dart | 18 + lib/utils/shuffle_play_helper.dart | 5 +- lib/utils/user_switching_utils.dart | 3 +- lib/widgets/context_menu_wrapper.dart | 9 +- lib/widgets/file_info_bottom_sheet.dart | 55 +- lib/widgets/hotkey_recorder_widget.dart | 9 +- lib/widgets/media_card.dart | 7 +- lib/widgets/media_context_menu.dart | 27 +- lib/widgets/pin_entry_dialog.dart | 9 +- lib/widgets/profile_switch_dialog.dart | 7 +- lib/widgets/server_list_tile.dart | 11 +- lib/widgets/sort_bottom_sheet.dart | 3 +- lib/widgets/user_avatar_widget.dart | 7 +- .../sheets/audio_sync_sheet.dart | 15 +- .../sheets/audio_track_sheet.dart | 7 +- .../sheets/sleep_timer_sheet.dart | 7 +- .../sheets/subtitle_track_sheet.dart | 7 +- .../sheets/video_settings_sheet.dart | 11 +- .../video_controls/video_controls.dart | 15 +- .../widgets/sync_offset_control.dart | 13 +- pubspec.lock | 116 +- pubspec.yaml | 3 + 42 files changed, 3457 insertions(+), 502 deletions(-) create mode 100644 lib/i18n/strings.g.dart create mode 100644 lib/i18n/strings.i18n.json create mode 100644 lib/i18n/strings_sv.i18n.json diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart new file mode 100644 index 00000000..88bf442a --- /dev/null +++ b/lib/i18n/strings.g.dart @@ -0,0 +1,1972 @@ +/// Generated file. Do not edit. +/// +/// Original: lib/i18n +/// To regenerate, run: `dart run slang` +/// +/// Locales: 2 +/// Strings: 618 (309 per locale) +/// +/// Built on 2025-11-10 at 08:56 UTC + +// coverage:ignore-file +// ignore_for_file: type=lint + +import 'package:flutter/widgets.dart'; +import 'package:slang/builder/model/node.dart'; +import 'package:slang_flutter/slang_flutter.dart'; +export 'package:slang_flutter/slang_flutter.dart'; + +const AppLocale _baseLocale = AppLocale.en; + +/// Supported locales, see extension methods below. +/// +/// Usage: +/// - LocaleSettings.setLocale(AppLocale.en) // set locale +/// - Locale locale = AppLocale.en.flutterLocale // get flutter locale from enum +/// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check +enum AppLocale with BaseAppLocale { + en(languageCode: 'en', build: Translations.build), + sv(languageCode: 'sv', build: _StringsSv.build); + + const AppLocale({required this.languageCode, this.scriptCode, this.countryCode, required this.build}); // ignore: unused_element + + @override final String languageCode; + @override final String? scriptCode; + @override final String? countryCode; + @override final TranslationBuilder build; + + /// Gets current instance managed by [LocaleSettings]. + Translations get translations => LocaleSettings.instance.translationMap[this]!; +} + +/// Method A: Simple +/// +/// No rebuild after locale change. +/// Translation happens during initialization of the widget (call of t). +/// Configurable via 'translate_var'. +/// +/// Usage: +/// String a = t.someKey.anotherKey; +/// String b = t['someKey.anotherKey']; // Only for edge cases! +Translations get t => LocaleSettings.instance.currentTranslations; + +/// Method B: Advanced +/// +/// All widgets using this method will trigger a rebuild when locale changes. +/// Use this if you have e.g. a settings page where the user can select the locale during runtime. +/// +/// Step 1: +/// wrap your App with +/// TranslationProvider( +/// child: MyApp() +/// ); +/// +/// Step 2: +/// final t = Translations.of(context); // Get t variable. +/// String a = t.someKey.anotherKey; // Use t variable. +/// String b = t['someKey.anotherKey']; // Only for edge cases! +class TranslationProvider extends BaseTranslationProvider { + TranslationProvider({required super.child}) : super(settings: LocaleSettings.instance); + + static InheritedLocaleData of(BuildContext context) => InheritedLocaleData.of(context); +} + +/// Method B shorthand via [BuildContext] extension method. +/// Configurable via 'translate_var'. +/// +/// Usage (e.g. in a widget's build method): +/// context.t.someKey.anotherKey +extension BuildContextTranslationsExtension on BuildContext { + Translations get t => TranslationProvider.of(this).translations; +} + +/// Manages all translation instances and the current locale +class LocaleSettings extends BaseFlutterLocaleSettings { + LocaleSettings._() : super(utils: AppLocaleUtils.instance); + + static final instance = LocaleSettings._(); + + // static aliases (checkout base methods for documentation) + static AppLocale get currentLocale => instance.currentLocale; + static Stream getLocaleStream() => instance.getLocaleStream(); + static AppLocale setLocale(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocale(locale, listenToDeviceLocale: listenToDeviceLocale); + static AppLocale setLocaleRaw(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRaw(rawLocale, listenToDeviceLocale: listenToDeviceLocale); + static AppLocale useDeviceLocale() => instance.useDeviceLocale(); + @Deprecated('Use [AppLocaleUtils.supportedLocales]') static List get supportedLocales => instance.supportedLocales; + @Deprecated('Use [AppLocaleUtils.supportedLocalesRaw]') static List get supportedLocalesRaw => instance.supportedLocalesRaw; + static void setPluralResolver({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolver( + language: language, + locale: locale, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); +} + +/// Provides utility functions without any side effects. +class AppLocaleUtils extends BaseAppLocaleUtils { + AppLocaleUtils._() : super(baseLocale: _baseLocale, locales: AppLocale.values); + + static final instance = AppLocaleUtils._(); + + // static aliases (checkout base methods for documentation) + static AppLocale parse(String rawLocale) => instance.parse(rawLocale); + static AppLocale parseLocaleParts({required String languageCode, String? scriptCode, String? countryCode}) => instance.parseLocaleParts(languageCode: languageCode, scriptCode: scriptCode, countryCode: countryCode); + static AppLocale findDeviceLocale() => instance.findDeviceLocale(); + static List get supportedLocales => instance.supportedLocales; + static List get supportedLocalesRaw => instance.supportedLocalesRaw; +} + +// translations + +// Path: +class Translations implements BaseTranslations { + /// Returns the current translations of the given [context]. + /// + /// Usage: + /// final t = Translations.of(context); + static Translations of(BuildContext context) => InheritedLocaleData.of(context).translations; + + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + Translations.build({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = TranslationMetadata( + locale: AppLocale.en, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ) { + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + dynamic operator[](String key) => $meta.getTranslation(key); + + late final Translations _root = this; // ignore: unused_field + + // Translations + late final _StringsAppEn app = _StringsAppEn._(_root); + late final _StringsAuthEn auth = _StringsAuthEn._(_root); + late final _StringsCommonEn common = _StringsCommonEn._(_root); + late final _StringsScreensEn screens = _StringsScreensEn._(_root); + late final _StringsUpdateEn update = _StringsUpdateEn._(_root); + late final _StringsSettingsEn settings = _StringsSettingsEn._(_root); + late final _StringsSearchEn search = _StringsSearchEn._(_root); + late final _StringsHotkeysEn hotkeys = _StringsHotkeysEn._(_root); + late final _StringsPinEntryEn pinEntry = _StringsPinEntryEn._(_root); + late final _StringsFileInfoEn fileInfo = _StringsFileInfoEn._(_root); + late final _StringsMediaMenuEn mediaMenu = _StringsMediaMenuEn._(_root); + late final _StringsTooltipsEn tooltips = _StringsTooltipsEn._(_root); + late final _StringsVideoControlsEn videoControls = _StringsVideoControlsEn._(_root); + late final _StringsUserStatusEn userStatus = _StringsUserStatusEn._(_root); + late final _StringsMessagesEn messages = _StringsMessagesEn._(_root); + late final _StringsProfileEn profile = _StringsProfileEn._(_root); + late final _StringsSubtitlingStylingEn subtitlingStyling = _StringsSubtitlingStylingEn._(_root); + late final _StringsDialogEn dialog = _StringsDialogEn._(_root); + late final _StringsDiscoverEn discover = _StringsDiscoverEn._(_root); + late final _StringsErrorsEn errors = _StringsErrorsEn._(_root); + late final _StringsLibrariesEn libraries = _StringsLibrariesEn._(_root); + late final _StringsAboutEn about = _StringsAboutEn._(_root); + late final _StringsServerSelectionEn serverSelection = _StringsServerSelectionEn._(_root); + late final _StringsHubDetailEn hubDetail = _StringsHubDetailEn._(_root); + late final _StringsLogsEn logs = _StringsLogsEn._(_root); + late final _StringsLicensesEn licenses = _StringsLicensesEn._(_root); + late final _StringsNavigationEn navigation = _StringsNavigationEn._(_root); +} + +// Path: app +class _StringsAppEn { + _StringsAppEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'Plezy'; + String get loading => 'Loading...'; +} + +// Path: auth +class _StringsAuthEn { + _StringsAuthEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get signInWithPlex => 'Sign in with Plex'; + String get showQRCode => 'Show QR Code'; + String get cancel => 'Cancel'; + String get authenticate => 'Authenticate'; + String get retry => 'Retry'; + String get debugEnterToken => 'Debug: Enter Plex Token'; + String get plexTokenLabel => 'Plex Auth Token'; + String get plexTokenHint => 'Enter your Plex.tv token'; + String get authenticationTimeout => 'Authentication timed out. Please try again.'; + String get scanQRCodeInstruction => 'Scan this QR code with a device logged into Plex to authenticate.'; + String get waitingForAuth => 'Waiting for authentication...\nPlease complete sign-in in your browser.'; +} + +// Path: common +class _StringsCommonEn { + _StringsCommonEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get cancel => 'Cancel'; + String get save => 'Save'; + String get close => 'Close'; + String get clear => 'Clear'; + String get reset => 'Reset'; + String get later => 'Later'; + String get submit => 'Submit'; + String get confirm => 'Confirm'; + String get retry => 'Retry'; + String get playNow => 'Play Now'; + String get logout => 'Logout'; + String get online => 'Online'; + String get offline => 'Offline'; + String get owned => 'Owned'; + String get shared => 'Shared'; + String get current => 'CURRENT'; + String get unknown => 'Unknown'; + String get refresh => 'Refresh'; + String get yes => 'Yes'; + String get no => 'No'; + String get server => 'Server'; +} + +// Path: screens +class _StringsScreensEn { + _StringsScreensEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get licenses => 'Licenses'; + String get selectServer => 'Select Server'; + String get switchProfile => 'Switch Profile'; + String get subtitleStyling => 'Subtitle Styling'; + String get search => 'Search'; + String get logs => 'Logs'; +} + +// Path: update +class _StringsUpdateEn { + _StringsUpdateEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get available => 'Update Available'; + String versionAvailable({required Object version}) => 'Version ${version} is available'; + String currentVersion({required Object version}) => 'Current: ${version}'; + String get skipVersion => 'Skip This Version'; + String get viewRelease => 'View Release'; + String get latestVersion => 'You are on the latest version'; + String get checkFailed => 'Failed to check for updates'; +} + +// Path: settings +class _StringsSettingsEn { + _StringsSettingsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'Settings'; + String get language => 'Language'; + String get theme => 'Theme'; + String get appearance => 'Appearance'; + String get videoPlayback => 'Video Playback'; + String get shufflePlay => 'Shuffle Play'; + String get advanced => 'Advanced'; + String get useSeasonPostersDescription => 'Show season poster instead of series poster for episodes'; + String get showHeroSectionDescription => 'Display featured content carousel on home screen'; + String get secondsLabel => 'Seconds'; + String get minutesLabel => 'Minutes'; + String get secondsShort => 's'; + String get minutesShort => 'm'; + String durationHint({required Object min, required Object max}) => 'Enter duration (${min}-${max})'; + String get systemTheme => 'System'; + String get systemThemeDescription => 'Follow system settings'; + String get lightTheme => 'Light'; + String get darkTheme => 'Dark'; + String get libraryDensity => 'Library Density'; + String get compact => 'Compact'; + String get compactDescription => 'Smaller cards, more items visible'; + String get normal => 'Normal'; + String get normalDescription => 'Default size'; + String get comfortable => 'Comfortable'; + String get comfortableDescription => 'Larger cards, fewer items visible'; + String get viewMode => 'View Mode'; + String get gridView => 'Grid'; + String get gridViewDescription => 'Display items in a grid layout'; + String get listView => 'List'; + String get listViewDescription => 'Display items in a list layout'; + String get useSeasonPosters => 'Use Season Posters'; + String get showHeroSection => 'Show Hero Section'; + String get hardwareDecoding => 'Hardware Decoding'; + String get hardwareDecodingDescription => 'Use hardware acceleration when available'; + String get bufferSize => 'Buffer Size'; + String bufferSizeMB({required Object size}) => '${size}MB'; + String get subtitleStyling => 'Subtitle Styling'; + String get subtitleStylingDescription => 'Customize subtitle appearance'; + String get smallSkipDuration => 'Small Skip Duration'; + String get largeSkipDuration => 'Large Skip Duration'; + String secondsUnit({required Object seconds}) => '${seconds} seconds'; + String get defaultSleepTimer => 'Default Sleep Timer'; + String minutesUnit({required Object minutes}) => '${minutes} minutes'; + String get unwatchedOnly => 'Unwatched Only'; + String get unwatchedOnlyDescription => 'Only include unwatched episodes in shuffle queue'; + String get shuffleOrderNavigation => 'Shuffle Order Navigation'; + String get shuffleOrderNavigationDescription => 'Next/previous buttons follow shuffled order'; + String get loopShuffleQueue => 'Loop Shuffle Queue'; + String get loopShuffleQueueDescription => 'Restart queue when reaching the end'; + String get videoPlayerControls => 'Video Player Controls'; + String get keyboardShortcuts => 'Keyboard Shortcuts'; + String get keyboardShortcutsDescription => 'Customize keyboard shortcuts'; + String get debugLogging => 'Debug Logging'; + String get debugLoggingDescription => 'Enable detailed logging for troubleshooting'; + String get viewLogs => 'View Logs'; + String get viewLogsDescription => 'View application logs'; + String get clearCache => 'Clear Cache'; + String get clearCacheDescription => 'This will clear all cached images and data. The app may take longer to load content after clearing the cache.'; + String get clearCacheSuccess => 'Cache cleared successfully'; + String get resetSettings => 'Reset Settings'; + String get resetSettingsDescription => 'This will reset all settings to their default values. This action cannot be undone.'; + String get resetSettingsSuccess => 'Settings reset successfully'; + String get shortcutsReset => 'Shortcuts reset to defaults'; + String get about => 'About'; + String get aboutDescription => 'App information and licenses'; + String get updates => 'Updates'; + String get updateAvailable => 'Update Available'; + String get checkForUpdates => 'Check for Updates'; + String get validationErrorEnterNumber => 'Please enter a valid number'; + String validationErrorDuration({required Object min, required Object max, required Object unit}) => 'Duration must be between ${min} and ${max} ${unit}'; + String shortcutAlreadyAssigned({required Object action}) => 'Shortcut already assigned to ${action}'; + String shortcutUpdated({required Object action}) => 'Shortcut updated for ${action}'; +} + +// Path: search +class _StringsSearchEn { + _StringsSearchEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get hint => 'Search movies, shows, music...'; + String get tryDifferentTerm => 'Try a different search term'; +} + +// Path: hotkeys +class _StringsHotkeysEn { + _StringsHotkeysEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String setShortcutFor({required Object actionName}) => 'Set Shortcut for ${actionName}'; + String get clearShortcut => 'Clear shortcut'; +} + +// Path: pinEntry +class _StringsPinEntryEn { + _StringsPinEntryEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get enterPin => 'Enter PIN'; + String get showPin => 'Show PIN'; + String get hidePin => 'Hide PIN'; +} + +// Path: fileInfo +class _StringsFileInfoEn { + _StringsFileInfoEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'File Info'; + String get video => 'Video'; + String get audio => 'Audio'; + String get file => 'File'; + String get advanced => 'Advanced'; + String get codec => 'Codec'; + String get resolution => 'Resolution'; + String get bitrate => 'Bitrate'; + String get frameRate => 'Frame Rate'; + String get aspectRatio => 'Aspect Ratio'; + String get profile => 'Profile'; + String get bitDepth => 'Bit Depth'; + String get colorSpace => 'Color Space'; + String get colorRange => 'Color Range'; + String get colorPrimaries => 'Color Primaries'; + String get chromaSubsampling => 'Chroma Subsampling'; + String get channels => 'Channels'; + String get path => 'Path'; + String get size => 'Size'; + String get container => 'Container'; + String get duration => 'Duration'; + String get optimizedForStreaming => 'Optimized for Streaming'; + String get has64bitOffsets => '64-bit Offsets'; +} + +// Path: mediaMenu +class _StringsMediaMenuEn { + _StringsMediaMenuEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get markAsWatched => 'Mark as Watched'; + String get markAsUnwatched => 'Mark as Unwatched'; + String get goToSeries => 'Go to series'; + String get goToSeason => 'Go to season'; + String get shufflePlay => 'Shuffle Play'; + String get fileInfo => 'File Info'; +} + +// Path: tooltips +class _StringsTooltipsEn { + _StringsTooltipsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get shufflePlay => 'Shuffle play'; + String get markAsWatched => 'Mark as watched'; + String get markAsUnwatched => 'Mark as unwatched'; +} + +// Path: videoControls +class _StringsVideoControlsEn { + _StringsVideoControlsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get audioLabel => 'Audio'; + String get subtitlesLabel => 'Subtitles'; + String get resetToZero => 'Reset to 0ms'; + String addTime({required Object amount, required Object unit}) => '+${amount}${unit}'; + String minusTime({required Object amount, required Object unit}) => '-${amount}${unit}'; + String playsLater({required Object label}) => '${label} plays later'; + String playsEarlier({required Object label}) => '${label} plays earlier'; + String get noOffset => 'No offset'; + String get letterbox => 'Letterbox'; + String get fillScreen => 'Fill screen'; + String get stretch => 'Stretch'; + String get lockRotation => 'Lock rotation'; + String get unlockRotation => 'Unlock rotation'; +} + +// Path: userStatus +class _StringsUserStatusEn { + _StringsUserStatusEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get admin => 'Admin'; + String get restricted => 'Restricted'; + String get protected => 'Protected'; +} + +// Path: messages +class _StringsMessagesEn { + _StringsMessagesEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get markedAsWatched => 'Marked as watched'; + String get markedAsUnwatched => 'Marked as unwatched'; + String errorLoading({required Object error}) => 'Error: ${error}'; + String get fileInfoNotAvailable => 'File information not available'; + String errorLoadingFileInfo({required Object error}) => 'Error loading file info: ${error}'; + String get errorLoadingSeries => 'Error loading series'; + String get errorLoadingSeason => 'Error loading season'; + String get musicNotSupported => 'Music playback is not yet supported'; + String get logsCleared => 'Logs cleared'; + String get logsCopied => 'Logs copied to clipboard'; + String get noLogsAvailable => 'No logs available'; + String libraryScanning({required Object title}) => 'Scanning "${title}"...'; + String libraryScanStarted({required Object title}) => 'Library scan started for "${title}"'; + String libraryScanFailed({required Object error}) => 'Failed to scan library: ${error}'; + String metadataRefreshing({required Object title}) => 'Refreshing metadata for "${title}"...'; + String metadataRefreshStarted({required Object title}) => 'Metadata refresh started for "${title}"'; + String metadataRefreshFailed({required Object error}) => 'Failed to refresh metadata: ${error}'; + String get noPlexToken => 'No Plex token found. Please login again.'; + String get logoutConfirm => 'Are you sure you want to logout?'; + String get noSeasonsFound => 'No seasons found'; + String get noEpisodesFound => 'No episodes found in first season'; + String get noEpisodesFoundGeneral => 'No episodes found'; + String get noResultsFound => 'No results found'; + String sleepTimerSet({required Object label}) => 'Sleep timer set for ${label}'; + String failedToSwitchProfile({required Object displayName}) => 'Failed to switch to ${displayName}'; +} + +// Path: profile +class _StringsProfileEn { + _StringsProfileEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get noUsersAvailable => 'No users available'; +} + +// Path: subtitlingStyling +class _StringsSubtitlingStylingEn { + _StringsSubtitlingStylingEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get stylingOptions => 'Styling Options'; + String get fontSize => 'Font Size'; + String get textColor => 'Text Color'; + String get borderSize => 'Border Size'; + String get borderColor => 'Border Color'; + String get backgroundOpacity => 'Background Opacity'; + String get backgroundColor => 'Background Color'; +} + +// Path: dialog +class _StringsDialogEn { + _StringsDialogEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get confirmAction => 'Confirm Action'; + String get areYouSure => 'Are you sure you want to perform this action?'; + String get cancel => 'Cancel'; + String get playNow => 'Play Now'; +} + +// Path: discover +class _StringsDiscoverEn { + _StringsDiscoverEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'Discover'; + String get switchProfile => 'Switch Profile'; + String get switchServer => 'Switch Server'; + String get logout => 'Logout'; + String get noContentAvailable => 'No content available'; + String get addMediaToLibraries => 'Add some media to your libraries'; + String get continueWatching => 'Continue Watching'; + String get recentlyAdded => 'Recently Added'; + String get play => 'Play'; + String get resume => 'Resume'; + String playEpisode({required Object season, required Object episode}) => 'Play S${season}, E${episode}'; + String resumeEpisode({required Object season, required Object episode}) => 'Resume S${season}, E${episode}'; + String get pause => 'Pause'; + String get overview => 'Overview'; + String episodeCount({required Object count}) => '${count} episodes'; + String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} watched'; + String get movie => 'Movie'; + String get tvShow => 'TV Show'; + String minutesLeft({required Object minutes}) => '${minutes} min left'; +} + +// Path: errors +class _StringsErrorsEn { + _StringsErrorsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String searchFailed({required Object error}) => 'Search failed: ${error}'; + String connectionTimeout({required Object context}) => 'Connection timeout while loading ${context}'; + String get connectionFailed => 'Unable to connect to Plex server'; + String failedToLoad({required Object context, required Object error}) => 'Failed to load ${context}: ${error}'; + String get noClientAvailable => 'No client available'; + String authenticationFailed({required Object error}) => 'Authentication failed: ${error}'; + String get couldNotLaunchUrl => 'Could not launch auth URL'; + String get pleaseEnterToken => 'Please enter a token'; + String get invalidToken => 'Invalid token'; + String failedToVerifyToken({required Object error}) => 'Failed to verify token: ${error}'; + String failedToSwitchProfile({required Object displayName}) => 'Failed to switch to ${displayName}'; + String get connectionFailedGeneric => 'Connection failed'; +} + +// Path: libraries +class _StringsLibrariesEn { + _StringsLibrariesEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'Libraries'; + String get scanLibraryFiles => 'Scan Library Files'; + String get scanLibrary => 'Scan Library'; + String get analyze => 'Analyze'; + String get analyzeLibrary => 'Analyze Library'; + String get refreshMetadata => 'Refresh Metadata'; + String get emptyTrash => 'Empty Trash'; + String emptyingTrash({required Object title}) => 'Emptying trash for "${title}"...'; + String trashEmptied({required Object title}) => 'Trash emptied for "${title}"'; + String failedToEmptyTrash({required Object error}) => 'Failed to empty trash: ${error}'; + String analyzing({required Object title}) => 'Analyzing "${title}"...'; + String analysisStarted({required Object title}) => 'Analysis started for "${title}"'; + String failedToAnalyze({required Object error}) => 'Failed to analyze library: ${error}'; + String get noLibrariesFound => 'No libraries found'; + String get thisLibraryIsEmpty => 'This library is empty'; + String get all => 'All'; + String get clearAll => 'Clear All'; + String scanLibraryConfirm({required Object title}) => 'Are you sure you want to scan "${title}"?'; + String analyzeLibraryConfirm({required Object title}) => 'Are you sure you want to analyze "${title}"?'; + String refreshMetadataConfirm({required Object title}) => 'Are you sure you want to refresh metadata for "${title}"?'; + String emptyTrashConfirm({required Object title}) => 'Are you sure you want to empty trash for "${title}"?'; + String get manageLibraries => 'Manage Libraries'; + String get sort => 'Sort'; + String get sortBy => 'Sort By'; + String get filters => 'Filters'; + String loadingLibraryWithCount({required Object count}) => 'Loading library... (${count} items loaded)'; + String get confirmActionMessage => 'Are you sure you want to perform this action?'; + String get showLibrary => 'Show library'; + String get hideLibrary => 'Hide library'; + String get libraryOptions => 'Library options'; +} + +// Path: about +class _StringsAboutEn { + _StringsAboutEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'About'; + String get openSourceLicenses => 'Open Source Licenses'; + String versionLabel({required Object version}) => 'Version ${version}'; + String get appDescription => 'A beautiful Plex client for Flutter'; + String get viewLicensesDescription => 'View licenses of third-party libraries'; +} + +// Path: serverSelection +class _StringsServerSelectionEn { + _StringsServerSelectionEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get connectingToServer => 'Connecting to server...'; + String get serverDebugCopied => 'Server debug data copied to clipboard'; + String get copyDebugData => 'Copy Debug Data'; + String get noServersFound => 'No servers found'; + String malformedServerData({required Object count}) => 'Found ${count} server(s) with malformed data. No valid servers available.'; + String get incompleteServerInfo => 'Some servers have incomplete information and were skipped. Please check your Plex.tv account.'; + String get incompleteConnectionInfo => 'Server connection information is incomplete. Please try again.'; + String malformedServerInfo({required Object message}) => 'Server information is malformed: ${message}'; + String get networkConnectionFailed => 'Network connection failed. Please check your internet connection and try again.'; + String get authenticationFailed => 'Authentication failed. Please sign in again.'; + String get plexServiceUnavailable => 'Plex service unavailable. Please try again later.'; + String failedToLoadServers({required Object error}) => 'Failed to load servers: ${error}'; +} + +// Path: hubDetail +class _StringsHubDetailEn { + _StringsHubDetailEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'Title'; + String get releaseYear => 'Release Year'; + String get dateAdded => 'Date Added'; + String get rating => 'Rating'; + String get noItemsFound => 'No items found'; +} + +// Path: logs +class _StringsLogsEn { + _StringsLogsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'Logs'; + String get clearLogs => 'Clear Logs'; + String get copyLogs => 'Copy Logs'; + String get exportLogs => 'Export Logs'; + String get noLogsToShow => 'No logs to show'; + String get error => 'Error:'; + String get stackTrace => 'Stack Trace:'; +} + +// Path: licenses +class _StringsLicensesEn { + _StringsLicensesEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get relatedPackages => 'Related Packages'; + String get license => 'License'; + String licenseNumber({required Object number}) => 'License ${number}'; + String licensesCount({required Object count}) => '${count} licenses'; +} + +// Path: navigation +class _StringsNavigationEn { + _StringsNavigationEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get home => 'Home'; + String get search => 'Search'; + String get libraries => 'Libraries'; + String get settings => 'Settings'; +} + +// Path: +class _StringsSv implements Translations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + _StringsSv.build({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = TranslationMetadata( + locale: AppLocale.sv, + 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 _StringsSv _root = this; // ignore: unused_field + + // Translations + @override late final _StringsAppSv app = _StringsAppSv._(_root); + @override late final _StringsAuthSv auth = _StringsAuthSv._(_root); + @override late final _StringsCommonSv common = _StringsCommonSv._(_root); + @override late final _StringsScreensSv screens = _StringsScreensSv._(_root); + @override late final _StringsUpdateSv update = _StringsUpdateSv._(_root); + @override late final _StringsSettingsSv settings = _StringsSettingsSv._(_root); + @override late final _StringsSearchSv search = _StringsSearchSv._(_root); + @override late final _StringsHotkeysSv hotkeys = _StringsHotkeysSv._(_root); + @override late final _StringsPinEntrySv pinEntry = _StringsPinEntrySv._(_root); + @override late final _StringsFileInfoSv fileInfo = _StringsFileInfoSv._(_root); + @override late final _StringsMediaMenuSv mediaMenu = _StringsMediaMenuSv._(_root); + @override late final _StringsTooltipsSv tooltips = _StringsTooltipsSv._(_root); + @override late final _StringsVideoControlsSv videoControls = _StringsVideoControlsSv._(_root); + @override late final _StringsUserStatusSv userStatus = _StringsUserStatusSv._(_root); + @override late final _StringsMessagesSv messages = _StringsMessagesSv._(_root); + @override late final _StringsProfileSv profile = _StringsProfileSv._(_root); + @override late final _StringsSubtitlingStylingSv subtitlingStyling = _StringsSubtitlingStylingSv._(_root); + @override late final _StringsDialogSv dialog = _StringsDialogSv._(_root); + @override late final _StringsDiscoverSv discover = _StringsDiscoverSv._(_root); + @override late final _StringsErrorsSv errors = _StringsErrorsSv._(_root); + @override late final _StringsLibrariesSv libraries = _StringsLibrariesSv._(_root); + @override late final _StringsAboutSv about = _StringsAboutSv._(_root); + @override late final _StringsServerSelectionSv serverSelection = _StringsServerSelectionSv._(_root); + @override late final _StringsHubDetailSv hubDetail = _StringsHubDetailSv._(_root); + @override late final _StringsLogsSv logs = _StringsLogsSv._(_root); + @override late final _StringsLicensesSv licenses = _StringsLicensesSv._(_root); + @override late final _StringsNavigationSv navigation = _StringsNavigationSv._(_root); +} + +// Path: app +class _StringsAppSv implements _StringsAppEn { + _StringsAppSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Plezy'; + @override String get loading => 'Laddar...'; +} + +// Path: auth +class _StringsAuthSv implements _StringsAuthEn { + _StringsAuthSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get signInWithPlex => 'Logga in med Plex'; + @override String get showQRCode => 'Visa QR-kod'; + @override String get cancel => 'Avbryt'; + @override String get authenticate => 'Autentisera'; + @override String get retry => 'Försök igen'; + @override String get debugEnterToken => 'Debug: Ange Plex-token'; + @override String get plexTokenLabel => 'Plex-autentiseringstoken'; + @override String get plexTokenHint => 'Ange din Plex.tv-token'; + @override String get authenticationTimeout => 'Autentisering tog för lång tid. Försök igen.'; + @override String get scanQRCodeInstruction => 'Skanna denna QR-kod med en enhet inloggad på Plex för att autentisera.'; + @override String get waitingForAuth => 'Väntar på autentisering...\nVänligen slutför inloggning i din webbläsare.'; +} + +// Path: common +class _StringsCommonSv implements _StringsCommonEn { + _StringsCommonSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get cancel => 'Avbryt'; + @override String get save => 'Spara'; + @override String get close => 'Stäng'; + @override String get clear => 'Rensa'; + @override String get reset => 'Återställ'; + @override String get later => 'Senare'; + @override String get submit => 'Skicka'; + @override String get confirm => 'Bekräfta'; + @override String get retry => 'Försök igen'; + @override String get playNow => 'Spela nu'; + @override String get logout => 'Logga ut'; + @override String get online => 'Online'; + @override String get offline => 'Offline'; + @override String get owned => 'Egen'; + @override String get shared => 'Delad'; + @override String get current => 'NUVARANDE'; + @override String get unknown => 'Okänd'; + @override String get refresh => 'Uppdatera'; + @override String get yes => 'Ja'; + @override String get no => 'Nej'; + @override String get server => 'Server'; +} + +// Path: screens +class _StringsScreensSv implements _StringsScreensEn { + _StringsScreensSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get licenses => 'Licenser'; + @override String get selectServer => 'Välj server'; + @override String get switchProfile => 'Byt profil'; + @override String get subtitleStyling => 'Undertext-styling'; + @override String get search => 'Sök'; + @override String get logs => 'Loggar'; +} + +// Path: update +class _StringsUpdateSv implements _StringsUpdateEn { + _StringsUpdateSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get available => 'Uppdatering tillgänglig'; + @override String versionAvailable({required Object version}) => 'Version ${version} är tillgänglig'; + @override String currentVersion({required Object version}) => 'Nuvarande: ${version}'; + @override String get skipVersion => 'Hoppa över denna version'; + @override String get viewRelease => 'Visa release'; + @override String get latestVersion => 'Du har den senaste versionen'; + @override String get checkFailed => 'Misslyckades att kontrollera uppdateringar'; +} + +// Path: settings +class _StringsSettingsSv implements _StringsSettingsEn { + _StringsSettingsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Inställningar'; + @override String get language => 'Språk'; + @override String get theme => 'Tema'; + @override String get appearance => 'Utseende'; + @override String get videoPlayback => 'Videouppspelning'; + @override String get shufflePlay => 'Blanda uppspelning'; + @override String get advanced => 'Avancerat'; + @override String get useSeasonPostersDescription => 'Visa säsongsaffisch istället för serieaffisch för avsnitt'; + @override String get showHeroSectionDescription => 'Visa utvalda innehållskarusell på startsidan'; + @override String get secondsLabel => 'Sekunder'; + @override String get minutesLabel => 'Minuter'; + @override String get secondsShort => 's'; + @override String get minutesShort => 'm'; + @override String durationHint({required Object min, required Object max}) => 'Ange tid (${min}-${max})'; + @override String get systemTheme => 'System'; + @override String get systemThemeDescription => 'Följ systeminställningar'; + @override String get lightTheme => 'Ljust'; + @override String get darkTheme => 'Mörkt'; + @override String get libraryDensity => 'Biblioteksdensitet'; + @override String get compact => 'Kompakt'; + @override String get compactDescription => 'Mindre kort, fler objekt synliga'; + @override String get normal => 'Normal'; + @override String get normalDescription => 'Standardstorlek'; + @override String get comfortable => 'Bekväm'; + @override String get comfortableDescription => 'Större kort, färre objekt synliga'; + @override String get viewMode => 'Visningsläge'; + @override String get gridView => 'Rutnät'; + @override String get gridViewDescription => 'Visa objekt i rutnätslayout'; + @override String get listView => 'Lista'; + @override String get listViewDescription => 'Visa objekt i listlayout'; + @override String get useSeasonPosters => 'Använd säsongsaffischer'; + @override String get showHeroSection => 'Visa hjältesektion'; + @override String get hardwareDecoding => 'Hårdvaruavkodning'; + @override String get hardwareDecodingDescription => 'Använd hårdvaruacceleration när tillgängligt'; + @override String get bufferSize => 'Bufferstorlek'; + @override String bufferSizeMB({required Object size}) => '${size}MB'; + @override String get subtitleStyling => 'Undertext-styling'; + @override String get subtitleStylingDescription => 'Anpassa undertextutseende'; + @override String get smallSkipDuration => 'Kort hoppvaraktighet'; + @override String get largeSkipDuration => 'Lång hoppvaraktighet'; + @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 unwatchedOnly => 'Endast osedda'; + @override String get unwatchedOnlyDescription => 'Inkludera endast osedda avsnitt i blandningskön'; + @override String get shuffleOrderNavigation => 'Blandningsordning-navigation'; + @override String get shuffleOrderNavigationDescription => 'Nästa/föregående knappar följer blandad ordning'; + @override String get loopShuffleQueue => 'Loopa blandningskö'; + @override String get loopShuffleQueueDescription => 'Starta om kö när slutet nås'; + @override String get videoPlayerControls => 'Videospelar-kontroller'; + @override String get keyboardShortcuts => 'Tangentbordsgenvägar'; + @override String get keyboardShortcutsDescription => 'Anpassa tangentbordsgenvägar'; + @override String get debugLogging => 'Felsökningsloggning'; + @override String get debugLoggingDescription => 'Aktivera detaljerad loggning för felsökning'; + @override String get viewLogs => 'Visa loggar'; + @override String get viewLogsDescription => 'Visa applikationsloggar'; + @override String get clearCache => 'Rensa cache'; + @override String get clearCacheDescription => 'Detta rensar alla cachade bilder och data. Appen kan ta längre tid att ladda innehåll efter cache-rensning.'; + @override String get clearCacheSuccess => 'Cache rensad framgångsrikt'; + @override String get resetSettings => 'Återställ inställningar'; + @override String get resetSettingsDescription => 'Detta återställer alla inställningar till standardvärden. Denna åtgärd kan inte ångras.'; + @override String get resetSettingsSuccess => 'Inställningar återställda framgångsrikt'; + @override String get shortcutsReset => 'Genvägar återställda till standard'; + @override String get about => 'Om'; + @override String get aboutDescription => 'Appinformation och licenser'; + @override String get updates => 'Uppdateringar'; + @override String get updateAvailable => 'Uppdatering tillgänglig'; + @override String get checkForUpdates => 'Kontrollera uppdateringar'; + @override String get validationErrorEnterNumber => 'Vänligen ange ett giltigt nummer'; + @override String validationErrorDuration({required Object min, required Object max, required Object unit}) => 'Tiden måste vara mellan ${min} och ${max} ${unit}'; + @override String shortcutAlreadyAssigned({required Object action}) => 'Genväg redan tilldelad ${action}'; + @override String shortcutUpdated({required Object action}) => 'Genväg uppdaterad för ${action}'; +} + +// Path: search +class _StringsSearchSv implements _StringsSearchEn { + _StringsSearchSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get hint => 'Sök filmer, serier, musik...'; + @override String get tryDifferentTerm => 'Prova en annan sökterm'; +} + +// Path: hotkeys +class _StringsHotkeysSv implements _StringsHotkeysEn { + _StringsHotkeysSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String setShortcutFor({required Object actionName}) => 'Sätt genväg för ${actionName}'; + @override String get clearShortcut => 'Rensa genväg'; +} + +// Path: pinEntry +class _StringsPinEntrySv implements _StringsPinEntryEn { + _StringsPinEntrySv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get enterPin => 'Ange PIN'; + @override String get showPin => 'Visa PIN'; + @override String get hidePin => 'Dölj PIN'; +} + +// Path: fileInfo +class _StringsFileInfoSv implements _StringsFileInfoEn { + _StringsFileInfoSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Filinformation'; + @override String get video => 'Video'; + @override String get audio => 'Ljud'; + @override String get file => 'Fil'; + @override String get advanced => 'Avancerat'; + @override String get codec => 'Kodek'; + @override String get resolution => 'Upplösning'; + @override String get bitrate => 'Bithastighet'; + @override String get frameRate => 'Bildfrekvens'; + @override String get aspectRatio => 'Bildförhållande'; + @override String get profile => 'Profil'; + @override String get bitDepth => 'Bitdjup'; + @override String get colorSpace => 'Färgrymd'; + @override String get colorRange => 'Färgområde'; + @override String get colorPrimaries => 'Färggrunder'; + @override String get chromaSubsampling => 'Kroma-undersampling'; + @override String get channels => 'Kanaler'; + @override String get path => 'Sökväg'; + @override String get size => 'Storlek'; + @override String get container => 'Container'; + @override String get duration => 'Varaktighet'; + @override String get optimizedForStreaming => 'Optimerad för streaming'; + @override String get has64bitOffsets => '64-bit offset'; +} + +// Path: mediaMenu +class _StringsMediaMenuSv implements _StringsMediaMenuEn { + _StringsMediaMenuSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get markAsWatched => 'Markera som sedd'; + @override String get markAsUnwatched => 'Markera som osedd'; + @override String get goToSeries => 'Gå till serie'; + @override String get goToSeason => 'Gå till säsong'; + @override String get shufflePlay => 'Blanda uppspelning'; + @override String get fileInfo => 'Filinformation'; +} + +// Path: tooltips +class _StringsTooltipsSv implements _StringsTooltipsEn { + _StringsTooltipsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get shufflePlay => 'Blanda uppspelning'; + @override String get markAsWatched => 'Markera som sedd'; + @override String get markAsUnwatched => 'Markera som osedd'; +} + +// Path: videoControls +class _StringsVideoControlsSv implements _StringsVideoControlsEn { + _StringsVideoControlsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get audioLabel => 'Ljud'; + @override String get subtitlesLabel => 'Undertexter'; + @override String get resetToZero => 'Återställ till 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} spelas senare'; + @override String playsEarlier({required Object label}) => '${label} spelas tidigare'; + @override String get noOffset => 'Ingen offset'; + @override String get letterbox => 'Letterbox'; + @override String get fillScreen => 'Fyll skärm'; + @override String get stretch => 'Sträck'; + @override String get lockRotation => 'Lås rotation'; + @override String get unlockRotation => 'Lås upp rotation'; +} + +// Path: userStatus +class _StringsUserStatusSv implements _StringsUserStatusEn { + _StringsUserStatusSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get admin => 'Admin'; + @override String get restricted => 'Begränsad'; + @override String get protected => 'Skyddad'; +} + +// Path: messages +class _StringsMessagesSv implements _StringsMessagesEn { + _StringsMessagesSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get markedAsWatched => 'Markerad som sedd'; + @override String get markedAsUnwatched => 'Markerad som osedd'; + @override String errorLoading({required Object error}) => 'Fel: ${error}'; + @override String get fileInfoNotAvailable => 'Filinformation inte tillgänglig'; + @override String errorLoadingFileInfo({required Object error}) => 'Fel vid laddning av filinformation: ${error}'; + @override String get errorLoadingSeries => 'Fel vid laddning av serie'; + @override String get errorLoadingSeason => 'Fel vid laddning av säsong'; + @override String get musicNotSupported => 'Musikuppspelning stöds inte ännu'; + @override String get logsCleared => 'Loggar rensade'; + @override String get logsCopied => 'Loggar kopierade till urklipp'; + @override String get noLogsAvailable => 'Inga loggar tillgängliga'; + @override String libraryScanning({required Object title}) => 'Skannar "${title}"...'; + @override String libraryScanStarted({required Object title}) => 'Biblioteksskanning startad för "${title}"'; + @override String libraryScanFailed({required Object error}) => 'Misslyckades att skanna bibliotek: ${error}'; + @override String metadataRefreshing({required Object title}) => 'Uppdaterar metadata för "${title}"...'; + @override String metadataRefreshStarted({required Object title}) => 'Metadata-uppdatering startad för "${title}"'; + @override String metadataRefreshFailed({required Object error}) => 'Misslyckades att uppdatera metadata: ${error}'; + @override String get noPlexToken => 'Ingen Plex-token hittad. Vänligen logga in igen.'; + @override String get logoutConfirm => 'Är du säker på att du vill logga ut?'; + @override String get noSeasonsFound => 'Inga säsonger hittades'; + @override String get noEpisodesFound => 'Inga avsnitt hittades i första säsongen'; + @override String get noEpisodesFoundGeneral => 'Inga avsnitt hittades'; + @override String get noResultsFound => 'Inga resultat hittades'; + @override String sleepTimerSet({required Object label}) => 'Sovtimer inställd för ${label}'; + @override String failedToSwitchProfile({required Object displayName}) => 'Misslyckades att byta till ${displayName}'; +} + +// Path: profile +class _StringsProfileSv implements _StringsProfileEn { + _StringsProfileSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get noUsersAvailable => 'Inga användare tillgängliga'; +} + +// Path: subtitlingStyling +class _StringsSubtitlingStylingSv implements _StringsSubtitlingStylingEn { + _StringsSubtitlingStylingSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get stylingOptions => 'Stilalternativ'; + @override String get fontSize => 'Teckenstorlek'; + @override String get textColor => 'Textfärg'; + @override String get borderSize => 'Kantstorlek'; + @override String get borderColor => 'Kantfärg'; + @override String get backgroundOpacity => 'Bakgrundsopacitet'; + @override String get backgroundColor => 'Bakgrundsfärg'; +} + +// Path: dialog +class _StringsDialogSv implements _StringsDialogEn { + _StringsDialogSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get confirmAction => 'Bekräfta åtgärd'; + @override String get areYouSure => 'Är du säker på att du vill utföra denna åtgärd?'; + @override String get cancel => 'Avbryt'; + @override String get playNow => 'Spela nu'; +} + +// Path: discover +class _StringsDiscoverSv implements _StringsDiscoverEn { + _StringsDiscoverSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Upptäck'; + @override String get switchProfile => 'Byt profil'; + @override String get switchServer => 'Byt server'; + @override String get logout => 'Logga ut'; + @override String get noContentAvailable => 'Inget innehåll tillgängligt'; + @override String get addMediaToLibraries => 'Lägg till media till dina bibliotek'; + @override String get continueWatching => 'Fortsätt titta'; + @override String get recentlyAdded => 'Nyligen tillagda'; + @override String get play => 'Spela'; + @override String get resume => 'Återuppta'; + @override String playEpisode({required Object season, required Object episode}) => 'Spela S${season}, E${episode}'; + @override String resumeEpisode({required Object season, required Object episode}) => 'Återuppta S${season}, E${episode}'; + @override String get pause => 'Pausa'; + @override String get overview => 'Översikt'; + @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'; + @override String get tvShow => 'TV-serie'; + @override String minutesLeft({required Object minutes}) => '${minutes} min kvar'; +} + +// Path: errors +class _StringsErrorsSv implements _StringsErrorsEn { + _StringsErrorsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String searchFailed({required Object error}) => 'Sökning misslyckades: ${error}'; + @override String connectionTimeout({required Object context}) => 'Anslutnings-timeout vid laddning ${context}'; + @override String get connectionFailed => 'Kan inte ansluta till Plex-server'; + @override String failedToLoad({required Object context, required Object error}) => 'Misslyckades att ladda ${context}: ${error}'; + @override String get noClientAvailable => 'Ingen klient tillgänglig'; + @override String authenticationFailed({required Object error}) => 'Autentisering misslyckades: ${error}'; + @override String get couldNotLaunchUrl => 'Kunde inte öppna autentiserings-URL'; + @override String get pleaseEnterToken => 'Vänligen ange en token'; + @override String get invalidToken => 'Ogiltig token'; + @override String failedToVerifyToken({required Object error}) => 'Misslyckades att verifiera token: ${error}'; + @override String failedToSwitchProfile({required Object displayName}) => 'Misslyckades att byta till ${displayName}'; + @override String get connectionFailedGeneric => 'Anslutning misslyckades'; +} + +// Path: libraries +class _StringsLibrariesSv implements _StringsLibrariesEn { + _StringsLibrariesSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Bibliotek'; + @override String get scanLibraryFiles => 'Skanna biblioteksfiler'; + @override String get scanLibrary => 'Skanna bibliotek'; + @override String get analyze => 'Analysera'; + @override String get analyzeLibrary => 'Analysera bibliotek'; + @override String get refreshMetadata => 'Uppdatera metadata'; + @override String get emptyTrash => 'Töm papperskorg'; + @override String emptyingTrash({required Object title}) => 'Tömmer papperskorg för "${title}"...'; + @override String trashEmptied({required Object title}) => 'Papperskorg tömd för "${title}"'; + @override String failedToEmptyTrash({required Object error}) => 'Misslyckades att tömma papperskorg: ${error}'; + @override String analyzing({required Object title}) => 'Analyserar "${title}"...'; + @override String analysisStarted({required Object title}) => 'Analys startad för "${title}"'; + @override String failedToAnalyze({required Object error}) => 'Misslyckades att analysera bibliotek: ${error}'; + @override String get noLibrariesFound => 'Inga bibliotek hittades'; + @override String get thisLibraryIsEmpty => 'Detta bibliotek är tomt'; + @override String get all => 'Alla'; + @override String get clearAll => 'Rensa alla'; + @override String scanLibraryConfirm({required Object title}) => 'Är du säker på att du vill skanna "${title}"?'; + @override String analyzeLibraryConfirm({required Object title}) => 'Är du säker på att du vill analysera "${title}"?'; + @override String refreshMetadataConfirm({required Object title}) => 'Är du säker på att du vill uppdatera metadata för "${title}"?'; + @override String emptyTrashConfirm({required Object title}) => 'Är du säker på att du vill tömma papperskorgen för "${title}"?'; + @override String get manageLibraries => 'Hantera bibliotek'; + @override String get sort => 'Sortera'; + @override String get sortBy => 'Sortera efter'; + @override String get filters => 'Filter'; + @override String loadingLibraryWithCount({required Object count}) => 'Laddar bibliotek... (${count} objekt laddade)'; + @override String get confirmActionMessage => 'Är du säker på att du vill utföra denna åtgärd?'; + @override String get showLibrary => 'Visa bibliotek'; + @override String get hideLibrary => 'Dölj bibliotek'; + @override String get libraryOptions => 'Biblioteksalternativ'; +} + +// Path: about +class _StringsAboutSv implements _StringsAboutEn { + _StringsAboutSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Om'; + @override String get openSourceLicenses => 'Öppen källkod-licenser'; + @override String versionLabel({required Object version}) => 'Version ${version}'; + @override String get appDescription => 'En vacker Plex-klient för Flutter'; + @override String get viewLicensesDescription => 'Visa licenser för tredjepartsbibliotek'; +} + +// Path: serverSelection +class _StringsServerSelectionSv implements _StringsServerSelectionEn { + _StringsServerSelectionSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get connectingToServer => 'Ansluter till server...'; + @override String get serverDebugCopied => 'Server-felsökningsdata kopierad till urklipp'; + @override String get copyDebugData => 'Kopiera felsökningsdata'; + @override String get noServersFound => 'Inga servrar hittades'; + @override String malformedServerData({required Object count}) => 'Hittade ${count} server(ar) med felformaterad data. Inga giltiga servrar tillgängliga.'; + @override String get incompleteServerInfo => 'Vissa servrar har ofullständig information och hoppades över. Vänligen kontrollera ditt Plex.tv-konto.'; + @override String get incompleteConnectionInfo => 'Server-anslutningsinformation är ofullständig. Försök igen.'; + @override String malformedServerInfo({required Object message}) => 'Serverinformation är felformaterad: ${message}'; + @override String get networkConnectionFailed => 'Nätverksanslutning misslyckades. Kontrollera din internetanslutning och försök igen.'; + @override String get authenticationFailed => 'Autentisering misslyckades. Logga in igen.'; + @override String get plexServiceUnavailable => 'Plex-tjänst otillgänglig. Försök igen senare.'; + @override String failedToLoadServers({required Object error}) => 'Misslyckades att ladda servrar: ${error}'; +} + +// Path: hubDetail +class _StringsHubDetailSv implements _StringsHubDetailEn { + _StringsHubDetailSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Titel'; + @override String get releaseYear => 'Utgivningsår'; + @override String get dateAdded => 'Datum tillagd'; + @override String get rating => 'Betyg'; + @override String get noItemsFound => 'Inga objekt hittades'; +} + +// Path: logs +class _StringsLogsSv implements _StringsLogsEn { + _StringsLogsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Loggar'; + @override String get clearLogs => 'Rensa loggar'; + @override String get copyLogs => 'Kopiera loggar'; + @override String get exportLogs => 'Exportera loggar'; + @override String get noLogsToShow => 'Inga loggar att visa'; + @override String get error => 'Fel:'; + @override String get stackTrace => 'Stack trace:'; +} + +// Path: licenses +class _StringsLicensesSv implements _StringsLicensesEn { + _StringsLicensesSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get relatedPackages => 'Relaterade paket'; + @override String get license => 'Licens'; + @override String licenseNumber({required Object number}) => 'Licens ${number}'; + @override String licensesCount({required Object count}) => '${count} licenser'; +} + +// Path: navigation +class _StringsNavigationSv implements _StringsNavigationEn { + _StringsNavigationSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get home => 'Hem'; + @override String get search => 'Sök'; + @override String get libraries => 'Bibliotek'; + @override String get settings => 'Inställningar'; +} + +/// Flat map(s) containing all translations. +/// Only for edge cases! For simple maps, use the map function of this library. + +extension on Translations { + dynamic _flatMapFunction(String path) { + switch (path) { + case 'app.title': return 'Plezy'; + case 'app.loading': return 'Loading...'; + case 'auth.signInWithPlex': return 'Sign in with Plex'; + case 'auth.showQRCode': return 'Show QR Code'; + case 'auth.cancel': return 'Cancel'; + case 'auth.authenticate': return 'Authenticate'; + case 'auth.retry': return 'Retry'; + case 'auth.debugEnterToken': return 'Debug: Enter Plex Token'; + case 'auth.plexTokenLabel': return 'Plex Auth Token'; + case 'auth.plexTokenHint': return 'Enter your Plex.tv token'; + case 'auth.authenticationTimeout': return 'Authentication timed out. Please try again.'; + case 'auth.scanQRCodeInstruction': return 'Scan this QR code with a device logged into Plex to authenticate.'; + case 'auth.waitingForAuth': return 'Waiting for authentication...\nPlease complete sign-in in your browser.'; + case 'common.cancel': return 'Cancel'; + case 'common.save': return 'Save'; + case 'common.close': return 'Close'; + case 'common.clear': return 'Clear'; + case 'common.reset': return 'Reset'; + case 'common.later': return 'Later'; + case 'common.submit': return 'Submit'; + case 'common.confirm': return 'Confirm'; + case 'common.retry': return 'Retry'; + case 'common.playNow': return 'Play Now'; + case 'common.logout': return 'Logout'; + case 'common.online': return 'Online'; + case 'common.offline': return 'Offline'; + case 'common.owned': return 'Owned'; + case 'common.shared': return 'Shared'; + case 'common.current': return 'CURRENT'; + case 'common.unknown': return 'Unknown'; + case 'common.refresh': return 'Refresh'; + case 'common.yes': return 'Yes'; + case 'common.no': return 'No'; + case 'common.server': return 'Server'; + case 'screens.licenses': return 'Licenses'; + case 'screens.selectServer': return 'Select Server'; + case 'screens.switchProfile': return 'Switch Profile'; + case 'screens.subtitleStyling': return 'Subtitle Styling'; + case 'screens.search': return 'Search'; + case 'screens.logs': return 'Logs'; + case 'update.available': return 'Update Available'; + case 'update.versionAvailable': return ({required Object version}) => 'Version ${version} is available'; + case 'update.currentVersion': return ({required Object version}) => 'Current: ${version}'; + case 'update.skipVersion': return 'Skip This Version'; + case 'update.viewRelease': return 'View Release'; + case 'update.latestVersion': return 'You are on the latest version'; + case 'update.checkFailed': return 'Failed to check for updates'; + case 'settings.title': return 'Settings'; + case 'settings.language': return 'Language'; + case 'settings.theme': return 'Theme'; + case 'settings.appearance': return 'Appearance'; + case 'settings.videoPlayback': return 'Video Playback'; + case 'settings.shufflePlay': return 'Shuffle Play'; + case 'settings.advanced': return 'Advanced'; + case 'settings.useSeasonPostersDescription': return 'Show season poster instead of series poster for episodes'; + case 'settings.showHeroSectionDescription': return 'Display featured content carousel on home screen'; + case 'settings.secondsLabel': return 'Seconds'; + case 'settings.minutesLabel': return 'Minutes'; + case 'settings.secondsShort': return 's'; + case 'settings.minutesShort': return 'm'; + case 'settings.durationHint': return ({required Object min, required Object max}) => 'Enter duration (${min}-${max})'; + case 'settings.systemTheme': return 'System'; + case 'settings.systemThemeDescription': return 'Follow system settings'; + case 'settings.lightTheme': return 'Light'; + case 'settings.darkTheme': return 'Dark'; + case 'settings.libraryDensity': return 'Library Density'; + case 'settings.compact': return 'Compact'; + case 'settings.compactDescription': return 'Smaller cards, more items visible'; + case 'settings.normal': return 'Normal'; + case 'settings.normalDescription': return 'Default size'; + case 'settings.comfortable': return 'Comfortable'; + case 'settings.comfortableDescription': return 'Larger cards, fewer items visible'; + case 'settings.viewMode': return 'View Mode'; + case 'settings.gridView': return 'Grid'; + case 'settings.gridViewDescription': return 'Display items in a grid layout'; + case 'settings.listView': return 'List'; + case 'settings.listViewDescription': return 'Display items in a list layout'; + case 'settings.useSeasonPosters': return 'Use Season Posters'; + case 'settings.showHeroSection': return 'Show Hero Section'; + case 'settings.hardwareDecoding': return 'Hardware Decoding'; + case 'settings.hardwareDecodingDescription': return 'Use hardware acceleration when available'; + case 'settings.bufferSize': return 'Buffer Size'; + case 'settings.bufferSizeMB': return ({required Object size}) => '${size}MB'; + case 'settings.subtitleStyling': return 'Subtitle Styling'; + case 'settings.subtitleStylingDescription': return 'Customize subtitle appearance'; + case 'settings.smallSkipDuration': return 'Small Skip Duration'; + case 'settings.largeSkipDuration': return 'Large Skip Duration'; + 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.unwatchedOnly': return 'Unwatched Only'; + case 'settings.unwatchedOnlyDescription': return 'Only include unwatched episodes in shuffle queue'; + case 'settings.shuffleOrderNavigation': return 'Shuffle Order Navigation'; + case 'settings.shuffleOrderNavigationDescription': return 'Next/previous buttons follow shuffled order'; + case 'settings.loopShuffleQueue': return 'Loop Shuffle Queue'; + case 'settings.loopShuffleQueueDescription': return 'Restart queue when reaching the end'; + case 'settings.videoPlayerControls': return 'Video Player Controls'; + case 'settings.keyboardShortcuts': return 'Keyboard Shortcuts'; + case 'settings.keyboardShortcutsDescription': return 'Customize keyboard shortcuts'; + case 'settings.debugLogging': return 'Debug Logging'; + case 'settings.debugLoggingDescription': return 'Enable detailed logging for troubleshooting'; + case 'settings.viewLogs': return 'View Logs'; + case 'settings.viewLogsDescription': return 'View application logs'; + case 'settings.clearCache': return 'Clear Cache'; + case 'settings.clearCacheDescription': return 'This will clear all cached images and data. The app may take longer to load content after clearing the cache.'; + case 'settings.clearCacheSuccess': return 'Cache cleared successfully'; + case 'settings.resetSettings': return 'Reset Settings'; + case 'settings.resetSettingsDescription': return 'This will reset all settings to their default values. This action cannot be undone.'; + case 'settings.resetSettingsSuccess': return 'Settings reset successfully'; + case 'settings.shortcutsReset': return 'Shortcuts reset to defaults'; + case 'settings.about': return 'About'; + case 'settings.aboutDescription': return 'App information and licenses'; + case 'settings.updates': return 'Updates'; + case 'settings.updateAvailable': return 'Update Available'; + case 'settings.checkForUpdates': return 'Check for Updates'; + case 'settings.validationErrorEnterNumber': return 'Please enter a valid number'; + case 'settings.validationErrorDuration': return ({required Object min, required Object max, required Object unit}) => 'Duration must be between ${min} and ${max} ${unit}'; + case 'settings.shortcutAlreadyAssigned': return ({required Object action}) => 'Shortcut already assigned to ${action}'; + case 'settings.shortcutUpdated': return ({required Object action}) => 'Shortcut updated for ${action}'; + case 'search.hint': return 'Search movies, shows, music...'; + case 'search.tryDifferentTerm': return 'Try a different search term'; + case 'hotkeys.setShortcutFor': return ({required Object actionName}) => 'Set Shortcut for ${actionName}'; + case 'hotkeys.clearShortcut': return 'Clear shortcut'; + case 'pinEntry.enterPin': return 'Enter PIN'; + case 'pinEntry.showPin': return 'Show PIN'; + case 'pinEntry.hidePin': return 'Hide PIN'; + case 'fileInfo.title': return 'File Info'; + case 'fileInfo.video': return 'Video'; + case 'fileInfo.audio': return 'Audio'; + case 'fileInfo.file': return 'File'; + case 'fileInfo.advanced': return 'Advanced'; + case 'fileInfo.codec': return 'Codec'; + case 'fileInfo.resolution': return 'Resolution'; + case 'fileInfo.bitrate': return 'Bitrate'; + case 'fileInfo.frameRate': return 'Frame Rate'; + case 'fileInfo.aspectRatio': return 'Aspect Ratio'; + case 'fileInfo.profile': return 'Profile'; + case 'fileInfo.bitDepth': return 'Bit Depth'; + case 'fileInfo.colorSpace': return 'Color Space'; + case 'fileInfo.colorRange': return 'Color Range'; + case 'fileInfo.colorPrimaries': return 'Color Primaries'; + case 'fileInfo.chromaSubsampling': return 'Chroma Subsampling'; + case 'fileInfo.channels': return 'Channels'; + case 'fileInfo.path': return 'Path'; + case 'fileInfo.size': return 'Size'; + case 'fileInfo.container': return 'Container'; + case 'fileInfo.duration': return 'Duration'; + case 'fileInfo.optimizedForStreaming': return 'Optimized for Streaming'; + case 'fileInfo.has64bitOffsets': return '64-bit Offsets'; + case 'mediaMenu.markAsWatched': return 'Mark as Watched'; + case 'mediaMenu.markAsUnwatched': return 'Mark as Unwatched'; + case 'mediaMenu.goToSeries': return 'Go to series'; + case 'mediaMenu.goToSeason': return 'Go to season'; + case 'mediaMenu.shufflePlay': return 'Shuffle Play'; + case 'mediaMenu.fileInfo': return 'File Info'; + case 'tooltips.shufflePlay': return 'Shuffle play'; + case 'tooltips.markAsWatched': return 'Mark as watched'; + case 'tooltips.markAsUnwatched': return 'Mark as unwatched'; + case 'videoControls.audioLabel': return 'Audio'; + case 'videoControls.subtitlesLabel': return 'Subtitles'; + case 'videoControls.resetToZero': return 'Reset to 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} plays later'; + case 'videoControls.playsEarlier': return ({required Object label}) => '${label} plays earlier'; + case 'videoControls.noOffset': return 'No offset'; + case 'videoControls.letterbox': return 'Letterbox'; + case 'videoControls.fillScreen': return 'Fill screen'; + case 'videoControls.stretch': return 'Stretch'; + case 'videoControls.lockRotation': return 'Lock rotation'; + case 'videoControls.unlockRotation': return 'Unlock rotation'; + case 'userStatus.admin': return 'Admin'; + case 'userStatus.restricted': return 'Restricted'; + case 'userStatus.protected': return 'Protected'; + case 'messages.markedAsWatched': return 'Marked as watched'; + case 'messages.markedAsUnwatched': return 'Marked as unwatched'; + case 'messages.errorLoading': return ({required Object error}) => 'Error: ${error}'; + case 'messages.fileInfoNotAvailable': return 'File information not available'; + case 'messages.errorLoadingFileInfo': return ({required Object error}) => 'Error loading file info: ${error}'; + case 'messages.errorLoadingSeries': return 'Error loading series'; + case 'messages.errorLoadingSeason': return 'Error loading season'; + case 'messages.musicNotSupported': return 'Music playback is not yet supported'; + case 'messages.logsCleared': return 'Logs cleared'; + case 'messages.logsCopied': return 'Logs copied to clipboard'; + case 'messages.noLogsAvailable': return 'No logs available'; + case 'messages.libraryScanning': return ({required Object title}) => 'Scanning "${title}"...'; + case 'messages.libraryScanStarted': return ({required Object title}) => 'Library scan started for "${title}"'; + case 'messages.libraryScanFailed': return ({required Object error}) => 'Failed to scan library: ${error}'; + case 'messages.metadataRefreshing': return ({required Object title}) => 'Refreshing metadata for "${title}"...'; + case 'messages.metadataRefreshStarted': return ({required Object title}) => 'Metadata refresh started for "${title}"'; + case 'messages.metadataRefreshFailed': return ({required Object error}) => 'Failed to refresh metadata: ${error}'; + case 'messages.noPlexToken': return 'No Plex token found. Please login again.'; + case 'messages.logoutConfirm': return 'Are you sure you want to logout?'; + case 'messages.noSeasonsFound': return 'No seasons found'; + case 'messages.noEpisodesFound': return 'No episodes found in first season'; + case 'messages.noEpisodesFoundGeneral': return 'No episodes found'; + case 'messages.noResultsFound': return 'No results found'; + case 'messages.sleepTimerSet': return ({required Object label}) => 'Sleep timer set for ${label}'; + case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Failed to switch to ${displayName}'; + case 'profile.noUsersAvailable': return 'No users available'; + case 'subtitlingStyling.stylingOptions': return 'Styling Options'; + case 'subtitlingStyling.fontSize': return 'Font Size'; + case 'subtitlingStyling.textColor': return 'Text Color'; + case 'subtitlingStyling.borderSize': return 'Border Size'; + case 'subtitlingStyling.borderColor': return 'Border Color'; + case 'subtitlingStyling.backgroundOpacity': return 'Background Opacity'; + case 'subtitlingStyling.backgroundColor': return 'Background Color'; + case 'dialog.confirmAction': return 'Confirm Action'; + case 'dialog.areYouSure': return 'Are you sure you want to perform this action?'; + case 'dialog.cancel': return 'Cancel'; + case 'dialog.playNow': return 'Play Now'; + case 'discover.title': return 'Discover'; + case 'discover.switchProfile': return 'Switch Profile'; + case 'discover.switchServer': return 'Switch Server'; + case 'discover.logout': return 'Logout'; + case 'discover.noContentAvailable': return 'No content available'; + case 'discover.addMediaToLibraries': return 'Add some media to your libraries'; + case 'discover.continueWatching': return 'Continue Watching'; + case 'discover.recentlyAdded': return 'Recently Added'; + case 'discover.play': return 'Play'; + case 'discover.resume': return 'Resume'; + case 'discover.playEpisode': return ({required Object season, required Object episode}) => 'Play S${season}, E${episode}'; + case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => 'Resume S${season}, E${episode}'; + case 'discover.pause': return 'Pause'; + case 'discover.overview': return 'Overview'; + 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'; + case 'discover.tvShow': return 'TV Show'; + case 'discover.minutesLeft': return ({required Object minutes}) => '${minutes} min left'; + case 'errors.searchFailed': return ({required Object error}) => 'Search failed: ${error}'; + case 'errors.connectionTimeout': return ({required Object context}) => 'Connection timeout while loading ${context}'; + case 'errors.connectionFailed': return 'Unable to connect to Plex server'; + case 'errors.failedToLoad': return ({required Object context, required Object error}) => 'Failed to load ${context}: ${error}'; + case 'errors.noClientAvailable': return 'No client available'; + case 'errors.authenticationFailed': return ({required Object error}) => 'Authentication failed: ${error}'; + case 'errors.couldNotLaunchUrl': return 'Could not launch auth URL'; + case 'errors.pleaseEnterToken': return 'Please enter a token'; + case 'errors.invalidToken': return 'Invalid token'; + case 'errors.failedToVerifyToken': return ({required Object error}) => 'Failed to verify token: ${error}'; + case 'errors.failedToSwitchProfile': return ({required Object displayName}) => 'Failed to switch to ${displayName}'; + case 'errors.connectionFailedGeneric': return 'Connection failed'; + case 'libraries.title': return 'Libraries'; + case 'libraries.scanLibraryFiles': return 'Scan Library Files'; + case 'libraries.scanLibrary': return 'Scan Library'; + case 'libraries.analyze': return 'Analyze'; + case 'libraries.analyzeLibrary': return 'Analyze Library'; + case 'libraries.refreshMetadata': return 'Refresh Metadata'; + case 'libraries.emptyTrash': return 'Empty Trash'; + case 'libraries.emptyingTrash': return ({required Object title}) => 'Emptying trash for "${title}"...'; + case 'libraries.trashEmptied': return ({required Object title}) => 'Trash emptied for "${title}"'; + case 'libraries.failedToEmptyTrash': return ({required Object error}) => 'Failed to empty trash: ${error}'; + case 'libraries.analyzing': return ({required Object title}) => 'Analyzing "${title}"...'; + case 'libraries.analysisStarted': return ({required Object title}) => 'Analysis started for "${title}"'; + case 'libraries.failedToAnalyze': return ({required Object error}) => 'Failed to analyze library: ${error}'; + case 'libraries.noLibrariesFound': return 'No libraries found'; + case 'libraries.thisLibraryIsEmpty': return 'This library is empty'; + case 'libraries.all': return 'All'; + case 'libraries.clearAll': return 'Clear All'; + case 'libraries.scanLibraryConfirm': return ({required Object title}) => 'Are you sure you want to scan "${title}"?'; + case 'libraries.analyzeLibraryConfirm': return ({required Object title}) => 'Are you sure you want to analyze "${title}"?'; + case 'libraries.refreshMetadataConfirm': return ({required Object title}) => 'Are you sure you want to refresh metadata for "${title}"?'; + case 'libraries.emptyTrashConfirm': return ({required Object title}) => 'Are you sure you want to empty trash for "${title}"?'; + case 'libraries.manageLibraries': return 'Manage Libraries'; + case 'libraries.sort': return 'Sort'; + case 'libraries.sortBy': return 'Sort By'; + case 'libraries.filters': return 'Filters'; + case 'libraries.loadingLibraryWithCount': return ({required Object count}) => 'Loading library... (${count} items loaded)'; + case 'libraries.confirmActionMessage': return 'Are you sure you want to perform this action?'; + case 'libraries.showLibrary': return 'Show library'; + case 'libraries.hideLibrary': return 'Hide library'; + case 'libraries.libraryOptions': return 'Library options'; + case 'about.title': return 'About'; + case 'about.openSourceLicenses': return 'Open Source Licenses'; + case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; + case 'about.appDescription': return 'A beautiful Plex client for Flutter'; + case 'about.viewLicensesDescription': return 'View licenses of third-party libraries'; + case 'serverSelection.connectingToServer': return 'Connecting to server...'; + case 'serverSelection.serverDebugCopied': return 'Server debug data copied to clipboard'; + case 'serverSelection.copyDebugData': return 'Copy Debug Data'; + case 'serverSelection.noServersFound': return 'No servers found'; + case 'serverSelection.malformedServerData': return ({required Object count}) => 'Found ${count} server(s) with malformed data. No valid servers available.'; + case 'serverSelection.incompleteServerInfo': return 'Some servers have incomplete information and were skipped. Please check your Plex.tv account.'; + case 'serverSelection.incompleteConnectionInfo': return 'Server connection information is incomplete. Please try again.'; + case 'serverSelection.malformedServerInfo': return ({required Object message}) => 'Server information is malformed: ${message}'; + case 'serverSelection.networkConnectionFailed': return 'Network connection failed. Please check your internet connection and try again.'; + case 'serverSelection.authenticationFailed': return 'Authentication failed. Please sign in again.'; + case 'serverSelection.plexServiceUnavailable': return 'Plex service unavailable. Please try again later.'; + case 'serverSelection.failedToLoadServers': return ({required Object error}) => 'Failed to load servers: ${error}'; + case 'hubDetail.title': return 'Title'; + case 'hubDetail.releaseYear': return 'Release Year'; + case 'hubDetail.dateAdded': return 'Date Added'; + case 'hubDetail.rating': return 'Rating'; + case 'hubDetail.noItemsFound': return 'No items found'; + case 'logs.title': return 'Logs'; + case 'logs.clearLogs': return 'Clear Logs'; + case 'logs.copyLogs': return 'Copy Logs'; + case 'logs.exportLogs': return 'Export Logs'; + case 'logs.noLogsToShow': return 'No logs to show'; + case 'logs.error': return 'Error:'; + case 'logs.stackTrace': return 'Stack Trace:'; + case 'licenses.relatedPackages': return 'Related Packages'; + case 'licenses.license': return 'License'; + case 'licenses.licenseNumber': return ({required Object number}) => 'License ${number}'; + case 'licenses.licensesCount': return ({required Object count}) => '${count} licenses'; + case 'navigation.home': return 'Home'; + case 'navigation.search': return 'Search'; + case 'navigation.libraries': return 'Libraries'; + case 'navigation.settings': return 'Settings'; + default: return null; + } + } +} + +extension on _StringsSv { + dynamic _flatMapFunction(String path) { + switch (path) { + case 'app.title': return 'Plezy'; + case 'app.loading': return 'Laddar...'; + case 'auth.signInWithPlex': return 'Logga in med Plex'; + case 'auth.showQRCode': return 'Visa QR-kod'; + case 'auth.cancel': return 'Avbryt'; + case 'auth.authenticate': return 'Autentisera'; + case 'auth.retry': return 'Försök igen'; + case 'auth.debugEnterToken': return 'Debug: Ange Plex-token'; + case 'auth.plexTokenLabel': return 'Plex-autentiseringstoken'; + case 'auth.plexTokenHint': return 'Ange din Plex.tv-token'; + case 'auth.authenticationTimeout': return 'Autentisering tog för lång tid. Försök igen.'; + case 'auth.scanQRCodeInstruction': return 'Skanna denna QR-kod med en enhet inloggad på Plex för att autentisera.'; + case 'auth.waitingForAuth': return 'Väntar på autentisering...\nVänligen slutför inloggning i din webbläsare.'; + case 'common.cancel': return 'Avbryt'; + case 'common.save': return 'Spara'; + case 'common.close': return 'Stäng'; + case 'common.clear': return 'Rensa'; + case 'common.reset': return 'Återställ'; + case 'common.later': return 'Senare'; + case 'common.submit': return 'Skicka'; + case 'common.confirm': return 'Bekräfta'; + case 'common.retry': return 'Försök igen'; + case 'common.playNow': return 'Spela nu'; + case 'common.logout': return 'Logga ut'; + case 'common.online': return 'Online'; + case 'common.offline': return 'Offline'; + case 'common.owned': return 'Egen'; + case 'common.shared': return 'Delad'; + case 'common.current': return 'NUVARANDE'; + case 'common.unknown': return 'Okänd'; + case 'common.refresh': return 'Uppdatera'; + case 'common.yes': return 'Ja'; + case 'common.no': return 'Nej'; + case 'common.server': return 'Server'; + case 'screens.licenses': return 'Licenser'; + case 'screens.selectServer': return 'Välj server'; + case 'screens.switchProfile': return 'Byt profil'; + case 'screens.subtitleStyling': return 'Undertext-styling'; + case 'screens.search': return 'Sök'; + case 'screens.logs': return 'Loggar'; + case 'update.available': return 'Uppdatering tillgänglig'; + case 'update.versionAvailable': return ({required Object version}) => 'Version ${version} är tillgänglig'; + case 'update.currentVersion': return ({required Object version}) => 'Nuvarande: ${version}'; + case 'update.skipVersion': return 'Hoppa över denna version'; + case 'update.viewRelease': return 'Visa release'; + case 'update.latestVersion': return 'Du har den senaste versionen'; + case 'update.checkFailed': return 'Misslyckades att kontrollera uppdateringar'; + case 'settings.title': return 'Inställningar'; + case 'settings.language': return 'Språk'; + case 'settings.theme': return 'Tema'; + case 'settings.appearance': return 'Utseende'; + case 'settings.videoPlayback': return 'Videouppspelning'; + case 'settings.shufflePlay': return 'Blanda uppspelning'; + case 'settings.advanced': return 'Avancerat'; + case 'settings.useSeasonPostersDescription': return 'Visa säsongsaffisch istället för serieaffisch för avsnitt'; + case 'settings.showHeroSectionDescription': return 'Visa utvalda innehållskarusell på startsidan'; + case 'settings.secondsLabel': return 'Sekunder'; + case 'settings.minutesLabel': return 'Minuter'; + case 'settings.secondsShort': return 's'; + case 'settings.minutesShort': return 'm'; + case 'settings.durationHint': return ({required Object min, required Object max}) => 'Ange tid (${min}-${max})'; + case 'settings.systemTheme': return 'System'; + case 'settings.systemThemeDescription': return 'Följ systeminställningar'; + case 'settings.lightTheme': return 'Ljust'; + case 'settings.darkTheme': return 'Mörkt'; + case 'settings.libraryDensity': return 'Biblioteksdensitet'; + case 'settings.compact': return 'Kompakt'; + case 'settings.compactDescription': return 'Mindre kort, fler objekt synliga'; + case 'settings.normal': return 'Normal'; + case 'settings.normalDescription': return 'Standardstorlek'; + case 'settings.comfortable': return 'Bekväm'; + case 'settings.comfortableDescription': return 'Större kort, färre objekt synliga'; + case 'settings.viewMode': return 'Visningsläge'; + case 'settings.gridView': return 'Rutnät'; + case 'settings.gridViewDescription': return 'Visa objekt i rutnätslayout'; + case 'settings.listView': return 'Lista'; + case 'settings.listViewDescription': return 'Visa objekt i listlayout'; + case 'settings.useSeasonPosters': return 'Använd säsongsaffischer'; + case 'settings.showHeroSection': return 'Visa hjältesektion'; + case 'settings.hardwareDecoding': return 'Hårdvaruavkodning'; + case 'settings.hardwareDecodingDescription': return 'Använd hårdvaruacceleration när tillgängligt'; + case 'settings.bufferSize': return 'Bufferstorlek'; + case 'settings.bufferSizeMB': return ({required Object size}) => '${size}MB'; + case 'settings.subtitleStyling': return 'Undertext-styling'; + case 'settings.subtitleStylingDescription': return 'Anpassa undertextutseende'; + case 'settings.smallSkipDuration': return 'Kort hoppvaraktighet'; + case 'settings.largeSkipDuration': return 'Lång hoppvaraktighet'; + 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.unwatchedOnly': return 'Endast osedda'; + case 'settings.unwatchedOnlyDescription': return 'Inkludera endast osedda avsnitt i blandningskön'; + case 'settings.shuffleOrderNavigation': return 'Blandningsordning-navigation'; + case 'settings.shuffleOrderNavigationDescription': return 'Nästa/föregående knappar följer blandad ordning'; + case 'settings.loopShuffleQueue': return 'Loopa blandningskö'; + case 'settings.loopShuffleQueueDescription': return 'Starta om kö när slutet nås'; + case 'settings.videoPlayerControls': return 'Videospelar-kontroller'; + case 'settings.keyboardShortcuts': return 'Tangentbordsgenvägar'; + case 'settings.keyboardShortcutsDescription': return 'Anpassa tangentbordsgenvägar'; + case 'settings.debugLogging': return 'Felsökningsloggning'; + case 'settings.debugLoggingDescription': return 'Aktivera detaljerad loggning för felsökning'; + case 'settings.viewLogs': return 'Visa loggar'; + case 'settings.viewLogsDescription': return 'Visa applikationsloggar'; + case 'settings.clearCache': return 'Rensa cache'; + case 'settings.clearCacheDescription': return 'Detta rensar alla cachade bilder och data. Appen kan ta längre tid att ladda innehåll efter cache-rensning.'; + case 'settings.clearCacheSuccess': return 'Cache rensad framgångsrikt'; + case 'settings.resetSettings': return 'Återställ inställningar'; + case 'settings.resetSettingsDescription': return 'Detta återställer alla inställningar till standardvärden. Denna åtgärd kan inte ångras.'; + case 'settings.resetSettingsSuccess': return 'Inställningar återställda framgångsrikt'; + case 'settings.shortcutsReset': return 'Genvägar återställda till standard'; + case 'settings.about': return 'Om'; + case 'settings.aboutDescription': return 'Appinformation och licenser'; + case 'settings.updates': return 'Uppdateringar'; + case 'settings.updateAvailable': return 'Uppdatering tillgänglig'; + case 'settings.checkForUpdates': return 'Kontrollera uppdateringar'; + case 'settings.validationErrorEnterNumber': return 'Vänligen ange ett giltigt nummer'; + case 'settings.validationErrorDuration': return ({required Object min, required Object max, required Object unit}) => 'Tiden måste vara mellan ${min} och ${max} ${unit}'; + case 'settings.shortcutAlreadyAssigned': return ({required Object action}) => 'Genväg redan tilldelad ${action}'; + case 'settings.shortcutUpdated': return ({required Object action}) => 'Genväg uppdaterad för ${action}'; + case 'search.hint': return 'Sök filmer, serier, musik...'; + case 'search.tryDifferentTerm': return 'Prova en annan sökterm'; + case 'hotkeys.setShortcutFor': return ({required Object actionName}) => 'Sätt genväg för ${actionName}'; + case 'hotkeys.clearShortcut': return 'Rensa genväg'; + case 'pinEntry.enterPin': return 'Ange PIN'; + case 'pinEntry.showPin': return 'Visa PIN'; + case 'pinEntry.hidePin': return 'Dölj PIN'; + case 'fileInfo.title': return 'Filinformation'; + case 'fileInfo.video': return 'Video'; + case 'fileInfo.audio': return 'Ljud'; + case 'fileInfo.file': return 'Fil'; + case 'fileInfo.advanced': return 'Avancerat'; + case 'fileInfo.codec': return 'Kodek'; + case 'fileInfo.resolution': return 'Upplösning'; + case 'fileInfo.bitrate': return 'Bithastighet'; + case 'fileInfo.frameRate': return 'Bildfrekvens'; + case 'fileInfo.aspectRatio': return 'Bildförhållande'; + case 'fileInfo.profile': return 'Profil'; + case 'fileInfo.bitDepth': return 'Bitdjup'; + case 'fileInfo.colorSpace': return 'Färgrymd'; + case 'fileInfo.colorRange': return 'Färgområde'; + case 'fileInfo.colorPrimaries': return 'Färggrunder'; + case 'fileInfo.chromaSubsampling': return 'Kroma-undersampling'; + case 'fileInfo.channels': return 'Kanaler'; + case 'fileInfo.path': return 'Sökväg'; + case 'fileInfo.size': return 'Storlek'; + case 'fileInfo.container': return 'Container'; + case 'fileInfo.duration': return 'Varaktighet'; + case 'fileInfo.optimizedForStreaming': return 'Optimerad för streaming'; + case 'fileInfo.has64bitOffsets': return '64-bit offset'; + case 'mediaMenu.markAsWatched': return 'Markera som sedd'; + case 'mediaMenu.markAsUnwatched': return 'Markera som osedd'; + case 'mediaMenu.goToSeries': return 'Gå till serie'; + case 'mediaMenu.goToSeason': return 'Gå till säsong'; + case 'mediaMenu.shufflePlay': return 'Blanda uppspelning'; + case 'mediaMenu.fileInfo': return 'Filinformation'; + case 'tooltips.shufflePlay': return 'Blanda uppspelning'; + case 'tooltips.markAsWatched': return 'Markera som sedd'; + case 'tooltips.markAsUnwatched': return 'Markera som osedd'; + case 'videoControls.audioLabel': return 'Ljud'; + case 'videoControls.subtitlesLabel': return 'Undertexter'; + case 'videoControls.resetToZero': return 'Återställ till 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} spelas senare'; + case 'videoControls.playsEarlier': return ({required Object label}) => '${label} spelas tidigare'; + case 'videoControls.noOffset': return 'Ingen offset'; + case 'videoControls.letterbox': return 'Letterbox'; + case 'videoControls.fillScreen': return 'Fyll skärm'; + case 'videoControls.stretch': return 'Sträck'; + case 'videoControls.lockRotation': return 'Lås rotation'; + case 'videoControls.unlockRotation': return 'Lås upp rotation'; + case 'userStatus.admin': return 'Admin'; + case 'userStatus.restricted': return 'Begränsad'; + case 'userStatus.protected': return 'Skyddad'; + case 'messages.markedAsWatched': return 'Markerad som sedd'; + case 'messages.markedAsUnwatched': return 'Markerad som osedd'; + case 'messages.errorLoading': return ({required Object error}) => 'Fel: ${error}'; + case 'messages.fileInfoNotAvailable': return 'Filinformation inte tillgänglig'; + case 'messages.errorLoadingFileInfo': return ({required Object error}) => 'Fel vid laddning av filinformation: ${error}'; + case 'messages.errorLoadingSeries': return 'Fel vid laddning av serie'; + case 'messages.errorLoadingSeason': return 'Fel vid laddning av säsong'; + case 'messages.musicNotSupported': return 'Musikuppspelning stöds inte ännu'; + case 'messages.logsCleared': return 'Loggar rensade'; + case 'messages.logsCopied': return 'Loggar kopierade till urklipp'; + case 'messages.noLogsAvailable': return 'Inga loggar tillgängliga'; + case 'messages.libraryScanning': return ({required Object title}) => 'Skannar "${title}"...'; + case 'messages.libraryScanStarted': return ({required Object title}) => 'Biblioteksskanning startad för "${title}"'; + case 'messages.libraryScanFailed': return ({required Object error}) => 'Misslyckades att skanna bibliotek: ${error}'; + case 'messages.metadataRefreshing': return ({required Object title}) => 'Uppdaterar metadata för "${title}"...'; + case 'messages.metadataRefreshStarted': return ({required Object title}) => 'Metadata-uppdatering startad för "${title}"'; + case 'messages.metadataRefreshFailed': return ({required Object error}) => 'Misslyckades att uppdatera metadata: ${error}'; + case 'messages.noPlexToken': return 'Ingen Plex-token hittad. Vänligen logga in igen.'; + case 'messages.logoutConfirm': return 'Är du säker på att du vill logga ut?'; + case 'messages.noSeasonsFound': return 'Inga säsonger hittades'; + case 'messages.noEpisodesFound': return 'Inga avsnitt hittades i första säsongen'; + case 'messages.noEpisodesFoundGeneral': return 'Inga avsnitt hittades'; + case 'messages.noResultsFound': return 'Inga resultat hittades'; + case 'messages.sleepTimerSet': return ({required Object label}) => 'Sovtimer inställd för ${label}'; + case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Misslyckades att byta till ${displayName}'; + case 'profile.noUsersAvailable': return 'Inga användare tillgängliga'; + case 'subtitlingStyling.stylingOptions': return 'Stilalternativ'; + case 'subtitlingStyling.fontSize': return 'Teckenstorlek'; + case 'subtitlingStyling.textColor': return 'Textfärg'; + case 'subtitlingStyling.borderSize': return 'Kantstorlek'; + case 'subtitlingStyling.borderColor': return 'Kantfärg'; + case 'subtitlingStyling.backgroundOpacity': return 'Bakgrundsopacitet'; + case 'subtitlingStyling.backgroundColor': return 'Bakgrundsfärg'; + case 'dialog.confirmAction': return 'Bekräfta åtgärd'; + case 'dialog.areYouSure': return 'Är du säker på att du vill utföra denna åtgärd?'; + case 'dialog.cancel': return 'Avbryt'; + case 'dialog.playNow': return 'Spela nu'; + case 'discover.title': return 'Upptäck'; + case 'discover.switchProfile': return 'Byt profil'; + case 'discover.switchServer': return 'Byt server'; + case 'discover.logout': return 'Logga ut'; + case 'discover.noContentAvailable': return 'Inget innehåll tillgängligt'; + case 'discover.addMediaToLibraries': return 'Lägg till media till dina bibliotek'; + case 'discover.continueWatching': return 'Fortsätt titta'; + case 'discover.recentlyAdded': return 'Nyligen tillagda'; + case 'discover.play': return 'Spela'; + case 'discover.resume': return 'Återuppta'; + case 'discover.playEpisode': return ({required Object season, required Object episode}) => 'Spela S${season}, E${episode}'; + case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => 'Återuppta S${season}, E${episode}'; + case 'discover.pause': return 'Pausa'; + case 'discover.overview': return 'Översikt'; + 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'; + case 'discover.tvShow': return 'TV-serie'; + case 'discover.minutesLeft': return ({required Object minutes}) => '${minutes} min kvar'; + case 'errors.searchFailed': return ({required Object error}) => 'Sökning misslyckades: ${error}'; + case 'errors.connectionTimeout': return ({required Object context}) => 'Anslutnings-timeout vid laddning ${context}'; + case 'errors.connectionFailed': return 'Kan inte ansluta till Plex-server'; + case 'errors.failedToLoad': return ({required Object context, required Object error}) => 'Misslyckades att ladda ${context}: ${error}'; + case 'errors.noClientAvailable': return 'Ingen klient tillgänglig'; + case 'errors.authenticationFailed': return ({required Object error}) => 'Autentisering misslyckades: ${error}'; + case 'errors.couldNotLaunchUrl': return 'Kunde inte öppna autentiserings-URL'; + case 'errors.pleaseEnterToken': return 'Vänligen ange en token'; + case 'errors.invalidToken': return 'Ogiltig token'; + case 'errors.failedToVerifyToken': return ({required Object error}) => 'Misslyckades att verifiera token: ${error}'; + case 'errors.failedToSwitchProfile': return ({required Object displayName}) => 'Misslyckades att byta till ${displayName}'; + case 'errors.connectionFailedGeneric': return 'Anslutning misslyckades'; + case 'libraries.title': return 'Bibliotek'; + case 'libraries.scanLibraryFiles': return 'Skanna biblioteksfiler'; + case 'libraries.scanLibrary': return 'Skanna bibliotek'; + case 'libraries.analyze': return 'Analysera'; + case 'libraries.analyzeLibrary': return 'Analysera bibliotek'; + case 'libraries.refreshMetadata': return 'Uppdatera metadata'; + case 'libraries.emptyTrash': return 'Töm papperskorg'; + case 'libraries.emptyingTrash': return ({required Object title}) => 'Tömmer papperskorg för "${title}"...'; + case 'libraries.trashEmptied': return ({required Object title}) => 'Papperskorg tömd för "${title}"'; + case 'libraries.failedToEmptyTrash': return ({required Object error}) => 'Misslyckades att tömma papperskorg: ${error}'; + case 'libraries.analyzing': return ({required Object title}) => 'Analyserar "${title}"...'; + case 'libraries.analysisStarted': return ({required Object title}) => 'Analys startad för "${title}"'; + case 'libraries.failedToAnalyze': return ({required Object error}) => 'Misslyckades att analysera bibliotek: ${error}'; + case 'libraries.noLibrariesFound': return 'Inga bibliotek hittades'; + case 'libraries.thisLibraryIsEmpty': return 'Detta bibliotek är tomt'; + case 'libraries.all': return 'Alla'; + case 'libraries.clearAll': return 'Rensa alla'; + case 'libraries.scanLibraryConfirm': return ({required Object title}) => 'Är du säker på att du vill skanna "${title}"?'; + case 'libraries.analyzeLibraryConfirm': return ({required Object title}) => 'Är du säker på att du vill analysera "${title}"?'; + case 'libraries.refreshMetadataConfirm': return ({required Object title}) => 'Är du säker på att du vill uppdatera metadata för "${title}"?'; + case 'libraries.emptyTrashConfirm': return ({required Object title}) => 'Är du säker på att du vill tömma papperskorgen för "${title}"?'; + case 'libraries.manageLibraries': return 'Hantera bibliotek'; + case 'libraries.sort': return 'Sortera'; + case 'libraries.sortBy': return 'Sortera efter'; + case 'libraries.filters': return 'Filter'; + case 'libraries.loadingLibraryWithCount': return ({required Object count}) => 'Laddar bibliotek... (${count} objekt laddade)'; + case 'libraries.confirmActionMessage': return 'Är du säker på att du vill utföra denna åtgärd?'; + case 'libraries.showLibrary': return 'Visa bibliotek'; + case 'libraries.hideLibrary': return 'Dölj bibliotek'; + case 'libraries.libraryOptions': return 'Biblioteksalternativ'; + case 'about.title': return 'Om'; + case 'about.openSourceLicenses': return 'Öppen källkod-licenser'; + case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; + case 'about.appDescription': return 'En vacker Plex-klient för Flutter'; + case 'about.viewLicensesDescription': return 'Visa licenser för tredjepartsbibliotek'; + case 'serverSelection.connectingToServer': return 'Ansluter till server...'; + case 'serverSelection.serverDebugCopied': return 'Server-felsökningsdata kopierad till urklipp'; + case 'serverSelection.copyDebugData': return 'Kopiera felsökningsdata'; + case 'serverSelection.noServersFound': return 'Inga servrar hittades'; + case 'serverSelection.malformedServerData': return ({required Object count}) => 'Hittade ${count} server(ar) med felformaterad data. Inga giltiga servrar tillgängliga.'; + case 'serverSelection.incompleteServerInfo': return 'Vissa servrar har ofullständig information och hoppades över. Vänligen kontrollera ditt Plex.tv-konto.'; + case 'serverSelection.incompleteConnectionInfo': return 'Server-anslutningsinformation är ofullständig. Försök igen.'; + case 'serverSelection.malformedServerInfo': return ({required Object message}) => 'Serverinformation är felformaterad: ${message}'; + case 'serverSelection.networkConnectionFailed': return 'Nätverksanslutning misslyckades. Kontrollera din internetanslutning och försök igen.'; + case 'serverSelection.authenticationFailed': return 'Autentisering misslyckades. Logga in igen.'; + case 'serverSelection.plexServiceUnavailable': return 'Plex-tjänst otillgänglig. Försök igen senare.'; + case 'serverSelection.failedToLoadServers': return ({required Object error}) => 'Misslyckades att ladda servrar: ${error}'; + case 'hubDetail.title': return 'Titel'; + case 'hubDetail.releaseYear': return 'Utgivningsår'; + case 'hubDetail.dateAdded': return 'Datum tillagd'; + case 'hubDetail.rating': return 'Betyg'; + case 'hubDetail.noItemsFound': return 'Inga objekt hittades'; + case 'logs.title': return 'Loggar'; + case 'logs.clearLogs': return 'Rensa loggar'; + case 'logs.copyLogs': return 'Kopiera loggar'; + case 'logs.exportLogs': return 'Exportera loggar'; + case 'logs.noLogsToShow': return 'Inga loggar att visa'; + case 'logs.error': return 'Fel:'; + case 'logs.stackTrace': return 'Stack trace:'; + case 'licenses.relatedPackages': return 'Relaterade paket'; + case 'licenses.license': return 'Licens'; + case 'licenses.licenseNumber': return ({required Object number}) => 'Licens ${number}'; + case 'licenses.licensesCount': return ({required Object count}) => '${count} licenser'; + case 'navigation.home': return 'Hem'; + case 'navigation.search': return 'Sök'; + case 'navigation.libraries': return 'Bibliotek'; + case 'navigation.settings': return 'Inställningar'; + default: return null; + } + } +} diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json new file mode 100644 index 00000000..31fe867c --- /dev/null +++ b/lib/i18n/strings.i18n.json @@ -0,0 +1,365 @@ +{ + "app": { + "title": "Plezy", + "loading": "Loading..." + }, + "auth": { + "signInWithPlex": "Sign in with Plex", + "showQRCode": "Show QR Code", + "cancel": "Cancel", + "authenticate": "Authenticate", + "retry": "Retry", + "debugEnterToken": "Debug: Enter Plex Token", + "plexTokenLabel": "Plex Auth Token", + "plexTokenHint": "Enter your Plex.tv token", + "authenticationTimeout": "Authentication timed out. Please try again.", + "scanQRCodeInstruction": "Scan this QR code with a device logged into Plex to authenticate.", + "waitingForAuth": "Waiting for authentication...\nPlease complete sign-in in your browser." + }, + "common": { + "cancel": "Cancel", + "save": "Save", + "close": "Close", + "clear": "Clear", + "reset": "Reset", + "later": "Later", + "submit": "Submit", + "confirm": "Confirm", + "retry": "Retry", + "playNow": "Play Now", + "logout": "Logout", + "online": "Online", + "offline": "Offline", + "owned": "Owned", + "shared": "Shared", + "current": "CURRENT", + "unknown": "Unknown", + "refresh": "Refresh", + "yes": "Yes", + "no": "No", + "server": "Server" + }, + "screens": { + "licenses": "Licenses", + "selectServer": "Select Server", + "switchProfile": "Switch Profile", + "subtitleStyling": "Subtitle Styling", + "search": "Search", + "logs": "Logs" + }, + "update": { + "available": "Update Available", + "versionAvailable": "Version ${version} is available", + "currentVersion": "Current: ${version}", + "skipVersion": "Skip This Version", + "viewRelease": "View Release", + "latestVersion": "You are on the latest version", + "checkFailed": "Failed to check for updates" + }, + "settings": { + "title": "Settings", + "language": "Language", + "theme": "Theme", + "appearance": "Appearance", + "videoPlayback": "Video Playback", + "shufflePlay": "Shuffle Play", + "advanced": "Advanced", + "useSeasonPostersDescription": "Show season poster instead of series poster for episodes", + "showHeroSectionDescription": "Display featured content carousel on home screen", + "secondsLabel": "Seconds", + "minutesLabel": "Minutes", + "secondsShort": "s", + "minutesShort": "m", + "durationHint": "Enter duration (${min}-${max})", + "systemTheme": "System", + "systemThemeDescription": "Follow system settings", + "lightTheme": "Light", + "darkTheme": "Dark", + "libraryDensity": "Library Density", + "compact": "Compact", + "compactDescription": "Smaller cards, more items visible", + "normal": "Normal", + "normalDescription": "Default size", + "comfortable": "Comfortable", + "comfortableDescription": "Larger cards, fewer items visible", + "viewMode": "View Mode", + "gridView": "Grid", + "gridViewDescription": "Display items in a grid layout", + "listView": "List", + "listViewDescription": "Display items in a list layout", + "useSeasonPosters": "Use Season Posters", + "showHeroSection": "Show Hero Section", + "hardwareDecoding": "Hardware Decoding", + "hardwareDecodingDescription": "Use hardware acceleration when available", + "bufferSize": "Buffer Size", + "bufferSizeMB": "${size}MB", + "subtitleStyling": "Subtitle Styling", + "subtitleStylingDescription": "Customize subtitle appearance", + "smallSkipDuration": "Small Skip Duration", + "largeSkipDuration": "Large Skip Duration", + "secondsUnit": "${seconds} seconds", + "defaultSleepTimer": "Default Sleep Timer", + "minutesUnit": "${minutes} minutes", + "unwatchedOnly": "Unwatched Only", + "unwatchedOnlyDescription": "Only include unwatched episodes in shuffle queue", + "shuffleOrderNavigation": "Shuffle Order Navigation", + "shuffleOrderNavigationDescription": "Next/previous buttons follow shuffled order", + "loopShuffleQueue": "Loop Shuffle Queue", + "loopShuffleQueueDescription": "Restart queue when reaching the end", + "videoPlayerControls": "Video Player Controls", + "keyboardShortcuts": "Keyboard Shortcuts", + "keyboardShortcutsDescription": "Customize keyboard shortcuts", + "debugLogging": "Debug Logging", + "debugLoggingDescription": "Enable detailed logging for troubleshooting", + "viewLogs": "View Logs", + "viewLogsDescription": "View application logs", + "clearCache": "Clear Cache", + "clearCacheDescription": "This will clear all cached images and data. The app may take longer to load content after clearing the cache.", + "clearCacheSuccess": "Cache cleared successfully", + "resetSettings": "Reset Settings", + "resetSettingsDescription": "This will reset all settings to their default values. This action cannot be undone.", + "resetSettingsSuccess": "Settings reset successfully", + "shortcutsReset": "Shortcuts reset to defaults", + "about": "About", + "aboutDescription": "App information and licenses", + "updates": "Updates", + "updateAvailable": "Update Available", + "checkForUpdates": "Check for Updates", + "validationErrorEnterNumber": "Please enter a valid number", + "validationErrorDuration": "Duration must be between ${min} and ${max} ${unit}", + "shortcutAlreadyAssigned": "Shortcut already assigned to ${action}", + "shortcutUpdated": "Shortcut updated for ${action}" + }, + "search": { + "hint": "Search movies, shows, music...", + "tryDifferentTerm": "Try a different search term" + }, + "hotkeys": { + "setShortcutFor": "Set Shortcut for ${actionName}", + "clearShortcut": "Clear shortcut" + }, + "pinEntry": { + "enterPin": "Enter PIN", + "showPin": "Show PIN", + "hidePin": "Hide PIN" + }, + "fileInfo": { + "title": "File Info", + "video": "Video", + "audio": "Audio", + "file": "File", + "advanced": "Advanced", + "codec": "Codec", + "resolution": "Resolution", + "bitrate": "Bitrate", + "frameRate": "Frame Rate", + "aspectRatio": "Aspect Ratio", + "profile": "Profile", + "bitDepth": "Bit Depth", + "colorSpace": "Color Space", + "colorRange": "Color Range", + "colorPrimaries": "Color Primaries", + "chromaSubsampling": "Chroma Subsampling", + "channels": "Channels", + "path": "Path", + "size": "Size", + "container": "Container", + "duration": "Duration", + "optimizedForStreaming": "Optimized for Streaming", + "has64bitOffsets": "64-bit Offsets" + }, + "mediaMenu": { + "markAsWatched": "Mark as Watched", + "markAsUnwatched": "Mark as Unwatched", + "goToSeries": "Go to series", + "goToSeason": "Go to season", + "shufflePlay": "Shuffle Play", + "fileInfo": "File Info" + }, + "tooltips": { + "shufflePlay": "Shuffle play", + "markAsWatched": "Mark as watched", + "markAsUnwatched": "Mark as unwatched" + }, + "videoControls": { + "audioLabel": "Audio", + "subtitlesLabel": "Subtitles", + "resetToZero": "Reset to 0ms", + "addTime": "+${amount}${unit}", + "minusTime": "-${amount}${unit}", + "playsLater": "${label} plays later", + "playsEarlier": "${label} plays earlier", + "noOffset": "No offset", + "letterbox": "Letterbox", + "fillScreen": "Fill screen", + "stretch": "Stretch", + "lockRotation": "Lock rotation", + "unlockRotation": "Unlock rotation" + }, + "userStatus": { + "admin": "Admin", + "restricted": "Restricted", + "protected": "Protected" + }, + "messages": { + "markedAsWatched": "Marked as watched", + "markedAsUnwatched": "Marked as unwatched", + "errorLoading": "Error: ${error}", + "fileInfoNotAvailable": "File information not available", + "errorLoadingFileInfo": "Error loading file info: ${error}", + "errorLoadingSeries": "Error loading series", + "errorLoadingSeason": "Error loading season", + "musicNotSupported": "Music playback is not yet supported", + "logsCleared": "Logs cleared", + "logsCopied": "Logs copied to clipboard", + "noLogsAvailable": "No logs available", + "libraryScanning": "Scanning \"${title}\"...", + "libraryScanStarted": "Library scan started for \"${title}\"", + "libraryScanFailed": "Failed to scan library: ${error}", + "metadataRefreshing": "Refreshing metadata for \"${title}\"...", + "metadataRefreshStarted": "Metadata refresh started for \"${title}\"", + "metadataRefreshFailed": "Failed to refresh metadata: ${error}", + "noPlexToken": "No Plex token found. Please login again.", + "logoutConfirm": "Are you sure you want to logout?", + "noSeasonsFound": "No seasons found", + "noEpisodesFound": "No episodes found in first season", + "noEpisodesFoundGeneral": "No episodes found", + "noResultsFound": "No results found", + "sleepTimerSet": "Sleep timer set for ${label}", + "failedToSwitchProfile": "Failed to switch to ${displayName}" + }, + "profile": { + "noUsersAvailable": "No users available" + }, + "subtitlingStyling": { + "stylingOptions": "Styling Options", + "fontSize": "Font Size", + "textColor": "Text Color", + "borderSize": "Border Size", + "borderColor": "Border Color", + "backgroundOpacity": "Background Opacity", + "backgroundColor": "Background Color" + }, + "dialog": { + "confirmAction": "Confirm Action", + "areYouSure": "Are you sure you want to perform this action?", + "cancel": "Cancel", + "playNow": "Play Now" + }, + "discover": { + "title": "Discover", + "switchProfile": "Switch Profile", + "switchServer": "Switch Server", + "logout": "Logout", + "noContentAvailable": "No content available", + "addMediaToLibraries": "Add some media to your libraries", + "continueWatching": "Continue Watching", + "recentlyAdded": "Recently Added", + "play": "Play", + "resume": "Resume", + "playEpisode": "Play S${season}, E${episode}", + "resumeEpisode": "Resume S${season}, E${episode}", + "pause": "Pause", + "overview": "Overview", + "episodeCount": "${count} episodes", + "watchedProgress": "${watched}/${total} watched", + "movie": "Movie", + "tvShow": "TV Show", + "minutesLeft": "${minutes} min left" + }, + "errors": { + "searchFailed": "Search failed: ${error}", + "connectionTimeout": "Connection timeout while loading ${context}", + "connectionFailed": "Unable to connect to Plex server", + "failedToLoad": "Failed to load ${context}: ${error}", + "noClientAvailable": "No client available", + "authenticationFailed": "Authentication failed: ${error}", + "couldNotLaunchUrl": "Could not launch auth URL", + "pleaseEnterToken": "Please enter a token", + "invalidToken": "Invalid token", + "failedToVerifyToken": "Failed to verify token: ${error}", + "failedToSwitchProfile": "Failed to switch to ${displayName}", + "connectionFailedGeneric": "Connection failed" + }, + "libraries": { + "title": "Libraries", + "scanLibraryFiles": "Scan Library Files", + "scanLibrary": "Scan Library", + "analyze": "Analyze", + "analyzeLibrary": "Analyze Library", + "refreshMetadata": "Refresh Metadata", + "emptyTrash": "Empty Trash", + "emptyingTrash": "Emptying trash for \"${title}\"...", + "trashEmptied": "Trash emptied for \"${title}\"", + "failedToEmptyTrash": "Failed to empty trash: ${error}", + "analyzing": "Analyzing \"${title}\"...", + "analysisStarted": "Analysis started for \"${title}\"", + "failedToAnalyze": "Failed to analyze library: ${error}", + "noLibrariesFound": "No libraries found", + "thisLibraryIsEmpty": "This library is empty", + "all": "All", + "clearAll": "Clear All", + "scanLibraryConfirm": "Are you sure you want to scan \"${title}\"?", + "analyzeLibraryConfirm": "Are you sure you want to analyze \"${title}\"?", + "refreshMetadataConfirm": "Are you sure you want to refresh metadata for \"${title}\"?", + "emptyTrashConfirm": "Are you sure you want to empty trash for \"${title}\"?", + "manageLibraries": "Manage Libraries", + "sort": "Sort", + "sortBy": "Sort By", + "filters": "Filters", + "loadingLibraryWithCount": "Loading library... (${count} items loaded)", + "confirmActionMessage": "Are you sure you want to perform this action?", + "showLibrary": "Show library", + "hideLibrary": "Hide library", + "libraryOptions": "Library options" + }, + "about": { + "title": "About", + "openSourceLicenses": "Open Source Licenses", + "versionLabel": "Version ${version}", + "appDescription": "A beautiful Plex client for Flutter", + "viewLicensesDescription": "View licenses of third-party libraries" + }, + "serverSelection": { + "connectingToServer": "Connecting to server...", + "serverDebugCopied": "Server debug data copied to clipboard", + "copyDebugData": "Copy Debug Data", + "noServersFound": "No servers found", + "malformedServerData": "Found ${count} server(s) with malformed data. No valid servers available.", + "incompleteServerInfo": "Some servers have incomplete information and were skipped. Please check your Plex.tv account.", + "incompleteConnectionInfo": "Server connection information is incomplete. Please try again.", + "malformedServerInfo": "Server information is malformed: ${message}", + "networkConnectionFailed": "Network connection failed. Please check your internet connection and try again.", + "authenticationFailed": "Authentication failed. Please sign in again.", + "plexServiceUnavailable": "Plex service unavailable. Please try again later.", + "failedToLoadServers": "Failed to load servers: ${error}" + }, + "hubDetail": { + "title": "Title", + "releaseYear": "Release Year", + "dateAdded": "Date Added", + "rating": "Rating", + "noItemsFound": "No items found" + }, + "logs": { + "title": "Logs", + "clearLogs": "Clear Logs", + "copyLogs": "Copy Logs", + "exportLogs": "Export Logs", + "noLogsToShow": "No logs to show", + "error": "Error:", + "stackTrace": "Stack Trace:" + }, + "licenses": { + "relatedPackages": "Related Packages", + "license": "License", + "licenseNumber": "License ${number}", + "licensesCount": "${count} licenses" + }, + "navigation": { + "home": "Home", + "search": "Search", + "libraries": "Libraries", + "settings": "Settings" + } +} \ No newline at end of file diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json new file mode 100644 index 00000000..9427ce1a --- /dev/null +++ b/lib/i18n/strings_sv.i18n.json @@ -0,0 +1,365 @@ +{ + "app": { + "title": "Plezy", + "loading": "Laddar..." + }, + "auth": { + "signInWithPlex": "Logga in med Plex", + "showQRCode": "Visa QR-kod", + "cancel": "Avbryt", + "authenticate": "Autentisera", + "retry": "Försök igen", + "debugEnterToken": "Debug: Ange Plex-token", + "plexTokenLabel": "Plex-autentiseringstoken", + "plexTokenHint": "Ange din Plex.tv-token", + "authenticationTimeout": "Autentisering tog för lång tid. Försök igen.", + "scanQRCodeInstruction": "Skanna denna QR-kod med en enhet inloggad på Plex för att autentisera.", + "waitingForAuth": "Väntar på autentisering...\nVänligen slutför inloggning i din webbläsare." + }, + "common": { + "cancel": "Avbryt", + "save": "Spara", + "close": "Stäng", + "clear": "Rensa", + "reset": "Återställ", + "later": "Senare", + "submit": "Skicka", + "confirm": "Bekräfta", + "retry": "Försök igen", + "playNow": "Spela nu", + "logout": "Logga ut", + "online": "Online", + "offline": "Offline", + "owned": "Egen", + "shared": "Delad", + "current": "NUVARANDE", + "unknown": "Okänd", + "refresh": "Uppdatera", + "yes": "Ja", + "no": "Nej", + "server": "Server" + }, + "screens": { + "licenses": "Licenser", + "selectServer": "Välj server", + "switchProfile": "Byt profil", + "subtitleStyling": "Undertext-styling", + "search": "Sök", + "logs": "Loggar" + }, + "update": { + "available": "Uppdatering tillgänglig", + "versionAvailable": "Version ${version} är tillgänglig", + "currentVersion": "Nuvarande: ${version}", + "skipVersion": "Hoppa över denna version", + "viewRelease": "Visa release", + "latestVersion": "Du har den senaste versionen", + "checkFailed": "Misslyckades att kontrollera uppdateringar" + }, + "settings": { + "title": "Inställningar", + "language": "Språk", + "theme": "Tema", + "appearance": "Utseende", + "videoPlayback": "Videouppspelning", + "shufflePlay": "Blanda uppspelning", + "advanced": "Avancerat", + "useSeasonPostersDescription": "Visa säsongsaffisch istället för serieaffisch för avsnitt", + "showHeroSectionDescription": "Visa utvalda innehållskarusell på startsidan", + "secondsLabel": "Sekunder", + "minutesLabel": "Minuter", + "secondsShort": "s", + "minutesShort": "m", + "durationHint": "Ange tid (${min}-${max})", + "systemTheme": "System", + "systemThemeDescription": "Följ systeminställningar", + "lightTheme": "Ljust", + "darkTheme": "Mörkt", + "libraryDensity": "Biblioteksdensitet", + "compact": "Kompakt", + "compactDescription": "Mindre kort, fler objekt synliga", + "normal": "Normal", + "normalDescription": "Standardstorlek", + "comfortable": "Bekväm", + "comfortableDescription": "Större kort, färre objekt synliga", + "viewMode": "Visningsläge", + "gridView": "Rutnät", + "gridViewDescription": "Visa objekt i rutnätslayout", + "listView": "Lista", + "listViewDescription": "Visa objekt i listlayout", + "useSeasonPosters": "Använd säsongsaffischer", + "showHeroSection": "Visa hjältesektion", + "hardwareDecoding": "Hårdvaruavkodning", + "hardwareDecodingDescription": "Använd hårdvaruacceleration när tillgängligt", + "bufferSize": "Bufferstorlek", + "bufferSizeMB": "${size}MB", + "subtitleStyling": "Undertext-styling", + "subtitleStylingDescription": "Anpassa undertextutseende", + "smallSkipDuration": "Kort hoppvaraktighet", + "largeSkipDuration": "Lång hoppvaraktighet", + "secondsUnit": "${seconds} sekunder", + "defaultSleepTimer": "Standard sovtimer", + "minutesUnit": "${minutes} minuter", + "unwatchedOnly": "Endast osedda", + "unwatchedOnlyDescription": "Inkludera endast osedda avsnitt i blandningskön", + "shuffleOrderNavigation": "Blandningsordning-navigation", + "shuffleOrderNavigationDescription": "Nästa/föregående knappar följer blandad ordning", + "loopShuffleQueue": "Loopa blandningskö", + "loopShuffleQueueDescription": "Starta om kö när slutet nås", + "videoPlayerControls": "Videospelar-kontroller", + "keyboardShortcuts": "Tangentbordsgenvägar", + "keyboardShortcutsDescription": "Anpassa tangentbordsgenvägar", + "debugLogging": "Felsökningsloggning", + "debugLoggingDescription": "Aktivera detaljerad loggning för felsökning", + "viewLogs": "Visa loggar", + "viewLogsDescription": "Visa applikationsloggar", + "clearCache": "Rensa cache", + "clearCacheDescription": "Detta rensar alla cachade bilder och data. Appen kan ta längre tid att ladda innehåll efter cache-rensning.", + "clearCacheSuccess": "Cache rensad framgångsrikt", + "resetSettings": "Återställ inställningar", + "resetSettingsDescription": "Detta återställer alla inställningar till standardvärden. Denna åtgärd kan inte ångras.", + "resetSettingsSuccess": "Inställningar återställda framgångsrikt", + "shortcutsReset": "Genvägar återställda till standard", + "about": "Om", + "aboutDescription": "Appinformation och licenser", + "updates": "Uppdateringar", + "updateAvailable": "Uppdatering tillgänglig", + "checkForUpdates": "Kontrollera uppdateringar", + "validationErrorEnterNumber": "Vänligen ange ett giltigt nummer", + "validationErrorDuration": "Tiden måste vara mellan ${min} och ${max} ${unit}", + "shortcutAlreadyAssigned": "Genväg redan tilldelad ${action}", + "shortcutUpdated": "Genväg uppdaterad för ${action}" + }, + "search": { + "hint": "Sök filmer, serier, musik...", + "tryDifferentTerm": "Prova en annan sökterm" + }, + "hotkeys": { + "setShortcutFor": "Sätt genväg för ${actionName}", + "clearShortcut": "Rensa genväg" + }, + "pinEntry": { + "enterPin": "Ange PIN", + "showPin": "Visa PIN", + "hidePin": "Dölj PIN" + }, + "fileInfo": { + "title": "Filinformation", + "video": "Video", + "audio": "Ljud", + "file": "Fil", + "advanced": "Avancerat", + "codec": "Kodek", + "resolution": "Upplösning", + "bitrate": "Bithastighet", + "frameRate": "Bildfrekvens", + "aspectRatio": "Bildförhållande", + "profile": "Profil", + "bitDepth": "Bitdjup", + "colorSpace": "Färgrymd", + "colorRange": "Färgområde", + "colorPrimaries": "Färggrunder", + "chromaSubsampling": "Kroma-undersampling", + "channels": "Kanaler", + "path": "Sökväg", + "size": "Storlek", + "container": "Container", + "duration": "Varaktighet", + "optimizedForStreaming": "Optimerad för streaming", + "has64bitOffsets": "64-bit offset" + }, + "mediaMenu": { + "markAsWatched": "Markera som sedd", + "markAsUnwatched": "Markera som osedd", + "goToSeries": "Gå till serie", + "goToSeason": "Gå till säsong", + "shufflePlay": "Blanda uppspelning", + "fileInfo": "Filinformation" + }, + "tooltips": { + "shufflePlay": "Blanda uppspelning", + "markAsWatched": "Markera som sedd", + "markAsUnwatched": "Markera som osedd" + }, + "videoControls": { + "audioLabel": "Ljud", + "subtitlesLabel": "Undertexter", + "resetToZero": "Återställ till 0ms", + "addTime": "+${amount}${unit}", + "minusTime": "-${amount}${unit}", + "playsLater": "${label} spelas senare", + "playsEarlier": "${label} spelas tidigare", + "noOffset": "Ingen offset", + "letterbox": "Letterbox", + "fillScreen": "Fyll skärm", + "stretch": "Sträck", + "lockRotation": "Lås rotation", + "unlockRotation": "Lås upp rotation" + }, + "userStatus": { + "admin": "Admin", + "restricted": "Begränsad", + "protected": "Skyddad" + }, + "messages": { + "markedAsWatched": "Markerad som sedd", + "markedAsUnwatched": "Markerad som osedd", + "errorLoading": "Fel: ${error}", + "fileInfoNotAvailable": "Filinformation inte tillgänglig", + "errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}", + "errorLoadingSeries": "Fel vid laddning av serie", + "errorLoadingSeason": "Fel vid laddning av säsong", + "musicNotSupported": "Musikuppspelning stöds inte ännu", + "logsCleared": "Loggar rensade", + "logsCopied": "Loggar kopierade till urklipp", + "noLogsAvailable": "Inga loggar tillgängliga", + "libraryScanning": "Skannar \"${title}\"...", + "libraryScanStarted": "Biblioteksskanning startad för \"${title}\"", + "libraryScanFailed": "Misslyckades att skanna bibliotek: ${error}", + "metadataRefreshing": "Uppdaterar metadata för \"${title}\"...", + "metadataRefreshStarted": "Metadata-uppdatering startad för \"${title}\"", + "metadataRefreshFailed": "Misslyckades att uppdatera metadata: ${error}", + "noPlexToken": "Ingen Plex-token hittad. Vänligen logga in igen.", + "logoutConfirm": "Är du säker på att du vill logga ut?", + "noSeasonsFound": "Inga säsonger hittades", + "noEpisodesFound": "Inga avsnitt hittades i första säsongen", + "noEpisodesFoundGeneral": "Inga avsnitt hittades", + "noResultsFound": "Inga resultat hittades", + "sleepTimerSet": "Sovtimer inställd för ${label}", + "failedToSwitchProfile": "Misslyckades att byta till ${displayName}" + }, + "profile": { + "noUsersAvailable": "Inga användare tillgängliga" + }, + "subtitlingStyling": { + "stylingOptions": "Stilalternativ", + "fontSize": "Teckenstorlek", + "textColor": "Textfärg", + "borderSize": "Kantstorlek", + "borderColor": "Kantfärg", + "backgroundOpacity": "Bakgrundsopacitet", + "backgroundColor": "Bakgrundsfärg" + }, + "dialog": { + "confirmAction": "Bekräfta åtgärd", + "areYouSure": "Är du säker på att du vill utföra denna åtgärd?", + "cancel": "Avbryt", + "playNow": "Spela nu" + }, + "discover": { + "title": "Upptäck", + "switchProfile": "Byt profil", + "switchServer": "Byt server", + "logout": "Logga ut", + "noContentAvailable": "Inget innehåll tillgängligt", + "addMediaToLibraries": "Lägg till media till dina bibliotek", + "continueWatching": "Fortsätt titta", + "recentlyAdded": "Nyligen tillagda", + "play": "Spela", + "resume": "Återuppta", + "playEpisode": "Spela S${season}, E${episode}", + "resumeEpisode": "Återuppta S${season}, E${episode}", + "pause": "Pausa", + "overview": "Översikt", + "episodeCount": "${count} avsnitt", + "watchedProgress": "${watched}/${total} sedda", + "movie": "Film", + "tvShow": "TV-serie", + "minutesLeft": "${minutes} min kvar" + }, + "errors": { + "searchFailed": "Sökning misslyckades: ${error}", + "connectionTimeout": "Anslutnings-timeout vid laddning ${context}", + "connectionFailed": "Kan inte ansluta till Plex-server", + "failedToLoad": "Misslyckades att ladda ${context}: ${error}", + "noClientAvailable": "Ingen klient tillgänglig", + "authenticationFailed": "Autentisering misslyckades: ${error}", + "couldNotLaunchUrl": "Kunde inte öppna autentiserings-URL", + "pleaseEnterToken": "Vänligen ange en token", + "invalidToken": "Ogiltig token", + "failedToVerifyToken": "Misslyckades att verifiera token: ${error}", + "failedToSwitchProfile": "Misslyckades att byta till ${displayName}", + "connectionFailedGeneric": "Anslutning misslyckades" + }, + "libraries": { + "title": "Bibliotek", + "scanLibraryFiles": "Skanna biblioteksfiler", + "scanLibrary": "Skanna bibliotek", + "analyze": "Analysera", + "analyzeLibrary": "Analysera bibliotek", + "refreshMetadata": "Uppdatera metadata", + "emptyTrash": "Töm papperskorg", + "emptyingTrash": "Tömmer papperskorg för \"${title}\"...", + "trashEmptied": "Papperskorg tömd för \"${title}\"", + "failedToEmptyTrash": "Misslyckades att tömma papperskorg: ${error}", + "analyzing": "Analyserar \"${title}\"...", + "analysisStarted": "Analys startad för \"${title}\"", + "failedToAnalyze": "Misslyckades att analysera bibliotek: ${error}", + "noLibrariesFound": "Inga bibliotek hittades", + "thisLibraryIsEmpty": "Detta bibliotek är tomt", + "all": "Alla", + "clearAll": "Rensa alla", + "scanLibraryConfirm": "Är du säker på att du vill skanna \"${title}\"?", + "analyzeLibraryConfirm": "Är du säker på att du vill analysera \"${title}\"?", + "refreshMetadataConfirm": "Är du säker på att du vill uppdatera metadata för \"${title}\"?", + "emptyTrashConfirm": "Är du säker på att du vill tömma papperskorgen för \"${title}\"?", + "manageLibraries": "Hantera bibliotek", + "sort": "Sortera", + "sortBy": "Sortera efter", + "filters": "Filter", + "loadingLibraryWithCount": "Laddar bibliotek... (${count} objekt laddade)", + "confirmActionMessage": "Är du säker på att du vill utföra denna åtgärd?", + "showLibrary": "Visa bibliotek", + "hideLibrary": "Dölj bibliotek", + "libraryOptions": "Biblioteksalternativ" + }, + "about": { + "title": "Om", + "openSourceLicenses": "Öppen källkod-licenser", + "versionLabel": "Version ${version}", + "appDescription": "En vacker Plex-klient för Flutter", + "viewLicensesDescription": "Visa licenser för tredjepartsbibliotek" + }, + "serverSelection": { + "connectingToServer": "Ansluter till server...", + "serverDebugCopied": "Server-felsökningsdata kopierad till urklipp", + "copyDebugData": "Kopiera felsökningsdata", + "noServersFound": "Inga servrar hittades", + "malformedServerData": "Hittade ${count} server(ar) med felformaterad data. Inga giltiga servrar tillgängliga.", + "incompleteServerInfo": "Vissa servrar har ofullständig information och hoppades över. Vänligen kontrollera ditt Plex.tv-konto.", + "incompleteConnectionInfo": "Server-anslutningsinformation är ofullständig. Försök igen.", + "malformedServerInfo": "Serverinformation är felformaterad: ${message}", + "networkConnectionFailed": "Nätverksanslutning misslyckades. Kontrollera din internetanslutning och försök igen.", + "authenticationFailed": "Autentisering misslyckades. Logga in igen.", + "plexServiceUnavailable": "Plex-tjänst otillgänglig. Försök igen senare.", + "failedToLoadServers": "Misslyckades att ladda servrar: ${error}" + }, + "hubDetail": { + "title": "Titel", + "releaseYear": "Utgivningsår", + "dateAdded": "Datum tillagd", + "rating": "Betyg", + "noItemsFound": "Inga objekt hittades" + }, + "logs": { + "title": "Loggar", + "clearLogs": "Rensa loggar", + "copyLogs": "Kopiera loggar", + "exportLogs": "Exportera loggar", + "noLogsToShow": "Inga loggar att visa", + "error": "Fel:", + "stackTrace": "Stack trace:" + }, + "licenses": { + "relatedPackages": "Relaterade paket", + "license": "Licens", + "licenseNumber": "Licens ${number}", + "licensesCount": "${count} licenser" + }, + "navigation": { + "home": "Hem", + "search": "Sök", + "libraries": "Bibliotek", + "settings": "Inställningar" + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 8aab1ae3..0a8a041b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,10 +23,18 @@ import 'utils/language_codes.dart'; import 'utils/app_logger.dart'; import 'utils/provider_extensions.dart'; import 'utils/orientation_helper.dart'; +import 'i18n/strings.g.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Initialize settings first to get saved locale + final settings = await SettingsService.getInstance(); + final savedLocale = settings.getAppLocale(); + + // Initialize localization with saved locale + LocaleSettings.setLocale(savedLocale); + // Configure image cache for large libraries PaintingBinding.instance.imageCache.maximumSizeBytes = 500 << 20; // 500MB PaintingBinding.instance.imageCache.maximumSize = 500; // 500 images @@ -50,7 +58,6 @@ void main() async { await LanguageCodes.initialize(); // Initialize logger level based on debug setting - final settings = await SettingsService.getInstance(); final debugEnabled = settings.getEnableDebugLogging(); setLoggerLevel(debugEnabled); @@ -83,14 +90,16 @@ class MainApp extends StatelessWidget { ], child: Consumer( builder: (context, themeProvider, child) { - return MaterialApp( - title: 'Plezy', - debugShowCheckedModeBanner: false, - theme: themeProvider.lightTheme, - darkTheme: themeProvider.darkTheme, - themeMode: themeProvider.materialThemeMode, - navigatorObservers: [routeObserver], - home: const OrientationAwareSetup(), + return TranslationProvider( + child: MaterialApp( + title: t.app.title, + debugShowCheckedModeBanner: false, + theme: themeProvider.lightTheme, + darkTheme: themeProvider.darkTheme, + themeMode: themeProvider.materialThemeMode, + navigatorObservers: [routeObserver], + home: const OrientationAwareSetup(), + ), ); }, ), @@ -158,18 +167,18 @@ class _SetupScreenState extends State { context: context, builder: (BuildContext dialogContext) { return AlertDialog( - title: const Text('Update Available'), + title: Text(t.update.available), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Version ${updateInfo['latestVersion']} is available', + t.update.versionAvailable(version: updateInfo['latestVersion']), style: Theme.of(dialogContext).textTheme.titleMedium, ), const SizedBox(height: 8), Text( - 'Current: ${updateInfo['currentVersion']}', + t.update.currentVersion(version: updateInfo['currentVersion']), style: Theme.of(dialogContext).textTheme.bodySmall, ), ], @@ -179,7 +188,7 @@ class _SetupScreenState extends State { onPressed: () { Navigator.pop(dialogContext); }, - child: const Text('Later'), + child: Text(t.common.later), ), TextButton( onPressed: () async { @@ -188,7 +197,7 @@ class _SetupScreenState extends State { Navigator.pop(dialogContext); } }, - child: const Text('Skip This Version'), + child: Text(t.update.skipVersion), ), FilledButton( onPressed: () async { @@ -200,7 +209,7 @@ class _SetupScreenState extends State { Navigator.pop(dialogContext); } }, - child: const Text('View Release'), + child: Text(t.update.viewRelease), ), ], ); @@ -295,14 +304,14 @@ class _SetupScreenState extends State { @override Widget build(BuildContext context) { - return const Scaffold( + return Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(t.app.loading), ], ), ), diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index a7f28bf1..702b97f0 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../widgets/desktop_app_bar.dart'; +import '../i18n/strings.g.dart'; import 'licenses_screen.dart'; class AboutScreen extends StatefulWidget { @@ -23,7 +24,7 @@ class _AboutScreenState extends State { Future _loadPackageInfo() async { final packageInfo = await PackageInfo.fromPlatform(); setState(() { - _appName = 'Plezy'; + _appName = t.app.title; _appVersion = packageInfo.version; }); } @@ -36,7 +37,7 @@ class _AboutScreenState extends State { return Scaffold( body: CustomScrollView( slivers: [ - CustomAppBar(title: const Text('About'), pinned: true), + CustomAppBar(title: Text(t.about.title), pinned: true), SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( @@ -55,14 +56,14 @@ class _AboutScreenState extends State { ), const SizedBox(height: 8), Text( - 'Version $appVersion', + t.about.versionLabel(version: appVersion), style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: Colors.grey), ), const SizedBox(height: 24), Text( - 'A beautiful Plex client for Flutter', + t.about.appDescription, style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center, ), @@ -76,9 +77,9 @@ class _AboutScreenState extends State { Card( child: ListTile( leading: const Icon(Icons.description), - title: const Text('Open Source Licenses'), - subtitle: const Text( - 'View licenses of third-party libraries', + title: Text(t.about.openSourceLicenses), + subtitle: Text( + t.about.viewLicensesDescription, ), trailing: const Icon(Icons.chevron_right), onTap: () { diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index fdaeb535..adedcc1a 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:qr_flutter/qr_flutter.dart'; import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; +import '../i18n/strings.g.dart'; import 'server_selection_screen.dart'; class AuthScreen extends StatefulWidget { @@ -63,7 +64,7 @@ class _AuthScreenState extends State { if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.inAppBrowserView); } else { - throw Exception('Could not launch auth URL'); + throw Exception(t.errors.couldNotLaunchUrl); } } @@ -81,7 +82,7 @@ class _AuthScreenState extends State { if (token == null) { setState(() { _isAuthenticating = false; - _errorMessage = 'Authentication timed out. Please try again.'; + _errorMessage = t.auth.authenticationTimeout; }); return; } @@ -119,7 +120,7 @@ class _AuthScreenState extends State { } catch (e) { setState(() { _isAuthenticating = false; - _errorMessage = 'Authentication failed: $e'; + _errorMessage = t.errors.authenticationFailed(error: e); }); } } @@ -149,15 +150,15 @@ class _AuthScreenState extends State { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( - title: const Text('Debug: Enter Plex Token'), + title: Text(t.auth.debugEnterToken), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextFormField( controller: tokenController, decoration: InputDecoration( - labelText: 'Plex Auth Token', - hintText: 'Enter your Plex.tv token', + labelText: t.auth.plexTokenLabel, + hintText: t.auth.plexTokenHint, errorText: errorMessage, border: const OutlineInputBorder(), ), @@ -169,14 +170,14 @@ class _AuthScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + child: Text(t.auth.cancel), ), ElevatedButton( onPressed: () async { final token = tokenController.text.trim(); if (token.isEmpty) { setDialogState(() { - errorMessage = 'Please enter a token'; + errorMessage = t.errors.pleaseEnterToken; }); return; } @@ -187,7 +188,7 @@ class _AuthScreenState extends State { final isValid = await _authService.verifyToken(token); if (!isValid) { setDialogState(() { - errorMessage = 'Invalid token'; + errorMessage = t.errors.invalidToken; }); return; } @@ -210,11 +211,11 @@ class _AuthScreenState extends State { } } catch (e) { setDialogState(() { - errorMessage = 'Failed to verify token: $e'; + errorMessage = t.errors.failedToVerifyToken(error: e); }); } }, - child: const Text('Authenticate'), + child: Text(t.auth.authenticate), ), ], ); @@ -238,7 +239,7 @@ class _AuthScreenState extends State { Image.asset('assets/plezy.png', width: 120, height: 120), const SizedBox(height: 24), Text( - 'Plezy', + t.app.title, style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.bold, ), @@ -250,8 +251,8 @@ class _AuthScreenState extends State { const SizedBox(height: 16), Text( _useQrFlow - ? 'Scan this QR code with a device logged into Plex to authenticate.' - : 'Waiting for authentication...\nPlease complete sign-in in your browser.', + ? t.auth.scanQRCodeInstruction + : t.auth.waitingForAuth, textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey), ), @@ -281,7 +282,7 @@ class _AuthScreenState extends State { horizontal: 24, ), ), - child: const Text('Retry'), + child: Text(t.auth.retry), ), ] else ...[ // add QR button here ElevatedButton( @@ -289,7 +290,7 @@ class _AuthScreenState extends State { style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), - child: const Text('Sign in with Plex'), + child: Text(t.auth.signInWithPlex), ), const SizedBox(height: 12), OutlinedButton( @@ -302,7 +303,7 @@ class _AuthScreenState extends State { style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), - child: const Text('Show QR Code'), + child: Text(t.auth.showQRCode), ), if (kDebugMode) ...[ const SizedBox(height: 12), @@ -316,8 +317,8 @@ class _AuthScreenState extends State { ).colorScheme.outline.withValues(alpha: 0.5), ), ), - child: const Text( - 'Debug: Enter Token', + child: Text( + t.auth.debugEnterToken, style: TextStyle(fontSize: 12), ), ), diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 86e68736..3fc68f61 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -18,6 +18,7 @@ import 'hub_detail_screen.dart'; import '../providers/user_profile_provider.dart'; import '../providers/settings_provider.dart'; import '../mixins/refreshable.dart'; +import '../i18n/strings.g.dart'; import '../mixins/item_updatable.dart'; import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; @@ -414,8 +415,8 @@ class _DiscoverScreenState extends State if (plexToken == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('No Plex token found. Please login again.'), + SnackBar( + content: Text(t.messages.noPlexToken), ), ); } @@ -440,7 +441,7 @@ class _DiscoverScreenState extends State } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to initialize server selection: $e')), + SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))), ); } } @@ -450,16 +451,16 @@ class _DiscoverScreenState extends State final confirm = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Logout'), - content: const Text('Are you sure you want to logout?'), + title: Text(t.common.logout), + content: Text(t.messages.logoutConfirm), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), FilledButton( onPressed: () => Navigator.pop(context, true), - child: const Text('Logout'), + child: Text(t.common.logout), ), ], ), @@ -504,7 +505,7 @@ class _DiscoverScreenState extends State controller: _scrollController, slivers: [ DesktopSliverAppBar( - title: const Text('Discover'), + title: Text(t.discover.title), floating: true, pinned: true, backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -538,33 +539,33 @@ class _DiscoverScreenState extends State itemBuilder: (context) => [ // Only show Switch Profile if multiple users available if (userProvider.hasMultipleUsers) - const PopupMenuItem( + PopupMenuItem( value: 'switch_profile', child: Row( children: [ Icon(Icons.people), SizedBox(width: 8), - Text('Switch Profile'), + Text(t.discover.switchProfile), ], ), ), - const PopupMenuItem( + PopupMenuItem( value: 'switch_server', child: Row( children: [ Icon(Icons.swap_horiz), SizedBox(width: 8), - Text('Switch Server'), + Text(t.discover.switchServer), ], ), ), - const PopupMenuItem( + PopupMenuItem( value: 'logout', child: Row( children: [ Icon(Icons.logout), SizedBox(width: 8), - Text('Logout'), + Text(t.discover.logout), ], ), ), @@ -594,7 +595,7 @@ class _DiscoverScreenState extends State const SizedBox(height: 16), ElevatedButton( onPressed: _loadContent, - child: const Text('Retry'), + child: Text(t.common.retry), ), ], ), @@ -621,7 +622,7 @@ class _DiscoverScreenState extends State const Icon(Icons.play_circle_outline), const SizedBox(width: 8), Text( - 'Continue Watching', + t.discover.continueWatching, style: Theme.of(context).textTheme.titleLarge, ), ], @@ -641,7 +642,7 @@ class _DiscoverScreenState extends State const Icon(Icons.fiber_new), const SizedBox(width: 8), Text( - 'Recently Added', + t.discover.recentlyAdded, style: Theme.of(context).textTheme.titleLarge, ), ], @@ -691,7 +692,7 @@ class _DiscoverScreenState extends State ], if (_onDeck.isEmpty && _recentlyAdded.isEmpty && _hubs.isEmpty) - const SliverFillRemaining( + SliverFillRemaining( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -702,10 +703,10 @@ class _DiscoverScreenState extends State color: Colors.grey, ), SizedBox(height: 16), - Text('No content available'), + Text(t.discover.noContentAvailable), SizedBox(height: 8), Text( - 'Add some media to your libraries', + t.discover.addMediaToLibraries, style: TextStyle(color: Colors.grey), ), ], @@ -765,7 +766,7 @@ class _DiscoverScreenState extends State color: Colors.white, size: 18, semanticLabel: - '${_isAutoScrollPaused ? 'Play' : 'Pause'} auto-scroll', + '${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll', ), ), // Spacer to separate indicators from button @@ -855,8 +856,8 @@ class _DiscoverScreenState extends State // Determine content type label for chip final contentTypeLabel = heroItem.type.toLowerCase() == 'movie' - ? 'Movie' - : 'TV Show'; + ? t.discover.movie + : t.discover.tvShow; return Semantics( label: "media-hero-${heroItem.ratingKey}", @@ -1231,7 +1232,7 @@ class _DiscoverScreenState extends State ), const SizedBox(width: 8), Text( - '$minutesLeft min left', + t.discover.minutesLeft(minutes: minutesLeft), style: const TextStyle( color: Colors.black, fontSize: 14, @@ -1239,8 +1240,8 @@ class _DiscoverScreenState extends State ), ), ] else - const Text( - 'Play', + Text( + t.discover.play, style: TextStyle( color: Colors.black, fontSize: 14, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index ddba8b94..427ce57d 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -12,6 +12,7 @@ import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/sort_bottom_sheet.dart'; import '../mixins/refreshable.dart'; +import '../i18n/strings.g.dart'; /// Screen to display full content of a recommendation hub class HubDetailScreen extends StatefulWidget { @@ -99,23 +100,23 @@ class _HubDetailScreenState extends State with Refreshable { List _getDefaultSortOptions() { return [ - PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), + PlexSort(key: 'titleSort', title: t.hubDetail.title, defaultDirection: 'asc'), PlexSort( key: 'year', descKey: 'year:desc', - title: 'Release Year', + title: t.hubDetail.releaseYear, defaultDirection: 'desc', ), PlexSort( key: 'addedAt', descKey: 'addedAt:desc', - title: 'Date Added', + title: t.hubDetail.dateAdded, defaultDirection: 'desc', ), PlexSort( key: 'rating', descKey: 'rating:desc', - title: 'Rating', + title: t.hubDetail.rating, defaultDirection: 'desc', ), ]; @@ -216,7 +217,7 @@ class _HubDetailScreenState extends State with Refreshable { } catch (e) { appLogger.e('Failed to load hub content', error: e); setState(() { - _errorMessage = 'Failed to load content: $e'; + _errorMessage = t.messages.errorLoading(error: e.toString()); _isLoading = false; }); } @@ -248,7 +249,7 @@ class _HubDetailScreenState extends State with Refreshable { pinned: true, actions: [ IconButton( - icon: const Icon(Icons.swap_vert, semanticLabel: 'Sort'), + icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort), onPressed: _showSortBottomSheet, ), ], @@ -269,7 +270,7 @@ class _HubDetailScreenState extends State with Refreshable { const SizedBox(height: 16), ElevatedButton( onPressed: _loadMoreItems, - child: const Text('Retry'), + child: Text(t.common.retry), ), ], ), @@ -280,8 +281,8 @@ class _HubDetailScreenState extends State with Refreshable { child: Center(child: CircularProgressIndicator()), ) else if (_filteredItems.isEmpty) - const SliverFillRemaining( - child: Center(child: Text('No items found')), + SliverFillRemaining( + child: Center(child: Text(t.hubDetail.noItemsFound)), ) else SliverPadding( diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 55e34cd6..2c4cea24 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -20,6 +20,7 @@ import '../services/settings_service.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; +import '../i18n/strings.g.dart'; class LibrariesScreen extends StatefulWidget { const LibrariesScreen({super.key}); @@ -66,18 +67,18 @@ class _LibrariesScreenState extends State switch (error.type) { case DioExceptionType.connectionTimeout: case DioExceptionType.receiveTimeout: - return 'Connection timeout while loading $context'; + return t.errors.connectionTimeout(context: context); case DioExceptionType.connectionError: - return 'Unable to connect to Plex server'; + return t.errors.connectionFailed; default: appLogger.e('Error loading $context', error: error); - return 'Failed to load $context: ${error.message}'; + return t.errors.failedToLoad(context: context, error: error.message ?? 'Unknown error'); } } // Generic error appLogger.e('Unexpected error in $context', error: error); - return 'Failed to load $context: $error'; + return t.errors.failedToLoad(context: context, error: error.toString()); } Future _loadLibraries() async { @@ -99,7 +100,7 @@ class _LibrariesScreenState extends State try { final client = clientProvider.client; if (client == null) { - throw Exception('No client available'); + throw Exception(t.errors.noClientAvailable); } final storage = await StorageService.getInstance(); @@ -233,7 +234,7 @@ class _LibrariesScreenState extends State final client = clientProvider.client; if (client == null) { setState(() { - _errorMessage = 'No client available'; + _errorMessage = t.errors.noClientAvailable; _isLoadingItems = false; }); return; @@ -364,7 +365,7 @@ class _LibrariesScreenState extends State ); final client = clientProvider.client; if (client == null) { - throw Exception('No client available'); + throw Exception(t.errors.noClientAvailable); } final filters = await client.getLibraryFilters(libraryKey); @@ -387,7 +388,7 @@ class _LibrariesScreenState extends State ); final client = clientProvider.client; if (client == null) { - throw Exception('No client available'); + throw Exception(t.errors.noClientAvailable); } final sortOptions = await client.getLibrarySorts(libraryKey); @@ -449,7 +450,7 @@ class _LibrariesScreenState extends State ); final client = clientProvider.client; if (client == null) { - throw Exception('No client available'); + throw Exception(t.errors.noClientAvailable); } // Add sort parameter to filters if selected @@ -474,7 +475,7 @@ class _LibrariesScreenState extends State } setState(() { - _errorMessage = 'Failed to load library content: $e'; + _errorMessage = t.messages.errorLoading(error: e.toString()); _isLoadingItems = false; }); } @@ -597,39 +598,39 @@ class _LibrariesScreenState extends State ContextMenuItem( value: 'scan', icon: Icons.refresh, - label: 'Scan Library Files', + label: t.libraries.scanLibraryFiles, requiresConfirmation: true, - confirmationTitle: 'Scan Library', + confirmationTitle: t.libraries.scanLibrary, confirmationMessage: - 'This will scan "${library.title}" for new files. Continue?', + t.libraries.scanLibraryConfirm(title: library.title), ), ContextMenuItem( value: 'analyze', icon: Icons.analytics_outlined, - label: 'Analyze', + label: t.libraries.analyze, requiresConfirmation: true, - confirmationTitle: 'Analyze Library', + confirmationTitle: t.libraries.analyzeLibrary, confirmationMessage: - 'This will analyze "${library.title}" for intro markers and other metadata. This may take some time. Continue?', + t.libraries.analyzeLibraryConfirm(title: library.title), ), ContextMenuItem( value: 'refresh', icon: Icons.sync, - label: 'Refresh Metadata', + label: t.libraries.refreshMetadata, requiresConfirmation: true, - confirmationTitle: 'Refresh Metadata', + confirmationTitle: t.libraries.refreshMetadata, confirmationMessage: - 'This will refresh metadata for all items in "${library.title}". This may take some time. Continue?', + t.libraries.refreshMetadataConfirm(title: library.title), isDestructive: true, ), ContextMenuItem( value: 'empty_trash', icon: Icons.delete_outline, - label: 'Empty Trash', + label: t.libraries.emptyTrash, requiresConfirmation: true, - confirmationTitle: 'Empty Trash', + confirmationTitle: t.libraries.emptyTrash, confirmationMessage: - 'This will permanently delete all trashed items in "${library.title}". This action cannot be undone. Continue?', + t.libraries.emptyTrashConfirm(title: library.title), isDestructive: true, ), ]; @@ -682,14 +683,14 @@ class _LibrariesScreenState extends State final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { - throw Exception('No client available'); + throw Exception(t.errors.noClientAvailable); } // Show progress indicator if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Scanning "${library.title}"...'), + content: Text(t.messages.libraryScanning(title: library.title)), duration: const Duration(seconds: 2), ), ); @@ -700,7 +701,7 @@ class _LibrariesScreenState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Library scan started for "${library.title}"'), + content: Text(t.messages.libraryScanStarted(title: library.title)), duration: const Duration(seconds: 3), ), ); @@ -710,7 +711,7 @@ class _LibrariesScreenState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to scan library: $e'), + content: Text(t.messages.libraryScanFailed(error: e.toString())), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), @@ -724,14 +725,14 @@ class _LibrariesScreenState extends State final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { - throw Exception('No client available'); + throw Exception(t.errors.noClientAvailable); } // Show progress indicator if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Refreshing metadata for "${library.title}"...'), + content: Text(t.messages.metadataRefreshing(title: library.title)), duration: const Duration(seconds: 2), ), ); @@ -742,7 +743,7 @@ class _LibrariesScreenState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Metadata refresh started for "${library.title}"'), + content: Text(t.messages.metadataRefreshStarted(title: library.title)), duration: const Duration(seconds: 3), ), ); @@ -752,7 +753,7 @@ class _LibrariesScreenState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to refresh metadata: $e'), + content: Text(t.messages.metadataRefreshFailed(error: e.toString())), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), @@ -766,14 +767,14 @@ class _LibrariesScreenState extends State final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { - throw Exception('No client available'); + throw Exception(t.errors.noClientAvailable); } // Show progress indicator if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Emptying trash for "${library.title}"...'), + content: Text(t.libraries.emptyingTrash(title: library.title)), duration: const Duration(seconds: 2), ), ); @@ -784,7 +785,7 @@ class _LibrariesScreenState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Trash emptied for "${library.title}"'), + content: Text(t.libraries.trashEmptied(title: library.title)), duration: const Duration(seconds: 3), ), ); @@ -794,7 +795,7 @@ class _LibrariesScreenState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to empty trash: $e'), + content: Text(t.libraries.failedToEmptyTrash(error: e)), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), @@ -808,14 +809,14 @@ class _LibrariesScreenState extends State final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { - throw Exception('No client available'); + throw Exception(t.errors.noClientAvailable); } // Show progress indicator if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Analyzing "${library.title}"...'), + content: Text(t.libraries.analyzing(title: library.title)), duration: const Duration(seconds: 2), ), ); @@ -826,7 +827,7 @@ class _LibrariesScreenState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Analysis started for "${library.title}"'), + content: Text(t.libraries.analysisStarted(title: library.title)), duration: const Duration(seconds: 3), ), ); @@ -836,7 +837,7 @@ class _LibrariesScreenState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to analyze library: $e'), + content: Text(t.libraries.failedToAnalyze(error: e)), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), @@ -860,7 +861,7 @@ class _LibrariesScreenState extends State body: CustomScrollView( slivers: [ DesktopSliverAppBar( - title: const Text('Libraries'), + title: Text(t.libraries.title), floating: true, pinned: true, backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -870,15 +871,15 @@ class _LibrariesScreenState extends State actions: [ if (_allLibraries.isNotEmpty) IconButton( - icon: const Icon( + icon: Icon( Icons.edit, - semanticLabel: 'Manage Libraries', + semanticLabel: t.libraries.manageLibraries, ), onPressed: _showLibraryManagementSheet, ), if (_sortOptions.isNotEmpty) IconButton( - icon: const Icon(Icons.swap_vert, semanticLabel: 'Sort'), + icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort), onPressed: _showSortBottomSheet, ), if (_filters.isNotEmpty) @@ -886,15 +887,15 @@ class _LibrariesScreenState extends State icon: Badge( label: Text('${_selectedFilters.length}'), isLabelVisible: _selectedFilters.isNotEmpty, - child: const Icon( + child: Icon( Icons.filter_list, - semanticLabel: 'Filters', + semanticLabel: t.libraries.filters, ), ), onPressed: _showFiltersBottomSheet, ), IconButton( - icon: const Icon(Icons.refresh, semanticLabel: 'Refresh'), + icon: Icon(Icons.refresh, semanticLabel: t.common.refresh), onPressed: () => _loadLibraryContent(_selectedLibraryKey!), ), ], @@ -919,25 +920,25 @@ class _LibrariesScreenState extends State const SizedBox(height: 16), ElevatedButton( onPressed: _loadLibraries, - child: const Text('Retry'), + child: Text(t.common.retry), ), ], ), ), ) else if (visibleLibraries.isEmpty) - const SliverFillRemaining( + SliverFillRemaining( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.video_library_outlined, size: 64, color: Colors.grey, ), - SizedBox(height: 16), - Text('No libraries found'), + const SizedBox(height: 16), + Text(t.libraries.noLibrariesFound), ], ), ), @@ -1024,21 +1025,21 @@ class _LibrariesScreenState extends State ElevatedButton( onPressed: () => _loadLibraryContent(_selectedLibraryKey!), - child: const Text('Retry'), + child: Text(t.common.retry), ), ], ), ), ) else if (_items.isEmpty) - const SliverFillRemaining( + SliverFillRemaining( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.folder_open, size: 64, color: Colors.grey), - SizedBox(height: 16), - Text('This library is empty'), + const Icon(Icons.folder_open, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(t.libraries.thisLibraryIsEmpty), ], ), ), @@ -1096,7 +1097,7 @@ class _LibrariesScreenState extends State const CircularProgressIndicator(), const SizedBox(height: 8), Text( - 'Loading library... (${_items.length} items loaded)', + t.libraries.loadingLibraryWithCount(count: _items.length), style: Theme.of(context).textTheme.bodySmall, ), ], @@ -1231,7 +1232,7 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> { ); final client = clientProvider.client; if (client == null) { - throw Exception('No client available'); + throw Exception(t.errors.noClientAvailable); } final values = await client.getFilterValues(filter.key); @@ -1332,7 +1333,7 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> { _currentFilter!.filter, ); return ListTile( - title: const Text('All'), + title: Text(t.libraries.all), selected: isSelected, onTap: () { setState(() { @@ -1390,9 +1391,9 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> { children: [ const Icon(Icons.filter_list), const SizedBox(width: 12), - const Text( - 'Filters', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + Text( + t.libraries.filters, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), const Spacer(), if (_tempSelectedFilters.isNotEmpty) @@ -1404,7 +1405,7 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> { _applyFilters(); }, icon: const Icon(Icons.clear_all), - label: const Text('Clear All'), + label: Text(t.libraries.clearAll), ), IconButton( icon: const Icon(Icons.close), @@ -1534,10 +1535,10 @@ class _SortBottomSheetState extends State<_SortBottomSheet> { ), child: Row( children: [ - const Expanded( + Expanded( child: Text( - 'Sort By', - style: TextStyle( + t.libraries.sortBy, + style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), @@ -1717,22 +1718,22 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( - title: Text(selectedItem.confirmationTitle ?? 'Confirm Action'), + title: Text(selectedItem.confirmationTitle ?? t.dialog.confirmAction), content: Text( selectedItem.confirmationMessage ?? - 'Are you sure you want to perform this action?', + t.libraries.confirmActionMessage, ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), TextButton( onPressed: () => Navigator.pop(context, true), style: selectedItem.isDestructive ? TextButton.styleFrom(foregroundColor: Colors.red) : null, - child: const Text('Confirm'), + child: Text(t.common.confirm), ), ], ), @@ -1786,10 +1787,10 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { children: [ const Icon(Icons.edit), const SizedBox(width: 12), - const Expanded( + Expanded( child: Text( - 'Manage Libraries', - style: TextStyle( + t.libraries.manageLibraries, + style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), @@ -1851,13 +1852,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { : Icons.visibility, ), onPressed: () => widget.onToggleVisibility(library), - tooltip: isHidden ? 'Show library' : 'Hide library', + tooltip: isHidden ? t.libraries.showLibrary : t.libraries.hideLibrary, ), IconButton( icon: const Icon(Icons.more_vert), onPressed: () => _showLibraryMenuBottomSheet(context, library), - tooltip: 'Library options', + tooltip: t.libraries.libraryOptions, ), ], ), diff --git a/lib/screens/licenses_screen.dart b/lib/screens/licenses_screen.dart index 94df8fdb..87e2bb26 100644 --- a/lib/screens/licenses_screen.dart +++ b/lib/screens/licenses_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../widgets/desktop_app_bar.dart'; +import '../i18n/strings.g.dart'; class MergedLicenseEntry { final String packageName; @@ -73,7 +74,7 @@ class _LicensesScreenState extends State { return Scaffold( body: CustomScrollView( slivers: [ - const CustomAppBar(title: Text('Licenses'), pinned: true), + CustomAppBar(title: Text(t.screens.licenses), pinned: true), SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( @@ -92,7 +93,7 @@ class _LicensesScreenState extends State { ), subtitle: mergedLicense.licenseEntries.length > 1 ? Text( - '${mergedLicense.licenseEntries.length} licenses', + t.licenses.licensesCount(count: mergedLicense.licenseEntries.length), ) : null, trailing: const Icon(Icons.chevron_right), @@ -145,7 +146,7 @@ class _LicenseDetailScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Related Packages', + t.licenses.relatedPackages, style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), @@ -177,8 +178,8 @@ class _LicenseDetailScreen extends StatelessWidget { children: [ Text( isMultipleLicenses - ? 'License ${index + 1}' - : 'License', + ? t.licenses.licenseNumber(number: index + 1) + : t.licenses.license, style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), diff --git a/lib/screens/logs_screen.dart b/lib/screens/logs_screen.dart index aaea8d0e..e6368395 100644 --- a/lib/screens/logs_screen.dart +++ b/lib/screens/logs_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; +import '../i18n/strings.g.dart'; import '../utils/app_logger.dart'; import '../widgets/desktop_app_bar.dart'; @@ -40,7 +41,7 @@ class _LogsScreenState extends State { _logs = []; }); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Logs cleared')), + SnackBar(content: Text(t.messages.logsCleared)), ); } @@ -64,7 +65,7 @@ class _LogsScreenState extends State { } Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Logs copied to clipboard')), + SnackBar(content: Text(t.messages.logsCopied)), ); } @@ -108,30 +109,30 @@ class _LogsScreenState extends State { body: CustomScrollView( slivers: [ CustomAppBar( - title: const Text('Logs'), + title: Text(t.screens.logs), pinned: true, actions: [ IconButton( icon: const Icon(Icons.refresh), onPressed: _loadLogs, - tooltip: 'Refresh', + tooltip: t.common.refresh, ), IconButton( icon: const Icon(Icons.copy), onPressed: _logs.isNotEmpty ? _copyAllLogs : null, - tooltip: 'Copy All', + tooltip: t.logs.copyLogs, ), IconButton( icon: const Icon(Icons.delete_outline), onPressed: _logs.isNotEmpty ? _clearLogs : null, - tooltip: 'Clear Logs', + tooltip: t.logs.clearLogs, ), ], ), if (_logs.isEmpty) - const SliverFillRemaining( + SliverFillRemaining( child: Center( - child: Text('No logs available'), + child: Text(t.messages.noLogsAvailable), ), ) else @@ -259,7 +260,7 @@ class _LogEntryCardState extends State<_LogEntryCard> { const SizedBox(height: 8), if (widget.log.error != null) ...[ Text( - 'Error:', + t.logs.error, style: Theme.of(context).textTheme.titleSmall?.copyWith( color: widget.levelColor, fontWeight: FontWeight.bold, @@ -285,7 +286,7 @@ class _LogEntryCardState extends State<_LogEntryCard> { if (widget.log.stackTrace != null) ...[ const SizedBox(height: 12), Text( - 'Stack Trace:', + t.logs.stackTrace, style: Theme.of(context).textTheme.titleSmall?.copyWith( color: widget.levelColor, fontWeight: FontWeight.bold, diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 6532a4e6..79918672 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../client/plex_client.dart'; +import '../i18n/strings.g.dart'; import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; import '../main.dart'; @@ -127,26 +128,26 @@ class _MainScreenState extends State with RouteAware { _onDiscoverBecameVisible(); } }, - destinations: const [ + destinations: [ NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home', + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home), + label: t.navigation.home, ), NavigationDestination( - icon: Icon(Icons.video_library_outlined), - selectedIcon: Icon(Icons.video_library), - label: 'Libraries', + icon: const Icon(Icons.video_library_outlined), + selectedIcon: const Icon(Icons.video_library), + label: t.navigation.libraries, ), NavigationDestination( - icon: Icon(Icons.search), - selectedIcon: Icon(Icons.search), - label: 'Search', + icon: const Icon(Icons.search), + selectedIcon: const Icon(Icons.search), + label: t.navigation.search, ), NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + label: t.navigation.settings, ), ], ), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 4d896367..1064a987 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -12,6 +12,7 @@ import '../utils/video_player_navigation.dart'; import '../utils/content_rating_formatter.dart'; import '../utils/shuffle_play_helper.dart'; import '../theme/theme_helper.dart'; +import '../i18n/strings.g.dart'; import 'season_detail_screen.dart'; class MediaDetailScreen extends StatefulWidget { @@ -182,7 +183,7 @@ class _MediaDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of( context, - ).showSnackBar(const SnackBar(content: Text('No seasons found'))); + ).showSnackBar(SnackBar(content: Text(t.messages.noSeasonsFound))); } return; } @@ -196,7 +197,7 @@ class _MediaDetailScreenState extends State { if (episodes.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No episodes found in first season')), + SnackBar(content: Text(t.messages.noEpisodesFound)), ); } return; @@ -218,7 +219,7 @@ class _MediaDetailScreenState extends State { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error loading first episode: $e')), + SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))), ); } } @@ -583,7 +584,7 @@ class _MediaDetailScreenState extends State { await handleShufflePlay(context, metadata); }, icon: const Icon(Icons.shuffle), - tooltip: 'Shuffle play', + tooltip: t.tooltips.shufflePlay, iconSize: 20, style: IconButton.styleFrom( minimumSize: const Size(48, 48), @@ -603,8 +604,8 @@ class _MediaDetailScreenState extends State { if (context.mounted) { _watchStateChanged = true; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Marked as watched'), + SnackBar( + content: Text(t.messages.markedAsWatched), ), ); // Update watch state without full rebuild @@ -613,13 +614,13 @@ class _MediaDetailScreenState extends State { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e')), + SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))), ); } } }, icon: const Icon(Icons.check), - tooltip: 'Mark as watched', + tooltip: t.tooltips.markAsWatched, iconSize: 20, style: IconButton.styleFrom( minimumSize: const Size(48, 48), @@ -638,8 +639,8 @@ class _MediaDetailScreenState extends State { if (context.mounted) { _watchStateChanged = true; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Marked as unwatched'), + SnackBar( + content: Text(t.messages.markedAsUnwatched), ), ); // Update watch state without full rebuild @@ -648,13 +649,13 @@ class _MediaDetailScreenState extends State { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e')), + SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))), ); } } }, icon: const Icon(Icons.remove_done), - tooltip: 'Mark as unwatched', + tooltip: t.tooltips.markAsUnwatched, iconSize: 20, style: IconButton.styleFrom( minimumSize: const Size(48, 48), @@ -669,7 +670,7 @@ class _MediaDetailScreenState extends State { // Summary if (metadata.summary != null) ...[ Text( - 'Overview', + t.discover.overview, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), @@ -705,7 +706,7 @@ class _MediaDetailScreenState extends State { padding: const EdgeInsets.all(32), child: Center( child: Text( - 'No seasons found', + t.messages.noSeasonsFound, style: Theme.of( context, ).textTheme.bodyLarge?.copyWith(color: Colors.grey), @@ -845,7 +846,7 @@ class _MediaDetailScreenState extends State { const SizedBox(height: 4), if (season.leafCount != null) Text( - '${season.leafCount} episodes', + t.discover.episodeCount(count: season.leafCount.toString()), style: Theme.of(context).textTheme.bodyMedium ?.copyWith(color: Colors.grey), ), @@ -873,7 +874,10 @@ class _MediaDetailScreenState extends State { ), const SizedBox(height: 4), Text( - '${season.viewedLeafCount}/${season.leafCount} watched', + t.discover.watchedProgress( + watched: season.viewedLeafCount.toString(), + total: season.leafCount.toString(), + ), style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: Colors.grey), ), @@ -936,21 +940,21 @@ class _MediaDetailScreenState extends State { // Check if episode has been partially watched (viewOffset > 0) if (episode.viewOffset != null && episode.viewOffset! > 0) { - return 'Resume S$seasonNum, E$episodeNum'; + return t.discover.resumeEpisode(season: seasonNum.toString(), episode: episodeNum.toString()); } else { - return 'Play S$seasonNum, E$episodeNum'; + return t.discover.playEpisode(season: seasonNum.toString(), episode: episodeNum.toString()); } } else { // No on deck episode, will play first episode - return 'Play S1, E1'; + return t.discover.playEpisode(season: '1', episode: '1'); } } // For movies or episodes, check if partially watched if (metadata.viewOffset != null && metadata.viewOffset! > 0) { - return 'Resume'; + return t.discover.resume; } - return 'Play'; + return t.discover.play; } } diff --git a/lib/screens/profile_switch_screen.dart b/lib/screens/profile_switch_screen.dart index 31f3f666..a2a2062c 100644 --- a/lib/screens/profile_switch_screen.dart +++ b/lib/screens/profile_switch_screen.dart @@ -5,6 +5,7 @@ import '../providers/user_profile_provider.dart'; import '../utils/provider_extensions.dart'; import '../widgets/profile_list_tile.dart'; import '../widgets/desktop_app_bar.dart'; +import '../i18n/strings.g.dart'; class ProfileSwitchScreen extends StatelessWidget { const ProfileSwitchScreen({super.key}); @@ -16,7 +17,7 @@ class ProfileSwitchScreen extends StatelessWidget { return Scaffold( body: CustomScrollView( slivers: [ - const CustomAppBar(title: Text('Switch Profile')), + CustomAppBar(title: Text(t.screens.switchProfile)), SliverFillRemaining( child: Consumer( builder: (context, userProvider, child) { @@ -41,7 +42,7 @@ class ProfileSwitchScreen extends StatelessWidget { onPressed: () { userProvider.refreshCurrentUser(); }, - child: const Text('Retry'), + child: Text(t.common.retry), ), ], ), @@ -115,7 +116,7 @@ class ProfileSwitchScreen extends StatelessWidget { } else if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to switch to ${user.displayName}'), + content: Text(t.errors.failedToSwitchProfile(displayName: user.displayName)), backgroundColor: Theme.of(context).colorScheme.error, ), ); diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index e98a154e..3de0afe1 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -11,6 +11,7 @@ import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; +import '../i18n/strings.g.dart'; class SearchScreen extends StatefulWidget { const SearchScreen({super.key}); @@ -108,7 +109,7 @@ class _SearchScreenState extends State }); ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Search failed: $e'))); + ).showSnackBar(SnackBar(content: Text(t.errors.searchFailed(error: e)))); } } } @@ -150,13 +151,13 @@ class _SearchScreenState extends State body: SafeArea( child: CustomScrollView( slivers: [ - DesktopSliverAppBar(title: const Text('Search'), floating: true), + DesktopSliverAppBar(title: Text(t.screens.search), floating: true), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), child: SearchBar( controller: _searchController, - hintText: 'Search movies, shows, music...', + hintText: t.search.hint, leading: const Icon(Icons.search), trailing: [ if (_searchController.text.isNotEmpty) @@ -212,14 +213,14 @@ class _SearchScreenState extends State ), const SizedBox(height: 16), Text( - 'No results found', + t.messages.noResultsFound, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: Colors.grey.shade600, ), ), const SizedBox(height: 8), Text( - 'Try a different search term', + t.search.tryDifferentTerm, style: TextStyle(color: Colors.grey.shade600), ), ], diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index abe74ded..24995170 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -10,6 +10,7 @@ import '../widgets/desktop_app_bar.dart'; import '../widgets/media_context_menu.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; +import '../i18n/strings.g.dart'; class SeasonDetailScreen extends StatefulWidget { final PlexMetadata season; @@ -100,7 +101,7 @@ class _SeasonDetailScreenState extends State ), const SizedBox(height: 16), Text( - 'No episodes found', + t.messages.noEpisodesFoundGeneral, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: tokens(context).textMuted, ), diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart index ca10e951..4f1fc749 100644 --- a/lib/screens/server_selection_screen.dart +++ b/lib/screens/server_selection_screen.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../i18n/strings.g.dart'; import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; import '../services/server_connection_service.dart'; @@ -74,27 +75,27 @@ class _ServerSelectionScreenState extends State { String _getErrorMessage(dynamic error) { if (error is ServerParsingException) { - return 'Found ${error.invalidServerData.length} server(s) with malformed data. No valid servers available.'; + return t.serverSelection.malformedServerData(count: error.invalidServerData.length); } else if (error is FormatException) { // Handle JSON parsing errors with more user-friendly messages if (error.message.contains('Invalid server data')) { - return 'Some servers have incomplete information and were skipped. Please check your Plex.tv account.'; + return t.serverSelection.incompleteServerInfo; } else if (error.message.contains('Invalid connection data')) { - return 'Server connection information is incomplete. Please try again.'; + return t.serverSelection.incompleteConnectionInfo; } - return 'Server information is malformed: ${error.message}'; + return t.serverSelection.malformedServerInfo(message: error.message); } else if (error.toString().contains('SocketException') || error.toString().contains('TimeoutException')) { - return 'Network connection failed. Please check your internet connection and try again.'; + return t.serverSelection.networkConnectionFailed; } else if (error.toString().contains('401') || error.toString().contains('Unauthorized')) { - return 'Authentication failed. Please sign in again.'; + return t.serverSelection.authenticationFailed; } else if (error.toString().contains('404') || error.toString().contains('Not Found')) { - return 'Plex service unavailable. Please try again later.'; + return t.serverSelection.plexServiceUnavailable; } - return 'Failed to load servers: ${error.toString()}'; + return t.serverSelection.failedToLoadServers(error: error.toString()); } Future _copyDebugDataToClipboard() async { @@ -105,7 +106,7 @@ class _ServerSelectionScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Server debug data copied to clipboard')), + SnackBar(content: Text(t.serverSelection.serverDebugCopied)), ); } } @@ -116,13 +117,13 @@ class _ServerSelectionScreenState extends State { showDialog( context: context, barrierDismissible: false, - builder: (context) => const AlertDialog( + builder: (context) => AlertDialog( content: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 16), - Text('Connecting to server...'), + Text(t.serverSelection.connectingToServer), ], ), ), @@ -206,7 +207,7 @@ class _ServerSelectionScreenState extends State { // Show error if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(result.error ?? 'Connection failed')), + SnackBar(content: Text(result.error ?? t.errors.connectionFailedGeneric)), ); } } @@ -215,7 +216,7 @@ class _ServerSelectionScreenState extends State { if (mounted) { Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to connect to server: $e')), + SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))), ); } appLogger.e('Server selection failed', error: e); @@ -239,7 +240,7 @@ class _ServerSelectionScreenState extends State { return Scaffold( body: CustomScrollView( slivers: [ - const CustomAppBar(title: Text('Select Server')), + CustomAppBar(title: Text(t.screens.selectServer)), SliverFillRemaining( child: _isLoading ? const Center(child: CircularProgressIndicator()) @@ -264,14 +265,14 @@ class _ServerSelectionScreenState extends State { children: [ ElevatedButton( onPressed: _loadServers, - child: const Text('Retry'), + child: Text(t.common.retry), ), if (_debugServerData != null) ...[ const SizedBox(width: 16), OutlinedButton.icon( onPressed: _copyDebugDataToClipboard, icon: const Icon(Icons.copy), - label: const Text('Copy Debug Data'), + label: Text(t.serverSelection.copyDebugData), ), ], ], @@ -287,7 +288,7 @@ class _ServerSelectionScreenState extends State { ), ) : _servers == null || _servers!.isEmpty - ? const Center(child: Text('No servers found')) + ? Center(child: Text(t.serverSelection.noServersFound)) : ListView.builder( itemCount: _servers!.length, padding: const EdgeInsets.all(16), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9af5bca3..b5472d02 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -9,6 +9,7 @@ import '../services/keyboard_shortcuts_service.dart'; import '../services/update_service.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/hotkey_recorder_widget.dart'; +import '../i18n/strings.g.dart'; import 'about_screen.dart'; import 'logs_screen.dart'; import 'subtitle_styling_screen.dart'; @@ -66,7 +67,7 @@ class _SettingsScreenState extends State { return Scaffold( body: CustomScrollView( slivers: [ - const CustomAppBar(title: Text('Settings'), pinned: true), + CustomAppBar(title: Text(t.settings.title), pinned: true), SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( @@ -103,7 +104,7 @@ class _SettingsScreenState extends State { Padding( padding: const EdgeInsets.all(16), child: Text( - 'Appearance', + t.settings.appearance, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), @@ -113,18 +114,27 @@ class _SettingsScreenState extends State { builder: (context, themeProvider, child) { return ListTile( leading: Icon(themeProvider.themeModeIcon), - title: const Text('Theme'), + title: Text(t.settings.theme), subtitle: Text(themeProvider.themeModeDisplayName), trailing: const Icon(Icons.chevron_right), onTap: () => _showThemeDialog(themeProvider), ); }, ), + ListTile( + leading: const Icon(Icons.language), + title: Text(t.settings.language), + subtitle: Text( + _getLanguageDisplayName(LocaleSettings.currentLocale), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showLanguageDialog(), + ), Consumer( builder: (context, settingsProvider, child) { return ListTile( leading: const Icon(Icons.grid_view), - title: const Text('Library Density'), + title: Text(t.settings.libraryDensity), subtitle: Text(settingsProvider.libraryDensityDisplayName), trailing: const Icon(Icons.chevron_right), onTap: () => _showLibraryDensityDialog(), @@ -135,8 +145,12 @@ class _SettingsScreenState extends State { builder: (context, settingsProvider, child) { return ListTile( leading: const Icon(Icons.view_list), - title: const Text('View Mode'), - subtitle: Text(settingsProvider.viewMode == settings.ViewMode.grid ? 'Grid' : 'List'), + title: Text(t.settings.viewMode), + subtitle: Text( + settingsProvider.viewMode == settings.ViewMode.grid + ? t.settings.gridView + : t.settings.listView, + ), trailing: const Icon(Icons.chevron_right), onTap: () => _showViewModeDialog(), ); @@ -146,10 +160,8 @@ class _SettingsScreenState extends State { builder: (context, settingsProvider, child) { return SwitchListTile( secondary: const Icon(Icons.image), - title: const Text('Use Season Posters'), - subtitle: const Text( - 'Show season poster instead of series poster for episodes', - ), + title: Text(t.settings.useSeasonPosters), + subtitle: Text(t.settings.useSeasonPostersDescription), value: settingsProvider.useSeasonPoster, onChanged: (value) async { await settingsProvider.setUseSeasonPoster(value); @@ -161,10 +173,8 @@ class _SettingsScreenState extends State { builder: (context, settingsProvider, child) { return SwitchListTile( secondary: const Icon(Icons.featured_play_list), - title: const Text('Show Hero Section'), - subtitle: const Text( - 'Display featured content carousel on home screen', - ), + title: Text(t.settings.showHeroSection), + subtitle: Text(t.settings.showHeroSectionDescription), value: settingsProvider.showHeroSection, onChanged: (value) async { await settingsProvider.setShowHeroSection(value); @@ -185,7 +195,7 @@ class _SettingsScreenState extends State { Padding( padding: const EdgeInsets.all(16), child: Text( - 'Video Playback', + t.settings.videoPlayback, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), @@ -193,8 +203,8 @@ class _SettingsScreenState extends State { ), SwitchListTile( secondary: const Icon(Icons.hardware), - title: const Text('Hardware Decoding'), - subtitle: const Text('Use hardware acceleration when available'), + title: Text(t.settings.hardwareDecoding), + subtitle: Text(t.settings.hardwareDecodingDescription), value: _enableHardwareDecoding, onChanged: (value) async { setState(() { @@ -205,15 +215,17 @@ class _SettingsScreenState extends State { ), ListTile( leading: const Icon(Icons.memory), - title: const Text('Buffer Size'), - subtitle: Text('${_bufferSize}MB'), + title: Text(t.settings.bufferSize), + subtitle: Text( + t.settings.bufferSizeMB(size: _bufferSize.toString()), + ), trailing: const Icon(Icons.chevron_right), onTap: () => _showBufferSizeDialog(), ), ListTile( leading: const Icon(Icons.subtitles), - title: const Text('Subtitle Styling'), - subtitle: const Text('Customize subtitle appearance'), + title: Text(t.settings.subtitleStyling), + subtitle: Text(t.settings.subtitleStylingDescription), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( @@ -226,22 +238,28 @@ class _SettingsScreenState extends State { ), ListTile( leading: const Icon(Icons.replay_10), - title: const Text('Small Skip Duration'), - subtitle: Text('$_seekTimeSmall seconds'), + title: Text(t.settings.smallSkipDuration), + subtitle: Text( + t.settings.secondsUnit(seconds: _seekTimeSmall.toString()), + ), trailing: const Icon(Icons.chevron_right), onTap: () => _showSeekTimeSmallDialog(), ), ListTile( leading: const Icon(Icons.replay_30), - title: const Text('Large Skip Duration'), - subtitle: Text('$_seekTimeLarge seconds'), + title: Text(t.settings.largeSkipDuration), + subtitle: Text( + t.settings.secondsUnit(seconds: _seekTimeLarge.toString()), + ), trailing: const Icon(Icons.chevron_right), onTap: () => _showSeekTimeLargeDialog(), ), ListTile( leading: const Icon(Icons.bedtime), - title: const Text('Default Sleep Timer'), - subtitle: Text('$_sleepTimerDuration minutes'), + title: Text(t.settings.defaultSleepTimer), + subtitle: Text( + t.settings.minutesUnit(minutes: _sleepTimerDuration.toString()), + ), trailing: const Icon(Icons.chevron_right), onTap: () => _showSleepTimerDurationDialog(), ), @@ -258,7 +276,7 @@ class _SettingsScreenState extends State { Padding( padding: const EdgeInsets.all(16), child: Text( - 'Shuffle Play', + t.settings.shufflePlay, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), @@ -268,10 +286,8 @@ class _SettingsScreenState extends State { builder: (context, settingsProvider, child) { return SwitchListTile( secondary: const Icon(Icons.visibility_off), - title: const Text('Unwatched Only'), - subtitle: const Text( - 'Only include unwatched episodes in shuffle queue', - ), + title: Text(t.settings.unwatchedOnly), + subtitle: Text(t.settings.unwatchedOnlyDescription), value: settingsProvider.shuffleUnwatchedOnly, onChanged: (value) async { await settingsProvider.setShuffleUnwatchedOnly(value); @@ -283,10 +299,8 @@ class _SettingsScreenState extends State { builder: (context, settingsProvider, child) { return SwitchListTile( secondary: const Icon(Icons.shuffle), - title: const Text('Shuffle Order Navigation'), - subtitle: const Text( - 'Next/previous buttons follow shuffled order', - ), + title: Text(t.settings.shuffleOrderNavigation), + subtitle: Text(t.settings.shuffleOrderNavigationDescription), value: settingsProvider.shuffleOrderNavigation, onChanged: (value) async { await settingsProvider.setShuffleOrderNavigation(value); @@ -298,10 +312,8 @@ class _SettingsScreenState extends State { builder: (context, settingsProvider, child) { return SwitchListTile( secondary: const Icon(Icons.loop), - title: const Text('Loop Shuffle Queue'), - subtitle: const Text( - 'Restart queue when reaching the end', - ), + title: Text(t.settings.loopShuffleQueue), + subtitle: Text(t.settings.loopShuffleQueueDescription), value: settingsProvider.shuffleLoopQueue, onChanged: (value) async { await settingsProvider.setShuffleLoopQueue(value); @@ -322,7 +334,7 @@ class _SettingsScreenState extends State { Padding( padding: const EdgeInsets.all(16), child: Text( - 'Keyboard Shortcuts', + t.settings.keyboardShortcuts, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), @@ -330,8 +342,8 @@ class _SettingsScreenState extends State { ), ListTile( leading: const Icon(Icons.keyboard), - title: const Text('Video Player Controls'), - subtitle: const Text('Customize keyboard shortcuts'), + title: Text(t.settings.videoPlayerControls), + subtitle: Text(t.settings.keyboardShortcutsDescription), trailing: const Icon(Icons.chevron_right), onTap: () => _showKeyboardShortcutsDialog(), ), @@ -348,7 +360,7 @@ class _SettingsScreenState extends State { Padding( padding: const EdgeInsets.all(16), child: Text( - 'Advanced', + t.settings.advanced, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), @@ -356,8 +368,8 @@ class _SettingsScreenState extends State { ), SwitchListTile( secondary: const Icon(Icons.bug_report), - title: const Text('Debug Logging'), - subtitle: const Text('Enable detailed logging for troubleshooting'), + title: Text(t.settings.debugLogging), + subtitle: Text(t.settings.debugLoggingDescription), value: _enableDebugLogging, onChanged: (value) async { setState(() { @@ -368,8 +380,8 @@ class _SettingsScreenState extends State { ), ListTile( leading: const Icon(Icons.article), - title: const Text('View Logs'), - subtitle: const Text('View application logs'), + title: Text(t.settings.viewLogs), + subtitle: Text(t.settings.viewLogsDescription), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( @@ -380,15 +392,15 @@ class _SettingsScreenState extends State { ), ListTile( leading: const Icon(Icons.cleaning_services), - title: const Text('Clear Cache'), - subtitle: const Text('Free up storage space'), + title: Text(t.settings.clearCache), + subtitle: Text(t.settings.clearCacheDescription), trailing: const Icon(Icons.chevron_right), onTap: () => _showClearCacheDialog(), ), ListTile( leading: const Icon(Icons.restore), - title: const Text('Reset Settings'), - subtitle: const Text('Reset all settings to defaults'), + title: Text(t.settings.resetSettings), + subtitle: Text(t.settings.resetSettingsDescription), trailing: const Icon(Icons.chevron_right), onTap: () => _showResetSettingsDialog(), ), @@ -407,7 +419,7 @@ class _SettingsScreenState extends State { Padding( padding: const EdgeInsets.all(16), child: Text( - 'Updates', + t.settings.updates, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), @@ -418,10 +430,18 @@ class _SettingsScreenState extends State { hasUpdate ? Icons.system_update : Icons.check_circle, color: hasUpdate ? Colors.orange : null, ), - title: Text(hasUpdate ? 'Update Available' : 'Check for Updates'), + title: Text( + hasUpdate + ? t.settings.updateAvailable + : t.settings.checkForUpdates, + ), subtitle: hasUpdate - ? Text('Version ${_updateInfo!['latestVersion']} is available') - : const Text('Check for the latest version on GitHub'), + ? Text( + t.update.versionAvailable( + version: _updateInfo!['latestVersion'], + ), + ) + : Text(t.update.checkFailed), trailing: _isCheckingForUpdate ? const SizedBox( width: 24, @@ -448,8 +468,8 @@ class _SettingsScreenState extends State { return Card( child: ListTile( leading: const Icon(Icons.info), - title: const Text('About'), - subtitle: const Text('App information and licenses'), + title: Text(t.settings.about), + subtitle: Text(t.settings.aboutDescription), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( @@ -466,7 +486,7 @@ class _SettingsScreenState extends State { context: context, builder: (BuildContext context) { return AlertDialog( - title: const Text('Theme'), + title: Text(t.settings.theme), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -476,8 +496,8 @@ class _SettingsScreenState extends State { ? Icons.radio_button_checked : Icons.radio_button_unchecked, ), - title: const Text('System'), - subtitle: const Text('Follow system settings'), + title: Text(t.settings.systemTheme), + subtitle: Text(t.settings.systemThemeDescription), onTap: () { themeProvider.setThemeMode(settings.ThemeMode.system); Navigator.pop(context); @@ -489,7 +509,7 @@ class _SettingsScreenState extends State { ? Icons.radio_button_checked : Icons.radio_button_unchecked, ), - title: const Text('Light'), + title: Text(t.settings.lightTheme), onTap: () { themeProvider.setThemeMode(settings.ThemeMode.light); Navigator.pop(context); @@ -501,7 +521,7 @@ class _SettingsScreenState extends State { ? Icons.radio_button_checked : Icons.radio_button_unchecked, ), - title: const Text('Dark'), + title: Text(t.settings.darkTheme), onTap: () { themeProvider.setThemeMode(settings.ThemeMode.dark); Navigator.pop(context); @@ -512,7 +532,7 @@ class _SettingsScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), ], ); @@ -527,7 +547,7 @@ class _SettingsScreenState extends State { context: context, builder: (BuildContext context) { return AlertDialog( - title: const Text('Buffer Size'), + title: Text(t.settings.bufferSize), content: Column( mainAxisSize: MainAxisSize.min, children: options.map((size) { @@ -551,7 +571,7 @@ class _SettingsScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), ], ); @@ -569,24 +589,28 @@ class _SettingsScreenState extends State { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( - title: const Text('Small Skip Duration'), + title: Text(t.settings.smallSkipDuration), content: TextField( controller: controller, keyboardType: TextInputType.number, decoration: InputDecoration( - labelText: 'Seconds', - hintText: 'Enter duration (1-120)', + labelText: t.settings.secondsLabel, + hintText: t.settings.durationHint(min: 1, max: 120), errorText: errorText, - suffixText: 's', + suffixText: t.settings.secondsShort, ), autofocus: true, onChanged: (value) { final parsed = int.tryParse(value); setDialogState(() { if (parsed == null) { - errorText = 'Please enter a valid number'; + errorText = t.settings.validationErrorEnterNumber; } else if (parsed < 1 || parsed > 120) { - errorText = 'Duration must be between 1 and 120 seconds'; + errorText = t.settings.validationErrorDuration( + min: 1, + max: 120, + unit: t.settings.secondsLabel.toLowerCase(), + ); } else { errorText = null; } @@ -596,7 +620,7 @@ class _SettingsScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), TextButton( onPressed: () async { @@ -613,7 +637,7 @@ class _SettingsScreenState extends State { } } }, - child: const Text('Save'), + child: Text(t.common.save), ), ], ); @@ -633,24 +657,28 @@ class _SettingsScreenState extends State { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( - title: const Text('Large Skip Duration'), + title: Text(t.settings.largeSkipDuration), content: TextField( controller: controller, keyboardType: TextInputType.number, decoration: InputDecoration( - labelText: 'Seconds', - hintText: 'Enter duration (1-120)', + labelText: t.settings.secondsLabel, + hintText: t.settings.durationHint(min: 1, max: 120), errorText: errorText, - suffixText: 's', + suffixText: t.settings.secondsShort, ), autofocus: true, onChanged: (value) { final parsed = int.tryParse(value); setDialogState(() { if (parsed == null) { - errorText = 'Please enter a valid number'; + errorText = t.settings.validationErrorEnterNumber; } else if (parsed < 1 || parsed > 120) { - errorText = 'Duration must be between 1 and 120 seconds'; + errorText = t.settings.validationErrorDuration( + min: 1, + max: 120, + unit: t.settings.secondsLabel.toLowerCase(), + ); } else { errorText = null; } @@ -660,7 +688,7 @@ class _SettingsScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), TextButton( onPressed: () async { @@ -677,7 +705,7 @@ class _SettingsScreenState extends State { } } }, - child: const Text('Save'), + child: Text(t.common.save), ), ], ); @@ -699,24 +727,28 @@ class _SettingsScreenState extends State { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( - title: const Text('Default Sleep Timer'), + title: Text(t.settings.defaultSleepTimer), content: TextField( controller: controller, keyboardType: TextInputType.number, decoration: InputDecoration( - labelText: 'Minutes', - hintText: 'Enter duration (5-180)', + labelText: t.settings.minutesLabel, + hintText: t.settings.durationHint(min: 5, max: 180), errorText: errorText, - suffixText: 'min', + suffixText: t.settings.minutesShort, ), autofocus: true, onChanged: (value) { final parsed = int.tryParse(value); setDialogState(() { if (parsed == null) { - errorText = 'Please enter a valid number'; + errorText = t.settings.validationErrorEnterNumber; } else if (parsed < 5 || parsed > 180) { - errorText = 'Duration must be between 5 and 180 minutes'; + errorText = t.settings.validationErrorDuration( + min: 5, + max: 180, + unit: t.settings.minutesLabel.toLowerCase(), + ); } else { errorText = null; } @@ -726,7 +758,7 @@ class _SettingsScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), TextButton( onPressed: () async { @@ -741,7 +773,7 @@ class _SettingsScreenState extends State { } } }, - child: const Text('Save'), + child: Text(t.common.save), ), ], ); @@ -766,14 +798,12 @@ class _SettingsScreenState extends State { context: context, builder: (BuildContext context) { return AlertDialog( - title: const Text('Clear Cache'), - content: const Text( - 'This will clear all cached images and data. The app may take longer to load content after clearing the cache.', - ), + title: Text(t.settings.clearCache), + content: Text(t.settings.clearCacheDescription), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), TextButton( onPressed: () async { @@ -783,11 +813,11 @@ class _SettingsScreenState extends State { if (mounted) { navigator.pop(); messenger.showSnackBar( - const SnackBar(content: Text('Cache cleared successfully')), + SnackBar(content: Text(t.settings.clearCacheSuccess)), ); } }, - child: const Text('Clear'), + child: Text(t.common.clear), ), ], ); @@ -800,14 +830,12 @@ class _SettingsScreenState extends State { context: context, builder: (BuildContext context) { return AlertDialog( - title: const Text('Reset Settings'), - content: const Text( - 'This will reset all settings to their default values. This action cannot be undone.', - ), + title: Text(t.settings.resetSettings), + content: Text(t.settings.resetSettingsDescription), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), TextButton( onPressed: () async { @@ -818,15 +846,13 @@ class _SettingsScreenState extends State { if (mounted) { navigator.pop(); messenger.showSnackBar( - const SnackBar( - content: Text('Settings reset successfully'), - ), + SnackBar(content: Text(t.settings.resetSettingsSuccess)), ); // Reload settings _loadSettings(); } }, - child: const Text('Reset'), + child: Text(t.common.reset), ), ], ); @@ -834,6 +860,76 @@ class _SettingsScreenState extends State { ); } + String _getLanguageDisplayName(AppLocale locale) { + switch (locale) { + case AppLocale.en: + return 'English'; + case AppLocale.sv: + return 'Svenska'; + } + } + + void _showLanguageDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(t.settings.language), + content: Column( + mainAxisSize: MainAxisSize.min, + children: AppLocale.values.map((locale) { + final isSelected = LocaleSettings.currentLocale == locale; + return ListTile( + title: Text(_getLanguageDisplayName(locale)), + leading: Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: isSelected ? Theme.of(context).colorScheme.primary : null, + ), + tileColor: isSelected + ? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3) + : null, + onTap: () async { + // Save the locale to settings + await _settingsService.setAppLocale(locale); + + // Set the locale immediately + LocaleSettings.setLocale(locale); + + // Close dialog + if (context.mounted) { + Navigator.pop(context); + } + + // Trigger app-wide rebuild by restarting the app + if (context.mounted) { + _restartApp(); + } + }, + ); + }).toList(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + ], + ); + }, + ); + } + + void _restartApp() { + // Navigate to the root and remove all previous routes + Navigator.pushNamedAndRemoveUntil( + context, + '/', + (route) => false, + ); + } + Future _checkForUpdates() async { setState(() { _isCheckingForUpdate = true; @@ -851,8 +947,8 @@ class _SettingsScreenState extends State { if (updateInfo == null || updateInfo['hasUpdate'] != true) { // Show "no updates" message ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('You are on the latest version'), + SnackBar( + content: Text(t.update.latestVersion), duration: Duration(seconds: 2), ), ); @@ -865,8 +961,8 @@ class _SettingsScreenState extends State { }); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to check for updates'), + SnackBar( + content: Text(t.update.checkFailed), duration: Duration(seconds: 2), ), ); @@ -881,18 +977,22 @@ class _SettingsScreenState extends State { context: context, builder: (BuildContext context) { return AlertDialog( - title: const Text('Update Available'), + title: Text(t.settings.updateAvailable), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Version ${_updateInfo!['latestVersion']} is available', + t.update.versionAvailable( + version: _updateInfo!['latestVersion'], + ), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( - 'Current: ${_updateInfo!['currentVersion']}', + t.update.currentVersion( + version: _updateInfo!['currentVersion'], + ), style: Theme.of(context).textTheme.bodySmall, ), ], @@ -900,7 +1000,7 @@ class _SettingsScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Close'), + child: Text(t.common.close), ), FilledButton( onPressed: () async { @@ -910,7 +1010,7 @@ class _SettingsScreenState extends State { } if (context.mounted) Navigator.pop(context); }, - child: const Text('View Release'), + child: Text(t.update.viewRelease), ), ], ); @@ -926,7 +1026,7 @@ class _SettingsScreenState extends State { return Consumer( builder: (context, provider, child) { return AlertDialog( - title: const Text('Library Density'), + title: Text(t.settings.libraryDensity), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -936,8 +1036,8 @@ class _SettingsScreenState extends State { ? Icons.radio_button_checked : Icons.radio_button_unchecked, ), - title: const Text('Compact'), - subtitle: const Text('Smaller cards, more items visible'), + title: Text(t.settings.compact), + subtitle: Text(t.settings.compactDescription), onTap: () async { await settingsProvider.setLibraryDensity( settings.LibraryDensity.compact, @@ -951,8 +1051,8 @@ class _SettingsScreenState extends State { ? Icons.radio_button_checked : Icons.radio_button_unchecked, ), - title: const Text('Normal'), - subtitle: const Text('Default size'), + title: Text(t.settings.normal), + subtitle: Text(t.settings.normalDescription), onTap: () async { await settingsProvider.setLibraryDensity( settings.LibraryDensity.normal, @@ -967,8 +1067,8 @@ class _SettingsScreenState extends State { ? Icons.radio_button_checked : Icons.radio_button_unchecked, ), - title: const Text('Comfortable'), - subtitle: const Text('Larger cards, fewer items visible'), + title: Text(t.settings.comfortable), + subtitle: Text(t.settings.comfortableDescription), onTap: () async { await settingsProvider.setLibraryDensity( settings.LibraryDensity.comfortable, @@ -981,7 +1081,7 @@ class _SettingsScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), ], ); @@ -999,7 +1099,7 @@ class _SettingsScreenState extends State { return Consumer( builder: (context, provider, child) { return AlertDialog( - title: const Text('View Mode'), + title: Text(t.settings.viewMode), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1009,8 +1109,8 @@ class _SettingsScreenState extends State { ? Icons.radio_button_checked : Icons.radio_button_unchecked, ), - title: const Text('Grid'), - subtitle: const Text('Display items in a grid layout'), + title: Text(t.settings.gridView), + subtitle: Text(t.settings.gridViewDescription), onTap: () async { await settingsProvider.setViewMode( settings.ViewMode.grid, @@ -1024,8 +1124,8 @@ class _SettingsScreenState extends State { ? Icons.radio_button_checked : Icons.radio_button_unchecked, ), - title: const Text('List'), - subtitle: const Text('Display items in a list layout'), + title: Text(t.settings.listView), + subtitle: Text(t.settings.listViewDescription), onTap: () async { await settingsProvider.setViewMode( settings.ViewMode.list, @@ -1038,7 +1138,7 @@ class _SettingsScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), ], ); @@ -1087,7 +1187,7 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { body: CustomScrollView( slivers: [ CustomAppBar( - title: const Text('Keyboard Shortcuts'), + title: Text(t.settings.keyboardShortcuts), pinned: true, actions: [ TextButton( @@ -1097,13 +1197,11 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { await _loadHotkeys(); if (mounted) { messenger.showSnackBar( - const SnackBar( - content: Text('Shortcuts reset to defaults'), - ), + SnackBar(content: Text(t.settings.shortcutsReset)), ); } }, - child: const Text('Reset'), + child: Text(t.common.reset), ), ], ), @@ -1169,7 +1267,11 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { messenger.showSnackBar( SnackBar( content: Text( - 'Shortcut already assigned to ${widget.keyboardService.getActionDisplayName(existingAction)}', + t.settings.shortcutAlreadyAssigned( + action: widget.keyboardService.getActionDisplayName( + existingAction, + ), + ), ), ), ); @@ -1190,7 +1292,11 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { messenger.showSnackBar( SnackBar( content: Text( - 'Shortcut updated for ${widget.keyboardService.getActionDisplayName(action)}', + t.settings.shortcutUpdated( + action: widget.keyboardService.getActionDisplayName( + action, + ), + ), ), ), ); diff --git a/lib/screens/subtitle_styling_screen.dart b/lib/screens/subtitle_styling_screen.dart index c4cbeb58..b38379b6 100644 --- a/lib/screens/subtitle_styling_screen.dart +++ b/lib/screens/subtitle_styling_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flex_color_picker/flex_color_picker.dart'; +import '../i18n/strings.g.dart'; import '../services/settings_service.dart'; import '../widgets/desktop_app_bar.dart'; @@ -100,7 +101,7 @@ class _SubtitleStylingScreenState extends State { return Scaffold( body: CustomScrollView( slivers: [ - const CustomAppBar(title: Text('Subtitle Styling'), pinned: true), + CustomAppBar(title: Text(t.screens.subtitleStyling), pinned: true), SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( @@ -123,7 +124,7 @@ class _SubtitleStylingScreenState extends State { Padding( padding: const EdgeInsets.all(16), child: Text( - 'Styling Options', + t.subtitlingStyling.stylingOptions, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), @@ -136,7 +137,7 @@ class _SubtitleStylingScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Font Size'), + Text(t.subtitlingStyling.fontSize), Text('$_fontSize'), ], ), @@ -179,11 +180,11 @@ class _SubtitleStylingScreenState extends State { borderRadius: BorderRadius.circular(4), ), ), - title: const Text('Text Color'), + title: Text(t.subtitlingStyling.textColor), subtitle: Text(_textColor), trailing: const Icon(Icons.chevron_right), onTap: () { - _showColorPicker('Text Color', _textColor, (color) { + _showColorPicker(t.subtitlingStyling.textColor, _textColor, (color) { setState(() { _textColor = color; }); @@ -201,7 +202,7 @@ class _SubtitleStylingScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Border Size'), + Text(t.subtitlingStyling.borderSize), Text('$_borderSize'), ], ), @@ -244,11 +245,11 @@ class _SubtitleStylingScreenState extends State { borderRadius: BorderRadius.circular(4), ), ), - title: const Text('Border Color'), + title: Text(t.subtitlingStyling.borderColor), subtitle: Text(_borderColor), trailing: const Icon(Icons.chevron_right), onTap: () { - _showColorPicker('Border Color', _borderColor, (color) { + _showColorPicker(t.subtitlingStyling.borderColor, _borderColor, (color) { setState(() { _borderColor = color; }); @@ -266,7 +267,7 @@ class _SubtitleStylingScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Background Opacity'), + Text(t.subtitlingStyling.backgroundOpacity), Text('$_backgroundOpacity%'), ], ), @@ -309,11 +310,11 @@ class _SubtitleStylingScreenState extends State { borderRadius: BorderRadius.circular(4), ), ), - title: const Text('Background Color'), + title: Text(t.subtitlingStyling.backgroundColor), subtitle: Text(_backgroundColor), trailing: const Icon(Icons.chevron_right), onTap: () { - _showColorPicker('Background Color', _backgroundColor, (color) { + _showColorPicker(t.subtitlingStyling.backgroundColor, _backgroundColor, (color) { setState(() { _backgroundColor = color; }); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 00e608ae..690c8931 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -21,6 +21,7 @@ import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../widgets/video_controls/video_controls.dart'; +import '../i18n/strings.g.dart'; class VideoPlayerScreen extends StatefulWidget { final PlexMetadata metadata; @@ -420,7 +421,7 @@ class VideoPlayerScreenState extends State { } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not find video file')), + SnackBar(content: Text(t.messages.fileInfoNotAvailable)), ); } } @@ -428,7 +429,7 @@ class VideoPlayerScreenState extends State { if (mounted) { ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); + ).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString())))); } } } @@ -1497,7 +1498,7 @@ class VideoPlayerScreenState extends State { vertical: 16, ), ), - child: const Text('Cancel'), + child: Text(t.dialog.cancel), ), const SizedBox(width: 16), FilledButton( @@ -1510,7 +1511,7 @@ class VideoPlayerScreenState extends State { vertical: 16, ), ), - child: const Text('Play Now'), + child: Text(t.dialog.playNow), ), ], ), diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index c24ff73f..6034e0be 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/services.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:plezy/utils/app_logger.dart'; +import '../i18n/strings.g.dart'; enum ThemeMode { system, light, dark } @@ -40,6 +41,7 @@ class SettingsService { static const String _keyShuffleUnwatchedOnly = 'shuffle_unwatched_only'; static const String _keyShuffleOrderNavigation = 'shuffle_order_navigation'; static const String _keyShuffleLoopQueue = 'shuffle_loop_queue'; + static const String _keyAppLocale = 'app_locale'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -780,6 +782,21 @@ class SettingsService { } } + // App Locale + Future setAppLocale(AppLocale locale) async { + await _prefs.setString(_keyAppLocale, locale.languageCode); + } + + AppLocale getAppLocale() { + final localeString = _prefs.getString(_keyAppLocale); + if (localeString == null) return AppLocale.en; // Default to English + + return AppLocale.values.firstWhere( + (locale) => locale.languageCode == localeString, + orElse: () => AppLocale.en, + ); + } + // Shuffle Play Settings /// Shuffle Unwatched Only - Filter shuffle queue to unwatched episodes only @@ -840,6 +857,7 @@ class SettingsService { _prefs.remove(_keyShuffleUnwatchedOnly), _prefs.remove(_keyShuffleOrderNavigation), _prefs.remove(_keyShuffleLoopQueue), + _prefs.remove(_keyAppLocale), ]); } diff --git a/lib/utils/shuffle_play_helper.dart b/lib/utils/shuffle_play_helper.dart index 8f3ab23e..a39b4b3f 100644 --- a/lib/utils/shuffle_play_helper.dart +++ b/lib/utils/shuffle_play_helper.dart @@ -5,6 +5,7 @@ import '../providers/playback_state_provider.dart'; import '../providers/settings_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; +import '../i18n/strings.g.dart'; /// Handle shuffle play action for shows and seasons /// @@ -84,7 +85,7 @@ Future handleShufflePlay( if (episodes.isEmpty) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No episodes found')), + SnackBar(content: Text(t.messages.noEpisodesFound)), ); } return; @@ -108,7 +109,7 @@ Future handleShufflePlay( if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error starting shuffle play: $e')), + SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))), ); } } diff --git a/lib/utils/user_switching_utils.dart b/lib/utils/user_switching_utils.dart index 5841f714..50f5a125 100644 --- a/lib/utils/user_switching_utils.dart +++ b/lib/utils/user_switching_utils.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../models/plex_home_user.dart'; +import '../i18n/strings.g.dart'; import 'provider_extensions.dart'; class UserSwitchingUtils { @@ -17,7 +18,7 @@ class UserSwitchingUtils { } else if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to switch to ${user.displayName}'), + content: Text(t.messages.failedToSwitchProfile(displayName: user.displayName)), backgroundColor: Theme.of(context).colorScheme.error, ), ); diff --git a/lib/widgets/context_menu_wrapper.dart b/lib/widgets/context_menu_wrapper.dart index 120b4643..7f05d288 100644 --- a/lib/widgets/context_menu_wrapper.dart +++ b/lib/widgets/context_menu_wrapper.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../utils/platform_detector.dart'; +import '../i18n/strings.g.dart'; /// A menu action item for context menus class ContextMenuItem { @@ -67,14 +68,14 @@ class _ContextMenuWrapperState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), TextButton( onPressed: () => Navigator.pop(context, true), style: isDestructive ? TextButton.styleFrom(foregroundColor: Colors.red) : null, - child: const Text('Confirm'), + child: Text(t.common.confirm), ), ], ), @@ -165,10 +166,10 @@ class _ContextMenuWrapperState extends State { if (selectedItem.requiresConfirmation) { final confirmed = await _showConfirmationDialog( - title: selectedItem.confirmationTitle ?? 'Confirm Action', + title: selectedItem.confirmationTitle ?? t.dialog.confirmAction, message: selectedItem.confirmationMessage ?? - 'Are you sure you want to perform this action?', + t.dialog.areYouSure, isDestructive: selectedItem.isDestructive, ); diff --git a/lib/widgets/file_info_bottom_sheet.dart b/lib/widgets/file_info_bottom_sheet.dart index 4e22d40f..1e2d21d1 100644 --- a/lib/widgets/file_info_bottom_sheet.dart +++ b/lib/widgets/file_info_bottom_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../models/plex_file_info.dart'; +import '../i18n/strings.g.dart'; class FileInfoBottomSheet extends StatelessWidget { final PlexFileInfo fileInfo; @@ -39,7 +40,7 @@ class FileInfoBottomSheet extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text( - 'File Info', + t.fileInfo.title, style: const TextStyle( color: Colors.white, fontSize: 20, @@ -74,56 +75,56 @@ class FileInfoBottomSheet extends StatelessWidget { ], // Video Section - _buildSectionHeader('Video'), + _buildSectionHeader(t.fileInfo.video), const SizedBox(height: 8), - _buildInfoRow('Codec', fileInfo.videoCodec ?? 'Unknown'), - _buildInfoRow('Resolution', fileInfo.resolutionFormatted), - _buildInfoRow('Bitrate', fileInfo.bitrateFormatted), - _buildInfoRow('Frame Rate', fileInfo.frameRateFormatted), - _buildInfoRow('Aspect Ratio', fileInfo.aspectRatioFormatted), + _buildInfoRow(t.fileInfo.codec, fileInfo.videoCodec ?? t.common.unknown), + _buildInfoRow(t.fileInfo.resolution, fileInfo.resolutionFormatted), + _buildInfoRow(t.fileInfo.bitrate, fileInfo.bitrateFormatted), + _buildInfoRow(t.fileInfo.frameRate, fileInfo.frameRateFormatted), + _buildInfoRow(t.fileInfo.aspectRatio, fileInfo.aspectRatioFormatted), if (fileInfo.videoProfile != null) - _buildInfoRow('Profile', fileInfo.videoProfile!), + _buildInfoRow(t.fileInfo.profile, fileInfo.videoProfile!), if (fileInfo.bitDepth != null) - _buildInfoRow('Bit Depth', '${fileInfo.bitDepth} bit'), + _buildInfoRow(t.fileInfo.bitDepth, '${fileInfo.bitDepth} bit'), if (fileInfo.colorSpace != null) - _buildInfoRow('Color Space', fileInfo.colorSpace!), + _buildInfoRow(t.fileInfo.colorSpace, fileInfo.colorSpace!), if (fileInfo.colorRange != null) - _buildInfoRow('Color Range', fileInfo.colorRange!), + _buildInfoRow(t.fileInfo.colorRange, fileInfo.colorRange!), if (fileInfo.colorPrimaries != null) - _buildInfoRow('Color Primaries', fileInfo.colorPrimaries!), + _buildInfoRow(t.fileInfo.colorPrimaries, fileInfo.colorPrimaries!), if (fileInfo.chromaSubsampling != null) - _buildInfoRow('Chroma Subsampling', fileInfo.chromaSubsampling!), + _buildInfoRow(t.fileInfo.chromaSubsampling, fileInfo.chromaSubsampling!), const SizedBox(height: 20), // Audio Section - _buildSectionHeader('Audio'), + _buildSectionHeader(t.fileInfo.audio), const SizedBox(height: 8), - _buildInfoRow('Codec', fileInfo.audioCodec ?? 'Unknown'), - _buildInfoRow('Channels', fileInfo.audioChannelsFormatted), + _buildInfoRow(t.fileInfo.codec, fileInfo.audioCodec ?? t.common.unknown), + _buildInfoRow(t.fileInfo.channels, fileInfo.audioChannelsFormatted), if (fileInfo.audioProfile != null) - _buildInfoRow('Profile', fileInfo.audioProfile!), + _buildInfoRow(t.fileInfo.profile, fileInfo.audioProfile!), const SizedBox(height: 20), // File Section - _buildSectionHeader('File'), + _buildSectionHeader(t.fileInfo.file), const SizedBox(height: 8), if (fileInfo.filePath != null) - _buildInfoRow('Path', fileInfo.filePath!, isMonospace: true), - _buildInfoRow('Size', fileInfo.fileSizeFormatted), - _buildInfoRow('Container', fileInfo.container ?? 'Unknown'), - _buildInfoRow('Duration', fileInfo.durationFormatted), + _buildInfoRow(t.fileInfo.path, fileInfo.filePath!, isMonospace: true), + _buildInfoRow(t.fileInfo.size, fileInfo.fileSizeFormatted), + _buildInfoRow(t.fileInfo.container, fileInfo.container ?? t.common.unknown), + _buildInfoRow(t.fileInfo.duration, fileInfo.durationFormatted), const SizedBox(height: 20), // Advanced Section - _buildSectionHeader('Advanced'), + _buildSectionHeader(t.fileInfo.advanced), const SizedBox(height: 8), _buildInfoRow( - 'Optimized for Streaming', - fileInfo.optimizedForStreaming == true ? 'Yes' : 'No', + t.fileInfo.optimizedForStreaming, + fileInfo.optimizedForStreaming == true ? t.common.yes : t.common.no, ), _buildInfoRow( - '64-bit Offsets', - fileInfo.has64bitOffsets == true ? 'Yes' : 'No', + t.fileInfo.has64bitOffsets, + fileInfo.has64bitOffsets == true ? t.common.yes : t.common.no, ), ], ), diff --git a/lib/widgets/hotkey_recorder_widget.dart b/lib/widgets/hotkey_recorder_widget.dart index 39b4611b..b9029099 100644 --- a/lib/widgets/hotkey_recorder_widget.dart +++ b/lib/widgets/hotkey_recorder_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; +import '../i18n/strings.g.dart'; class HotKeyRecorderWidget extends StatefulWidget { final String actionName; @@ -31,7 +32,7 @@ class _HotKeyRecorderWidgetState extends State { @override Widget build(BuildContext context) { return AlertDialog( - title: Text('Set Shortcut for ${widget.actionName}'), + title: Text(t.hotkeys.setShortcutFor(actionName: widget.actionName)), content: SizedBox( width: double.maxFinite, child: SingleChildScrollView( @@ -81,7 +82,7 @@ class _HotKeyRecorderWidgetState extends State { minWidth: 24, minHeight: 24, ), - tooltip: 'Clear shortcut', + tooltip: t.hotkeys.clearShortcut, ), ], ), @@ -101,12 +102,12 @@ class _HotKeyRecorderWidgetState extends State { ), ), actions: [ - TextButton(onPressed: widget.onCancel, child: const Text('Cancel')), + TextButton(onPressed: widget.onCancel, child: Text(t.common.cancel)), TextButton( onPressed: _recordedHotKey != null ? () => widget.onHotKeyRecorded(_recordedHotKey!) : null, - child: const Text('Save'), + child: Text(t.common.save), ), ], ); diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 963d0c18..96a26e6e 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -11,6 +11,7 @@ import '../utils/content_rating_formatter.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../theme/theme_helper.dart'; +import '../i18n/strings.g.dart'; import 'media_context_menu.dart'; class MediaCard extends StatefulWidget { @@ -44,9 +45,9 @@ class _MediaCardState extends State { if (itemType == 'artist' || itemType == 'album' || itemType == 'track') { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Music playback is not yet supported'), - duration: Duration(seconds: 2), + SnackBar( + content: Text(t.messages.musicNotSupported), + duration: const Duration(seconds: 2), ), ); } diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 95e192b5..355647ad 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -6,6 +6,7 @@ import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../widgets/file_info_bottom_sheet.dart'; import '../utils/shuffle_play_helper.dart'; +import '../i18n/strings.g.dart'; /// Helper class to store menu action data class _MenuAction { @@ -66,7 +67,7 @@ class _MediaContextMenuState extends State { _MenuAction( value: 'watch', icon: Icons.check_circle_outline, - label: 'Mark as Watched', + label: t.mediaMenu.markAsWatched, ), ); } @@ -77,7 +78,7 @@ class _MediaContextMenuState extends State { _MenuAction( value: 'unwatch', icon: Icons.remove_circle_outline, - label: 'Mark as Unwatched', + label: t.mediaMenu.markAsUnwatched, ), ); } @@ -86,7 +87,7 @@ class _MediaContextMenuState extends State { if ((itemType == 'episode' || itemType == 'season') && widget.metadata.grandparentTitle != null) { menuActions.add( - _MenuAction(value: 'series', icon: Icons.tv, label: 'Go to series'), + _MenuAction(value: 'series', icon: Icons.tv, label: t.mediaMenu.goToSeries), ); } @@ -96,7 +97,7 @@ class _MediaContextMenuState extends State { _MenuAction( value: 'season', icon: Icons.playlist_play, - label: 'Go to season', + label: t.mediaMenu.goToSeason, ), ); } @@ -107,7 +108,7 @@ class _MediaContextMenuState extends State { _MenuAction( value: 'shuffle_play', icon: Icons.shuffle, - label: 'Shuffle Play', + label: t.mediaMenu.shufflePlay, ), ); } @@ -118,7 +119,7 @@ class _MediaContextMenuState extends State { _MenuAction( value: 'fileinfo', icon: Icons.info_outline, - label: 'File Info', + label: t.mediaMenu.fileInfo, ), ); } @@ -214,7 +215,7 @@ class _MediaContextMenuState extends State { await _executeAction( context, () => client.markAsWatched(widget.metadata.ratingKey), - 'Marked as watched', + t.messages.markedAsWatched, ); break; @@ -222,7 +223,7 @@ class _MediaContextMenuState extends State { await _executeAction( context, () => client.markAsUnwatched(widget.metadata.ratingKey), - 'Marked as unwatched', + t.messages.markedAsUnwatched, ); break; @@ -231,7 +232,7 @@ class _MediaContextMenuState extends State { context, widget.metadata.grandparentRatingKey, (metadata) => MediaDetailScreen(metadata: metadata), - 'Error loading series', + t.messages.errorLoadingSeries, ); break; @@ -240,7 +241,7 @@ class _MediaContextMenuState extends State { context, widget.metadata.parentRatingKey, (metadata) => SeasonDetailScreen(season: metadata), - 'Error loading season', + t.messages.errorLoadingSeason, ); break; @@ -272,7 +273,7 @@ class _MediaContextMenuState extends State { if (context.mounted) { ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); + ).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString())))); } } } @@ -345,7 +346,7 @@ class _MediaContextMenuState extends State { ); } else if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('File information not available')), + SnackBar(content: Text(t.messages.fileInfoNotAvailable)), ); } } catch (e) { @@ -357,7 +358,7 @@ class _MediaContextMenuState extends State { if (context.mounted) { ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Error loading file info: $e'))); + ).showSnackBar(SnackBar(content: Text(t.messages.errorLoadingFileInfo(error: e.toString())))); } } } diff --git a/lib/widgets/pin_entry_dialog.dart b/lib/widgets/pin_entry_dialog.dart index 86fad92a..aa4af9e5 100644 --- a/lib/widgets/pin_entry_dialog.dart +++ b/lib/widgets/pin_entry_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../i18n/strings.g.dart'; /// Dialog for entering a PIN to access a protected profile class PinEntryDialog extends StatefulWidget { @@ -110,7 +111,7 @@ class _PinEntryDialogState extends State LengthLimitingTextInputFormatter(10), ], decoration: InputDecoration( - hintText: 'Enter PIN', + hintText: t.pinEntry.enterPin, border: const OutlineInputBorder(), errorText: widget.errorMessage, errorMaxLines: 2, @@ -124,7 +125,7 @@ class _PinEntryDialogState extends State _obscureText = !_obscureText; }); }, - tooltip: _obscureText ? 'Show PIN' : 'Hide PIN', + tooltip: _obscureText ? t.pinEntry.showPin : t.pinEntry.hidePin, ), ), onSubmitted: (_) => _submit(), @@ -134,9 +135,9 @@ class _PinEntryDialogState extends State actions: [ TextButton( onPressed: () => Navigator.of(context).pop(null), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), - FilledButton(onPressed: _submit, child: const Text('Submit')), + FilledButton(onPressed: _submit, child: Text(t.common.submit)), ], ), ); diff --git a/lib/widgets/profile_switch_dialog.dart b/lib/widgets/profile_switch_dialog.dart index 3b834f59..f4f947b6 100644 --- a/lib/widgets/profile_switch_dialog.dart +++ b/lib/widgets/profile_switch_dialog.dart @@ -4,6 +4,7 @@ import '../models/plex_home_user.dart'; import '../providers/user_profile_provider.dart'; import '../utils/user_switching_utils.dart'; import 'profile_list_tile.dart'; +import '../i18n/strings.g.dart'; class ProfileSwitchDialog extends StatelessWidget { const ProfileSwitchDialog({super.key}); @@ -28,8 +29,8 @@ class ProfileSwitchDialog extends StatelessWidget { child: Center(child: CircularProgressIndicator()), ) else if (users.isEmpty) - const Expanded( - child: Center(child: Text('No users available')), + Expanded( + child: Center(child: Text(t.profile.noUsersAvailable)), ) else Expanded( @@ -73,7 +74,7 @@ class ProfileSwitchDialog extends StatelessWidget { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), ], ); diff --git a/lib/widgets/server_list_tile.dart b/lib/widgets/server_list_tile.dart index 788eb20e..427d7c40 100644 --- a/lib/widgets/server_list_tile.dart +++ b/lib/widgets/server_list_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../services/plex_auth_service.dart'; +import '../i18n/strings.g.dart'; class ServerListTile extends StatelessWidget { final PlexServer server; @@ -30,12 +31,12 @@ class ServerListTile extends StatelessWidget { color: isOnline ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), - semanticLabel: 'Server', + semanticLabel: t.common.server, ), title: Text(server.name), subtitle: Semantics( label: - '${isOnline ? 'Online' : 'Offline'}, ${server.owned ? 'Owned' : 'Shared'}', + '${isOnline ? t.common.online : t.common.offline}, ${server.owned ? t.common.owned : t.common.shared}', excludeSemantics: true, child: Row( children: [ @@ -49,7 +50,7 @@ class ServerListTile extends StatelessWidget { ), const SizedBox(width: 4), Text( - isOnline ? 'Online' : 'Offline', + isOnline ? t.common.online : t.common.offline, style: TextStyle( fontSize: 12, color: isOnline ? Colors.green : Colors.grey, @@ -70,7 +71,7 @@ class ServerListTile extends StatelessWidget { ), const SizedBox(width: 8), Text( - server.owned ? 'Owned' : 'Shared', + server.owned ? t.common.owned : t.common.shared, style: const TextStyle(fontSize: 12), ), ], @@ -84,7 +85,7 @@ class ServerListTile extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: Text( - 'CURRENT', + t.common.current, style: TextStyle( fontSize: 10, color: Theme.of(context).colorScheme.onPrimary, diff --git a/lib/widgets/sort_bottom_sheet.dart b/lib/widgets/sort_bottom_sheet.dart index 0636b873..61f0c964 100644 --- a/lib/widgets/sort_bottom_sheet.dart +++ b/lib/widgets/sort_bottom_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../models/plex_sort.dart'; +import '../i18n/strings.g.dart'; class SortBottomSheet extends StatefulWidget { final List sortOptions; @@ -80,7 +81,7 @@ class _SortBottomSheetState extends State { if (widget.onClear != null) TextButton( onPressed: _handleClear, - child: const Text('Clear'), + child: Text(t.common.clear), ), IconButton( icon: const Icon(Icons.close), diff --git a/lib/widgets/user_avatar_widget.dart b/lib/widgets/user_avatar_widget.dart index 6cd949c9..ca050007 100644 --- a/lib/widgets/user_avatar_widget.dart +++ b/lib/widgets/user_avatar_widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../models/plex_home_user.dart'; +import '../i18n/strings.g.dart'; class UserAvatarWidget extends StatelessWidget { final PlexHomeUser user; @@ -48,7 +49,7 @@ class UserAvatarWidget extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: Text( - 'Admin', + t.userStatus.admin, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onPrimary, fontWeight: FontWeight.bold, @@ -67,7 +68,7 @@ class UserAvatarWidget extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: Text( - 'Restricted', + t.userStatus.restricted, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onPrimary, fontWeight: FontWeight.bold, @@ -86,7 +87,7 @@ class UserAvatarWidget extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: Text( - 'Protected', + t.userStatus.protected, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSecondary, fontWeight: FontWeight.bold, diff --git a/lib/widgets/video_controls/sheets/audio_sync_sheet.dart b/lib/widgets/video_controls/sheets/audio_sync_sheet.dart index 8726945b..5d1af9a3 100644 --- a/lib/widgets/video_controls/sheets/audio_sync_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_sync_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:plezy/services/settings_service.dart'; +import '../../../i18n/strings.g.dart'; /// Bottom sheet for adjusting audio sync offset class AudioSyncSheet extends StatefulWidget { @@ -136,9 +137,9 @@ class _AudioSyncSheetState extends State { // Slider Row( children: [ - const Text( - '-2s', - style: TextStyle(color: Colors.white70), + Text( + t.videoControls.minusTime(amount: "2", unit: "s"), + style: const TextStyle(color: Colors.white70), ), Expanded( child: Slider( @@ -158,9 +159,9 @@ class _AudioSyncSheetState extends State { }, ), ), - const Text( - '+2s', - style: TextStyle(color: Colors.white70), + Text( + t.videoControls.addTime(amount: "2", unit: "s"), + style: const TextStyle(color: Colors.white70), ), ], ), @@ -169,7 +170,7 @@ class _AudioSyncSheetState extends State { ElevatedButton.icon( onPressed: _currentOffset != 0 ? _resetOffset : null, icon: const Icon(Icons.restart_alt), - label: const Text('Reset to 0ms'), + label: Text(t.videoControls.resetToZero), style: ElevatedButton.styleFrom( backgroundColor: Colors.grey[800], foregroundColor: Colors.white, diff --git a/lib/widgets/video_controls/sheets/audio_track_sheet.dart b/lib/widgets/video_controls/sheets/audio_track_sheet.dart index 201a72cb..ef372e37 100644 --- a/lib/widgets/video_controls/sheets/audio_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_track_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; +import '../../../i18n/strings.g.dart'; /// Bottom sheet for selecting audio tracks class AudioTrackSheet extends StatelessWidget { @@ -53,9 +54,9 @@ class AudioTrackSheet extends StatelessWidget { children: [ const Icon(Icons.audiotrack, color: Colors.white), const SizedBox(width: 12), - const Text( - 'Audio Tracks', - style: TextStyle( + Text( + t.videoControls.audioLabel, + style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, diff --git a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart index b191ca78..d8564c26 100644 --- a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart +++ b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../services/settings_service.dart'; import '../../../services/sleep_timer_service.dart'; +import '../../../i18n/strings.g.dart'; /// Bottom sheet for sleep timer configuration class SleepTimerSheet extends StatelessWidget { @@ -135,7 +136,7 @@ class SleepTimerSheet extends StatelessWidget { children: [ OutlinedButton.icon( icon: const Icon(Icons.add), - label: const Text('+15 min'), + label: Text(t.videoControls.addTime(amount: "15", unit: " min")), style: OutlinedButton.styleFrom( foregroundColor: Colors.white, side: const BorderSide( @@ -151,7 +152,7 @@ class SleepTimerSheet extends StatelessWidget { const SizedBox(width: 12), FilledButton.icon( icon: const Icon(Icons.cancel), - label: const Text('Cancel'), + label: Text(t.common.cancel), style: FilledButton.styleFrom( backgroundColor: Colors.red, ), @@ -215,7 +216,7 @@ class SleepTimerSheet extends StatelessWidget { // Show confirmation snackbar ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Sleep timer set for $label'), + content: Text(t.messages.sleepTimerSet(label: label)), duration: const Duration(seconds: 2), ), ); diff --git a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart index dc8a4ecc..e03e9fa0 100644 --- a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; +import '../../../i18n/strings.g.dart'; /// Bottom sheet for selecting subtitle tracks class SubtitleTrackSheet extends StatelessWidget { @@ -53,9 +54,9 @@ class SubtitleTrackSheet extends StatelessWidget { children: [ const Icon(Icons.subtitles, color: Colors.white), const SizedBox(width: 12), - const Text( - 'Subtitles', - style: TextStyle( + Text( + t.videoControls.subtitlesLabel, + style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index a22a21c7..67d322ca 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -4,6 +4,7 @@ import '../../../services/settings_service.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../utils/platform_detector.dart'; import '../widgets/sync_offset_control.dart'; +import '../../../i18n/strings.g.dart'; enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice } @@ -431,7 +432,7 @@ class _VideoSettingsSheetState extends State { children: [ OutlinedButton.icon( icon: const Icon(Icons.add), - label: const Text('+15 min'), + label: Text(t.videoControls.addTime(amount: "15", unit: " min")), style: OutlinedButton.styleFrom( foregroundColor: Colors.white, side: const BorderSide(color: Colors.white54), @@ -443,7 +444,7 @@ class _VideoSettingsSheetState extends State { const SizedBox(width: 12), FilledButton.icon( icon: const Icon(Icons.cancel), - label: const Text('Cancel'), + label: Text(t.common.cancel), style: FilledButton.styleFrom( backgroundColor: Colors.red, ), @@ -493,7 +494,7 @@ class _VideoSettingsSheetState extends State { Navigator.pop(context); // Close after selection ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Sleep timer set for $label'), + content: Text(t.messages.sleepTimerSet(label: label)), duration: const Duration(seconds: 2), ), ); @@ -513,7 +514,7 @@ class _VideoSettingsSheetState extends State { player: widget.player, propertyName: 'audio-delay', initialOffset: _audioSyncOffset, - labelText: 'Audio', + labelText: t.videoControls.audioLabel, onOffsetChanged: (offset) async { final settings = await SettingsService.getInstance(); await settings.setAudioSyncOffset(offset); @@ -529,7 +530,7 @@ class _VideoSettingsSheetState extends State { player: widget.player, propertyName: 'sub-delay', initialOffset: _subtitleSyncOffset, - labelText: 'Subtitles', + labelText: t.videoControls.subtitlesLabel, onOffsetChanged: (offset) async { final settings = await SettingsService.getInstance(); await settings.setSubtitleSyncOffset(offset); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index e7a628e8..279055b9 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -18,6 +18,7 @@ import '../../services/sleep_timer_service.dart'; import '../../utils/desktop_window_padding.dart'; import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; +import '../../i18n/strings.g.dart'; import '../app_bar_back_button.dart'; import 'painters/chapter_marker_painter.dart'; import 'sheets/audio_track_sheet.dart'; @@ -394,13 +395,13 @@ class _PlexVideoControlsState extends State String _getBoxFitTooltip(int mode) { switch (mode) { case 0: - return 'Letterbox'; + return t.videoControls.letterbox; case 1: - return 'Fill screen'; + return t.videoControls.fillScreen; case 2: - return 'Stretch'; + return t.videoControls.stretch; default: - return 'Letterbox'; + return t.videoControls.letterbox; } } @@ -505,8 +506,8 @@ class _PlexVideoControlsState extends State ? Icons.screen_lock_rotation : Icons.screen_rotation, tooltip: _isRotationLocked - ? 'Unlock rotation' - : 'Lock rotation', + ? t.videoControls.unlockRotation + : t.videoControls.lockRotation, onPressed: _toggleRotationLock, ), // Fullscreen toggle (desktop only) @@ -1603,7 +1604,7 @@ class _PlexVideoControlsState extends State if (mounted) { ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Error switching version: $e'))); + ).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString())))); } } } diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index bb9382a5..58d68f74 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; +import '../../../i18n/strings.g.dart'; /// Reusable widget for adjusting sync offsets (audio or subtitle) class SyncOffsetControl extends StatefulWidget { @@ -69,11 +70,11 @@ class _SyncOffsetControlState extends State { String _getDescriptionText() { if (_currentOffset > 0) { - return '${widget.labelText} plays later'; + return t.videoControls.playsLater(label: widget.labelText); } else if (_currentOffset < 0) { - return '${widget.labelText} plays earlier'; + return t.videoControls.playsEarlier(label: widget.labelText); } else { - return 'No offset'; + return t.videoControls.noOffset; } } @@ -102,7 +103,7 @@ class _SyncOffsetControlState extends State { // Slider Row( children: [ - const Text('-2s', style: TextStyle(color: Colors.white70)), + Text(t.videoControls.minusTime(amount: "2", unit: "s"), style: const TextStyle(color: Colors.white70)), Expanded( child: Slider( value: _currentOffset, @@ -121,7 +122,7 @@ class _SyncOffsetControlState extends State { }, ), ), - const Text('+2s', style: TextStyle(color: Colors.white70)), + Text(t.videoControls.addTime(amount: "2", unit: "s"), style: const TextStyle(color: Colors.white70)), ], ), const SizedBox(height: 24), @@ -129,7 +130,7 @@ class _SyncOffsetControlState extends State { ElevatedButton.icon( onPressed: _currentOffset != 0 ? _resetOffset : null, icon: const Icon(Icons.restart_alt), - label: const Text('Reset to 0ms'), + label: Text(t.videoControls.resetToZero), style: ElevatedButton.styleFrom( backgroundColor: Colors.grey[800], foregroundColor: Colors.white, diff --git a/pubspec.lock b/pubspec.lock index e7baf52d..7069f238 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "85.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "7.7.1" archive: dependency: transitive description: @@ -53,18 +53,18 @@ packages: dependency: transitive description: name: build - sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9 + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "2.5.4" build_config: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.1.2" build_daemon: dependency: transitive description: @@ -73,14 +73,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: a9461b8e586bf018dd4afd2e13b49b08c6a844a4b226c8d1d10f3a723cdd78c3 + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" url: "https://pub.dev" source: hosted - version: "2.10.1" + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" built_collection: dependency: transitive description: @@ -185,14 +201,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csv: + dependency: transitive + description: + name: csv + sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c + url: "https://pub.dev" + source: hosted + version: "6.0.0" dart_style: dependency: transitive description: name: dart_style - sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.1" dbus: dependency: transitive description: @@ -304,6 +328,14 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" glob: dependency: transitive description: @@ -400,6 +432,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json2yaml: + dependency: transitive + description: + name: json2yaml + sha256: da94630fbc56079426fdd167ae58373286f603371075b69bf46d848d63ba3e51 + url: "https://pub.dev" + source: hosted + version: "3.0.1" json_annotation: dependency: "direct main" description: @@ -412,10 +460,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe" + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c url: "https://pub.dev" source: hosted - version: "6.11.1" + version: "6.9.5" leak_tracker: dependency: transitive description: @@ -909,22 +957,46 @@ packages: description: flutter source: sdk version: "0.0.0" + slang: + dependency: "direct main" + description: + name: slang + sha256: a466773de768eb95bdf681e0a92e7c8010d44bb247b62130426c83ece33aeaed + url: "https://pub.dev" + source: hosted + version: "3.32.0" + slang_build_runner: + dependency: "direct dev" + description: + name: slang_build_runner + sha256: b2e0c63f3c801a4aa70b4ca43173893d6eb7d5a421fc9d97ad983527397631b3 + url: "https://pub.dev" + source: hosted + version: "3.32.0" + slang_flutter: + dependency: "direct main" + description: + name: slang_flutter + sha256: "1a98e878673996902fa5ef0b61ce5c245e41e4d25640d18af061c6aab917b0c7" + url: "https://pub.dev" + source: hosted + version: "3.32.0" source_gen: dependency: transitive description: name: source_gen - sha256: "9098ab86015c4f1d8af6486b547b11100e73b193e1899015033cb3e14ad20243" + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "2.0.0" source_helper: dependency: transitive description: name: source_helper - sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca url: "https://pub.dev" source: hosted - version: "1.3.8" + version: "1.3.7" source_span: dependency: transitive description: @@ -1029,6 +1101,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9b478dce..87ac1b08 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,8 @@ dependencies: hotkey_manager: ^0.2.3 flex_color_picker: ^3.6.0 qr_flutter: ^4.1.0 + slang: ^3.31.2 + slang_flutter: ^3.31.0 os_media_controls: git: url: https://github.com/edde746/os-media-controls @@ -50,6 +52,7 @@ dev_dependencies: build_runner: ^2.4.7 json_serializable: ^6.7.1 flutter_launcher_icons: ^0.14.4 + slang_build_runner: ^3.31.0 flutter: uses-material-design: true From 0c102ff3a37880d201751826eb19cca1da317e18 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:13:01 +0100 Subject: [PATCH 4/9] refactor: reduce requests made for playback --- lib/client/plex_client.dart | 119 +++++++++++++++++++++++ lib/models/plex_video_playback_data.dart | 30 ++++++ lib/screens/video_player_screen.dart | 42 +++----- 3 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 lib/models/plex_video_playback_data.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 65b9e324..5ad70e89 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -10,6 +10,7 @@ import '../models/plex_library.dart'; import '../models/plex_media_info.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_video_playback_data.dart'; import '../models/plex_sort.dart'; import '../utils/app_logger.dart'; @@ -639,6 +640,124 @@ class PlexClient { return []; } + /// Get consolidated video playback data (URL, media info, and versions) in a single API call + /// This method combines the functionality of getVideoUrl(), getMediaInfo(), and getMediaVersions() + /// to reduce redundant API calls during video playback initialization. + Future getVideoPlaybackData( + String ratingKey, { + int mediaIndex = 0, + }) async { + final response = await _dio.get('/library/metadata/$ratingKey'); + final metadataJson = _getFirstMetadataJson(response); + + String? videoUrl; + PlexMediaInfo? mediaInfo; + List availableVersions = []; + + if (metadataJson != null && + metadataJson['Media'] != null && + (metadataJson['Media'] as List).isNotEmpty) { + final mediaList = metadataJson['Media'] as List; + + // Parse available media versions first + availableVersions = mediaList + .map( + (media) => PlexMediaVersion.fromJson(media as Map), + ) + .toList(); + + // Ensure the requested index is valid + if (mediaIndex < 0 || mediaIndex >= mediaList.length) { + mediaIndex = 0; + } + + final media = mediaList[mediaIndex]; + if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { + final part = media['Part'][0]; + final partKey = part['key'] as String?; + + if (partKey != null) { + // Get video URL + videoUrl = '${config.baseUrl}$partKey?X-Plex-Token=${config.token}'; + + // Parse streams (audio and subtitle tracks) for media info + final streams = part['Stream'] as List? ?? []; + final audioTracks = []; + final subtitleTracks = []; + + for (var stream in streams) { + final streamType = stream['streamType'] as int?; + + if (streamType == 2) { + // Audio track + audioTracks.add( + PlexAudioTrack( + id: stream['id'] as int, + index: stream['index'] as int?, + codec: stream['codec'] as String?, + language: stream['language'] as String?, + languageCode: stream['languageCode'] as String?, + title: stream['title'] as String?, + displayTitle: stream['displayTitle'] as String?, + channels: stream['channels'] as int?, + selected: stream['selected'] == 1, + ), + ); + } else if (streamType == 3) { + // Subtitle track + subtitleTracks.add( + PlexSubtitleTrack( + id: stream['id'] as int, + index: stream['index'] as int?, + codec: stream['codec'] as String?, + language: stream['language'] as String?, + languageCode: stream['languageCode'] as String?, + title: stream['title'] as String?, + displayTitle: stream['displayTitle'] as String?, + selected: stream['selected'] == 1, + forced: stream['forced'] == 1, + key: stream['key'] as String?, + ), + ); + } + } + + // Parse chapters + final chapters = []; + if (metadataJson['Chapter'] != null) { + final chapterList = metadataJson['Chapter'] as List; + for (var chapter in chapterList) { + chapters.add( + PlexChapter( + id: chapter['id'] as int, + index: chapter['index'] as int?, + startTimeOffset: chapter['startTimeOffset'] as int?, + endTimeOffset: chapter['endTimeOffset'] as int?, + title: chapter['title'] as String?, + thumb: chapter['thumb'] as String?, + ), + ); + } + } + + // Create media info + mediaInfo = PlexMediaInfo( + videoUrl: videoUrl, + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + chapters: chapters, + ); + } + } + } + + return PlexVideoPlaybackData( + videoUrl: videoUrl, + mediaInfo: mediaInfo, + availableVersions: availableVersions, + ); + } + /// Get file information for a media item Future getFileInfo(String ratingKey) async { try { diff --git a/lib/models/plex_video_playback_data.dart b/lib/models/plex_video_playback_data.dart new file mode 100644 index 00000000..90851cde --- /dev/null +++ b/lib/models/plex_video_playback_data.dart @@ -0,0 +1,30 @@ +import 'plex_media_info.dart'; +import 'plex_media_version.dart'; + +/// Consolidated data model containing all information needed for video playback. +/// This model combines data from multiple Plex API endpoints to reduce redundant requests. +class PlexVideoPlaybackData { + /// Direct video URL for playback + final String? videoUrl; + + /// Media information including audio/subtitle tracks and chapters + final PlexMediaInfo? mediaInfo; + + /// Available media versions/qualities for this content + final List availableVersions; + + PlexVideoPlaybackData({ + required this.videoUrl, + required this.mediaInfo, + required this.availableVersions, + }); + + /// Returns true if this playback data has a valid video URL + bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty; + + /// Returns true if media info is available + bool get hasMediaInfo => mediaInfo != null; + + /// Returns true if there are multiple media versions available + bool get hasMultipleVersions => availableVersions.length > 1; +} \ No newline at end of file diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 690c8931..a425f19f 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -10,6 +10,7 @@ import 'package:provider/provider.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; +import '../models/plex_video_playback_data.dart'; import '../providers/playback_state_provider.dart'; import '../providers/plex_client_provider.dart'; import '../providers/settings_provider.dart'; @@ -181,9 +182,6 @@ class VideoPlayerScreenState extends State { // Get the video URL and start playback _startPlayback(); - // Load available media versions - _loadMediaVersions(); - // Set fullscreen mode and orientation based on rotation lock setting if (mounted) { try { @@ -304,18 +302,22 @@ class VideoPlayerScreenState extends State { throw Exception('No client available'); } - // Get the direct file URL from the server using the selected media index - final videoUrl = await client.getVideoUrl( + // Get consolidated playback data (URL, media info, and versions) in a single API call + final playbackData = await client.getVideoPlaybackData( widget.metadata.ratingKey, mediaIndex: widget.selectedMediaIndex, ); - if (videoUrl != null) { - // Fetch media info to check for external subtitle tracks - final mediaInfo = await client.getMediaInfo( - widget.metadata.ratingKey, - mediaIndex: widget.selectedMediaIndex, - ); + if (playbackData.hasValidVideoUrl) { + final videoUrl = playbackData.videoUrl!; + final mediaInfo = playbackData.mediaInfo; + + // Update available versions from the playback data + if (mounted) { + setState(() { + _availableVersions = playbackData.availableVersions; + }); + } // Build list of external subtitle tracks for media_kit final externalSubtitles = []; @@ -434,24 +436,6 @@ class VideoPlayerScreenState extends State { } } - /// Load available media versions for this item - Future _loadMediaVersions() async { - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; - - final versions = await client.getMediaVersions(widget.metadata.ratingKey); - if (mounted) { - setState(() { - _availableVersions = versions; - }); - } - } catch (e) { - appLogger.e('Error loading media versions: $e'); - } - } - /// Cycle through BoxFit modes: contain → cover → fill → contain (for button) void _cycleBoxFitMode() { setState(() { From f828ee926b54ef6eeb966edeec3c5caa41aeb525 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:17:14 +0100 Subject: [PATCH 5/9] refactor: fix warnings --- lib/screens/subtitle_styling_screen.dart | 10 ++++------ lib/screens/video_player_screen.dart | 1 - 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/screens/subtitle_styling_screen.dart b/lib/screens/subtitle_styling_screen.dart index b38379b6..10b9a2ae 100644 --- a/lib/screens/subtitle_styling_screen.dart +++ b/lib/screens/subtitle_styling_screen.dart @@ -52,13 +52,13 @@ class _SubtitleStylingScreenState extends State { } String _colorToHex(Color color) { - return '#${color.red.toRadixString(16).padLeft(2, '0')}${color.green.toRadixString(16).padLeft(2, '0')}${color.blue.toRadixString(16).padLeft(2, '0')}'.toUpperCase(); + return '#${((color.r * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.g * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.b * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}'.toUpperCase(); } Future _showColorPicker(String title, String currentColor, Function(String) onColorSelected) async { Color initialColor = _hexToColor(currentColor); - final Color? selectedColor = await showColorPickerDialog( + final Color selectedColor = await showColorPickerDialog( context, initialColor, title: Text(title), @@ -86,10 +86,8 @@ class _SubtitleStylingScreenState extends State { ), ); - if (selectedColor != null) { - final hexColor = _colorToHex(selectedColor); - onColorSelected(hexColor); - } + final hexColor = _colorToHex(selectedColor); + onColorSelected(hexColor); } @override diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index a425f19f..13443a48 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -10,7 +10,6 @@ import 'package:provider/provider.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; -import '../models/plex_video_playback_data.dart'; import '../providers/playback_state_provider.dart'; import '../providers/plex_client_provider.dart'; import '../providers/settings_provider.dart'; From 94bf264b075b10bb79bff85c7a405fd15f9f82a8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:20:20 +0100 Subject: [PATCH 6/9] docs: contributing --- CONTRIBUTING.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..1742ed9d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing + +## Getting Started + +1. Fork and clone the repository +2. Run `flutter pub get` to install dependencies +3. Run `dart run build_runner build` to generate code +4. Start developing! + +## Development + +- Follow Dart/Flutter conventions +- Run `flutter analyze` before submitting +- Test your changes thoroughly + +## Internationalization (i18n) + +This project uses `slang` for internationalization with JSON files. + +### Adding New Strings + +1. Add your string to `lib/i18n/strings.i18n.json`: + ```json + { + "section": { + "myNewString": "My new text" + } + } + ``` + +2. Run `dart run slang` to regenerate translation files + +3. Use in your code: + ```dart + Text(t.section.myNewString) + ``` + +### Adding New Languages + +1. Create new JSON file: `lib/i18n/strings_[locale].i18n.json` +2. Copy structure from `strings.i18n.json` and translate values +3. Run `dart run slang` to regenerate files + +### Guidelines + +- Organize strings logically in nested objects +- Use camelCase for keys +- Keep strings concise and clear +- Always run `dart run slang` after changes From a878a9a5122c415954914d1aafe6c3fa479f83d3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:08:36 +0100 Subject: [PATCH 7/9] fix: respect library home screen exclusion --- lib/models/plex_library.dart | 2 + lib/models/plex_library.g.dart | 2 + lib/screens/discover_screen.dart | 95 +++++++++++++------------------- 3 files changed, 41 insertions(+), 58 deletions(-) diff --git a/lib/models/plex_library.dart b/lib/models/plex_library.dart index 10ee2f31..34db55b4 100644 --- a/lib/models/plex_library.dart +++ b/lib/models/plex_library.dart @@ -13,6 +13,7 @@ class PlexLibrary { final String? uuid; final int? updatedAt; final int? createdAt; + final int? hidden; PlexLibrary({ required this.key, @@ -24,6 +25,7 @@ class PlexLibrary { this.uuid, this.updatedAt, this.createdAt, + this.hidden, }); factory PlexLibrary.fromJson(Map json) => diff --git a/lib/models/plex_library.g.dart b/lib/models/plex_library.g.dart index 1f77d21a..78565719 100644 --- a/lib/models/plex_library.g.dart +++ b/lib/models/plex_library.g.dart @@ -16,6 +16,7 @@ PlexLibrary _$PlexLibraryFromJson(Map json) => PlexLibrary( uuid: json['uuid'] as String?, updatedAt: (json['updatedAt'] as num?)?.toInt(), createdAt: (json['createdAt'] as num?)?.toInt(), + hidden: (json['hidden'] as num?)?.toInt(), ); Map _$PlexLibraryToJson(PlexLibrary instance) => @@ -29,4 +30,5 @@ Map _$PlexLibraryToJson(PlexLibrary instance) => 'uuid': instance.uuid, 'updatedAt': instance.updatedAt, 'createdAt': instance.createdAt, + 'hidden': instance.hidden, }; diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 3fc68f61..b213b930 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -43,7 +43,6 @@ class _DiscoverScreenState extends State PlexClient get client => context.clientSafe; List _onDeck = []; - List _recentlyAdded = []; List _hubs = []; bool _isLoading = true; String? _errorMessage; @@ -168,7 +167,7 @@ class _DiscoverScreenState extends State }); try { - appLogger.d('Fetching onDeck and recentlyAdded from Plex'); + appLogger.d('Fetching onDeck and hubs from Plex'); final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { @@ -176,48 +175,45 @@ class _DiscoverScreenState extends State } final onDeck = await client.getOnDeck(); - final recentlyAdded = await client.getRecentlyAdded(limit: 20); // Load hubs from all libraries final libraries = await client.getLibraries(); final allHubs = []; for (final library in libraries) { - // Only fetch hubs for movie and show libraries - if (library.type == 'movie' || library.type == 'show') { - try { - final libraryHubs = await client.getLibraryHubs( - library.key, - limit: 12, - ); - // Filter out duplicate hubs that we already fetch separately - final filteredHubs = libraryHubs.where((hub) { - final hubId = hub.hubIdentifier?.toLowerCase() ?? ''; - final title = hub.title.toLowerCase(); - // Skip "Continue Watching", "On Deck", and "Recently Added" hubs - return !hubId.contains('ondeck') && - !hubId.contains('continue') && - !hubId.contains('recentlyadded') && - !title.contains('continue watching') && - !title.contains('on deck') && - !title.contains('recently added'); - }).toList(); - allHubs.addAll(filteredHubs); - } catch (e) { - appLogger.w( - 'Failed to load hubs for library ${library.title}', - error: e, - ); - } + // Skip libraries that are not movie/show or are hidden + if (library.type != 'movie' && library.type != 'show') continue; + if (library.hidden != 0) continue; + + try { + final libraryHubs = await client.getLibraryHubs( + library.key, + limit: 12, + ); + // Filter out duplicate hubs that we already fetch separately + final filteredHubs = libraryHubs.where((hub) { + final hubId = hub.hubIdentifier?.toLowerCase() ?? ''; + final title = hub.title.toLowerCase(); + // Skip "Continue Watching" and "On Deck" hubs (we handle these separately) + return !hubId.contains('ondeck') && + !hubId.contains('continue') && + !title.contains('continue watching') && + !title.contains('on deck'); + }).toList(); + allHubs.addAll(filteredHubs); + } catch (e) { + appLogger.w( + 'Failed to load hubs for library ${library.title}', + error: e, + ); } } appLogger.d( - 'Received ${onDeck.length} on deck items, ${recentlyAdded.length} recently added items, and ${allHubs.length} hubs', + 'Received ${onDeck.length} on deck items and ${allHubs.length} hubs', ); setState(() { _onDeck = onDeck; - _recentlyAdded = recentlyAdded; _hubs = allHubs; _isLoading = false; @@ -285,7 +281,7 @@ class _DiscoverScreenState extends State // Public method to fully reload all content (for profile switches) void fullRefresh() { appLogger.d('DiscoverScreen.fullRefresh() called - reloading all content'); - // Reload all content including Recently Added and content hubs + // Reload all content including On Deck and content hubs _loadContent(); } @@ -399,12 +395,14 @@ class _DiscoverScreenState extends State _onDeck[onDeckIndex] = updatedMetadata; } - // Check and update in _recentlyAdded list - final recentlyAddedIndex = _recentlyAdded.indexWhere( - (item) => item.ratingKey == ratingKey, - ); - if (recentlyAddedIndex != -1) { - _recentlyAdded[recentlyAddedIndex] = updatedMetadata; + // Check and update in hub items + for (final hub in _hubs) { + final itemIndex = hub.items.indexWhere( + (item) => item.ratingKey == ratingKey, + ); + if (itemIndex != -1) { + hub.items[itemIndex] = updatedMetadata; + } } } @@ -632,25 +630,6 @@ class _DiscoverScreenState extends State _buildHorizontalList(_onDeck, isLarge: false), ], - // Recently Added - if (_recentlyAdded.isNotEmpty) ...[ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), - child: Row( - children: [ - const Icon(Icons.fiber_new), - const SizedBox(width: 8), - Text( - t.discover.recentlyAdded, - style: Theme.of(context).textTheme.titleLarge, - ), - ], - ), - ), - ), - _buildHorizontalList(_recentlyAdded, isLarge: false), - ], // Recommendation Hubs (Trending, Top in Genre, etc.) for (final hub in _hubs) ...[ @@ -691,7 +670,7 @@ class _DiscoverScreenState extends State _buildHorizontalList(hub.items, isLarge: false), ], - if (_onDeck.isEmpty && _recentlyAdded.isEmpty && _hubs.isEmpty) + if (_onDeck.isEmpty && _hubs.isEmpty) SliverFillRemaining( child: Center( child: Column( From 57ca53b9a66f51d5c838beadbb540be7104cbb8c Mon Sep 17 00:00:00 2001 From: fixx1983 Date: Mon, 10 Nov 2025 21:16:00 +0100 Subject: [PATCH 8/9] Create strings_it.i18n.json --- lib/i18n/strings_it.i18n.json | 365 ++++++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 lib/i18n/strings_it.i18n.json diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json new file mode 100644 index 00000000..a15456b6 --- /dev/null +++ b/lib/i18n/strings_it.i18n.json @@ -0,0 +1,365 @@ +{ + "app": { + "title": "Plezy", + "loading": "Caricamento..." + }, + "auth": { + "signInWithPlex": "Accedi con Plex", + "showQRCode": "Mostra QR Code", + "cancel": "Cancella", + "authenticate": "Autenticazione", + "retry": "Riprova", + "debugEnterToken": "Debug: Inserisci Token Plex", + "plexTokenLabel": "Token Auth Plex", + "plexTokenHint": "Inserisci il tuo token di Plex.tv", + "authenticationTimeout": "Autenticazione scaduta. Riprova.", + "scanQRCodeInstruction": "Scansiona questo QR code con un dispositivo connesso a Plex per autenticarti.", + "waitingForAuth": "In attesa di autenticazione...\nCompleta l'accesso dal tuo browser." + }, + "common": { + "cancel": "Cancella", + "save": "Salva", + "close": "Chiudi", + "clear": "Pulisci", + "reset": "Ripristina", + "later": "Più tardi", + "submit": "Invia", + "confirm": "Conferma", + "retry": "Riprova", + "playNow": "Riproduci ora", + "logout": "Disconnetti", + "online": "Online", + "offline": "Offline", + "owned": "Di proprietà", + "shared": "Condiviso", + "current": "CORRENTE", + "unknown": "Sconosciuto", + "refresh": "Aggiorna", + "yes": "Sì", + "no": "No", + "server": "Server" + }, + "screens": { + "licenses": "Licenze", + "selectServer": "Seleziona server", + "switchProfile": "Cambia profilo", + "subtitleStyling": "Stile sottotitoli", + "search": "Cerca", + "logs": "Logs" + }, + "update": { + "available": "Update Available", + "versionAvailable": "Versione ${version} disponibile", + "currentVersion": "Corrente: ${version}", + "skipVersion": "Salta questa versione", + "viewRelease": "Visualizza dettagli release", + "latestVersion": "La versione insallata è l'ultima disponibile", + "checkFailed": "Impossibile controllare gli aggiornamenti" + }, + "settings": { + "title": "Impostazioni", + "language": "Lingua", + "theme": "Tema", + "appearance": "Aspetto", + "videoPlayback": "Riproduzione video", + "shufflePlay": "Riproduzione casuale", + "advanced": "Avanzate", + "useSeasonPostersDescription": "Mostra il poster della stagione invece del poster della serie per gli episodi", + "showHeroSectionDescription": "Visualizza il carosello dei contenuti in primo piano sulla schermata iniziale", + "secondsLabel": "Secondi", + "minutesLabel": "Minuti", + "secondsShort": "s", + "minutesShort": "m", + "durationHint": "Inserisci durata (${min}-${max})", + "systemTheme": "Sistema", + "systemThemeDescription": "Segui le impostazioni di sistema", + "lightTheme": "Chiaro", + "darkTheme": "Scuro", + "libraryDensity": "Densità libreria", + "compact": "Compatta", + "compactDescription": "Schede più piccole, più elementi visibili", + "normal": "Normale", + "normalDescription": "Default size", + "comfortable": "Comoda", + "comfortableDescription": "Schede più grandi, meno elementi visibili", + "viewMode": "View Mode", + "gridView": "Griglia", + "gridViewDescription": "Visualizza gli elementi in un layout a griglia", + "listView": "Elenco", + "listViewDescription": "Visualizza gli elementi in un layout a elenco", + "useSeasonPosters": "Usa poster delle stagioni", + "showHeroSection": "Mostra sezione Hero", + "hardwareDecoding": "Decodifica Hardware", + "hardwareDecodingDescription": "Utilizza l'accelerazione hardware quando disponibile", + "bufferSize": "Dimensione buffer", + "bufferSizeMB": "${size}MB", + "subtitleStyling": "Stile sottotitoli", + "subtitleStylingDescription": "Personalizza l'aspetto dei sottotitoli", + "smallSkipDuration": "Durata skip breve", + "largeSkipDuration": "Durata skip lungo", + "secondsUnit": "${seconds} secondi", + "defaultSleepTimer": "Timer spegnimento predefinito", + "minutesUnit": "${minutes} minuti", + "unwatchedOnly": "Solo non guardati", + "unwatchedOnlyDescription": "Includi solo gli episodi non guardati nella coda di riproduzione casuale", + "shuffleOrderNavigation": "Navigazione in ordine casuale", + "shuffleOrderNavigationDescription": "I pulsanti Avanti/Indietro seguono l'ordine casuale", + "loopShuffleQueue": "Coda di riproduzione casuale in loop", + "loopShuffleQueueDescription": "Riavvia la coda quando raggiungi la fine", + "videoPlayerControls": "Controlli del lettore video", + "keyboardShortcuts": "Scorciatoie da tastiera", + "keyboardShortcutsDescription": "Personalizza le scorciatoie da tastiera", + "debugLogging": "Log di debug", + "debugLoggingDescription": "Abilita il logging dettagliato per la risoluzione dei problemi", + "viewLogs": "Visualizza log", + "viewLogsDescription": "Visualizza i log dell'applicazione", + "clearCache": "Svuota cache", + "clearCacheDescription": "Questa opzione cancellerà tutte le immagini e i dati memorizzati nella cache. Dopo aver cancellato la cache, l'app potrebbe impiegare più tempo per caricare i contenuti.", + "clearCacheSuccess": "Cache cancellata correttamente", + "resetSettings": "Ripristina impostazioni", + "resetSettingsDescription": "Questa opzione ripristinerà tutte le impostazioni ai valori predefiniti. Non può essere annullata.", + "resetSettingsSuccess": "Impostazioni ripristinate correttamente", + "shortcutsReset": "Scorciatoie ripristinate alle impostazioni predefinite", + "about": "Informazioni", + "aboutDescription": "Informazioni sull'app e le licenze", + "updates": "Aggiornamenti", + "updateAvailable": "Aggiornamento disponibile", + "checkForUpdates": "Controlla aggiornamenti", + "validationErrorEnterNumber": "Inserisci un numero valido", + "validationErrorDuration": "la durata deve essere compresa tra ${min} e ${max} ${unit}", + "shortcutAlreadyAssigned": "Scorciatoia già assegnata a ${action}", + "shortcutUpdated": "Scorciatoia aggiornata per ${action}" + }, + "search": { + "hint": "Cerca film. spettacoli, musica...", + "tryDifferentTerm": "Prova altri termini di ricerca" + }, + "hotkeys": { + "setShortcutFor": "Imposta scorciatoia per ${actionName}", + "clearShortcut": "Elimina scorciatoia" + }, + "pinEntry": { + "enterPin": "Inserisci PIN", + "showPin": "Mostra PIN", + "hidePin": "Nascondi PIN" + }, + "fileInfo": { + "title": "Info sul file", + "video": "Video", + "audio": "Audio", + "file": "File", + "advanced": "Avanzate", + "codec": "Codec", + "resolution": "Risoluzione", + "bitrate": "Bitrate", + "frameRate": "Frame Rate", + "aspectRatio": "Aspect Ratio", + "profile": "Profilo", + "bitDepth": "Profondità colore", + "colorSpace": "Spazio colore", + "colorRange": "Gamma colori", + "colorPrimaries": "Colori primari", + "chromaSubsampling": "Sottocampionamento cromatico", + "channels": "Canali", + "path": "Percorso", + "size": "Dimensione", + "container": "Contenitore", + "duration": "Durata", + "optimizedForStreaming": "Ottimizzato per lo streaming", + "has64bitOffsets": "Offset a 64-bit" + }, + "mediaMenu": { + "markAsWatched": "Segna come visto", + "markAsUnwatched": "Segna come non visto", + "goToSeries": "Vai alle serie", + "goToSeason": "Vai alla stagione", + "shufflePlay": "Riproduzione casuale", + "fileInfo": "Info sul file" + }, + "tooltips": { + "shufflePlay": "Riproduzione casuale", + "markAsWatched": "Segna come visto", + "markAsUnwatched": "Segna come non visto" + }, + "videoControls": { + "audioLabel": "Audio", + "subtitlesLabel": "Sottotitoli", + "resetToZero": "Riporta a 0ms", + "addTime": "+${amount}${unit}", + "minusTime": "-${amount}${unit}", + "playsLater": "${label} riprodotto dopo", + "playsEarlier": "${label} riprodotto prima", + "noOffset": "No offset", + "letterbox": "Letterbox", + "fillScreen": "Riempi schermo", + "stretch": "Allunga", + "lockRotation": "Blocca rotazione", + "unlockRotation": "Sblocca rotazione" + }, + "userStatus": { + "admin": "Admin", + "restricted": "Limitato", + "protected": "Protetto" + }, + "messages": { + "markedAsWatched": "Segna come visto", + "markedAsUnwatched": "Segna come non visto", + "errorLoading": "Errore: ${error}", + "fileInfoNotAvailable": "Informazioni sul file non disponibili", + "errorLoadingFileInfo": "Errore caricamento informazioni sul file: ${error}", + "errorLoadingSeries": "Errore caricamento serie", + "errorLoadingSeason": "Errore caricamento stagione", + "musicNotSupported": "La riproduzione musicale non è ancora supportata", + "logsCleared": "Log eliminati", + "logsCopied": "Log copiati negli appunti", + "noLogsAvailable": "Nessun log disponibile", + "libraryScanning": "Scansione \"${title}\"...", + "libraryScanStarted": "Scansione libreria iniziata per \"${title}\"", + "libraryScanFailed": "Impossibile eseguire scansione della libreria: ${error}", + "metadataRefreshing": "Aggiornamento metadati per \"${title}\"...", + "metadataRefreshStarted": "Aggiornamento metadati per \"${title}\"", + "metadataRefreshFailed": "Errore aggiornamento metadati: ${error}", + "noPlexToken": "Nessun token Plex trovato. Riesegui l'accesso.", + "logoutConfirm": "Sei sicuro di volerti disconnettere?", + "noSeasonsFound": "Nessuna stagione trovata", + "noEpisodesFound": "Nessun episodio trovato nella prima stagione", + "noEpisodesFoundGeneral": "Nessun episodio trovato", + "noResultsFound": "Nessun risultato", + "sleepTimerSet": "Imposta timer spegnimento per ${label}", + "failedToSwitchProfile": "Impossibile passare a ${displayName}" + }, + "profile": { + "noUsersAvailable": "Nessun utente disponibile" + }, + "subtitlingStyling": { + "stylingOptions": "Opzioni stile", + "fontSize": "Dimensione", + "textColor": "Colore testo", + "borderSize": "Dimensione bordo", + "borderColor": "Colore bordo", + "backgroundOpacity": "Opacità sfondo", + "backgroundColor": "Colore sfondo" + }, + "dialog": { + "confirmAction": "Conferma azione", + "areYouSure": "Sei sicuro di voler eseguire questa azione?", + "cancel": "Cancella", + "playNow": "Riproduci ora" + }, + "discover": { + "title": "Discover", + "switchProfile": "Cambia profilo", + "switchServer": "Cambia server", + "logout": "Disconnetti", + "noContentAvailable": "Nessun contenuto disponibile", + "addMediaToLibraries": "Aggiungi alcuni file multimediali alle tue librerie", + "continueWatching": "Continua a guardare", + "recentlyAdded": "Aggiunti di recente", + "play": "Riproduci", + "resume": "Riprendi", + "playEpisode": "Riproduci S${season}, E${episode}", + "resumeEpisode": "Riprendi S${season}, E${episode}", + "pause": "Pausa", + "overview": "Panoramica", + "episodeCount": "${count} episodi", + "watchedProgress": "${watched}/${total} guardati", + "movie": "Film", + "tvShow": "Serie TV", + "minutesLeft": "${minutes} minuti rimanenti" + }, + "errors": { + "searchFailed": "Ricerca fallita: ${error}", + "connectionTimeout": "Timeout connessione durante caricamento di ${context}", + "connectionFailed": "Impossibile connettersi al server Plex.", + "failedToLoad": "Impossibile caricare ${context}: ${error}", + "noClientAvailable": "Nessun client disponibile", + "authenticationFailed": "Autenticazione fallita: ${error}", + "couldNotLaunchUrl": "Impossibile avviare URL di autenticazione", + "pleaseEnterToken": "Inserisci token", + "invalidToken": "Token non valido", + "failedToVerifyToken": "Verifica token fallita: ${error}", + "failedToSwitchProfile": "Impossibile passare a ${displayName}", + "connectionFailedGeneric": "Connessione fallita" + }, + "libraries": { + "title": "Librerie", + "scanLibraryFiles": "Scansiona file libreria", + "scanLibrary": "Scansiona libreria", + "analyze": "Analizza", + "analyzeLibrary": "Analizza libreria", + "refreshMetadata": "Aggiorna metadati", + "emptyTrash": "Svuota cestino", + "emptyingTrash": "Svuotamento cestino per \"${title}\"...", + "trashEmptied": "Cestino svuotato per \"${title}\"", + "failedToEmptyTrash": "Impossibile svuotare cestino: ${error}", + "analyzing": "Analisi \"${title}\"...", + "analysisStarted": "Analisi iniziata per \"${title}\"", + "failedToAnalyze": "Impossibile analizzare libreria: ${error}", + "noLibrariesFound": "Nessuna libreria trovata", + "thisLibraryIsEmpty": "Questa libreria è vuota", + "all": "Tutto", + "clearAll": "Cancella tutto", + "scanLibraryConfirm": "Sei sicuro di voler scansionare \"${title}\"?", + "analyzeLibraryConfirm": "Sei sicuro di voler analizzare \"${title}\"?", + "refreshMetadataConfirm": "Sei sicuro di voler aggiornare i metadati per \"${title}\"?", + "emptyTrashConfirm": "Sei sicuro di voler svuotare il cestino per \"${title}\"?", + "manageLibraries": "Gestisci librerie", + "sort": "Ordina", + "sortBy": "Ordina per", + "filters": "Filtri", + "loadingLibraryWithCount": "Caricamento librerie... (${count} oggetti caricati)", + "confirmActionMessage": "Sei sicuro di voler eseguire questa azione?", + "showLibrary": "Mostra libreria", + "hideLibrary": "Nascondi libreria", + "libraryOptions": "Opzioni libreria" + }, + "about": { + "title": "Informazioni", + "openSourceLicenses": "Licenze Open Source", + "versionLabel": "Versione ${version}", + "appDescription": "Un bellissimo client Plex per Flutter", + "viewLicensesDescription": "Visualizza le licenze delle librerie di terze parti" + }, + "serverSelection": { + "connectingToServer": "Connesione al server...", + "serverDebugCopied": "Dati di debug del server copiati negli appunti", + "copyDebugData": "Copia dati di debug", + "noServersFound": "Nessun server trovato", + "malformedServerData": "Trovato ${count} server con dati difettosi. Nessun server valido disponibile.", + "incompleteServerInfo": "Alcuni server presentano informazioni incomplete e sono stati ignorati. Controlla il tuo account Plex.tv.", + "incompleteConnectionInfo": "Le informazioni di connessione al server sono incomplete. Riprova.", + "malformedServerInfo": "Le informazioni sul server sono errate: ${message}", + "networkConnectionFailed": "Connessione di rete non riuscita. Controlla la tua connessione Internet e riprova.", + "authenticationFailed": "Autenticazione fallita. Effettua nuovamente l'accesso.", + "plexServiceUnavailable": "Servizio Plex non disponibile. Riprova più tardi.", + "failedToLoadServers": "Impossibile caricare i server: ${error}" + }, + "hubDetail": { + "title": "Titolo", + "releaseYear": "Anno rilascio", + "dateAdded": "Data aggiunta", + "rating": "Valutazione", + "noItemsFound": "Nessun elemento trovato" + }, + "logs": { + "title": "Log", + "clearLogs": "Cancella log", + "copyLogs": "Copia log", + "exportLogs": "Esporta log", + "noLogsToShow": "Nessun log da mostrare", + "error": "Errore:", + "stackTrace": "Traccia dello stack:" + }, + "licenses": { + "relatedPackages": "Pacchetti correlati", + "license": "Licenza", + "licenseNumber": "Licenza ${number}", + "licensesCount": "${count} licenze" + }, + "navigation": { + "home": "Home", + "search": "Cerca", + "libraries": "Librerie", + "settings": "Impostazioni" + } +} From 6e06244b1c05dc537e6e88e0af8655209a79785b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:23:46 +0100 Subject: [PATCH 9/9] i18n(it): minor fixes --- lib/i18n/strings_it.i18n.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index a15456b6..08a5c176 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -48,12 +48,12 @@ "logs": "Logs" }, "update": { - "available": "Update Available", + "available": "Aggiornamento disponibile", "versionAvailable": "Versione ${version} disponibile", "currentVersion": "Corrente: ${version}", "skipVersion": "Salta questa versione", "viewRelease": "Visualizza dettagli release", - "latestVersion": "La versione insallata è l'ultima disponibile", + "latestVersion": "La versione installata è l'ultima disponibile", "checkFailed": "Impossibile controllare gli aggiornamenti" }, "settings": { @@ -79,16 +79,16 @@ "compact": "Compatta", "compactDescription": "Schede più piccole, più elementi visibili", "normal": "Normale", - "normalDescription": "Default size", + "normalDescription": "Dimensione predefinita", "comfortable": "Comoda", "comfortableDescription": "Schede più grandi, meno elementi visibili", - "viewMode": "View Mode", + "viewMode": "Modalità di visualizzazione", "gridView": "Griglia", "gridViewDescription": "Visualizza gli elementi in un layout a griglia", "listView": "Elenco", "listViewDescription": "Visualizza gli elementi in un layout a elenco", "useSeasonPosters": "Usa poster delle stagioni", - "showHeroSection": "Mostra sezione Hero", + "showHeroSection": "Mostra sezione principale", "hardwareDecoding": "Decodifica Hardware", "hardwareDecodingDescription": "Utilizza l'accelerazione hardware quando disponibile", "bufferSize": "Dimensione buffer", @@ -321,7 +321,7 @@ "viewLicensesDescription": "Visualizza le licenze delle librerie di terze parti" }, "serverSelection": { - "connectingToServer": "Connesione al server...", + "connectingToServer": "Connessione al server...", "serverDebugCopied": "Dati di debug del server copiati negli appunti", "copyDebugData": "Copia dati di debug", "noServersFound": "Nessun server trovato",