From 3366ed02f57819a4bdf22717e33e23e85073f590 Mon Sep 17 00:00:00 2001 From: Doezer Date: Thu, 13 Nov 2025 22:53:31 +0100 Subject: [PATCH 01/25] Ran dart format using this pwsh command $FILES = Get-ChildItem -Path lib, test -Recurse -Filter "*.dart" -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -notmatch '\.(g|freezed)\.dart$' } | Select-Object -ExpandProperty FullName; if (-not $FILES) { Write-Host "No Dart files found to format"; exit 0 } else { dart format --output=write --set-exit-if-changed $FILES } --- lib/models/plex_file_info.dart | 3 +- lib/models/plex_video_playback_data.dart | 2 +- lib/providers/playback_state_provider.dart | 5 +- lib/screens/about_screen.dart | 4 +- lib/screens/auth_screen.dart | 36 +++++--- lib/screens/discover_screen.dart | 23 ++--- lib/screens/hub_detail_screen.dart | 6 +- lib/screens/libraries_screen.dart | 56 ++++++++---- lib/screens/licenses_screen.dart | 8 +- lib/screens/logs_screen.dart | 85 ++++++++---------- lib/screens/profile_switch_screen.dart | 4 +- lib/screens/server_selection_screen.dart | 18 ++-- lib/screens/subtitle_styling_screen.dart | 71 +++++++++++---- lib/screens/video_player_screen.dart | 6 +- lib/services/keyboard_shortcuts_service.dart | 4 +- lib/services/plex_auth_service.dart | 37 ++++++-- lib/services/settings_service.dart | 6 +- lib/services/track_selection_service.dart | 28 ++++-- lib/services/update_service.dart | 16 ++-- lib/utils/app_logger.dart | 33 ++++--- lib/utils/shuffle_play_helper.dart | 17 ++-- lib/utils/user_switching_utils.dart | 4 +- lib/utils/video_player_navigation.dart | 4 +- lib/widgets/context_menu_wrapper.dart | 4 +- lib/widgets/file_info_bottom_sheet.dart | 89 ++++++++++++++----- .../horizontal_scroll_with_arrows.dart | 23 ++--- lib/widgets/media_card.dart | 61 +++++++------ lib/widgets/media_context_menu.dart | 25 ++++-- lib/widgets/pin_entry_dialog.dart | 4 +- lib/widgets/sort_bottom_sheet.dart | 20 ++--- .../sheets/audio_track_sheet.dart | 15 ++-- .../video_controls/sheets/chapter_sheet.dart | 68 +++++++------- .../sheets/playback_speed_sheet.dart | 5 +- .../sheets/sleep_timer_sheet.dart | 61 +++++++------ .../sheets/subtitle_track_sheet.dart | 19 ++-- .../sheets/video_settings_sheet.dart | 15 +++- .../video_controls/video_controls.dart | 6 +- .../widgets/sync_offset_control.dart | 10 ++- 38 files changed, 530 insertions(+), 371 deletions(-) diff --git a/lib/models/plex_file_info.dart b/lib/models/plex_file_info.dart index 84972061..3ebf85cb 100644 --- a/lib/models/plex_file_info.dart +++ b/lib/models/plex_file_info.dart @@ -140,7 +140,8 @@ class PlexFileInfo { /// Format audio channels (e.g., "2 channels (stereo)") String get audioChannelsFormatted { if (audioChannels != null) { - String channelText = '$audioChannels channel${audioChannels! > 1 ? 's' : ''}'; + String channelText = + '$audioChannels channel${audioChannels! > 1 ? 's' : ''}'; if (audioChannelLayout != null) { channelText += ' ($audioChannelLayout)'; } diff --git a/lib/models/plex_video_playback_data.dart b/lib/models/plex_video_playback_data.dart index 90851cde..8f5cd912 100644 --- a/lib/models/plex_video_playback_data.dart +++ b/lib/models/plex_video_playback_data.dart @@ -27,4 +27,4 @@ class PlexVideoPlaybackData { /// 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/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index b61ce5df..721345af 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -26,7 +26,10 @@ class PlaybackStateProvider with ChangeNotifier { /// Gets the next episode in the shuffle queue. /// Returns null if queue is exhausted or current episode is not in queue. /// [loopQueue] - If true, restart from beginning when queue is exhausted - PlexMetadata? getNextEpisode(String currentEpisodeKey, {bool loopQueue = false}) { + PlexMetadata? getNextEpisode( + String currentEpisodeKey, { + bool loopQueue = false, + }) { if (_shuffleQueue.isEmpty) return null; // Find current episode in queue diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index 702b97f0..640e3531 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -78,9 +78,7 @@ class _AboutScreenState extends State { child: ListTile( leading: const Icon(Icons.description), title: Text(t.about.openSourceLicenses), - subtitle: Text( - t.about.viewLicensesDescription, - ), + subtitle: Text(t.about.viewLicensesDescription), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 2f41bf84..93c67716 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -245,13 +245,16 @@ class _AuthScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Image.asset('assets/plezy.png', width: 120, height: 120), + Image.asset( + 'assets/plezy.png', + width: 120, + height: 120, + ), const SizedBox(height: 24), Text( t.app.title, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.headlineMedium + ?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), ], @@ -321,7 +324,9 @@ class _AuthScreenState extends State { ElevatedButton( onPressed: _startAuthentication, style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.symmetric( + vertical: 16, + ), ), child: Text(t.auth.signInWithPlex), ), @@ -334,7 +339,9 @@ class _AuthScreenState extends State { _startAuthentication(); }, style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.symmetric( + vertical: 16, + ), ), child: Text(t.auth.showQRCode), ), @@ -343,11 +350,12 @@ class _AuthScreenState extends State { OutlinedButton( onPressed: _handleDebugTap, style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric( + vertical: 12, + ), side: BorderSide( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.5), + color: Theme.of(context).colorScheme.outline + .withValues(alpha: 0.5), ), ), child: Text( @@ -380,9 +388,8 @@ class _AuthScreenState extends State { const SizedBox(height: 24), Text( t.app.title, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.headlineMedium + ?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), const SizedBox(height: 48), @@ -405,7 +412,8 @@ class _AuthScreenState extends State { ), child: Text(t.auth.retry), ), - ] else ...[ // add QR button here + ] else ...[ + // add QR button here ElevatedButton( onPressed: _startAuthentication, style: ElevatedButton.styleFrom( diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index a133ca17..051e1d79 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -240,7 +240,7 @@ class _DiscoverScreenState extends State /// This is called when returning to the home screen to avoid blocking UI Future _refreshContinueWatching() async { appLogger.d('Refreshing Continue Watching in background'); - + try { final clientProvider = context.plexClient; final client = clientProvider.client; @@ -250,7 +250,7 @@ class _DiscoverScreenState extends State } final onDeck = await client.getOnDeck(); - + if (mounted) { setState(() { _onDeck = onDeck; @@ -412,11 +412,9 @@ class _DiscoverScreenState extends State if (plexToken == null) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.noPlexToken), - ), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.messages.noPlexToken))); } return; } @@ -627,10 +625,13 @@ class _DiscoverScreenState extends State ), ), ), - _buildHorizontalList(_onDeck, isLarge: false, isInContinueWatching: true), + _buildHorizontalList( + _onDeck, + isLarge: false, + isInContinueWatching: true, + ), ], - // Recommendation Hubs (Trending, Top in Genre, etc.) for (final hub in _hubs) ...[ SliverToBoxAdapter( @@ -1279,8 +1280,8 @@ class _DiscoverScreenState extends State width: cardWidth, height: posterHeight, onRefresh: updateItem, - onRemoveFromContinueWatching: isInContinueWatching - ? _refreshContinueWatching + onRemoveFromContinueWatching: isInContinueWatching + ? _refreshContinueWatching : null, forceGridMode: true, isInContinueWatching: isInContinueWatching, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 427ce57d..5e3fd5a4 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -100,7 +100,11 @@ class _HubDetailScreenState extends State with Refreshable { List _getDefaultSortOptions() { return [ - PlexSort(key: 'titleSort', title: t.hubDetail.title, defaultDirection: 'asc'), + PlexSort( + key: 'titleSort', + title: t.hubDetail.title, + defaultDirection: 'asc', + ), PlexSort( key: 'year', descKey: 'year:desc', diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 2c4cea24..b7a65fd2 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -72,7 +72,10 @@ class _LibrariesScreenState extends State return t.errors.connectionFailed; default: appLogger.e('Error loading $context', error: error); - return t.errors.failedToLoad(context: context, error: error.message ?? 'Unknown error'); + return t.errors.failedToLoad( + context: context, + error: error.message ?? 'Unknown error', + ); } } @@ -601,8 +604,9 @@ class _LibrariesScreenState extends State label: t.libraries.scanLibraryFiles, requiresConfirmation: true, confirmationTitle: t.libraries.scanLibrary, - confirmationMessage: - t.libraries.scanLibraryConfirm(title: library.title), + confirmationMessage: t.libraries.scanLibraryConfirm( + title: library.title, + ), ), ContextMenuItem( value: 'analyze', @@ -610,8 +614,9 @@ class _LibrariesScreenState extends State label: t.libraries.analyze, requiresConfirmation: true, confirmationTitle: t.libraries.analyzeLibrary, - confirmationMessage: - t.libraries.analyzeLibraryConfirm(title: library.title), + confirmationMessage: t.libraries.analyzeLibraryConfirm( + title: library.title, + ), ), ContextMenuItem( value: 'refresh', @@ -619,8 +624,9 @@ class _LibrariesScreenState extends State label: t.libraries.refreshMetadata, requiresConfirmation: true, confirmationTitle: t.libraries.refreshMetadata, - confirmationMessage: - t.libraries.refreshMetadataConfirm(title: library.title), + confirmationMessage: t.libraries.refreshMetadataConfirm( + title: library.title, + ), isDestructive: true, ), ContextMenuItem( @@ -629,8 +635,9 @@ class _LibrariesScreenState extends State label: t.libraries.emptyTrash, requiresConfirmation: true, confirmationTitle: t.libraries.emptyTrash, - confirmationMessage: - t.libraries.emptyTrashConfirm(title: library.title), + confirmationMessage: t.libraries.emptyTrashConfirm( + title: library.title, + ), isDestructive: true, ), ]; @@ -743,7 +750,9 @@ class _LibrariesScreenState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.metadataRefreshStarted(title: library.title)), + content: Text( + t.messages.metadataRefreshStarted(title: library.title), + ), duration: const Duration(seconds: 3), ), ); @@ -753,7 +762,9 @@ class _LibrariesScreenState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.metadataRefreshFailed(error: e.toString())), + content: Text( + t.messages.metadataRefreshFailed(error: e.toString()), + ), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), @@ -1037,7 +1048,11 @@ class _LibrariesScreenState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.folder_open, size: 64, color: Colors.grey), + const Icon( + Icons.folder_open, + size: 64, + color: Colors.grey, + ), const SizedBox(height: 16), Text(t.libraries.thisLibraryIsEmpty), ], @@ -1097,7 +1112,9 @@ class _LibrariesScreenState extends State const CircularProgressIndicator(), const SizedBox(height: 8), Text( - t.libraries.loadingLibraryWithCount(count: _items.length), + t.libraries.loadingLibraryWithCount( + count: _items.length, + ), style: Theme.of(context).textTheme.bodySmall, ), ], @@ -1393,7 +1410,10 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> { const SizedBox(width: 12), Text( t.libraries.filters, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), ), const Spacer(), if (_tempSelectedFilters.isNotEmpty) @@ -1718,7 +1738,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( - title: Text(selectedItem.confirmationTitle ?? t.dialog.confirmAction), + title: Text( + selectedItem.confirmationTitle ?? t.dialog.confirmAction, + ), content: Text( selectedItem.confirmationMessage ?? t.libraries.confirmActionMessage, @@ -1852,7 +1874,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { : Icons.visibility, ), onPressed: () => widget.onToggleVisibility(library), - tooltip: isHidden ? t.libraries.showLibrary : t.libraries.hideLibrary, + tooltip: isHidden + ? t.libraries.showLibrary + : t.libraries.hideLibrary, ), IconButton( icon: const Icon(Icons.more_vert), diff --git a/lib/screens/licenses_screen.dart b/lib/screens/licenses_screen.dart index 87e2bb26..042123f6 100644 --- a/lib/screens/licenses_screen.dart +++ b/lib/screens/licenses_screen.dart @@ -93,7 +93,9 @@ class _LicensesScreenState extends State { ), subtitle: mergedLicense.licenseEntries.length > 1 ? Text( - t.licenses.licensesCount(count: mergedLicense.licenseEntries.length), + t.licenses.licensesCount( + count: mergedLicense.licenseEntries.length, + ), ) : null, trailing: const Icon(Icons.chevron_right), @@ -178,7 +180,9 @@ class _LicenseDetailScreen extends StatelessWidget { children: [ Text( isMultipleLicenses - ? t.licenses.licenseNumber(number: index + 1) + ? 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 e6368395..6b91e738 100644 --- a/lib/screens/logs_screen.dart +++ b/lib/screens/logs_screen.dart @@ -40,9 +40,9 @@ class _LogsScreenState extends State { MemoryLogOutput.clearLogs(); _logs = []; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.messages.logsCleared)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.messages.logsCleared))); } void _copyAllLogs() { @@ -55,7 +55,8 @@ class _LogsScreenState extends State { isFirst = false; buffer.write( - '[${_formatTime(log.timestamp)}] [${log.level.name.toUpperCase()}] ${log.message}'); + '[${_formatTime(log.timestamp)}] [${log.level.name.toUpperCase()}] ${log.message}', + ); if (log.error != null) { buffer.write('\nError: ${log.error}'); } @@ -64,9 +65,9 @@ class _LogsScreenState extends State { } } Clipboard.setData(ClipboardData(text: buffer.toString())); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.messages.logsCopied)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.messages.logsCopied))); } Color _getLevelColor(Level level) { @@ -131,26 +132,21 @@ class _LogsScreenState extends State { ), if (_logs.isEmpty) SliverFillRemaining( - child: Center( - child: Text(t.messages.noLogsAvailable), - ), + child: Center(child: Text(t.messages.noLogsAvailable)), ) else SliverPadding( padding: const EdgeInsets.all(8), sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final log = _logs[index]; - return _LogEntryCard( - log: log, - formatTime: _formatTime, - levelColor: _getLevelColor(log.level), - levelIcon: _getLevelIcon(log.level), - ); - }, - childCount: _logs.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + final log = _logs[index]; + return _LogEntryCard( + log: log, + formatTime: _formatTime, + levelColor: _getLevelColor(log.level), + levelIcon: _getLevelIcon(log.level), + ); + }, childCount: _logs.length), ), ), ], @@ -198,11 +194,7 @@ class _LogEntryCardState extends State<_LogEntryCard> { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - widget.levelIcon, - color: widget.levelColor, - size: 20, - ), + Icon(widget.levelIcon, color: widget.levelColor, size: 20), const SizedBox(width: 8), Expanded( child: Column( @@ -221,9 +213,7 @@ class _LogEntryCardState extends State<_LogEntryCard> { const SizedBox(width: 8), Text( widget.formatTime(widget.log.timestamp), - style: Theme.of(context) - .textTheme - .bodySmall + style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: Theme.of(context) .textTheme @@ -244,13 +234,10 @@ class _LogEntryCardState extends State<_LogEntryCard> { ), if (hasErrorOrStackTrace) Icon( - _isExpanded - ? Icons.expand_less - : Icons.expand_more, - color: Theme.of(context) - .iconTheme - .color - ?.withValues(alpha: 0.6), + _isExpanded ? Icons.expand_less : Icons.expand_more, + color: Theme.of( + context, + ).iconTheme.color?.withValues(alpha: 0.6), ), ], ), @@ -262,9 +249,9 @@ class _LogEntryCardState extends State<_LogEntryCard> { Text( t.logs.error, style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: widget.levelColor, - fontWeight: FontWeight.bold, - ), + color: widget.levelColor, + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 4), Container( @@ -277,9 +264,9 @@ class _LogEntryCardState extends State<_LogEntryCard> { ), child: SelectableText( widget.log.error.toString(), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), ), ), ], @@ -288,9 +275,9 @@ class _LogEntryCardState extends State<_LogEntryCard> { Text( t.logs.stackTrace, style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: widget.levelColor, - fontWeight: FontWeight.bold, - ), + color: widget.levelColor, + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 4), Container( @@ -303,9 +290,9 @@ class _LogEntryCardState extends State<_LogEntryCard> { ), child: SelectableText( widget.log.stackTrace.toString(), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), ), ), ], diff --git a/lib/screens/profile_switch_screen.dart b/lib/screens/profile_switch_screen.dart index a2a2062c..42f84707 100644 --- a/lib/screens/profile_switch_screen.dart +++ b/lib/screens/profile_switch_screen.dart @@ -116,7 +116,9 @@ class ProfileSwitchScreen extends StatelessWidget { } else if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.errors.failedToSwitchProfile(displayName: user.displayName)), + content: Text( + t.errors.failedToSwitchProfile(displayName: user.displayName), + ), backgroundColor: Theme.of(context).colorScheme.error, ), ); diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart index 4f1fc749..8f142ca2 100644 --- a/lib/screens/server_selection_screen.dart +++ b/lib/screens/server_selection_screen.dart @@ -75,7 +75,9 @@ class _ServerSelectionScreenState extends State { String _getErrorMessage(dynamic error) { if (error is ServerParsingException) { - return t.serverSelection.malformedServerData(count: error.invalidServerData.length); + 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')) { @@ -85,13 +87,13 @@ class _ServerSelectionScreenState extends State { } return t.serverSelection.malformedServerInfo(message: error.message); } else if (error.toString().contains('SocketException') || - error.toString().contains('TimeoutException')) { + error.toString().contains('TimeoutException')) { return t.serverSelection.networkConnectionFailed; } else if (error.toString().contains('401') || - error.toString().contains('Unauthorized')) { + error.toString().contains('Unauthorized')) { return t.serverSelection.authenticationFailed; } else if (error.toString().contains('404') || - error.toString().contains('Not Found')) { + error.toString().contains('Not Found')) { return t.serverSelection.plexServiceUnavailable; } @@ -101,7 +103,9 @@ class _ServerSelectionScreenState extends State { Future _copyDebugDataToClipboard() async { if (_debugServerData == null) return; - final jsonString = const JsonEncoder.withIndent(' ').convert(_debugServerData); + final jsonString = const JsonEncoder.withIndent( + ' ', + ).convert(_debugServerData); await Clipboard.setData(ClipboardData(text: jsonString)); if (mounted) { @@ -207,7 +211,9 @@ class _ServerSelectionScreenState extends State { // Show error if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(result.error ?? t.errors.connectionFailedGeneric)), + SnackBar( + content: Text(result.error ?? t.errors.connectionFailedGeneric), + ), ); } } diff --git a/lib/screens/subtitle_styling_screen.dart b/lib/screens/subtitle_styling_screen.dart index 10b9a2ae..4c10c7a1 100644 --- a/lib/screens/subtitle_styling_screen.dart +++ b/lib/screens/subtitle_styling_screen.dart @@ -52,10 +52,15 @@ class _SubtitleStylingScreenState extends State { } String _colorToHex(Color color) { - 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(); + 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 { + Future _showColorPicker( + String title, + String currentColor, + Function(String) onColorSelected, + ) async { Color initialColor = _hexToColor(currentColor); final Color selectedColor = await showColorPickerDialog( @@ -123,7 +128,9 @@ class _SubtitleStylingScreenState extends State { padding: const EdgeInsets.all(16), child: Text( t.subtitlingStyling.stylingOptions, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), // Font Size Slider @@ -142,7 +149,10 @@ class _SubtitleStylingScreenState extends State { const SizedBox(height: 8), Row( children: [ - const Text('30', style: TextStyle(fontSize: 12, color: Colors.grey)), + const Text( + '30', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), Expanded( child: Slider( value: _fontSize.toDouble(), @@ -160,7 +170,10 @@ class _SubtitleStylingScreenState extends State { }, ), ), - const Text('80', style: TextStyle(fontSize: 12, color: Colors.grey)), + const Text( + '80', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), ], ), ], @@ -182,7 +195,9 @@ class _SubtitleStylingScreenState extends State { subtitle: Text(_textColor), trailing: const Icon(Icons.chevron_right), onTap: () { - _showColorPicker(t.subtitlingStyling.textColor, _textColor, (color) { + _showColorPicker(t.subtitlingStyling.textColor, _textColor, ( + color, + ) { setState(() { _textColor = color; }); @@ -207,7 +222,10 @@ class _SubtitleStylingScreenState extends State { const SizedBox(height: 8), Row( children: [ - const Text('0', style: TextStyle(fontSize: 12, color: Colors.grey)), + const Text( + '0', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), Expanded( child: Slider( value: _borderSize.toDouble(), @@ -225,7 +243,10 @@ class _SubtitleStylingScreenState extends State { }, ), ), - const Text('5', style: TextStyle(fontSize: 12, color: Colors.grey)), + const Text( + '5', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), ], ), ], @@ -247,7 +268,9 @@ class _SubtitleStylingScreenState extends State { subtitle: Text(_borderColor), trailing: const Icon(Icons.chevron_right), onTap: () { - _showColorPicker(t.subtitlingStyling.borderColor, _borderColor, (color) { + _showColorPicker(t.subtitlingStyling.borderColor, _borderColor, ( + color, + ) { setState(() { _borderColor = color; }); @@ -272,7 +295,10 @@ class _SubtitleStylingScreenState extends State { const SizedBox(height: 8), Row( children: [ - const Text('0%', style: TextStyle(fontSize: 12, color: Colors.grey)), + const Text( + '0%', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), Expanded( child: Slider( value: _backgroundOpacity.toDouble(), @@ -286,11 +312,16 @@ class _SubtitleStylingScreenState extends State { }); }, onChangeEnd: (value) { - _settingsService.setSubtitleBackgroundOpacity(_backgroundOpacity); + _settingsService.setSubtitleBackgroundOpacity( + _backgroundOpacity, + ); }, ), ), - const Text('100%', style: TextStyle(fontSize: 12, color: Colors.grey)), + const Text( + '100%', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), ], ), ], @@ -312,12 +343,16 @@ class _SubtitleStylingScreenState extends State { subtitle: Text(_backgroundColor), trailing: const Icon(Icons.chevron_right), onTap: () { - _showColorPicker(t.subtitlingStyling.backgroundColor, _backgroundColor, (color) { - setState(() { - _backgroundColor = color; - }); - _settingsService.setSubtitleBackgroundColor(color); - }); + _showColorPicker( + t.subtitlingStyling.backgroundColor, + _backgroundColor, + (color) { + setState(() { + _backgroundColor = color; + }); + _settingsService.setSubtitleBackgroundColor(color); + }, + ); }, ), ], diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 13443a48..7d5a5826 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -428,9 +428,9 @@ class VideoPlayerScreenState extends State { } } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))), + ); } } } diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index abfd4a6c..86361db3 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -239,8 +239,8 @@ class KeyboardShortcutsService { // Clamp between 0 and video duration final clampedPosition = newPosition.isNegative - ? Duration.zero - : (newPosition > duration ? duration : newPosition); + ? Duration.zero + : (newPosition > duration ? duration : newPosition); player.seek(clampedPosition); } diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 54b5ec4a..7c6ec57c 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -271,7 +271,9 @@ 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)'); + throw FormatException( + 'Invalid server data: missing required fields (name, clientIdentifier, accessToken, or connections)', + ); } final List connectionsJson = json['connections'] as List; @@ -309,8 +311,10 @@ class PlexServer { return PlexServer( 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 + 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?, @@ -326,10 +330,12 @@ class PlexServer { if (json['name'] is! String || (json['name'] as String).isEmpty) { return false; } - if (json['clientIdentifier'] is! String || (json['clientIdentifier'] as String).isEmpty) { + if (json['clientIdentifier'] is! String || + (json['clientIdentifier'] as String).isEmpty) { return false; } - if (json['accessToken'] is! String || (json['accessToken'] as String).isEmpty) { + if (json['accessToken'] is! String || + (json['accessToken'] as String).isEmpty) { return false; } @@ -394,8 +400,16 @@ class PlexServer { final httpCandidates = <_ConnectionCandidate>[]; for (final connection in connections) { - final uriCandidate = _ConnectionCandidate(connection, connection.uri, true); - final directCandidate = _ConnectionCandidate(connection, connection.directUrl, false); + final uriCandidate = _ConnectionCandidate( + connection, + connection.uri, + true, + ); + final directCandidate = _ConnectionCandidate( + connection, + connection.directUrl, + false, + ); if (connection.protocol == 'https') { httpsCandidates.add(uriCandidate); @@ -574,7 +588,9 @@ 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)'); + throw FormatException( + 'Invalid connection data: missing required fields (protocol, address, port, or uri)', + ); } return PlexConnection( @@ -634,7 +650,10 @@ class PlexConnection { /// Create an HTTP fallback version of this HTTPS connection /// This allows testing HTTP when HTTPS is unavailable (e.g., certificate issues) PlexConnection toHttpFallback() { - assert(protocol == 'https', 'Can only create HTTP fallback for HTTPS connections'); + assert( + protocol == 'https', + 'Can only create HTTP fallback for HTTPS connections', + ); return PlexConnection( protocol: 'http', diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 6034e0be..8c745318 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -37,7 +37,8 @@ class SettingsService { static const String _keySubtitleBorderSize = 'subtitle_border_size'; static const String _keySubtitleBorderColor = 'subtitle_border_color'; static const String _keySubtitleBackgroundColor = 'subtitle_background_color'; - static const String _keySubtitleBackgroundOpacity = 'subtitle_background_opacity'; + static const String _keySubtitleBackgroundOpacity = + 'subtitle_background_opacity'; static const String _keyShuffleUnwatchedOnly = 'shuffle_unwatched_only'; static const String _keyShuffleOrderNavigation = 'shuffle_order_navigation'; static const String _keyShuffleLoopQueue = 'shuffle_loop_queue'; @@ -228,7 +229,8 @@ class SettingsService { } bool getRotationLocked() { - return _prefs.getBool(_keyRotationLocked) ?? true; // Default: locked (landscape only) + return _prefs.getBool(_keyRotationLocked) ?? + true; // Default: locked (landscape only) } // Subtitle Styling Settings diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 11f6df7b..d852abef 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -68,7 +68,8 @@ class TrackSelectionService { // Mode 1: Shown with foreign audio if (profile.autoSelectSubtitle == 1) { // Check if audio language matches user's preferred subtitle language - if (selectedAudioTrack != null && profile.defaultSubtitleLanguage != null) { + if (selectedAudioTrack != null && + profile.defaultSubtitleLanguage != null) { final audioLang = selectedAudioTrack.languageCode; final prefLang = profile.defaultSubtitleLanguage; @@ -108,10 +109,16 @@ class TrackSelectionService { var candidateTracks = tracks; // Apply SDH (hearing impaired) filtering - candidateTracks = _filterBySDH(candidateTracks, profile.defaultSubtitleAccessibility); + candidateTracks = _filterBySDH( + candidateTracks, + profile.defaultSubtitleAccessibility, + ); // Apply forced subtitle filtering - candidateTracks = _filterByForced(candidateTracks, profile.defaultSubtitleForced); + candidateTracks = _filterByForced( + candidateTracks, + profile.defaultSubtitleForced, + ); // If no candidates after filtering, relax filters if (candidateTracks.isEmpty) { @@ -200,17 +207,20 @@ class TrackSelectionService { // Look for common SDH indicators return title.contains('sdh') || - displayTitle.contains('sdh') || - title.contains('cc') || - displayTitle.contains('cc') || - title.contains('hearing impaired') || - displayTitle.contains('hearing impaired'); + displayTitle.contains('sdh') || + title.contains('cc') || + displayTitle.contains('cc') || + title.contains('hearing impaired') || + displayTitle.contains('hearing impaired'); } /// Checks if a language code matches a preferred language /// /// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes - static bool _matchesLanguage(String? trackLanguage, String? preferredLanguage) { + static bool _matchesLanguage( + String? trackLanguage, + String? preferredLanguage, + ) { if (trackLanguage == null || preferredLanguage == null) { return false; } diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index a470f7a0..b2e2b93b 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -18,7 +18,10 @@ class UpdateService { /// Check if update checking is enabled via build flag static bool get isUpdateCheckEnabled { - const enabled = bool.fromEnvironment('ENABLE_UPDATE_CHECK', defaultValue: false); + const enabled = bool.fromEnvironment( + 'ENABLE_UPDATE_CHECK', + defaultValue: false, + ); return enabled; } @@ -62,24 +65,21 @@ class UpdateService { /// Check for updates on GitHub (manual check, ignores cooldown) /// Returns a map with update info, or null if no update or error - static Future?> checkForUpdates({bool silent = false}) async { + static Future?> checkForUpdates({ + bool silent = false, + }) async { if (!isUpdateCheckEnabled) { return null; } try { - final packageInfo = await PackageInfo.fromPlatform(); final currentVersion = packageInfo.version; final dio = Dio(); final response = await dio.get( 'https://api.github.com/repos/$_githubRepo/releases/latest', - options: Options( - headers: { - 'Accept': 'application/vnd.github+json', - }, - ), + options: Options(headers: {'Accept': 'application/vnd.github+json'}), ); if (response.statusCode == 200) { diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index 43be6817..07c39cb6 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -13,7 +13,10 @@ String _redactSensitiveData(String message) { // Redact authorization headers redacted = redacted.replaceAllMapped( - RegExp(r'([Aa]uthorization[=:]\s*)([A-Za-z0-9_\-\.]+)', caseSensitive: false), + RegExp( + r'([Aa]uthorization[=:]\s*)([A-Za-z0-9_\-\.]+)', + caseSensitive: false, + ), (match) => '${match.group(1)}[REDACTED]', ); @@ -31,7 +34,9 @@ String _redactSensitiveData(String message) { // Redact full URLs with tokens in query parameters redacted = redacted.replaceAllMapped( - RegExp(r'(https?://[^\s]*[?&])([Xx]-[Pp]lex-[Tt]oken|token)=([A-Za-z0-9_-]+)'), + RegExp( + r'(https?://[^\s]*[?&])([Xx]-[Pp]lex-[Tt]oken|token)=([A-Za-z0-9_-]+)', + ), (match) => '${match.group(1)}${match.group(2)}=[REDACTED]', ); @@ -49,18 +54,18 @@ String _redactSensitiveData(String message) { // Redact standalone token-like strings (20+ alphanumeric characters) // Only if they appear in common token contexts - redacted = redacted.replaceAllMapped( - RegExp(r'\b([A-Za-z0-9_-]{20,})\b'), - (match) { - final token = match.group(1)!; - // Only redact if it looks like a token (mixed case or contains hyphens/underscores) - if (token.contains(RegExp(r'[A-Z]')) && token.contains(RegExp(r'[a-z]')) || - token.contains('_') || token.contains('-')) { - return '[REDACTED_TOKEN]'; - } - return token; - }, - ); + redacted = redacted.replaceAllMapped(RegExp(r'\b([A-Za-z0-9_-]{20,})\b'), ( + match, + ) { + final token = match.group(1)!; + // Only redact if it looks like a token (mixed case or contains hyphens/underscores) + if (token.contains(RegExp(r'[A-Z]')) && token.contains(RegExp(r'[a-z]')) || + token.contains('_') || + token.contains('-')) { + return '[REDACTED_TOKEN]'; + } + return token; + }); return redacted; } diff --git a/lib/utils/shuffle_play_helper.dart b/lib/utils/shuffle_play_helper.dart index a39b4b3f..58abed1c 100644 --- a/lib/utils/shuffle_play_helper.dart +++ b/lib/utils/shuffle_play_helper.dart @@ -32,8 +32,7 @@ Future handleShufflePlay( showDialog( context: context, barrierDismissible: false, - builder: (context) => - const Center(child: CircularProgressIndicator()), + builder: (context) => const Center(child: CircularProgressIndicator()), ); } @@ -42,9 +41,7 @@ Future handleShufflePlay( if (itemType == 'show') { if (unwatchedOnly) { // Get only unwatched episodes - episodes = await client.getAllUnwatchedEpisodes( - metadata.ratingKey, - ); + episodes = await client.getAllUnwatchedEpisodes(metadata.ratingKey); } else { // Get all episodes from all seasons final allEpisodes = []; @@ -71,9 +68,7 @@ Future handleShufflePlay( } else { // Get all episodes in season final seasonEpisodes = await client.getChildren(metadata.ratingKey); - episodes = seasonEpisodes - .where((ep) => ep.type == 'episode') - .toList(); + episodes = seasonEpisodes.where((ep) => ep.type == 'episode').toList(); } } @@ -84,9 +79,9 @@ Future handleShufflePlay( if (episodes.isEmpty) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.messages.noEpisodesFound)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.messages.noEpisodesFound))); } return; } diff --git a/lib/utils/user_switching_utils.dart b/lib/utils/user_switching_utils.dart index 50f5a125..38827aa0 100644 --- a/lib/utils/user_switching_utils.dart +++ b/lib/utils/user_switching_utils.dart @@ -18,7 +18,9 @@ class UserSwitchingUtils { } else if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.failedToSwitchProfile(displayName: user.displayName)), + content: Text( + t.messages.failedToSwitchProfile(displayName: user.displayName), + ), backgroundColor: Theme.of(context).colorScheme.error, ), ); diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 22f57523..d0996f21 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -41,7 +41,9 @@ Future navigateToVideoPlayer( try { final settingsService = await SettingsService.getInstance(); final seriesKey = metadata.grandparentRatingKey ?? metadata.ratingKey; - final savedPreference = settingsService.getMediaVersionPreference(seriesKey); + final savedPreference = settingsService.getMediaVersionPreference( + seriesKey, + ); if (savedPreference != null) { mediaIndex = savedPreference; } diff --git a/lib/widgets/context_menu_wrapper.dart b/lib/widgets/context_menu_wrapper.dart index 7f05d288..474daf2f 100644 --- a/lib/widgets/context_menu_wrapper.dart +++ b/lib/widgets/context_menu_wrapper.dart @@ -167,9 +167,7 @@ class _ContextMenuWrapperState extends State { if (selectedItem.requiresConfirmation) { final confirmed = await _showConfirmationDialog( title: selectedItem.confirmationTitle ?? t.dialog.confirmAction, - message: - selectedItem.confirmationMessage ?? - t.dialog.areYouSure, + message: selectedItem.confirmationMessage ?? 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 1e2d21d1..58b92d48 100644 --- a/lib/widgets/file_info_bottom_sheet.dart +++ b/lib/widgets/file_info_bottom_sheet.dart @@ -77,30 +77,66 @@ class FileInfoBottomSheet extends StatelessWidget { // Video Section _buildSectionHeader(t.fileInfo.video), const SizedBox(height: 8), - _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), + _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(t.fileInfo.profile, fileInfo.videoProfile!), if (fileInfo.bitDepth != null) - _buildInfoRow(t.fileInfo.bitDepth, '${fileInfo.bitDepth} bit'), + _buildInfoRow( + t.fileInfo.bitDepth, + '${fileInfo.bitDepth} bit', + ), if (fileInfo.colorSpace != null) - _buildInfoRow(t.fileInfo.colorSpace, fileInfo.colorSpace!), + _buildInfoRow( + t.fileInfo.colorSpace, + fileInfo.colorSpace!, + ), if (fileInfo.colorRange != null) - _buildInfoRow(t.fileInfo.colorRange, fileInfo.colorRange!), + _buildInfoRow( + t.fileInfo.colorRange, + fileInfo.colorRange!, + ), if (fileInfo.colorPrimaries != null) - _buildInfoRow(t.fileInfo.colorPrimaries, fileInfo.colorPrimaries!), + _buildInfoRow( + t.fileInfo.colorPrimaries, + fileInfo.colorPrimaries!, + ), if (fileInfo.chromaSubsampling != null) - _buildInfoRow(t.fileInfo.chromaSubsampling, fileInfo.chromaSubsampling!), + _buildInfoRow( + t.fileInfo.chromaSubsampling, + fileInfo.chromaSubsampling!, + ), const SizedBox(height: 20), // Audio Section _buildSectionHeader(t.fileInfo.audio), const SizedBox(height: 8), - _buildInfoRow(t.fileInfo.codec, fileInfo.audioCodec ?? t.common.unknown), - _buildInfoRow(t.fileInfo.channels, fileInfo.audioChannelsFormatted), + _buildInfoRow( + t.fileInfo.codec, + fileInfo.audioCodec ?? t.common.unknown, + ), + _buildInfoRow( + t.fileInfo.channels, + fileInfo.audioChannelsFormatted, + ), if (fileInfo.audioProfile != null) _buildInfoRow(t.fileInfo.profile, fileInfo.audioProfile!), const SizedBox(height: 20), @@ -109,10 +145,20 @@ class FileInfoBottomSheet extends StatelessWidget { _buildSectionHeader(t.fileInfo.file), const SizedBox(height: 8), if (fileInfo.filePath != null) - _buildInfoRow(t.fileInfo.path, fileInfo.filePath!, isMonospace: true), + _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), + _buildInfoRow( + t.fileInfo.container, + fileInfo.container ?? t.common.unknown, + ), + _buildInfoRow( + t.fileInfo.duration, + fileInfo.durationFormatted, + ), const SizedBox(height: 20), // Advanced Section @@ -120,11 +166,15 @@ class FileInfoBottomSheet extends StatelessWidget { const SizedBox(height: 8), _buildInfoRow( t.fileInfo.optimizedForStreaming, - fileInfo.optimizedForStreaming == true ? t.common.yes : t.common.no, + fileInfo.optimizedForStreaming == true + ? t.common.yes + : t.common.no, ), _buildInfoRow( t.fileInfo.has64bitOffsets, - fileInfo.has64bitOffsets == true ? t.common.yes : t.common.no, + fileInfo.has64bitOffsets == true + ? t.common.yes + : t.common.no, ), ], ), @@ -157,10 +207,7 @@ class FileInfoBottomSheet extends StatelessWidget { width: 140, child: Text( label, - style: TextStyle( - color: Colors.grey[400], - fontSize: 14, - ), + style: TextStyle(color: Colors.grey[400], fontSize: 14), ), ), Expanded( diff --git a/lib/widgets/horizontal_scroll_with_arrows.dart b/lib/widgets/horizontal_scroll_with_arrows.dart index 8a01d067..1e1a0475 100644 --- a/lib/widgets/horizontal_scroll_with_arrows.dart +++ b/lib/widgets/horizontal_scroll_with_arrows.dart @@ -56,9 +56,9 @@ class _HorizontalScrollWithArrowsState void _scrollLeft() { final position = _scrollController.position; - final targetScroll = (position.pixels - - (position.viewportDimension * widget.scrollAmount)) - .clamp(0.0, position.maxScrollExtent); + final targetScroll = + (position.pixels - (position.viewportDimension * widget.scrollAmount)) + .clamp(0.0, position.maxScrollExtent); _scrollController.animateTo( targetScroll, @@ -69,9 +69,9 @@ class _HorizontalScrollWithArrowsState void _scrollRight() { final position = _scrollController.position; - final targetScroll = (position.pixels + - (position.viewportDimension * widget.scrollAmount)) - .clamp(0.0, position.maxScrollExtent); + final targetScroll = + (position.pixels + (position.viewportDimension * widget.scrollAmount)) + .clamp(0.0, position.maxScrollExtent); _scrollController.animateTo( targetScroll, @@ -145,10 +145,7 @@ class _NavigationArrow extends StatefulWidget { final IconData icon; final VoidCallback onPressed; - const _NavigationArrow({ - required this.icon, - required this.onPressed, - }); + const _NavigationArrow({required this.icon, required this.onPressed}); @override State<_NavigationArrow> createState() => _NavigationArrowState(); @@ -182,11 +179,7 @@ class _NavigationArrowState extends State<_NavigationArrow> { ), ], ), - child: Icon( - widget.icon, - color: Colors.white, - size: 32, - ), + child: Icon(widget.icon, color: Colors.white, size: 32), ), ), ); diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index fe03780a..0801f86e 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -181,34 +181,31 @@ class _MediaCardGrid extends StatelessWidget { item.displaySubtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ) else if (item.parentTitle != null) Text( item.parentTitle!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ) else if (item.year != null) Text( '${item.year}', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ), ], ), @@ -473,10 +470,12 @@ class _MediaCardList extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted.withValues(alpha: 0.9), - fontSize: _metadataFontSize, - fontWeight: FontWeight.w500, - ), + color: tokens( + context, + ).textMuted.withValues(alpha: 0.9), + fontSize: _metadataFontSize, + fontWeight: FontWeight.w500, + ), ), const SizedBox(height: 2), ], @@ -487,9 +486,11 @@ class _MediaCardList extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted.withValues(alpha: 0.85), - fontSize: _subtitleFontSize, - ), + color: tokens( + context, + ).textMuted.withValues(alpha: 0.85), + fontSize: _subtitleFontSize, + ), ), const SizedBox(height: 4), ], @@ -500,10 +501,12 @@ class _MediaCardList extends StatelessWidget { maxLines: _summaryMaxLines, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted.withValues(alpha: 0.7), - fontSize: _summaryFontSize, - height: 1.3, - ), + color: tokens( + context, + ).textMuted.withValues(alpha: 0.7), + fontSize: _summaryFontSize, + height: 1.3, + ), ), ], ], diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index f3dcddde..a186469d 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -102,7 +102,11 @@ class _MediaContextMenuState extends State { if ((itemType == 'episode' || itemType == 'season') && widget.metadata.grandparentTitle != null) { menuActions.add( - _MenuAction(value: 'series', icon: Icons.tv, label: t.mediaMenu.goToSeries), + _MenuAction( + value: 'series', + icon: Icons.tv, + label: t.mediaMenu.goToSeries, + ), ); } @@ -262,7 +266,9 @@ class _MediaContextMenuState extends State { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))), + SnackBar( + content: Text(t.messages.errorLoading(error: e.toString())), + ), ); } } @@ -312,9 +318,9 @@ class _MediaContextMenuState extends State { } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))), + ); } } } @@ -397,14 +403,15 @@ class _MediaContextMenuState extends State { } if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.messages.errorLoadingFileInfo(error: e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.messages.errorLoadingFileInfo(error: e.toString())), + ), + ); } } } - @override Widget build(BuildContext context) { return GestureDetector( diff --git a/lib/widgets/pin_entry_dialog.dart b/lib/widgets/pin_entry_dialog.dart index aa4af9e5..c66e9167 100644 --- a/lib/widgets/pin_entry_dialog.dart +++ b/lib/widgets/pin_entry_dialog.dart @@ -125,7 +125,9 @@ class _PinEntryDialogState extends State _obscureText = !_obscureText; }); }, - tooltip: _obscureText ? t.pinEntry.showPin : t.pinEntry.hidePin, + tooltip: _obscureText + ? t.pinEntry.showPin + : t.pinEntry.hidePin, ), ), onSubmitted: (_) => _submit(), diff --git a/lib/widgets/sort_bottom_sheet.dart b/lib/widgets/sort_bottom_sheet.dart index 61f0c964..6d5908fd 100644 --- a/lib/widgets/sort_bottom_sheet.dart +++ b/lib/widgets/sort_bottom_sheet.dart @@ -95,10 +95,7 @@ class _SortBottomSheetState extends State { groupValue: _currentSort, onChanged: (PlexSort? value) { if (value != null) { - _handleSortChange( - value, - value.defaultDirection == 'desc', - ); + _handleSortChange(value, value.defaultDirection == 'desc'); } }, child: ListView.builder( @@ -124,7 +121,10 @@ class _SortBottomSheetState extends State { ), ButtonSegment( value: true, - icon: Icon(Icons.arrow_downward, size: 16), + icon: Icon( + Icons.arrow_downward, + size: 16, + ), ), ], selected: {_currentDescending}, @@ -135,12 +135,12 @@ class _SortBottomSheetState extends State { ], ) : null, - leading: Radio( - value: sort, - toggleable: false, - ), + leading: Radio(value: sort, toggleable: false), onTap: () { - _handleSortChange(sort, sort.defaultDirection == 'desc'); + _handleSortChange( + sort, + sort.defaultDirection == 'desc', + ); }, ); }, diff --git a/lib/widgets/video_controls/sheets/audio_track_sheet.dart b/lib/widgets/video_controls/sheets/audio_track_sheet.dart index ef372e37..ba7cd991 100644 --- a/lib/widgets/video_controls/sheets/audio_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_track_sheet.dart @@ -6,10 +6,7 @@ import '../../../i18n/strings.g.dart'; class AudioTrackSheet extends StatelessWidget { final Player player; - const AudioTrackSheet({ - super.key, - required this.player, - }); + const AudioTrackSheet({super.key, required this.player}); static BoxConstraints getBottomSheetConstraints(BuildContext context) { final size = MediaQuery.of(context).size; @@ -123,15 +120,13 @@ class AudioTrackSheet extends StatelessWidget { title: Text( label, style: TextStyle( - color: - isSelected ? Colors.blue : Colors.white, + color: isSelected + ? Colors.blue + : Colors.white, ), ), trailing: isSelected - ? const Icon( - Icons.check, - color: Colors.blue, - ) + ? const Icon(Icons.check, color: Colors.blue) : null, onTap: () { player.setAudioTrack(audioTrack); diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 706ab4ba..f77ea04a 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -73,7 +73,8 @@ class ChapterSheet extends StatelessWidget { for (int i = 0; i < chapters.length; i++) { final chapter = chapters[i]; final startMs = chapter.startTimeOffset ?? 0; - final endMs = chapter.endTimeOffset ?? + final endMs = + chapter.endTimeOffset ?? (i < chapters.length - 1 ? chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); @@ -142,41 +143,43 @@ class ChapterSheet extends StatelessWidget { child: Consumer( builder: (context, clientProvider, child) { - final client = clientProvider.client; - if (client == null) { - return const Icon( - Icons.image, - color: Colors.white54, - size: 34, - ); - } - return Image.network( - client.getThumbnailUrl( - chapter.thumb, - ), - width: 60, - height: 34, - fit: BoxFit.cover, - errorBuilder: ( - context, - error, - stackTrace, - ) => - const Icon( - Icons.image, - color: Colors.white54, - size: 34, - ), - ); - }, + final client = + clientProvider.client; + if (client == null) { + return const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ); + } + return Image.network( + client.getThumbnailUrl( + chapter.thumb, + ), + width: 60, + height: 34, + fit: BoxFit.cover, + errorBuilder: + ( + context, + error, + stackTrace, + ) => const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ), + ); + }, ), ), if (isCurrentChapter) Positioned.fill( child: Container( decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(4), + borderRadius: BorderRadius.circular( + 4, + ), border: Border.all( color: Colors.blue, width: 2, @@ -190,8 +193,9 @@ class ChapterSheet extends StatelessWidget { title: Text( chapter.label, style: TextStyle( - color: - isCurrentChapter ? Colors.blue : Colors.white, + color: isCurrentChapter + ? Colors.blue + : Colors.white, fontWeight: isCurrentChapter ? FontWeight.bold : FontWeight.normal, diff --git a/lib/widgets/video_controls/sheets/playback_speed_sheet.dart b/lib/widgets/video_controls/sheets/playback_speed_sheet.dart index c82204aa..ea96e225 100644 --- a/lib/widgets/video_controls/sheets/playback_speed_sheet.dart +++ b/lib/widgets/video_controls/sheets/playback_speed_sheet.dart @@ -5,10 +5,7 @@ import 'package:media_kit/media_kit.dart'; class PlaybackSpeedSheet extends StatelessWidget { final Player player; - const PlaybackSpeedSheet({ - super.key, - required this.player, - }); + const PlaybackSpeedSheet({super.key, required this.player}); static BoxConstraints getBottomSheetConstraints(BuildContext context) { final size = MediaQuery.of(context).size; diff --git a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart index d8564c26..b2786ba3 100644 --- a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart +++ b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart @@ -37,10 +37,8 @@ class SleepTimerSheet extends StatelessWidget { backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: getBottomSheetConstraints(context), - builder: (context) => SleepTimerSheet( - player: player, - defaultDuration: defaultDuration, - ), + builder: (context) => + SleepTimerSheet(player: player, defaultDuration: defaultDuration), ); } @@ -86,7 +84,9 @@ class SleepTimerSheet extends StatelessWidget { sleepTimer.isActive ? Icons.bedtime : Icons.bedtime_outlined, - color: sleepTimer.isActive ? Colors.amber : Colors.white, + color: sleepTimer.isActive + ? Colors.amber + : Colors.white, ), const SizedBox(width: 12), const Text( @@ -136,12 +136,15 @@ class SleepTimerSheet extends StatelessWidget { children: [ OutlinedButton.icon( icon: const Icon(Icons.add), - label: Text(t.videoControls.addTime(amount: "15", unit: " min")), + label: Text( + t.videoControls.addTime( + amount: "15", + unit: " min", + ), + ), style: OutlinedButton.styleFrom( foregroundColor: Colors.white, - side: const BorderSide( - color: Colors.white54, - ), + side: const BorderSide(color: Colors.white54), ), onPressed: () { sleepTimer.extendTimer( @@ -180,10 +183,7 @@ class SleepTimerSheet extends StatelessWidget { : '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; return ListTile( - leading: const Icon( - Icons.timer, - color: Colors.white70, - ), + leading: const Icon(Icons.timer, color: Colors.white70), title: Text( label, style: const TextStyle( @@ -192,31 +192,30 @@ class SleepTimerSheet extends StatelessWidget { ), ), onTap: () { - sleepTimer.startTimer( - Duration(minutes: minutes), - () { - // Pause playback when timer completes - player.pause(); + sleepTimer.startTimer(Duration(minutes: minutes), () { + // Pause playback when timer completes + player.pause(); - // Show a snackbar notification - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Sleep timer completed - playback paused', - ), - duration: Duration(seconds: 3), + // Show a snackbar notification + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Sleep timer completed - playback paused', ), - ); - } - }, - ); + duration: Duration(seconds: 3), + ), + ); + } + }); Navigator.pop(context); // Show confirmation snackbar ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.sleepTimerSet(label: 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 e03e9fa0..ab3858b7 100644 --- a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart @@ -6,10 +6,7 @@ import '../../../i18n/strings.g.dart'; class SubtitleTrackSheet extends StatelessWidget { final Player player; - const SubtitleTrackSheet({ - super.key, - required this.player, - }); + const SubtitleTrackSheet({super.key, required this.player}); static BoxConstraints getBottomSheetConstraints(BuildContext context) { final size = MediaQuery.of(context).size; @@ -115,9 +112,7 @@ class SubtitleTrackSheet extends StatelessWidget { ) : null, onTap: () { - player.setSubtitleTrack( - SubtitleTrack.no(), - ); + player.setSubtitleTrack(SubtitleTrack.no()); Navigator.pop(context); }, ); @@ -162,15 +157,13 @@ class SubtitleTrackSheet extends StatelessWidget { title: Text( label, style: TextStyle( - color: - isSelected ? Colors.blue : Colors.white, + color: isSelected + ? Colors.blue + : Colors.white, ), ), trailing: isSelected - ? const Icon( - Icons.check, - color: Colors.blue, - ) + ? const Icon(Icons.check, color: Colors.blue) : null, onTap: () { player.setSubtitleTrack(subtitle); diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 67d322ca..fb78e421 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -321,7 +321,8 @@ class _VideoSettingsSheetState extends State { stream: widget.player.stream.audioDevice, initialData: widget.player.state.audioDevice, builder: (context, snapshot) { - final currentDevice = snapshot.data ?? widget.player.state.audioDevice; + final currentDevice = + snapshot.data ?? widget.player.state.audioDevice; final deviceLabel = currentDevice.description.isEmpty ? currentDevice.name : currentDevice.description; @@ -338,7 +339,10 @@ class _VideoSettingsSheetState extends State { Flexible( child: Text( deviceLabel, - style: const TextStyle(color: Colors.white70, fontSize: 14), + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), overflow: TextOverflow.ellipsis, ), ), @@ -432,7 +436,9 @@ class _VideoSettingsSheetState extends State { children: [ OutlinedButton.icon( icon: const Icon(Icons.add), - label: Text(t.videoControls.addTime(amount: "15", unit: " min")), + label: Text( + t.videoControls.addTime(amount: "15", unit: " min"), + ), style: OutlinedButton.styleFrom( foregroundColor: Colors.white, side: const BorderSide(color: Colors.white54), @@ -552,7 +558,8 @@ class _VideoSettingsSheetState extends State { stream: widget.player.stream.audioDevice, initialData: widget.player.state.audioDevice, builder: (context, selectedSnapshot) { - final currentDevice = selectedSnapshot.data ?? widget.player.state.audioDevice; + final currentDevice = + selectedSnapshot.data ?? widget.player.state.audioDevice; return ListView.builder( itemCount: devices.length, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 279055b9..c3bc93b4 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1602,9 +1602,9 @@ class _PlexVideoControlsState extends State } } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString())))); + ScaffoldMessenger.of(context).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 58d68f74..16a8a7ea 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -103,7 +103,10 @@ class _SyncOffsetControlState extends State { // Slider Row( children: [ - Text(t.videoControls.minusTime(amount: "2", unit: "s"), style: const TextStyle(color: Colors.white70)), + Text( + t.videoControls.minusTime(amount: "2", unit: "s"), + style: const TextStyle(color: Colors.white70), + ), Expanded( child: Slider( value: _currentOffset, @@ -122,7 +125,10 @@ class _SyncOffsetControlState extends State { }, ), ), - Text(t.videoControls.addTime(amount: "2", unit: "s"), style: const TextStyle(color: Colors.white70)), + Text( + t.videoControls.addTime(amount: "2", unit: "s"), + style: const TextStyle(color: Colors.white70), + ), ], ), const SizedBox(height: 24), From aeeacfc84bb10bb5f0797da0b5f73118260b2e37 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Nov 2025 20:04:47 +0000 Subject: [PATCH 02/25] Add comprehensive CI sanity checks and documentation Co-authored-by: Doezer <11655673+Doezer@users.noreply.github.com> --- .github/workflows/ci.yml | 115 +++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 20 ++++++- README.md | 17 ++++++ analysis_options.yaml | 5 ++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..68da105c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,115 @@ +name: CI - Sanity Checks + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + analyze: + name: Code Analysis + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pub- + + - name: Install dependencies + run: flutter pub get + + - name: Verify formatting + run: | + # Find all Dart files excluding generated files + FILES=$(find lib test -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null || true) + if [ -z "$FILES" ]; then + echo "No Dart files found to format" + exit 0 + fi + echo "$FILES" | xargs dart format --output=none --set-exit-if-changed + + - name: Analyze code + run: flutter analyze + + test: + name: Unit Tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pub- + + - name: Install dependencies + run: flutter pub get + + - name: Run tests + run: | + if [ -d "test" ] && [ "$(find test -name '*_test.dart' | wc -l)" -gt 0 ]; then + flutter test + else + echo "No tests found, skipping test execution" + fi + + dependency-check: + name: Dependency Validation + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pub- + + - name: Verify dependencies + run: | + flutter pub get + flutter pub outdated diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1742ed9d..5f452b28 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,9 +10,27 @@ ## Development - Follow Dart/Flutter conventions -- Run `flutter analyze` before submitting +- Run `dart format .` to format your code +- Run `flutter analyze` before submitting to check for issues +- Run `flutter test` if tests are available - Test your changes thoroughly +### Code Quality Checks + +The project includes automated CI checks that run on all pull requests: + +1. **Code Formatting**: Ensures code follows Dart formatting standards + - Run locally: `dart format .` + - CI check excludes generated files (`.g.dart`, `.freezed.dart`) + +2. **Static Analysis**: Checks for code issues and potential bugs + - Run locally: `flutter analyze` + +3. **Tests**: Runs unit and widget tests (when available) + - Run locally: `flutter test` + +All these checks must pass before your changes can be merged. + ## Internationalization (i18n) This project uses `slang` for internationalization with JSON files. diff --git a/README.md b/README.md index e2092494..a9fff3c1 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,23 @@ flutter run ## Development +### Code Quality + +Before submitting changes, ensure your code passes all quality checks: + +```bash +# Format code +dart format . + +# Analyze code for issues +flutter analyze + +# Run tests (if available) +flutter test +``` + +These checks are automatically run in CI for all pull requests. + ### Code Generation The project uses code generation for JSON serialization. After modifying model classes, run: diff --git a/analysis_options.yaml b/analysis_options.yaml index f9b30346..2e3c70e5 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,6 @@ include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" From fd64f26c5e3205af0cadf7fb8bf7573e15944b4a Mon Sep 17 00:00:00 2001 From: Doezer Date: Thu, 13 Nov 2025 23:04:06 +0100 Subject: [PATCH 03/25] Update .github/workflows/ci.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68da105c..9b9a27ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: - name: Verify formatting run: | # Find all Dart files excluding generated files - FILES=$(find lib test -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null || true) + FILES=$(find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null || true) if [ -z "$FILES" ]; then echo "No Dart files found to format" exit 0 From ce5e5d7f94ecd421db72a215dbf21ae15bbeaa59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Nov 2025 22:07:01 +0000 Subject: [PATCH 04/25] Clarify documentation: explain generated file exclusions Co-authored-by: Doezer <11655673+Doezer@users.noreply.github.com> --- CONTRIBUTING.md | 8 +++++--- README.md | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f452b28..9ce9e5e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ ## Development - Follow Dart/Flutter conventions -- Run `dart format .` to format your code +- Run `dart format .` to format your code (note: generated files like `*.g.dart` are excluded from CI checks) - Run `flutter analyze` before submitting to check for issues - Run `flutter test` if tests are available - Test your changes thoroughly @@ -20,11 +20,13 @@ The project includes automated CI checks that run on all pull requests: 1. **Code Formatting**: Ensures code follows Dart formatting standards - - Run locally: `dart format .` - - CI check excludes generated files (`.g.dart`, `.freezed.dart`) + - Run locally: `dart format .` to format all files + - Note: CI only checks non-generated files (excludes `.g.dart`, `.freezed.dart`) + - Generated files are reformatted automatically by build tools 2. **Static Analysis**: Checks for code issues and potential bugs - Run locally: `flutter analyze` + - Note: CI excludes generated files from analysis (configured in `analysis_options.yaml`) 3. **Tests**: Runs unit and widget tests (when available) - Run locally: `flutter test` diff --git a/README.md b/README.md index a9fff3c1..b74aed11 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ flutter run Before submitting changes, ensure your code passes all quality checks: ```bash -# Format code +# Format code (including generated files) dart format . # Analyze code for issues @@ -92,6 +92,8 @@ flutter analyze flutter test ``` +**Note**: CI checks exclude generated files (`.g.dart`, `.freezed.dart`) from formatting and analysis checks. You can run `dart format .` locally to format everything, but only your hand-written code will be validated in CI. + These checks are automatically run in CI for all pull requests. ### Code Generation From 6d3b68652468b590958dd3f05b89511229003ce2 Mon Sep 17 00:00:00 2001 From: Doezer Date: Fri, 14 Nov 2025 07:37:19 +0100 Subject: [PATCH 05/25] Added clean before pub get + applied suggestion --- .github/workflows/ci.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b9a27ea..f8a8b877 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,18 +35,22 @@ jobs: ${{ runner.os }}-pub- - name: Install dependencies - run: flutter pub get + run: | + flutter clean + flutter pub get - name: Verify formatting run: | # Find all Dart files excluding generated files - FILES=$(find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null || true) - if [ -z "$FILES" ]; then + find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null | while IFS= read -r file; do + files_found=true + break + done + if [ "$files_found" != "true" ]; then echo "No Dart files found to format" exit 0 fi - echo "$FILES" | xargs dart format --output=none --set-exit-if-changed - + find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null -print0 | xargs -0 dart format --output=none --set-exit-if-changed - name: Analyze code run: flutter analyze @@ -75,7 +79,9 @@ jobs: ${{ runner.os }}-pub- - name: Install dependencies - run: flutter pub get + run: | + flutter clean + flutter pub get - name: Run tests run: | @@ -111,5 +117,6 @@ jobs: - name: Verify dependencies run: | + flutter clean flutter pub get flutter pub outdated From d37195e01e35e9d2cedf5cbacb333127b7310b29 Mon Sep 17 00:00:00 2001 From: Jovan Date: Fri, 14 Nov 2025 23:27:21 +0800 Subject: [PATCH 06/25] add zh i18n --- lib/i18n/strings_zh.i18n..json | 370 +++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 lib/i18n/strings_zh.i18n..json diff --git a/lib/i18n/strings_zh.i18n..json b/lib/i18n/strings_zh.i18n..json new file mode 100644 index 00000000..e19091a2 --- /dev/null +++ b/lib/i18n/strings_zh.i18n..json @@ -0,0 +1,370 @@ +{ + "app": { + "title": "Plezy", + "loading": "加载中..." + }, + "auth": { + "signInWithPlex": "使用 Plex 登录", + "showQRCode": "显示二维码", + "cancel": "取消", + "authenticate": "验证", + "retry": "重试", + "debugEnterToken": "调试:输入 Plex Token", + "plexTokenLabel": "Plex 授权令牌 (Auth Token)", + "plexTokenHint": "输入你的 Plex.tv 令牌", + "authenticationTimeout": "验证超时。请重试。", + "scanQRCodeInstruction": "请使用已登录 Plex 的设备扫描此二维码进行验证。", + "waitingForAuth": "等待验证中...\n请在你的浏览器中完成登录。" + }, + "common": { + "cancel": "取消", + "save": "保存", + "close": "关闭", + "clear": "清除", + "reset": "重置", + "later": "稍后", + "submit": "提交", + "confirm": "确认", + "retry": "重试", + "playNow": "立即播放", + "logout": "登出", + "online": "在线", + "offline": "离线", + "owned": "拥有", + "shared": "共享", + "current": "当前", + "unknown": "未知", + "refresh": "刷新", + "yes": "是", + "no": "否", + "server": "服务器" + }, + "screens": { + "licenses": "许可证", + "selectServer": "选择服务器", + "switchProfile": "切换配置文件", + "subtitleStyling": "字幕样式", + "search": "搜索", + "logs": "日志" + }, + "update": { + "available": "有可用更新", + "versionAvailable": "版本 ${version} 已发布", + "currentVersion": "当前版本: ${version}", + "skipVersion": "跳过此版本", + "viewRelease": "查看发布详情", + "latestVersion": "已经使用的是最新版本", + "checkFailed": "无法检查更新" + }, + "settings": { + "title": "设置", + "language": "语言", + "theme": "主题", + "appearance": "外观", + "videoPlayback": "视频播放", + "shufflePlay": "随机播放", + "advanced": "高级", + "useSeasonPostersDescription": "为剧集显示季海报而非剧集海报", + "showHeroSectionDescription": "在主屏幕上显示精选内容轮播区", + "secondsLabel": "秒", + "minutesLabel": "分钟", + "secondsShort": "秒", + "minutesShort": "分", + "durationHint": "输入时长 (${min}-${max})", + "systemTheme": "系统", + "systemThemeDescription": "跟随系统设置", + "lightTheme": "浅色", + "darkTheme": "深色", + "libraryDensity": "媒体库密度", + "compact": "紧凑", + "compactDescription": "卡片更小,显示更多项目", + "normal": "标准", + "normalDescription": "默认尺寸", + "comfortable": "舒适", + "comfortableDescription": "卡片更大,显示更少项目", + "viewMode": "视图模式", + "gridView": "网格视图", + "gridViewDescription": "以网格布局显示项目", + "listView": "列表视图", + "listViewDescription": "以列表布局显示项目", + "useSeasonPosters": "使用季海报", + "showHeroSection": "显示主要精选区", + "hardwareDecoding": "硬件解码", + "hardwareDecodingDescription": "如果可用,使用硬件加速", + "bufferSize": "缓冲区大小", + "bufferSizeMB": "${size}MB", + "subtitleStyling": "字幕样式", + "subtitleStylingDescription": "调整字幕外观", + "smallSkipDuration": "短跳过时长", + "largeSkipDuration": "长跳过时长", + "secondsUnit": "${seconds} 秒", + "defaultSleepTimer": "默认睡眠定时器", + "minutesUnit": "在 ${minutes} 分钟", + "unwatchedOnly": "仅未观看", + "unwatchedOnlyDescription": "随机队列中仅包含未观看的剧集", + "shuffleOrderNavigation": "随机顺序导航", + "shuffleOrderNavigationDescription": "下一集/上一集按钮遵循随机播放顺序", + "loopShuffleQueue": "循环随机队列", + "loopShuffleQueueDescription": "在队列结束时重新开始", + "videoPlayerControls": "视频播放器控制", + "keyboardShortcuts": "键盘快捷键", + "keyboardShortcutsDescription": "自定义键盘快捷键", + "debugLogging": "调试日志", + "debugLoggingDescription": "启用详细日志记录以便故障排除", + "viewLogs": "查看日志", + "viewLogsDescription": "查看应用程序日志", + "clearCache": "清除缓存", + "clearCacheDescription": "这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。", + "clearCacheSuccess": "缓存清除成功", + "resetSettings": "重置设置", + "resetSettingsDescription": "这会将所有设置重置为其默认值。此操作无法撤销。", + "resetSettingsSuccess": "设置重置成功", + "shortcutsReset": "快捷键已重置为默认值", + "about": "关于", + "aboutDescription": "应用程序信息和许可证", + "updates": "更新", + "updateAvailable": "有可用更新", + "checkForUpdates": "检查更新", + "validationErrorEnterNumber": "请输入一个有效的数字", + "validationErrorDuration": "时长必须介于 ${min} 和 ${max} ${unit} 之间", + "shortcutAlreadyAssigned": "快捷键已被分配给 ${action}", + "shortcutUpdated": "快捷键已为 ${action} 更新" + }, + "search": { + "hint": "搜索电影、系列、音乐...", + "tryDifferentTerm": "尝试不同的搜索词", + "searchYourMedia": "搜索媒体", + "enterTitleActorOrKeyword": "输入标题、演员或关键词" + }, + "hotkeys": { + "setShortcutFor": "为 ${actionName} 设置快捷键", + "clearShortcut": "清除快捷键" + }, + "pinEntry": { + "enterPin": "输入 PIN", + "showPin": "显示 PIN", + "hidePin": "隐藏 PIN" + }, + "fileInfo": { + "title": "文件信息", + "video": "视频", + "audio": "音频", + "file": "文件", + "advanced": "高级", + "codec": "编解码器", + "resolution": "分辨率", + "bitrate": "比特率", + "frameRate": "帧率", + "aspectRatio": "宽高比", + "profile": "配置文件", + "bitDepth": "位深度", + "colorSpace": "色彩空间", + "colorRange": "色彩范围", + "colorPrimaries": "颜色原色", + "chromaSubsampling": "色度子采样", + "channels": "声道", + "path": "路径", + "size": "大小", + "container": "容器", + "duration": "时长", + "optimizedForStreaming": "已优化用于流媒体", + "has64bitOffsets": "64位偏移量" + }, + "mediaMenu": { + "markAsWatched": "标记为已观看", + "markAsUnwatched": "标记为未观看", + "removeFromContinueWatching": "从继续观看中移除", + "goToSeries": "转到系列", + "goToSeason": "转到季", + "shufflePlay": "随机播放", + "fileInfo": "文件信息" + }, + "tooltips": { + "shufflePlay": "随机播放", + "markAsWatched": "标记为已观看", + "markAsUnwatched": "标记为未观看" + }, + "videoControls": { + "audioLabel": "音频", + "subtitlesLabel": "字幕", + "resetToZero": "重置为 0ms", + "addTime": "+${amount}${unit}", + "minusTime": "-${amount}${unit}", + "playsLater": "${label} 播放较晚", + "playsEarlier": "${label} 播放较早", + "noOffset": "无偏移", + "letterbox": "信箱模式(Letterbox)", + "fillScreen": "填充屏幕", + "stretch": "拉伸", + "lockRotation": "锁定旋转", + "unlockRotation": "解锁旋转" + }, + "userStatus": { + "admin": "管理员", + "restricted": "受限", + "protected": "受保护" + }, + "messages": { + "markedAsWatched": "已标记为已观看", + "markedAsUnwatched": "已标记为未观看", + "removedFromContinueWatching": "已从继续观看中移除", + "errorLoading": "错误: ${error}", + "fileInfoNotAvailable": "文件信息不可用", + "errorLoadingFileInfo": "加载文件信息时出错: ${error}", + "errorLoadingSeries": "加载系列时出错", + "errorLoadingSeason": "加载季时出错", + "musicNotSupported": "尚不支持播放音乐", + "logsCleared": "日志已清除", + "logsCopied": "日志已复制到剪贴板", + "noLogsAvailable": "没有可用日志", + "libraryScanning": "正在扫描 “${title}”...", + "libraryScanStarted": "已开始扫描 “${title}” 媒体库", + "libraryScanFailed": "无法扫描媒体库: ${error}", + "metadataRefreshing": "正在刷新 “${title}” 的元数据...", + "metadataRefreshStarted": "已开始刷新 “${title}” 的元数据", + "metadataRefreshFailed": "无法刷新元数据: ${error}", + "noPlexToken": "未找到 Plex 令牌。请重新登录。", + "logoutConfirm": "你确定要登出吗?", + "noSeasonsFound": "未找到季", + "noEpisodesFound": "在第一季中未找到剧集", + "noEpisodesFoundGeneral": "未找到剧集", + "noResultsFound": "未找到结果", + "sleepTimerSet": "睡眠定时器已设置为 ${label}", + "failedToSwitchProfile": "无法切换到 ${displayName}" + }, + "profile": { + "noUsersAvailable": "没有可用用户" + }, + "subtitlingStyling": { + "stylingOptions": "样式选项", + "fontSize": "字号", + "textColor": "文本颜色", + "borderSize": "边框大小", + "borderColor": "边框颜色", + "backgroundOpacity": "背景不透明度", + "backgroundColor": "背景颜色" + }, + "dialog": { + "confirmAction": "确认操作", + "areYouSure": "确定要执行此操作吗?", + "cancel": "取消", + "playNow": "立即播放" + }, + "discover": { + "title": "发现", + "switchProfile": "切换配置文件", + "switchServer": "切换服务器", + "logout": "登出", + "noContentAvailable": "没有可用内容", + "addMediaToLibraries": "请向的媒体库添加一些媒体", + "continueWatching": "继续观看", + "recentlyAdded": "最近添加", + "play": "播放", + "resume": "继续", + "playEpisode": "播放 S${season}, E${episode}", + "resumeEpisode": "继续 S${season}, E${episode}", + "pause": "暂停", + "overview": "概述", + "cast": "演员表", + "episodeCount": "${count} 集", + "watchedProgress": "已观看 ${watched}/${total} 集", + "movie": "电影", + "tvShow": "电视剧", + "minutesLeft": "剩余 ${minutes} 分钟" + }, + "errors": { + "searchFailed": "搜索失败: ${error}", + "connectionTimeout": "加载 ${context} 时连接超时", + "connectionFailed": "无法连接到 Plex 服务器", + "failedToLoad": "无法加载 ${context}: ${error}", + "noClientAvailable": "没有可用客户端", + "authenticationFailed": "验证失败: ${error}", + "couldNotLaunchUrl": "无法打开授权 URL", + "pleaseEnterToken": "请输入一个令牌", + "invalidToken": "令牌无效", + "failedToVerifyToken": "无法验证令牌: ${error}", + "failedToSwitchProfile": "无法切换到 ${displayName}", + "connectionFailedGeneric": "连接失败" + }, + "libraries": { + "title": "媒体库", + "scanLibraryFiles": "扫描媒体库文件", + "scanLibrary": "扫描媒体库", + "analyze": "分析", + "analyzeLibrary": "分析媒体库", + "refreshMetadata": "刷新元数据", + "emptyTrash": "清空回收站", + "emptyingTrash": "正在清空 “${title}” 的回收站...", + "trashEmptied": "已清空 “${title}” 的回收站", + "failedToEmptyTrash": "无法清空回收站: ${error}", + "analyzing": "正在分析 “${title}”...", + "analysisStarted": "已开始分析 “${title}”", + "failedToAnalyze": "无法分析媒体库: ${error}", + "noLibrariesFound": "未找到媒体库", + "thisLibraryIsEmpty": "此媒体库为空", + "all": "全部", + "clearAll": "全部清除", + "scanLibraryConfirm": "确定要扫描 “${title}” 吗?", + "analyzeLibraryConfirm": "确定要分析 “${title}” 吗?", + "refreshMetadataConfirm": "确定要刷新 “${title}” 的元数据吗?", + "emptyTrashConfirm": "确定要清空 “${title}” 的回收站吗?", + "manageLibraries": "管理媒体库", + "sort": "排序", + "sortBy": "排序依据", + "filters": "筛选器", + "loadingLibraryWithCount": "正在加载媒体库... (已加载 ${count} 个项目)", + "confirmActionMessage": "确定要执行此操作吗?", + "showLibrary": "显示媒体库", + "hideLibrary": "隐藏媒体库", + "libraryOptions": "媒体库选项" + }, + "about": { + "title": "关于", + "openSourceLicenses": "开源许可证", + "versionLabel": "版本 ${version}", + "appDescription": "一款精美的 Flutter Plex 客户端", + "viewLicensesDescription": "查看第三方库的许可证" + }, + "serverSelection": { + "connectingToServer": "正在连接服务器...", + "serverDebugCopied": "服务器调试数据已复制到剪贴板", + "copyDebugData": "复制调试数据", + "noServersFound": "未找到服务器", + "malformedServerData": "找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。", + "incompleteServerInfo": "某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。", + "incompleteConnectionInfo": "服务器连接信息不完整。请重试。", + "malformedServerInfo": "服务器信息格式错误: ${message}", + "networkConnectionFailed": "网络连接失败。请检查你的网络连接并重试。", + "authenticationFailed": "验证失败。请重新登录。", + "plexServiceUnavailable": "Plex 服务不可用。请稍后重试。", + "failedToLoadServers": "无法加载服务器: ${error}" + }, + "hubDetail": { + "title": "标题", + "releaseYear": "发行年份", + "dateAdded": "添加日期", + "rating": "评分", + "noItemsFound": "未找到项目" + }, + "logs": { + "title": "日志", + "clearLogs": "清除日志", + "copyLogs": "复制日志", + "exportLogs": "导出日志", + "noLogsToShow": "没有可显示的日志", + "error": "错误:", + "stackTrace": "堆栈跟踪 (Stack Trace):" + }, + "licenses": { + "relatedPackages": "相关软件包", + "license": "许可证", + "licenseNumber": "许可证 ${number}", + "licensesCount": "${count} 个许可证" + }, + "navigation": { + "home": "主页", + "search": "搜索", + "libraries": "媒体库", + "settings": "设置" + } +} From 6c41e0921484c97123b126ff90ddb243ab3a326c Mon Sep 17 00:00:00 2001 From: Jovan Date: Fri, 14 Nov 2025 23:32:17 +0800 Subject: [PATCH 07/25] i18n(zh): minor fixes --- lib/i18n/strings_zh.i18n..json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/i18n/strings_zh.i18n..json b/lib/i18n/strings_zh.i18n..json index e19091a2..668d4197 100644 --- a/lib/i18n/strings_zh.i18n..json +++ b/lib/i18n/strings_zh.i18n..json @@ -53,7 +53,7 @@ "currentVersion": "当前版本: ${version}", "skipVersion": "跳过此版本", "viewRelease": "查看发布详情", - "latestVersion": "已经使用的是最新版本", + "latestVersion": "已安装的版本是可用的最新版本", "checkFailed": "无法检查更新" }, "settings": { From 0da4b11f17b83bcf0bca96670812d78ab04be02e Mon Sep 17 00:00:00 2001 From: Jovan Date: Fri, 14 Nov 2025 23:34:42 +0800 Subject: [PATCH 08/25] rename file --- lib/i18n/{strings_zh.i18n..json => strings_zh.i18n.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/i18n/{strings_zh.i18n..json => strings_zh.i18n.json} (100%) diff --git a/lib/i18n/strings_zh.i18n..json b/lib/i18n/strings_zh.i18n.json similarity index 100% rename from lib/i18n/strings_zh.i18n..json rename to lib/i18n/strings_zh.i18n.json From 415cb90af2877060fbddd8f5bf99b90ec6f39de8 Mon Sep 17 00:00:00 2001 From: Jovan Date: Fri, 14 Nov 2025 23:37:25 +0800 Subject: [PATCH 09/25] fix --- lib/i18n/strings_zh.i18n.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 668d4197..6fb78497 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -68,8 +68,8 @@ "showHeroSectionDescription": "在主屏幕上显示精选内容轮播区", "secondsLabel": "秒", "minutesLabel": "分钟", - "secondsShort": "秒", - "minutesShort": "分", + "secondsShort": "s", + "minutesShort": "m", "durationHint": "输入时长 (${min}-${max})", "systemTheme": "系统", "systemThemeDescription": "跟随系统设置", From 0b9998ddffb3090561abd4594857fb0bc5f5f122 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 14 Nov 2025 19:26:04 +0100 Subject: [PATCH 10/25] fix: don't crop subtitles --- lib/screens/video_player_screen.dart | 253 +++++++++++++++++++++++++-- macos/Podfile.lock | 5 +- pubspec.lock | 51 +++--- pubspec.yaml | 24 ++- 4 files changed, 291 insertions(+), 42 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 7d5a5826..2dd6e7ff 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -68,6 +68,11 @@ class VideoPlayerScreenState extends State { int _boxFitMode = 0; bool _isPinching = false; // Track if a pinch gesture is occurring + // Video cropping state for fill screen mode + Size? _playerSize; + Size? _videoSize; + Timer? _resizeDebounceTimer; + @override void initState() { super.initState(); @@ -108,6 +113,11 @@ class VideoPlayerScreenState extends State { appLogger.w('Failed to determine device type', error: e); _isPhone = false; // Default to tablet/desktop (all orientations) } + + // Update video filter when dependencies change (orientation, screen size, etc.) + WidgetsBinding.instance.addPostFrameCallback((_) { + _debouncedUpdateVideoFilter(); + }); } Future _initializePlayer() async { @@ -137,6 +147,7 @@ class VideoPlayerScreenState extends State { 'sub-border-color': settingsService.getSubtitleBorderColor(), 'sub-back-color': '#${(settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt().toRadixString(16).padLeft(2, '0').toUpperCase()}${settingsService.getSubtitleBackgroundColor().replaceFirst('#', '')}', + 'sub-ass-override': 'yes', }, ), ); @@ -316,6 +327,8 @@ class VideoPlayerScreenState extends State { setState(() { _availableVersions = playbackData.availableVersions; }); + // Update video filter once dimensions are available + _updateVideoFilter(); } // Build list of external subtitle tracks for media_kit @@ -440,6 +453,7 @@ class VideoPlayerScreenState extends State { setState(() { _boxFitMode = (_boxFitMode + 1) % 3; }); + _updateVideoFilter(); } /// Toggle between contain and cover modes only (for pinch gesture) @@ -447,6 +461,7 @@ class VideoPlayerScreenState extends State { setState(() { _boxFitMode = _boxFitMode == 0 ? 1 : 0; }); + _updateVideoFilter(); } /// Get current BoxFit based on mode @@ -463,11 +478,183 @@ class VideoPlayerScreenState extends State { } } + /// Calculate crop parameters to match what BoxFit.cover will show + Map? _calculateCropParameters() { + if (_boxFitMode != 1 || _playerSize == null || _videoSize == null) { + return null; + } + + final playerAspectRatio = _playerSize!.width / _playerSize!.height; + final videoAspectRatio = _videoSize!.width / _videoSize!.height; + + // No cropping needed if aspect ratios are very similar + if ((playerAspectRatio - videoAspectRatio).abs() < 0.01) { + return null; + } + + int cropWidth, cropHeight, cropX, cropY; + + // BoxFit.cover scales the video to fill the container, cropping the excess + // We need to crop the video to match what will actually be visible + if (videoAspectRatio > playerAspectRatio) { + // Video is wider than player - BoxFit.cover will crop horizontally + // Scale video height to match player height, then crop the width + final scale = _playerSize!.height / _videoSize!.height; + + cropHeight = _videoSize!.height.toInt(); + cropWidth = (_playerSize!.width / scale).toInt(); + cropX = ((_videoSize!.width - cropWidth) / 2).toInt(); + cropY = 0; + } else { + // Video is taller than player - BoxFit.cover will crop vertically + // Scale video width to match player width, then crop the height + final scale = _playerSize!.width / _videoSize!.width; + + cropWidth = _videoSize!.width.toInt(); + cropHeight = (_playerSize!.height / scale).toInt(); + cropX = 0; + cropY = ((_videoSize!.height - cropHeight) / 2).toInt(); + } + + // Calculate subtitle margins to prevent text subtitles from appearing in cropped areas + // MPV subtitle coordinates use a normalized system where video height = 720 + const double subCoordinateHeight = 720.0; + const double baseMarginY = + 40.0; // Base margin from bottom edge (subtitle coordinates) + const double baseMarginX = + 20.0; // Base margin from sides (subtitle coordinates) + + double subMarginX = baseMarginX; + double subMarginY = baseMarginY; + double subScale = 1.0; // Default scale + + if (videoAspectRatio > playerAspectRatio) { + // Horizontal crop - need additional horizontal margins and scaling + // Convert pixel margin to subtitle coordinate system + final subCoordinateWidth = subCoordinateHeight * videoAspectRatio; + final cropMarginX = (cropX / _videoSize!.width) * subCoordinateWidth; + + // Calculate scale factor first + subScale = cropWidth / _videoSize!.width; + + // Apply margin accounting for scaling (scaled margins are effectively larger) + subMarginX = (baseMarginX + cropMarginX) / subScale; + } else { + // Vertical crop - need additional vertical margins and scaling + // Convert pixel margin to subtitle coordinate system + final cropMarginY = (cropY / _videoSize!.height) * subCoordinateHeight; + + // Calculate scale factor first + subScale = cropHeight / _videoSize!.height; + + // Apply margin accounting for scaling (scaled margins are effectively larger) + subMarginY = (baseMarginY + cropMarginY) / subScale; + } + + return { + 'width': cropWidth, + 'height': cropHeight, + 'x': cropX, + 'y': cropY, + 'subMarginX': subMarginX.round(), + 'subMarginY': subMarginY.round(), + 'subScale': subScale, + }; + } + + /// Get video dimensions from the currently selected media version + Size? _getCurrentVideoSize() { + if (_availableVersions.isEmpty || + widget.selectedMediaIndex >= _availableVersions.length) { + return null; + } + + final currentVersion = _availableVersions[widget.selectedMediaIndex]; + if (currentVersion.width != null && currentVersion.height != null) { + return Size( + currentVersion.width!.toDouble(), + currentVersion.height!.toDouble(), + ); + } + + return null; + } + + /// Update the video filter based on current crop mode + void _updateVideoFilter() async { + if (player == null) return; + + try { + final nativePlayer = player!.platform as dynamic; + + if (_boxFitMode == 1) { + // Fill screen mode - apply crop filter + _videoSize = _getCurrentVideoSize(); + final cropParams = _calculateCropParameters(); + + if (cropParams != null) { + final cropFilter = + 'crop=${cropParams['width']}:${cropParams['height']}:${cropParams['x']}:${cropParams['y']}'; + appLogger.d( + 'Applying video filter: $cropFilter (player: $_playerSize, video: $_videoSize)', + ); + + // Apply crop filter + await nativePlayer.setProperty('vf', cropFilter); + + // Apply subtitle margins and scaling to compensate for crop zoom + final subMarginX = cropParams['subMarginX']!; + final subMarginY = cropParams['subMarginY']!; + final subScale = cropParams['subScale']!; + + appLogger.d( + 'Applying subtitle properties - margins: x=$subMarginX, y=$subMarginY, scale=$subScale', + ); + + await nativePlayer.setProperty('sub-margin-x', subMarginX.toString()); + await nativePlayer.setProperty('sub-margin-y', subMarginY.toString()); + await nativePlayer.setProperty('sub-scale', subScale.toString()); + } else { + // Clear filter but apply base margins if no cropping needed + appLogger.d( + 'Clearing video filter - aspect ratios similar, applying base margins (player: $_playerSize, video: $_videoSize)', + ); + await nativePlayer.setProperty('vf', ''); + await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin + await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin + await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale + } + } else { + // Other modes - clear video filter but apply base margins + appLogger.d( + 'Clearing video filter, applying base margins - BoxFit mode $_boxFitMode', + ); + await nativePlayer.setProperty('vf', ''); + await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin + await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin + await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale + } + } catch (e) { + appLogger.w('Failed to update video filter', error: e); + } + } + + /// Debounced version of _updateVideoFilter for resize events + void _debouncedUpdateVideoFilter() { + _resizeDebounceTimer?.cancel(); + _resizeDebounceTimer = Timer(const Duration(milliseconds: 50), () { + _updateVideoFilter(); + }); + } + @override void dispose() { // Stop progress tracking _progressTimer?.cancel(); + // Cancel debounce timer + _resizeDebounceTimer?.cancel(); + // Cancel stream subscriptions _playingSubscription?.cancel(); _completedSubscription?.cancel(); @@ -482,6 +669,19 @@ class VideoPlayerScreenState extends State { // Send final stopped state _sendProgress('stopped'); + // Clear video filter and reset subtitle margins before disposing player + try { + if (player != null) { + final nativePlayer = player!.platform as dynamic; + nativePlayer.setProperty('vf', ''); + nativePlayer.setProperty('sub-margin-x', '0'); + nativePlayer.setProperty('sub-margin-y', '0'); + nativePlayer.setProperty('sub-scale', '1.0'); + } + } catch (e) { + // Ignore errors during cleanup + } + // Restore system UI and orientation preferences (skip if navigating to another video) if (!_isReplacingWithVideo) { OrientationHelper.restoreSystemUI(); @@ -1378,19 +1578,46 @@ class VideoPlayerScreenState extends State { children: [ // Video player Center( - child: Video( - controller: controller!, - fit: _getCurrentBoxFit, - controls: (state) => plexVideoControlsBuilder( - player!, - widget.metadata, - onNext: _nextEpisode != null ? _playNext : null, - onPrevious: _previousEpisode != null ? _playPrevious : null, - availableVersions: _availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - boxFitMode: _boxFitMode, - onCycleBoxFitMode: _cycleBoxFitMode, - ), + child: LayoutBuilder( + builder: (context, constraints) { + // Update player size when layout changes + final newSize = Size( + constraints.maxWidth, + constraints.maxHeight, + ); + + // Check if size actually changed to avoid unnecessary updates + if (_playerSize == null || + (_playerSize!.width - newSize.width).abs() > 0.1 || + (_playerSize!.height - newSize.height).abs() > 0.1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _playerSize = newSize; + }); + // Use debounced update for resize events + _debouncedUpdateVideoFilter(); + } + }); + } + + return Video( + controller: controller!, + fit: _getCurrentBoxFit, + controls: (state) => plexVideoControlsBuilder( + player!, + widget.metadata, + onNext: _nextEpisode != null ? _playNext : null, + onPrevious: _previousEpisode != null + ? _playPrevious + : null, + availableVersions: _availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, + boxFitMode: _boxFitMode, + onCycleBoxFitMode: _cycleBoxFitMode, + ), + ); + }, ), ), // Play Next Dialog diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 528496e2..3035b5b1 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -10,6 +10,7 @@ PODS: - FlutterMacOS - media_kit_video (0.0.1): - FlutterMacOS + - media_kit_libs_macos_video - os_media_controls (0.0.1): - FlutterMacOS - package_info_plus (0.0.1): @@ -92,8 +93,8 @@ SPEC CHECKSUMS: HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d - media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 - media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + media_kit_libs_macos_video: 69caff44badac986515a602bae1eb7f988a29f93 + media_kit_video: 71dcfc00fc7d2e7dbe43e60b5eb505f15ab6a88e os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed package_info_plus: f0052d280d17aa382b932f399edf32507174e870 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 diff --git a/pubspec.lock b/pubspec.lock index d49b164d..328c7b93 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -540,26 +540,26 @@ packages: dependency: "direct main" description: path: media_kit - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" media_kit_libs_android_video: - dependency: transitive + dependency: "direct overridden" description: path: "libs/android/media_kit_libs_android_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.3.9" media_kit_libs_ios_video: - dependency: transitive + dependency: "direct overridden" description: path: "libs/ios/media_kit_libs_ios_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.1.4" @@ -567,17 +567,17 @@ packages: dependency: transitive description: path: "libs/linux/media_kit_libs_linux" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" media_kit_libs_macos_video: - dependency: transitive + dependency: "direct overridden" description: path: "libs/macos/media_kit_libs_macos_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.1.5" @@ -585,8 +585,8 @@ packages: dependency: "direct main" description: path: "libs/universal/media_kit_libs_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.0.7" @@ -594,27 +594,28 @@ packages: dependency: transitive description: path: "libs/windows/media_kit_libs_windows_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.0.12" media_kit_video: dependency: "direct main" description: - name: media_kit_video - sha256: "813858c3fe84eb46679eb698695f60665e2bfbef757766fac4d2e683f926e15a" - url: "https://pub.dev" - source: hosted + path: media_kit_video + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + url: "https://github.com/edde746/media-kit" + source: git version: "1.3.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1097,10 +1098,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6f952caa..74002982 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,13 +37,33 @@ dependency_overrides: media_kit: git: url: https://github.com/edde746/media-kit - ref: 9782771486c0356b48c2e31e47365d1ed0b7fcb5 + ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 path: media_kit + media_kit_video: + git: + url: https://github.com/edde746/media-kit + ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + path: media_kit_video media_kit_libs_video: git: url: https://github.com/edde746/media-kit - ref: 9782771486c0356b48c2e31e47365d1ed0b7fcb5 + ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 path: libs/universal/media_kit_libs_video + media_kit_libs_macos_video: + git: + url: https://github.com/edde746/media-kit + ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + path: libs/macos/media_kit_libs_macos_video + media_kit_libs_ios_video: + git: + url: https://github.com/edde746/media-kit + ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + path: libs/ios/media_kit_libs_ios_video + media_kit_libs_android_video: + git: + url: https://github.com/edde746/media-kit + ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + path: libs/android/media_kit_libs_android_video dev_dependencies: flutter_test: From 173ab5900777e085defe5e3e7329fba5b764a1c6 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 14 Nov 2025 22:02:49 +0100 Subject: [PATCH 11/25] refactor: remove unnecessary background mode --- ios/Podfile.lock | 3 ++- ios/Runner/Info.plist | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b352e747..70cb3503 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4,6 +4,7 @@ PODS: - Flutter - media_kit_video (0.0.1): - Flutter + - media_kit_libs_ios_video - os_media_controls (0.0.1): - Flutter - package_info_plus (0.4.5): @@ -64,7 +65,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 - media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 + media_kit_video: 6235abf1d299037d23692ad47119b89e05346b23 os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 03b3c3e7..0fc3e407 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -39,10 +39,6 @@ This app needs to connect to your Plex Media Server on your local network. UIApplicationSupportsIndirectInputEvents - UIBackgroundModes - - audio - UILaunchStoryboardName LaunchScreen UIMainStoryboardFile From a618218828e0b2fca331a81af80720a4a3ebffbd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 14 Nov 2025 22:27:12 +0100 Subject: [PATCH 12/25] feat: respect per media language preference --- lib/client/plex_client.dart | 34 +++ lib/i18n/strings.g.dart | 20 +- lib/i18n/strings.i18n.json | 2 + lib/i18n/strings_it.i18n.json | 2 + lib/i18n/strings_nl.i18n.json | 2 + lib/i18n/strings_sv.i18n.json | 2 + lib/models/plex_metadata.dart | 8 + lib/models/plex_metadata.g.dart | 4 + lib/screens/settings_screen.dart | 14 ++ lib/screens/video_player_screen.dart | 207 ++++++++++++++++-- lib/services/settings_service.dart | 14 ++ lib/services/track_selection_service.dart | 117 ++++++---- .../sheets/audio_track_sheet.dart | 13 +- .../sheets/subtitle_track_sheet.dart | 18 +- .../video_controls/video_controls.dart | 21 +- 15 files changed, 407 insertions(+), 71 deletions(-) diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index ebce3753..83a6a930 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -302,6 +302,40 @@ class PlexClient { : null; } + /// Set per-media language preferences (audio and subtitle) + /// For TV shows, use grandparentRatingKey to set preference for the entire series + /// For movies, use the movie's ratingKey + Future setMetadataPreferences( + String ratingKey, { + String? audioLanguage, + String? subtitleLanguage, + }) async { + try { + final queryParams = {}; + if (audioLanguage != null) { + queryParams['audioLanguage'] = audioLanguage; + } + if (subtitleLanguage != null) { + queryParams['subtitleLanguage'] = subtitleLanguage; + } + + // If no preferences to set, return early + if (queryParams.isEmpty) { + return true; + } + + final response = await _dio.put( + '/library/metadata/$ratingKey/prefs', + queryParameters: queryParams, + ); + + return response.statusCode == 200; + } catch (e) { + appLogger.e('Failed to set metadata preferences', error: e); + return false; + } + } + /// Search across all libraries using the hub search endpoint /// Only returns movies and shows, filtering out seasons and episodes Future> search(String query, {int limit = 10}) async { diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 6d055508..3ce44476 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1256 (314 per locale) +/// Strings: 1264 (316 per locale) /// -/// Built on 2025-11-13 at 21:35 UTC +/// Built on 2025-11-14 at 21:16 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -321,6 +321,8 @@ class _StringsSettingsEn { String secondsUnit({required Object seconds}) => '${seconds} seconds'; String get defaultSleepTimer => 'Default Sleep Timer'; String minutesUnit({required Object minutes}) => '${minutes} minutes'; + String get rememberTrackSelections => 'Remember track selections per show/movie'; + String get rememberTrackSelectionsDescription => 'Automatically save audio and subtitle language preferences when you change tracks during playback'; String get unwatchedOnly => 'Unwatched Only'; String get unwatchedOnlyDescription => 'Only include unwatched episodes in shuffle queue'; String get shuffleOrderNavigation => 'Shuffle Order Navigation'; @@ -931,6 +933,8 @@ class _StringsSettingsIt implements _StringsSettingsEn { @override String secondsUnit({required Object seconds}) => '${seconds} secondi'; @override String get defaultSleepTimer => 'Timer spegnimento predefinito'; @override String minutesUnit({required Object minutes}) => '${minutes} minuti'; + @override String get rememberTrackSelections => 'Ricorda selezioni tracce per serie/film'; + @override String get rememberTrackSelectionsDescription => 'Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione'; @override String get unwatchedOnly => 'Solo non guardati'; @override String get unwatchedOnlyDescription => 'Includi solo gli episodi non guardati nella coda di riproduzione casuale'; @override String get shuffleOrderNavigation => 'Navigazione in ordine casuale'; @@ -1541,6 +1545,8 @@ class _StringsSettingsNl implements _StringsSettingsEn { @override String secondsUnit({required Object seconds}) => '${seconds} seconden'; @override String get defaultSleepTimer => 'Standaard slaap timer'; @override String minutesUnit({required Object minutes}) => 'bij ${minutes} minuten'; + @override String get rememberTrackSelections => 'Onthoud track selecties per serie/film'; + @override String get rememberTrackSelectionsDescription => 'Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen'; @override String get unwatchedOnly => 'Alleen ongekeken'; @override String get unwatchedOnlyDescription => 'Alleen ongekeken afleveringen opnemen in willekeurige wachtrij'; @override String get shuffleOrderNavigation => 'Willekeurige volgorde navigatie'; @@ -2151,6 +2157,8 @@ class _StringsSettingsSv implements _StringsSettingsEn { @override String secondsUnit({required Object seconds}) => '${seconds} sekunder'; @override String get defaultSleepTimer => 'Standard sovtimer'; @override String minutesUnit({required Object minutes}) => '${minutes} minuter'; + @override String get rememberTrackSelections => 'Kom ihåg spårval per serie/film'; + @override String get rememberTrackSelectionsDescription => 'Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning'; @override String get unwatchedOnly => 'Endast osedda'; @override String get unwatchedOnlyDescription => 'Inkludera endast osedda avsnitt i blandningskön'; @override String get shuffleOrderNavigation => 'Blandningsordning-navigation'; @@ -2662,6 +2670,8 @@ extension on Translations { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} seconds'; case 'settings.defaultSleepTimer': return 'Default Sleep Timer'; case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} minutes'; + case 'settings.rememberTrackSelections': return 'Remember track selections per show/movie'; + case 'settings.rememberTrackSelectionsDescription': return 'Automatically save audio and subtitle language preferences when you change tracks during playback'; case 'settings.unwatchedOnly': return 'Unwatched Only'; case 'settings.unwatchedOnlyDescription': return 'Only include unwatched episodes in shuffle queue'; case 'settings.shuffleOrderNavigation': return 'Shuffle Order Navigation'; @@ -2984,6 +2994,8 @@ extension on _StringsIt { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} secondi'; case 'settings.defaultSleepTimer': return 'Timer spegnimento predefinito'; case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} minuti'; + case 'settings.rememberTrackSelections': return 'Ricorda selezioni tracce per serie/film'; + case 'settings.rememberTrackSelectionsDescription': return 'Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione'; case 'settings.unwatchedOnly': return 'Solo non guardati'; case 'settings.unwatchedOnlyDescription': return 'Includi solo gli episodi non guardati nella coda di riproduzione casuale'; case 'settings.shuffleOrderNavigation': return 'Navigazione in ordine casuale'; @@ -3306,6 +3318,8 @@ extension on _StringsNl { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} seconden'; case 'settings.defaultSleepTimer': return 'Standaard slaap timer'; case 'settings.minutesUnit': return ({required Object minutes}) => 'bij ${minutes} minuten'; + case 'settings.rememberTrackSelections': return 'Onthoud track selecties per serie/film'; + case 'settings.rememberTrackSelectionsDescription': return 'Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen'; case 'settings.unwatchedOnly': return 'Alleen ongekeken'; case 'settings.unwatchedOnlyDescription': return 'Alleen ongekeken afleveringen opnemen in willekeurige wachtrij'; case 'settings.shuffleOrderNavigation': return 'Willekeurige volgorde navigatie'; @@ -3628,6 +3642,8 @@ extension on _StringsSv { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} sekunder'; case 'settings.defaultSleepTimer': return 'Standard sovtimer'; case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} minuter'; + case 'settings.rememberTrackSelections': return 'Kom ihåg spårval per serie/film'; + case 'settings.rememberTrackSelectionsDescription': return 'Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning'; case 'settings.unwatchedOnly': return 'Endast osedda'; case 'settings.unwatchedOnlyDescription': return 'Inkludera endast osedda avsnitt i blandningskön'; case 'settings.shuffleOrderNavigation': return 'Blandningsordning-navigation'; diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 9a5182fa..2d250cbd 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} seconds", "defaultSleepTimer": "Default Sleep Timer", "minutesUnit": "${minutes} minutes", + "rememberTrackSelections": "Remember track selections per show/movie", + "rememberTrackSelectionsDescription": "Automatically save audio and subtitle language preferences when you change tracks during playback", "unwatchedOnly": "Unwatched Only", "unwatchedOnlyDescription": "Only include unwatched episodes in shuffle queue", "shuffleOrderNavigation": "Shuffle Order Navigation", diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index efe57079..b206b80d 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} secondi", "defaultSleepTimer": "Timer spegnimento predefinito", "minutesUnit": "${minutes} minuti", + "rememberTrackSelections": "Ricorda selezioni tracce per serie/film", + "rememberTrackSelectionsDescription": "Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione", "unwatchedOnly": "Solo non guardati", "unwatchedOnlyDescription": "Includi solo gli episodi non guardati nella coda di riproduzione casuale", "shuffleOrderNavigation": "Navigazione in ordine casuale", diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index 68f2084a..ec1e906f 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} seconden", "defaultSleepTimer": "Standaard slaap timer", "minutesUnit": "bij ${minutes} minuten", + "rememberTrackSelections": "Onthoud track selecties per serie/film", + "rememberTrackSelectionsDescription": "Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen", "unwatchedOnly": "Alleen ongekeken", "unwatchedOnlyDescription": "Alleen ongekeken afleveringen opnemen in willekeurige wachtrij", "shuffleOrderNavigation": "Willekeurige volgorde navigatie", diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index c81bc3fc..e118824d 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} sekunder", "defaultSleepTimer": "Standard sovtimer", "minutesUnit": "${minutes} minuter", + "rememberTrackSelections": "Kom ihåg spårval per serie/film", + "rememberTrackSelectionsDescription": "Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning", "unwatchedOnly": "Endast osedda", "unwatchedOnlyDescription": "Inkludera endast osedda avsnitt i blandningskön", "shuffleOrderNavigation": "Blandningsordning-navigation", diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 17f360f0..4a667cf8 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -38,6 +38,8 @@ class PlexMetadata { final int? viewedLeafCount; // Number of watched episodes in a series/season @JsonKey(name: 'Role') final List? role; // Cast members + final String? audioLanguage; // Per-media preferred audio language + final String? subtitleLanguage; // Per-media preferred subtitle language // Transient field for clear logo (extracted from Image array) String? _clearLogo; @@ -75,6 +77,8 @@ class PlexMetadata { this.leafCount, this.viewedLeafCount, this.role, + this.audioLanguage, + this.subtitleLanguage, }); /// Create a copy of this metadata with optional field overrides @@ -110,6 +114,8 @@ class PlexMetadata { int? leafCount, int? viewedLeafCount, List? role, + String? audioLanguage, + String? subtitleLanguage, }) { final copy = PlexMetadata( ratingKey: ratingKey ?? this.ratingKey, @@ -143,6 +149,8 @@ class PlexMetadata { leafCount: leafCount ?? this.leafCount, viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, role: role ?? this.role, + audioLanguage: audioLanguage ?? this.audioLanguage, + subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, ); // Preserve clearLogo copy._clearLogo = _clearLogo; diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index 5cf19850..9d6de3a1 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -40,6 +40,8 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( role: (json['Role'] as List?) ?.map((e) => PlexRole.fromJson(e as Map)) .toList(), + audioLanguage: json['audioLanguage'] as String?, + subtitleLanguage: json['subtitleLanguage'] as String?, ); Map _$PlexMetadataToJson(PlexMetadata instance) => @@ -75,4 +77,6 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 'leafCount': instance.leafCount, 'viewedLeafCount': instance.viewedLeafCount, 'Role': instance.role, + 'audioLanguage': instance.audioLanguage, + 'subtitleLanguage': instance.subtitleLanguage, }; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 20b08546..9a909197 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -33,6 +33,7 @@ class _SettingsScreenState extends State { int _seekTimeSmall = 10; int _seekTimeLarge = 30; int _sleepTimerDuration = 30; + bool _rememberTrackSelections = true; // Update checking state bool _isCheckingForUpdate = false; @@ -55,6 +56,7 @@ class _SettingsScreenState extends State { _seekTimeSmall = _settingsService.getSeekTimeSmall(); _seekTimeLarge = _settingsService.getSeekTimeLarge(); _sleepTimerDuration = _settingsService.getSleepTimerDuration(); + _rememberTrackSelections = _settingsService.getRememberTrackSelections(); _isLoading = false; }); } @@ -264,6 +266,18 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _showSleepTimerDurationDialog(), ), + SwitchListTile( + secondary: const Icon(Icons.bookmark), + title: Text(t.settings.rememberTrackSelections), + subtitle: Text(t.settings.rememberTrackSelectionsDescription), + value: _rememberTrackSelections, + onChanged: (value) async { + setState(() { + _rememberTrackSelections = value; + }); + await _settingsService.setRememberTrackSelections(value); + }, + ), ], ), ); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 2dd6e7ff..37c766b0 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -7,6 +7,7 @@ import 'package:media_kit_video/media_kit_video.dart'; import 'package:os_media_controls/os_media_controls.dart'; import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; @@ -1073,6 +1074,34 @@ class VideoPlayerScreenState extends State { return title.contains('forced'); } + /// Checks if a track language matches a preferred language + /// + /// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes + /// Also handles bibliographic variants and region codes (e.g., "en-US") + bool _languageMatches(String? trackLanguage, String? preferredLanguage) { + if (trackLanguage == null || preferredLanguage == null) { + return false; + } + + final track = trackLanguage.toLowerCase(); + final preferred = preferredLanguage.toLowerCase(); + + // Direct match + if (track == preferred) return true; + + // Extract base language codes (handle region codes like "en-US") + final trackBase = track.split('-').first; + final preferredBase = preferred.split('-').first; + + if (trackBase == preferredBase) return true; + + // Get all variations of the preferred language (e.g., "en" → ["en", "eng"]) + final variations = LanguageCodes.getVariations(preferredBase); + + // Check if track's base code matches any variation + return variations.contains(trackBase); + } + void _waitForTracksAndApply() async { // Helper function to process tracks Future processTracks(Tracks tracks) async { @@ -1102,7 +1131,7 @@ class VideoPlayerScreenState extends State { ); } - // Select audio track with priority: preferred > user profile > default > first + // Select audio track with priority: preferred > per-media > user profile > default > first appLogger.d('Audio track selection'); if (realAudioTracks.isNotEmpty) { AudioTrack? trackToSelect; @@ -1126,20 +1155,46 @@ class VideoPlayerScreenState extends State { appLogger.d('Priority 1: No preferred track from navigation'); } - // Priority 2: If no preferred track matched, try user profile preferences + // Priority 2: If no preferred track matched, try per-media language preference + if (trackToSelect == null && widget.metadata.audioLanguage != null) { + appLogger.d( + 'Priority 2: Checking per-media audio language preference', + ); + appLogger.d( + ' Per-media audio language: ${widget.metadata.audioLanguage}', + ); + trackToSelect = realAudioTracks.firstWhere( + (track) => + _languageMatches(track.language, widget.metadata.audioLanguage), + orElse: () => realAudioTracks.first, + ); + if (_languageMatches( + trackToSelect.language, + widget.metadata.audioLanguage, + )) { + appLogger.d(' Matched per-media audio language preference'); + } else { + appLogger.d(' No match found for per-media audio language'); + trackToSelect = null; + } + } else if (trackToSelect == null) { + appLogger.d('Priority 2: No per-media audio language preference'); + } + + // Priority 3: If no preferred track matched, try user profile preferences if (trackToSelect == null && profileSettings != null) { - appLogger.d('Priority 2: Checking user profile preferences'); + appLogger.d('Priority 3: Checking user profile preferences'); trackToSelect = _findAudioTrackByProfile( realAudioTracks, profileSettings, ); } else if (trackToSelect == null) { - appLogger.d('Priority 2: No user profile available'); + appLogger.d('Priority 3: No user profile available'); } - // Priority 3: If no match, use default or first track + // Priority 4: If no match, use default or first track if (trackToSelect == null) { - appLogger.d('Priority 3: Using default or first available track'); + appLogger.d('Priority 4: Using default or first available track'); trackToSelect = realAudioTracks.firstWhere( (t) => t.isDefault == true, orElse: () => realAudioTracks.first, @@ -1158,7 +1213,7 @@ class VideoPlayerScreenState extends State { appLogger.d('No audio tracks available'); } - // Select subtitle track with priority: preferred > user profile > default > off + // Select subtitle track with priority: preferred > per-media > user profile > default > off appLogger.d('Subtitle track selection'); SubtitleTrack? subtitleToSelect; @@ -1187,11 +1242,47 @@ class VideoPlayerScreenState extends State { appLogger.d('Priority 1: No preferred track from navigation'); } - // Priority 2: If no preferred match, apply user profile preferences + // Priority 2: If no preferred match, try per-media language preference + if (subtitleToSelect == null && + widget.metadata.subtitleLanguage != null) { + appLogger.d( + 'Priority 2: Checking per-media subtitle language preference', + ); + appLogger.d( + ' Per-media subtitle language: ${widget.metadata.subtitleLanguage}', + ); + // Check if subtitle should be disabled + if (widget.metadata.subtitleLanguage == 'none' || + widget.metadata.subtitleLanguage!.isEmpty) { + appLogger.d(' Per-media preference: Subtitles OFF'); + subtitleToSelect = SubtitleTrack.no(); + } else if (realSubtitleTracks.isNotEmpty) { + final matchedTrack = realSubtitleTracks.firstWhere( + (track) => _languageMatches( + track.language, + widget.metadata.subtitleLanguage, + ), + orElse: () => realSubtitleTracks.first, + ); + if (_languageMatches( + matchedTrack.language, + widget.metadata.subtitleLanguage, + )) { + subtitleToSelect = matchedTrack; + appLogger.d(' Matched per-media subtitle language preference'); + } else { + appLogger.d(' No match found for per-media subtitle language'); + } + } + } else if (subtitleToSelect == null) { + appLogger.d('Priority 2: No per-media subtitle language preference'); + } + + // Priority 3: If no preferred match, apply user profile preferences if (subtitleToSelect == null && profileSettings != null && realSubtitleTracks.isNotEmpty) { - appLogger.d('Priority 2: Checking user profile preferences'); + appLogger.d('Priority 3: Checking user profile preferences'); // Get the currently selected audio track final currentAudioTrack = realAudioTracks.firstWhere( (t) => t.id == player!.state.track.audio.id, @@ -1203,12 +1294,12 @@ class VideoPlayerScreenState extends State { selectedAudioTrack: currentAudioTrack, ); } else if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) { - appLogger.d('Priority 2: No user profile available'); + appLogger.d('Priority 3: No user profile available'); } - // Priority 3: If no profile match, check for default subtitle + // Priority 4: If no profile match, check for default subtitle if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) { - appLogger.d('Priority 3: Checking for default subtitle track'); + appLogger.d('Priority 4: Checking for default subtitle track'); final defaultTrackIndex = realSubtitleTracks.indexWhere( (t) => t.isDefault == true, ); @@ -1224,7 +1315,7 @@ class VideoPlayerScreenState extends State { // If still no subtitle selected, turn off if (subtitleToSelect == null) { - appLogger.d('Priority 4: No subtitle selected - Subtitles OFF'); + appLogger.d('Priority 5: No subtitle selected - Subtitles OFF'); subtitleToSelect = SubtitleTrack.no(); } @@ -1485,6 +1576,94 @@ class VideoPlayerScreenState extends State { await _navigateToEpisode(_previousEpisode!); } + /// Handle audio track changes from the user - save as per-media preference if enabled + Future _onAudioTrackChanged(AudioTrack track) async { + final settings = await SettingsService.getInstance(); + + // Only save if remember track selections is enabled + if (!settings.getRememberTrackSelections()) { + return; + } + + // Extract language code from the track + final languageCode = track.language; + if (languageCode == null || languageCode.isEmpty) { + appLogger.d('Audio track has no language code, not saving preference'); + return; + } + + // Determine which ratingKey to use + // For TV shows: use grandparentRatingKey (series level) + // For movies: use ratingKey (movie level) + final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; + final targetRatingKey = isEpisode + ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) + : widget.metadata.ratingKey; + + appLogger.i( + 'Saving audio language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)', + ); + + try { + if (!mounted) return; + final client = context.read(); + await client.setMetadataPreferences( + targetRatingKey, + audioLanguage: languageCode, + ); + appLogger.d('Successfully saved audio language preference'); + } catch (e) { + appLogger.e('Failed to save audio language preference', error: e); + } + } + + /// Handle subtitle track changes from the user - save as per-media preference if enabled + Future _onSubtitleTrackChanged(SubtitleTrack track) async { + final settings = await SettingsService.getInstance(); + + // Only save if remember track selections is enabled + if (!settings.getRememberTrackSelections()) { + return; + } + + // Handle "Off" selection + String? languageCode; + if (track.id == 'no') { + languageCode = 'none'; + appLogger.i('User turned subtitles off, saving preference'); + } else { + languageCode = track.language; + if (languageCode == null || languageCode.isEmpty) { + appLogger.d( + 'Subtitle track has no language code, not saving preference', + ); + return; + } + } + + // Determine which ratingKey to use + final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; + final targetRatingKey = isEpisode + ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) + : widget.metadata.ratingKey; + + appLogger.i( + 'Saving subtitle language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)', + ); + + try { + if (!mounted) return; + final client = context.read(); + await client.setMetadataPreferences( + targetRatingKey, + subtitleLanguage: languageCode, + ); + appLogger.d('Successfully saved subtitle language preference'); + } catch (e) { + appLogger.e('Failed to save subtitle language preference', error: e); + } + } + /// Set flag to skip orientation restoration when replacing with another video void setReplacingWithVideo() { _isReplacingWithVideo = true; @@ -1615,6 +1794,8 @@ class VideoPlayerScreenState extends State { selectedMediaIndex: widget.selectedMediaIndex, boxFitMode: _boxFitMode, onCycleBoxFitMode: _cycleBoxFitMode, + onAudioTrackChanged: _onAudioTrackChanged, + onSubtitleTrackChanged: _onSubtitleTrackChanged, ), ); }, diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 8c745318..8dc02100 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -43,6 +43,7 @@ class SettingsService { static const String _keyShuffleOrderNavigation = 'shuffle_order_navigation'; static const String _keyShuffleLoopQueue = 'shuffle_loop_queue'; static const String _keyAppLocale = 'app_locale'; + static const String _keyRememberTrackSelections = 'remember_track_selections'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -828,6 +829,17 @@ class SettingsService { return _prefs.getBool(_keyShuffleLoopQueue) ?? false; // Default: false } + // Track Selection Settings + + /// Remember Track Selections - Save per-media audio/subtitle language preferences + Future setRememberTrackSelections(bool enabled) async { + await _prefs.setBool(_keyRememberTrackSelections, enabled); + } + + bool getRememberTrackSelections() { + return _prefs.getBool(_keyRememberTrackSelections) ?? true; // Default: true + } + // Reset all settings to defaults Future resetAllSettings() async { await Future.wait([ @@ -860,6 +872,7 @@ class SettingsService { _prefs.remove(_keyShuffleOrderNavigation), _prefs.remove(_keyShuffleLoopQueue), _prefs.remove(_keyAppLocale), + _prefs.remove(_keyRememberTrackSelections), ]); } @@ -891,6 +904,7 @@ class SettingsService { 'keyboardHotkeys': hotkeys.map( (key, value) => MapEntry(key, _serializeHotKey(value)), ), + 'rememberTrackSelections': getRememberTrackSelections(), }; } } diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index d852abef..11c7da2b 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -1,18 +1,43 @@ import '../models/plex_media_info.dart'; +import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; +import '../utils/language_codes.dart'; /// Service for selecting audio and subtitle tracks based on user preferences class TrackSelectionService { /// Selects the best audio track based on user preferences /// + /// Priority order: + /// 1. Per-media preferred audio language (from metadata.audioLanguage) + /// 2. Profile-wide language preferences (if auto-select is enabled) + /// 3. Plex's selected track (if auto-select is disabled) + /// 4. First track + /// /// Returns the selected audio track, or null if no suitable track is found static PlexAudioTrack? selectAudioTrack( List tracks, - PlexUserProfile profile, - ) { + PlexUserProfile profile, { + PlexMetadata? metadata, + }) { if (tracks.isEmpty) return null; - // If auto-select is disabled, use Plex's selected track + // Priority 1: Check for per-media audio language preference + if (metadata?.audioLanguage != null) { + final perMediaTrack = tracks.firstWhere( + (track) => + _matchesLanguage(track.languageCode, metadata!.audioLanguage), + orElse: () => tracks.first, + ); + // Only use it if we actually found a matching track + if (_matchesLanguage( + perMediaTrack.languageCode, + metadata!.audioLanguage, + )) { + return perMediaTrack; + } + } + + // Priority 2: If auto-select is disabled, use Plex's selected track if (!profile.autoSelectAudio) { return tracks.firstWhere( (track) => track.selected, @@ -20,7 +45,7 @@ class TrackSelectionService { ); } - // Build list of preferred language codes + // Priority 3: Use profile-wide language preferences final preferredLanguages = []; if (profile.defaultAudioLanguage != null) { preferredLanguages.add(profile.defaultAudioLanguage!); @@ -52,14 +77,44 @@ class TrackSelectionService { /// Selects the best subtitle track based on user preferences /// + /// Priority order: + /// 1. Per-media preferred subtitle language (from metadata.subtitleLanguage) + /// 2. Profile-wide subtitle preferences (based on auto-select mode) + /// 3. Disabled (null) + /// /// Returns the selected subtitle track, or null if subtitles should be disabled static PlexSubtitleTrack? selectSubtitleTrack( List tracks, PlexUserProfile profile, - PlexAudioTrack? selectedAudioTrack, - ) { + PlexAudioTrack? selectedAudioTrack, { + PlexMetadata? metadata, + }) { if (tracks.isEmpty) return null; + // Priority 1: Check for per-media subtitle language preference + if (metadata?.subtitleLanguage != null && + metadata!.subtitleLanguage!.isNotEmpty) { + // Check if subtitle should be disabled (empty string or "none") + if (metadata.subtitleLanguage == 'none' || + metadata.subtitleLanguage == '') { + return null; + } + + final perMediaTrack = tracks.firstWhere( + (track) => + _matchesLanguage(track.languageCode, metadata.subtitleLanguage), + orElse: () => tracks.first, + ); + // Only use it if we actually found a matching track + if (_matchesLanguage( + perMediaTrack.languageCode, + metadata.subtitleLanguage, + )) { + return perMediaTrack; + } + } + + // Priority 2: Use profile-wide subtitle preferences // Mode 0: Manually selected - return null to disable subtitles if (profile.autoSelectSubtitle == 0) { return null; @@ -217,6 +272,7 @@ class TrackSelectionService { /// Checks if a language code matches a preferred language /// /// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes + /// Also handles bibliographic variants and region codes (e.g., "en-US") static bool _matchesLanguage( String? trackLanguage, String? preferredLanguage, @@ -231,49 +287,16 @@ class TrackSelectionService { // Direct match if (track == preferred) return true; - // Handle common 2-letter to 3-letter mappings - final languageMap = { - 'en': 'eng', - 'es': 'spa', - 'fr': 'fra', - 'de': 'deu', - 'it': 'ita', - 'pt': 'por', - 'ja': 'jpn', - 'ko': 'kor', - 'zh': 'zho', - 'ru': 'rus', - 'ar': 'ara', - 'hi': 'hin', - 'nl': 'nld', - 'pl': 'pol', - 'tr': 'tur', - 'sv': 'swe', - 'no': 'nor', - 'da': 'dan', - 'fi': 'fin', - 'cs': 'ces', - 'hu': 'hun', - 'ro': 'ron', - 'th': 'tha', - 'vi': 'vie', - 'id': 'ind', - 'uk': 'ukr', - 'el': 'ell', - 'he': 'heb', - }; + // Extract base language codes (handle region codes like "en-US") + final trackBase = track.split('-').first; + final preferredBase = preferred.split('-').first; - // Try mapping preferred to 3-letter and compare - if (languageMap[preferred] == track) return true; + if (trackBase == preferredBase) return true; - // Try mapping track to 3-letter and compare with preferred 3-letter - if (languageMap[track] == preferred) return true; + // Get all variations of the preferred language (e.g., "en" → ["en", "eng"]) + final variations = LanguageCodes.getVariations(preferredBase); - // Try reverse mapping (3-letter to 2-letter) - final reverseMap = languageMap.map((k, v) => MapEntry(v, k)); - if (reverseMap[preferred] == track) return true; - if (reverseMap[track] == preferred) return true; - - return false; + // Check if track's base code matches any variation + return variations.contains(trackBase); } } diff --git a/lib/widgets/video_controls/sheets/audio_track_sheet.dart b/lib/widgets/video_controls/sheets/audio_track_sheet.dart index ba7cd991..40354f17 100644 --- a/lib/widgets/video_controls/sheets/audio_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_track_sheet.dart @@ -5,8 +5,9 @@ import '../../../i18n/strings.g.dart'; /// Bottom sheet for selecting audio tracks class AudioTrackSheet extends StatelessWidget { final Player player; + final Function(AudioTrack)? onTrackChanged; - const AudioTrackSheet({super.key, required this.player}); + const AudioTrackSheet({super.key, required this.player, this.onTrackChanged}); static BoxConstraints getBottomSheetConstraints(BuildContext context) { final size = MediaQuery.of(context).size; @@ -19,13 +20,18 @@ class AudioTrackSheet extends StatelessWidget { ); } - static void show(BuildContext context, Player player) { + static void show( + BuildContext context, + Player player, { + Function(AudioTrack)? onTrackChanged, + }) { showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: getBottomSheetConstraints(context), - builder: (context) => AudioTrackSheet(player: player), + builder: (context) => + AudioTrackSheet(player: player, onTrackChanged: onTrackChanged), ); } @@ -130,6 +136,7 @@ class AudioTrackSheet extends StatelessWidget { : null, onTap: () { player.setAudioTrack(audioTrack); + onTrackChanged?.call(audioTrack); Navigator.pop(context); }, ); diff --git a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart index ab3858b7..4f375627 100644 --- a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart @@ -5,8 +5,13 @@ import '../../../i18n/strings.g.dart'; /// Bottom sheet for selecting subtitle tracks class SubtitleTrackSheet extends StatelessWidget { final Player player; + final Function(SubtitleTrack)? onTrackChanged; - const SubtitleTrackSheet({super.key, required this.player}); + const SubtitleTrackSheet({ + super.key, + required this.player, + this.onTrackChanged, + }); static BoxConstraints getBottomSheetConstraints(BuildContext context) { final size = MediaQuery.of(context).size; @@ -19,13 +24,18 @@ class SubtitleTrackSheet extends StatelessWidget { ); } - static void show(BuildContext context, Player player) { + static void show( + BuildContext context, + Player player, { + Function(SubtitleTrack)? onTrackChanged, + }) { showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: getBottomSheetConstraints(context), - builder: (context) => SubtitleTrackSheet(player: player), + builder: (context) => + SubtitleTrackSheet(player: player, onTrackChanged: onTrackChanged), ); } @@ -113,6 +123,7 @@ class SubtitleTrackSheet extends StatelessWidget { : null, onTap: () { player.setSubtitleTrack(SubtitleTrack.no()); + onTrackChanged?.call(SubtitleTrack.no()); Navigator.pop(context); }, ); @@ -167,6 +178,7 @@ class SubtitleTrackSheet extends StatelessWidget { : null, onTap: () { player.setSubtitleTrack(subtitle); + onTrackChanged?.call(subtitle); Navigator.pop(context); }, ); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index c3bc93b4..fde60db0 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -38,6 +38,8 @@ Widget plexVideoControlsBuilder( int? selectedMediaIndex, int boxFitMode = 0, VoidCallback? onCycleBoxFitMode, + Function(AudioTrack)? onAudioTrackChanged, + Function(SubtitleTrack)? onSubtitleTrackChanged, }) { return PlexVideoControls( player: player, @@ -48,6 +50,8 @@ Widget plexVideoControlsBuilder( selectedMediaIndex: selectedMediaIndex ?? 0, boxFitMode: boxFitMode, onCycleBoxFitMode: onCycleBoxFitMode, + onAudioTrackChanged: onAudioTrackChanged, + onSubtitleTrackChanged: onSubtitleTrackChanged, ); } @@ -60,6 +64,8 @@ class PlexVideoControls extends StatefulWidget { final int selectedMediaIndex; final int boxFitMode; final VoidCallback? onCycleBoxFitMode; + final Function(AudioTrack)? onAudioTrackChanged; + final Function(SubtitleTrack)? onSubtitleTrackChanged; const PlexVideoControls({ super.key, @@ -71,6 +77,8 @@ class PlexVideoControls extends StatefulWidget { this.selectedMediaIndex = 0, this.boxFitMode = 0, this.onCycleBoxFitMode, + this.onAudioTrackChanged, + this.onSubtitleTrackChanged, }); @override @@ -464,13 +472,20 @@ class _PlexVideoControlsState extends State if (_hasMultipleAudioTracks(tracks)) VideoControlButton( icon: Icons.audiotrack, - onPressed: () => AudioTrackSheet.show(context, widget.player), + onPressed: () => AudioTrackSheet.show( + context, + widget.player, + onTrackChanged: widget.onAudioTrackChanged, + ), ), if (_hasSubtitles(tracks)) VideoControlButton( icon: Icons.subtitles, - onPressed: () => - SubtitleTrackSheet.show(context, widget.player), + onPressed: () => SubtitleTrackSheet.show( + context, + widget.player, + onTrackChanged: widget.onSubtitleTrackChanged, + ), ), if (_chapters.isNotEmpty) VideoControlButton( From 0101dbde713a8a6f146f9ee4b4d381d279363888 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 14 Nov 2025 23:10:05 +0100 Subject: [PATCH 13/25] fix: make media controls follow app lifecycle --- lib/screens/video_player_screen.dart | 36 +++++++++++++++++++++++++++- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 37c766b0..6f40aed7 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -44,7 +44,8 @@ class VideoPlayerScreen extends StatefulWidget { State createState() => VideoPlayerScreenState(); } -class VideoPlayerScreenState extends State { +class VideoPlayerScreenState extends State + with WidgetsBindingObserver { Player? player; VideoController? controller; bool _isPlayerInitialized = false; @@ -91,6 +92,9 @@ class VideoPlayerScreenState extends State { appLogger.d('Preferred subtitle track: $subtitleDesc'); } + // Register app lifecycle observer + WidgetsBinding.instance.addObserver(this); + // Initialize player asynchronously with buffer size from settings _initializePlayer(); } @@ -121,6 +125,33 @@ class VideoPlayerScreenState extends State { }); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + switch (state) { + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + // Clear media controls when app goes to background or screen locks + // (we don't support background playback) + OsMediaControls.clear(); + appLogger.d('Media controls cleared due to app lifecycle state: $state'); + break; + case AppLifecycleState.resumed: + // Restore media controls when app is resumed + if (_isPlayerInitialized && mounted) { + _updateMediaMetadata(); + _updateMediaControlsPlaybackState(); + appLogger.d('Media controls restored on app resume'); + } + break; + case AppLifecycleState.detached: + case AppLifecycleState.hidden: + // No action needed for these states + break; + } + } + Future _initializePlayer() async { try { // Load buffer size from settings @@ -650,6 +681,9 @@ class VideoPlayerScreenState extends State { @override void dispose() { + // Unregister app lifecycle observer + WidgetsBinding.instance.removeObserver(this); + // Stop progress tracking _progressTimer?.cancel(); diff --git a/pubspec.lock b/pubspec.lock index 328c7b93..106c8339 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -644,8 +644,8 @@ packages: dependency: "direct main" description: path: "." - ref: "53d803e1c228eb7eab9ca642119d404c21f4a52f" - resolved-ref: "53d803e1c228eb7eab9ca642119d404c21f4a52f" + ref: a196b022dcbcc3433bae9233be179f02062327e7 + resolved-ref: a196b022dcbcc3433bae9233be179f02062327e7 url: "https://github.com/edde746/os-media-controls" source: git version: "0.0.2" diff --git a/pubspec.yaml b/pubspec.yaml index 74002982..5e3b7f22 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,7 +31,7 @@ dependencies: os_media_controls: git: url: https://github.com/edde746/os-media-controls - ref: 53d803e1c228eb7eab9ca642119d404c21f4a52f + ref: a196b022dcbcc3433bae9233be179f02062327e7 dependency_overrides: media_kit: From 9bb9879dc45f73520743e67fc872320cba3a2139 Mon Sep 17 00:00:00 2001 From: Jamal <15986930+jamal2362@users.noreply.github.com> Date: Fri, 14 Nov 2025 23:46:34 +0100 Subject: [PATCH 14/25] Add german translation --- lib/i18n/strings_de.i18n.json | 372 ++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 lib/i18n/strings_de.i18n.json diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json new file mode 100644 index 00000000..176effea --- /dev/null +++ b/lib/i18n/strings_de.i18n.json @@ -0,0 +1,372 @@ +{ + "app": { + "title": "Plezy", + "loading": "Lädt..." + }, + "auth": { + "signInWithPlex": "Mit Plex anmelden", + "showQRCode": "QR-Code anzeigen", + "cancel": "Abbrechen", + "authenticate": "Authentifizieren", + "retry": "Erneut versuchen", + "debugEnterToken": "Debug: Plex-Token eingeben", + "plexTokenLabel": "Plex-Auth-Token", + "plexTokenHint": "Plex.tv-Token eingeben", + "authenticationTimeout": "Authentifizierung abgelaufen. Bitte erneut versuchen.", + "scanQRCodeInstruction": "Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.", + "waitingForAuth": "Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen." + }, + "common": { + "cancel": "Abbrechen", + "save": "Speichern", + "close": "Schließen", + "clear": "Leeren", + "reset": "Zurücksetzen", + "later": "Später", + "submit": "Senden", + "confirm": "Bestätigen", + "retry": "Erneut versuchen", + "playNow": "Jetzt abspielen", + "logout": "Abmelden", + "online": "Online", + "offline": "Offline", + "owned": "Eigentum", + "shared": "Geteilt", + "current": "AKTUELL", + "unknown": "Unbekannt", + "refresh": "Aktualisieren", + "yes": "Ja", + "no": "Nein", + "server": "Server" + }, + "screens": { + "licenses": "Lizenzen", + "selectServer": "Server auswählen", + "switchProfile": "Profil wechseln", + "subtitleStyling": "Untertitel-Stil", + "search": "Suche", + "logs": "Protokolle" + }, + "update": { + "available": "Update verfügbar", + "versionAvailable": "Version ${version} ist verfügbar", + "currentVersion": "Aktuell: ${version}", + "skipVersion": "Diese Version überspringen", + "viewRelease": "Release anzeigen", + "latestVersion": "Aktuellste Version installiert", + "checkFailed": "Fehler bei der Updateprüfung" + }, + "settings": { + "title": "Einstellungen", + "language": "Sprache", + "theme": "Theme", + "appearance": "Darstellung", + "videoPlayback": "Videowiedergabe", + "shufflePlay": "Zufallswiedergabe", + "advanced": "Erweitert", + "useSeasonPostersDescription": "Staffelposter statt Serienposter für Episoden anzeigen", + "showHeroSectionDescription": "Bereich mit empfohlenen Inhalten auf der Startseite anzeigen", + "secondsLabel": "Sekunden", + "minutesLabel": "Minuten", + "secondsShort": "s", + "minutesShort": "m", + "durationHint": "Dauer eingeben (${min}-${max})", + "systemTheme": "System", + "systemThemeDescription": "Systemeinstellungen folgen", + "lightTheme": "Hell", + "darkTheme": "Dunkel", + "libraryDensity": "Bibliotheksdichte", + "compact": "Kompakt", + "compactDescription": "Kleinere Karten, mehr Elemente sichtbar", + "normal": "Normal", + "normalDescription": "Standardgröße", + "comfortable": "Großzügig", + "comfortableDescription": "Größere Karten, weniger Elemente sichtbar", + "viewMode": "Ansichtsmodus", + "gridView": "Raster", + "gridViewDescription": "Elemente im Raster anzeigen", + "listView": "Liste", + "listViewDescription": "Elemente in Listenansicht anzeigen", + "useSeasonPosters": "Staffelposter verwenden", + "showHeroSection": "Hero-Bereich anzeigen", + "hardwareDecoding": "Hardware-Decodierung", + "hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, wenn verfügbar", + "bufferSize": "Puffergröße", + "bufferSizeMB": "${size}MB", + "subtitleStyling": "Untertitel-Stil", + "subtitleStylingDescription": "Aussehen von Untertiteln anpassen", + "smallSkipDuration": "Kleine Sprungdauer", + "largeSkipDuration": "Große Sprungdauer", + "secondsUnit": "${seconds} Sekunden", + "defaultSleepTimer": "Standard-Sleep-Timer", + "minutesUnit": "${minutes} Minuten", + "rememberTrackSelections": "Spurauswahl pro Serie/Film merken", + "rememberTrackSelectionsDescription": "Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert", + "unwatchedOnly": "Nur ungesehene", + "unwatchedOnlyDescription": "Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen", + "shuffleOrderNavigation": "Navigation der Shuffle-Reihenfolge", + "shuffleOrderNavigationDescription": "Weiter/Zurück folgt der zufälligen Reihenfolge", + "loopShuffleQueue": "Shuffle-Warteschlange wiederholen", + "loopShuffleQueueDescription": "Warteschlange neu starten, wenn das Ende erreicht ist", + "videoPlayerControls": "Videoplayer-Steuerung", + "keyboardShortcuts": "Tastenkürzel", + "keyboardShortcutsDescription": "Tastenkürzel anpassen", + "debugLogging": "Debug-Protokollierung", + "debugLoggingDescription": "Detaillierte Protokolle zur Fehleranalyse aktivieren", + "viewLogs": "Protokolle anzeigen", + "viewLogsDescription": "App-Protokolle anzeigen", + "clearCache": "Cache löschen", + "clearCacheDescription": "Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.", + "clearCacheSuccess": "Cache erfolgreich gelöscht", + "resetSettings": "Einstellungen zurücksetzen", + "resetSettingsDescription": "Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.", + "resetSettingsSuccess": "Einstellungen erfolgreich zurückgesetzt", + "shortcutsReset": "Tastenkürzel auf Standard zurückgesetzt", + "about": "Über", + "aboutDescription": "App-Informationen und Lizenzen", + "updates": "Updates", + "updateAvailable": "Update verfügbar", + "checkForUpdates": "Nach Updates suchen", + "validationErrorEnterNumber": "Bitte eine gültige Zahl eingeben", + "validationErrorDuration": "Dauer muss zwischen ${min} und ${max} ${unit} liegen", + "shortcutAlreadyAssigned": "Tastenkürzel bereits zugewiesen an ${action}", + "shortcutUpdated": "Tastenkürzel aktualisiert für ${action}" + }, + "search": { + "hint": "Filme, Serien, Musik suchen...", + "tryDifferentTerm": "Anderen Suchbegriff versuchen", + "searchYourMedia": "In den eigenen Medien suchen", + "enterTitleActorOrKeyword": "Titel, Schauspieler oder Stichwort eingeben" + }, + "hotkeys": { + "setShortcutFor": "Tastenkürzel festlegen für ${actionName}", + "clearShortcut": "Kürzel löschen" + }, + "pinEntry": { + "enterPin": "PIN eingeben", + "showPin": "PIN anzeigen", + "hidePin": "PIN verbergen" + }, + "fileInfo": { + "title": "Dateiinfo", + "video": "Video", + "audio": "Audio", + "file": "Datei", + "advanced": "Erweitert", + "codec": "Codec", + "resolution": "Auflösung", + "bitrate": "Bitrate", + "frameRate": "Bildrate", + "aspectRatio": "Seitenverhältnis", + "profile": "Profil", + "bitDepth": "Farbtiefe", + "colorSpace": "Farbraum", + "colorRange": "Farbbereich", + "colorPrimaries": "Primärfarben", + "chromaSubsampling": "Chroma-Subsampling", + "channels": "Kanäle", + "path": "Pfad", + "size": "Größe", + "container": "Container", + "duration": "Dauer", + "optimizedForStreaming": "Für Streaming optimiert", + "has64bitOffsets": "64-Bit-Offsets" + }, + "mediaMenu": { + "markAsWatched": "Als gesehen markieren", + "markAsUnwatched": "Als ungesehen markieren", + "removeFromContinueWatching": "Aus ‚Weiterschauen‘ entfernen", + "goToSeries": "Zur Serie", + "goToSeason": "Zur Staffel", + "shufflePlay": "Zufallswiedergabe", + "fileInfo": "Dateiinfo" + }, + "tooltips": { + "shufflePlay": "Zufallswiedergabe", + "markAsWatched": "Als gesehen markieren", + "markAsUnwatched": "Als ungesehen markieren" + }, + "videoControls": { + "audioLabel": "Audio", + "subtitlesLabel": "Untertitel", + "resetToZero": "Auf 0 ms zurücksetzen", + "addTime": "+${amount}${unit}", + "minusTime": "-${amount}${unit}", + "playsLater": "${label} spielt später", + "playsEarlier": "${label} spielt früher", + "noOffset": "Kein Offset", + "letterbox": "Letterbox", + "fillScreen": "Bild füllen", + "stretch": "Strecken", + "lockRotation": "Rotation sperren", + "unlockRotation": "Rotation entsperren" + }, + "userStatus": { + "admin": "Admin", + "restricted": "Eingeschränkt", + "protected": "Geschützt" + }, + "messages": { + "markedAsWatched": "Als gesehen markiert", + "markedAsUnwatched": "Als ungesehen markiert", + "removedFromContinueWatching": "Aus ‚Weiterschauen‘ entfernt", + "errorLoading": "Fehler: ${error}", + "fileInfoNotAvailable": "Dateiinfo nicht verfügbar", + "errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}", + "errorLoadingSeries": "Fehler beim Laden der Serie", + "errorLoadingSeason": "Fehler beim Laden der Staffel", + "musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt", + "logsCleared": "Protokolle gelöscht", + "logsCopied": "Protokolle in Zwischenablage kopiert", + "noLogsAvailable": "Keine Protokolle verfügbar", + "libraryScanning": "Scanne „${title}“...", + "libraryScanStarted": "Bibliotheksscan gestartet für „${title}“", + "libraryScanFailed": "Fehler beim Scannen der Bibliothek: ${error}", + "metadataRefreshing": "Metadaten werden aktualisiert für „${title}“...", + "metadataRefreshStarted": "Metadaten-Aktualisierung gestartet für „${title}“", + "metadataRefreshFailed": "Metadaten konnten nicht aktualisiert werden: ${error}", + "noPlexToken": "Kein Plex-Token gefunden. Bitte erneut anmelden.", + "logoutConfirm": "Abmeldung wirklich durchführen?", + "noSeasonsFound": "Keine Staffeln gefunden", + "noEpisodesFound": "Keine Episoden in der ersten Staffel gefunden", + "noEpisodesFoundGeneral": "Keine Episoden gefunden", + "noResultsFound": "Keine Ergebnisse gefunden", + "sleepTimerSet": "Sleep-Timer gesetzt auf ${label}", + "failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen" + }, + "profile": { + "noUsersAvailable": "Keine Benutzer verfügbar" + }, + "subtitlingStyling": { + "stylingOptions": "Stiloptionen", + "fontSize": "Schriftgröße", + "textColor": "Textfarbe", + "borderSize": "Rahmengröße", + "borderColor": "Rahmenfarbe", + "backgroundOpacity": "Hintergrunddeckkraft", + "backgroundColor": "Hintergrundfarbe" + }, + "dialog": { + "confirmAction": "Aktion bestätigen", + "areYouSure": "Aktion wirklich ausführen?", + "cancel": "Abbrechen", + "playNow": "Jetzt abspielen" + }, + "discover": { + "title": "Entdecken", + "switchProfile": "Profil wechseln", + "switchServer": "Server wechseln", + "logout": "Abmelden", + "noContentAvailable": "Kein Inhalt verfügbar", + "addMediaToLibraries": "Medien zur Bibliothek hinzufügen", + "continueWatching": "Weiterschauen", + "recentlyAdded": "Kürzlich hinzugefügt", + "play": "Abspielen", + "resume": "Fortsetzen", + "playEpisode": "Spiele S${season}, E${episode}", + "resumeEpisode": "Fortsetzen S${season}, E${episode}", + "pause": "Pause", + "overview": "Übersicht", + "cast": "Besetzung", + "episodeCount": "${count} Episoden", + "watchedProgress": "${watched}/${total} gesehen", + "movie": "Film", + "tvShow": "Serie", + "minutesLeft": "${minutes} Min übrig" + }, + "errors": { + "searchFailed": "Suche fehlgeschlagen: ${error}", + "connectionTimeout": "Zeitüberschreitung beim Laden von ${context}", + "connectionFailed": "Verbindung zum Plex-Server fehlgeschlagen", + "failedToLoad": "Fehler beim Laden von ${context}: ${error}", + "noClientAvailable": "Kein Client verfügbar", + "authenticationFailed": "Authentifizierung fehlgeschlagen: ${error}", + "couldNotLaunchUrl": "Auth-URL konnte nicht geöffnet werden", + "pleaseEnterToken": "Bitte Token eingeben", + "invalidToken": "Ungültiges Token", + "failedToVerifyToken": "Token-Verifizierung fehlgeschlagen: ${error}", + "failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen", + "connectionFailedGeneric": "Verbindung fehlgeschlagen" + }, + "libraries": { + "title": "Bibliotheken", + "scanLibraryFiles": "Bibliotheksdateien scannen", + "scanLibrary": "Bibliothek scannen", + "analyze": "Analysieren", + "analyzeLibrary": "Bibliothek analysieren", + "refreshMetadata": "Metadaten aktualisieren", + "emptyTrash": "Papierkorb leeren", + "emptyingTrash": "Papierkorb für „${title}“ wird geleert...", + "trashEmptied": "Papierkorb für „${title}“ geleert", + "failedToEmptyTrash": "Papierkorb konnte nicht geleert werden: ${error}", + "analyzing": "Analysiere „${title}“...", + "analysisStarted": "Analyse gestartet für „${title}“", + "failedToAnalyze": "Analyse der Bibliothek fehlgeschlagen: ${error}", + "noLibrariesFound": "Keine Bibliotheken gefunden", + "thisLibraryIsEmpty": "Diese Bibliothek ist leer", + "all": "Alle", + "clearAll": "Alle löschen", + "scanLibraryConfirm": "„${title}“ wirklich scannen?", + "analyzeLibraryConfirm": "„${title}“ wirklich analysieren?", + "refreshMetadataConfirm": "Metadaten für „${title}“ wirklich aktualisieren?", + "emptyTrashConfirm": "Papierkorb für „${title}“ wirklich leeren?", + "manageLibraries": "Bibliotheken verwalten", + "sort": "Sortieren", + "sortBy": "Sortieren nach", + "filters": "Filter", + "loadingLibraryWithCount": "Lade Bibliothek... (${count} Elemente geladen)", + "confirmActionMessage": "Aktion wirklich durchführen?", + "showLibrary": "Bibliothek anzeigen", + "hideLibrary": "Bibliothek ausblenden", + "libraryOptions": "Bibliotheksoptionen" + }, + "about": { + "title": "Über", + "openSourceLicenses": "Open-Source-Lizenzen", + "versionLabel": "Version ${version}", + "appDescription": "Ein schöner Plex-Client für Flutter", + "viewLicensesDescription": "Lizenzen von Drittanbieter-Bibliotheken anzeigen" + }, + "serverSelection": { + "connectingToServer": "Verbinde mit Server...", + "serverDebugCopied": "Server-Debugdaten kopiert", + "copyDebugData": "Debugdaten kopieren", + "noServersFound": "Keine Server gefunden", + "malformedServerData": "${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.", + "incompleteServerInfo": "Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.", + "incompleteConnectionInfo": "Serververbindungsinformationen unvollständig. Bitte erneut versuchen.", + "malformedServerInfo": "Serverinformationen fehlerhaft: ${message}", + "networkConnectionFailed": "Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.", + "authenticationFailed": "Authentifizierung fehlgeschlagen. Bitte erneut anmelden.", + "plexServiceUnavailable": "Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.", + "failedToLoadServers": "Server konnten nicht geladen werden: ${error}" + }, + "hubDetail": { + "title": "Titel", + "releaseYear": "Erscheinungsjahr", + "dateAdded": "Hinzugefügt am", + "rating": "Bewertung", + "noItemsFound": "Keine Elemente gefunden" + }, + "logs": { + "title": "Protokolle", + "clearLogs": "Protokolle löschen", + "copyLogs": "Protokolle kopieren", + "exportLogs": "Protokolle exportieren", + "noLogsToShow": "Keine Protokolle zum Anzeigen", + "error": "Fehler:", + "stackTrace": "Stacktrace:" + }, + "licenses": { + "relatedPackages": "Verwandte Pakete", + "license": "Lizenz", + "licenseNumber": "Lizenz ${number}", + "licensesCount": "${count} Lizenzen" + }, + "navigation": { + "home": "Start", + "search": "Suche", + "libraries": "Bibliotheken", + "settings": "Einstellungen" + } +} From 831552d5b8ea8a55fc6c47c06e6abf41c937a3a1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 14 Nov 2025 23:37:26 +0100 Subject: [PATCH 15/25] refactor: better log redaction --- lib/services/storage_service.dart | 11 +++ lib/utils/app_logger.dart | 64 ++---------- lib/utils/log_redaction_manager.dart | 143 +++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 57 deletions(-) create mode 100644 lib/utils/log_redaction_manager.dart diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 1b1c9349..4c16d670 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -1,6 +1,9 @@ import 'dart:convert'; + import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/log_redaction_manager.dart'; + class StorageService { static const String _keyServerUrl = 'server_url'; static const String _keyToken = 'token'; @@ -32,11 +35,16 @@ class StorageService { Future _init() async { _prefs = await SharedPreferences.getInstance(); + // Seed known values so logs can redact immediately on startup. + LogRedactionManager.registerServerUrl(getServerUrl()); + LogRedactionManager.registerToken(getToken()); + LogRedactionManager.registerToken(getPlexToken()); } // Server URL Future saveServerUrl(String url) async { await _prefs.setString(_keyServerUrl, url); + LogRedactionManager.registerServerUrl(url); } String? getServerUrl() { @@ -46,6 +54,7 @@ class StorageService { // Server Access Token Future saveToken(String token) async { await _prefs.setString(_keyToken, token); + LogRedactionManager.registerToken(token); } String? getToken() { @@ -64,6 +73,7 @@ class StorageService { // Plex.tv Token (for API access) Future savePlexToken(String token) async { await _prefs.setString(_keyPlexToken, token); + LogRedactionManager.registerToken(token); } String? getPlexToken() { @@ -127,6 +137,7 @@ class StorageService { _prefs.remove(_keyHomeUsersCache), _prefs.remove(_keyHomeUsersCacheExpiry), ]); + LogRedactionManager.clearTrackedValues(); } // Get all credentials as a map diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index 07c39cb6..9450fed4 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -1,72 +1,22 @@ import 'package:logger/logger.dart'; -/// Redacts sensitive information from log messages +import 'log_redaction_manager.dart'; + +/// Redacts sensitive information from log messages based on known values. String _redactSensitiveData(String message) { - String redacted = message; + var redacted = LogRedactionManager.redact(message); - // Redact Plex tokens (alphanumeric strings typically 20+ characters) - // Pattern: X-Plex-Token=... or token=... or accessToken=... or similar + // Fallbacks for sensitive fields we cannot track ahead of time. redacted = redacted.replaceAllMapped( - RegExp(r'([Tt]oken[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false), + RegExp(r'([Aa]uthorization[=:]\s*)([^\s,]+)'), (match) => '${match.group(1)}[REDACTED]', ); - // Redact authorization headers redacted = redacted.replaceAllMapped( - RegExp( - r'([Aa]uthorization[=:]\s*)([A-Za-z0-9_\-\.]+)', - caseSensitive: false, - ), + RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)'), (match) => '${match.group(1)}[REDACTED]', ); - // Redact API keys - redacted = redacted.replaceAllMapped( - RegExp(r'([Aa]pi[Kk]ey[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false), - (match) => '${match.group(1)}[REDACTED]', - ); - - // Redact passwords - redacted = redacted.replaceAllMapped( - RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)', caseSensitive: false), - (match) => '${match.group(1)}[REDACTED]', - ); - - // Redact full URLs with tokens in query parameters - redacted = redacted.replaceAllMapped( - RegExp( - r'(https?://[^\s]*[?&])([Xx]-[Pp]lex-[Tt]oken|token)=([A-Za-z0-9_-]+)', - ), - (match) => '${match.group(1)}${match.group(2)}=[REDACTED]', - ); - - // Redact IP addresses in dot notation (e.g., 192.168.1.100) - redacted = redacted.replaceAllMapped( - RegExp(r'\b(\d{1,3}\.)(\d{1,3}\.)(\d{1,3}\.)(\d{1,3})\b'), - (match) => '${match.group(1)}***.***.${match.group(4)}', - ); - - // Redact IP addresses in dash notation (e.g., 192-168-1-11) - redacted = redacted.replaceAllMapped( - RegExp(r'\b(\d{1,3}-)(\d{1,3}-)(\d{1,3}-)(\d{1,3})\b'), - (match) => '${match.group(1)}***-***-${match.group(4)}', - ); - - // Redact standalone token-like strings (20+ alphanumeric characters) - // Only if they appear in common token contexts - redacted = redacted.replaceAllMapped(RegExp(r'\b([A-Za-z0-9_-]{20,})\b'), ( - match, - ) { - final token = match.group(1)!; - // Only redact if it looks like a token (mixed case or contains hyphens/underscores) - if (token.contains(RegExp(r'[A-Z]')) && token.contains(RegExp(r'[a-z]')) || - token.contains('_') || - token.contains('-')) { - return '[REDACTED_TOKEN]'; - } - return token; - }); - return redacted; } diff --git a/lib/utils/log_redaction_manager.dart b/lib/utils/log_redaction_manager.dart new file mode 100644 index 00000000..f0aff940 --- /dev/null +++ b/lib/utils/log_redaction_manager.dart @@ -0,0 +1,143 @@ +class LogRedactionManager { + static final Set _tokens = {}; + static final Set _urls = {}; + static final Set _customValues = {}; + static final RegExp _ipv4Pattern = + RegExp(r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b'); + static final RegExp _ipv4HostPattern = + RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$'); + + /// Register a server access token or Plex.tv token for redaction. + static void registerToken(String? token) { + final normalized = _normalize(token); + if (normalized == null) return; + + _tokens.add(normalized); + + // Tokens often appear URL encoded in query params. + final encoded = Uri.encodeQueryComponent(normalized); + if (encoded != normalized) { + _tokens.add(encoded); + } + } + + /// Register the server/base URL currently in use. + static void registerServerUrl(String? url) { + final normalized = _normalize(url); + if (normalized == null) return; + + final uri = Uri.tryParse(normalized); + final host = uri?.host; + if (host != null && host.isNotEmpty && _isIpv4Like(host)) { + // Do not register full IP-based URLs; regex redaction handles them. + return; + } + + if (host == null && _isIpv4Like(normalized)) { + return; + } + + final strippedSlash = normalized.endsWith('/') + ? normalized.substring(0, normalized.length - 1) + : normalized; + + if (strippedSlash.isNotEmpty) { + _urls.add(strippedSlash); + _urls.add('$strippedSlash/'); // Include trailing slash variant. + } + + // Capture origin and host-level strings as well to cover most cases. + if (uri != null && uri.host.isNotEmpty) { + final origin = + '${uri.scheme.isEmpty ? 'https' : uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; + _urls.add(origin); + if (origin.endsWith('/')) { + _urls.add(origin.substring(0, origin.length - 1)); + } + } + } + + /// Register other sensitive values that need redaction. + static void registerCustomValue(String? value) { + final normalized = _normalize(value); + if (normalized == null) return; + _customValues.add(normalized); + } + + /// Reset any tracked sensitive values (e.g., on logout). + static void clearTrackedValues() { + _tokens.clear(); + _urls.clear(); + _customValues.clear(); + } + + /// Redact known sensitive values from the provided message. + static String redact(String message) { + var redacted = message; + + redacted = redacted.replaceAllMapped( + _ipv4Pattern, + (match) => _maskIpv4(match.group(1)!, match.group(2)!, match.group(5)!), + ); + + for (final url in _urls) { + redacted = redacted.replaceAll(url, _maskUrlPreview(url)); + } + + for (final token in _tokens) { + redacted = redacted.replaceAll(token, '[REDACTED_TOKEN]'); + } + + for (final custom in _customValues) { + redacted = redacted.replaceAll(custom, '[REDACTED]'); + } + + return redacted; + } + + static String? _normalize(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + return trimmed; + } + + static bool _isIpv4Like(String value) { + return _ipv4HostPattern.hasMatch(value); + } + + static String _maskIpv4(String first, String separator, String last) { + return '$first$separator' + 'x$separator' + 'x$separator' + '$last'; + } + + static String _maskUrlPreview(String url) { + const startPreviewLength = 12; + const endPreviewLength = 8; + + if (url.isEmpty) { + return '[REDACTED_URL]'; + } + + if (url.length <= 4) { + return '[REDACTED_URL]'; + } + + final startLength = url.length <= startPreviewLength + ? (url.length / 2).ceil() + : startPreviewLength; + final remainingForEnd = url.length - startLength; + final endLength = + remainingForEnd <= endPreviewLength ? remainingForEnd : endPreviewLength; + + final start = url.substring(0, startLength); + if (endLength <= 0) { + return '$start...[REDACTED_URL]'; + } + + final end = url.substring(url.length - endLength); + return '$start...[REDACTED_URL]...$end'; + } +} From e6b699c9760522f6f9cbe060fd2033dff998f0df Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Nov 2025 00:42:45 +0100 Subject: [PATCH 16/25] fix: better connection selection --- ios/Podfile.lock | 6 + lib/client/plex_client.dart | 64 ++- .../endpoint_failover_interceptor.dart | 177 ++++++++ lib/services/plex_auth_service.dart | 421 +++++++++++++++--- lib/services/server_connection_service.dart | 307 ++++++++++--- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 24 + pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 884 insertions(+), 122 deletions(-) create mode 100644 lib/network/endpoint_failover_interceptor.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 70cb3503..7f1d716c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - connectivity_plus (0.0.1): + - Flutter - Flutter (1.0.0) - media_kit_libs_ios_video (1.0.4): - Flutter @@ -26,6 +28,7 @@ PODS: - Flutter DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) @@ -39,6 +42,8 @@ DEPENDENCIES: - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" Flutter: :path: Flutter media_kit_libs_ios_video: @@ -63,6 +68,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 media_kit_video: 6235abf1d299037d23692ad47119b89e05346b23 diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 83a6a930..b01e62f3 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -12,7 +12,9 @@ import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; import '../models/plex_sort.dart'; import '../models/plex_video_playback_data.dart'; +import '../network/endpoint_failover_interceptor.dart'; import '../utils/app_logger.dart'; +import '../utils/log_redaction_manager.dart'; /// Result of testing a connection, including success status and latency class ConnectionTestResult { @@ -25,6 +27,8 @@ class ConnectionTestResult { class PlexClient { PlexConfig config; late final Dio _dio; + final EndpointFailoverManager? _endpointManager; + final Future Function(String newBaseUrl)? _onEndpointChanged; /// Custom response decoder that handles malformed UTF-8 gracefully static String _lenientUtf8Decoder( @@ -35,7 +39,18 @@ class PlexClient { return utf8.decode(responseBytes, allowMalformed: true); } - PlexClient(this.config) { + PlexClient( + this.config, { + List? prioritizedEndpoints, + Future Function(String newBaseUrl)? onEndpointChanged, + }) : _endpointManager = (prioritizedEndpoints != null && + prioritizedEndpoints.isNotEmpty) + ? EndpointFailoverManager(prioritizedEndpoints) + : null, + _onEndpointChanged = onEndpointChanged { + LogRedactionManager.registerServerUrl(config.baseUrl); + LogRedactionManager.registerToken(config.token); + _dio = Dio( BaseOptions( baseUrl: config.baseUrl, @@ -59,6 +74,16 @@ class PlexClient { responseHeader: false, ), ); + + if (_endpointManager != null) { + _dio.interceptors.add( + EndpointFailoverInterceptor( + dio: _dio, + endpointManager: _endpointManager, + onEndpointSwitch: _handleEndpointSwitch, + ), + ); + } } /// Update the token used by this client @@ -66,9 +91,31 @@ class PlexClient { // Update both the Dio headers and the config to ensure consistency _dio.options.headers['X-Plex-Token'] = newToken; config = config.copyWith(token: newToken); + LogRedactionManager.registerToken(newToken); appLogger.d('PlexClient token updated (headers and config)'); } + /// Update endpoint priority list and optionally hop to the new best endpoint. + Future updateEndpointPreferences( + List prioritizedEndpoints, { + bool switchToFirst = false, + }) async { + if (_endpointManager == null || prioritizedEndpoints.isEmpty) { + return; + } + + final targetBaseUrl = + switchToFirst ? prioritizedEndpoints.first : config.baseUrl; + _endpointManager.reset( + prioritizedEndpoints, + currentBaseUrl: targetBaseUrl, + ); + + if (switchToFirst && targetBaseUrl != config.baseUrl) { + await _handleEndpointSwitch(targetBaseUrl); + } + } + /// Test connection to server Future testConnection() async { try { @@ -1209,4 +1256,19 @@ class PlexClient { Future analyzeLibrary(String sectionId) async { await _dio.get('/library/sections/$sectionId/analyze'); } + + Future _handleEndpointSwitch(String newBaseUrl) async { + if (config.baseUrl == newBaseUrl) { + return; + } + + appLogger.i('Applying Plex endpoint switch', error: newBaseUrl); + _dio.options.baseUrl = newBaseUrl; + config = config.copyWith(baseUrl: newBaseUrl); + LogRedactionManager.registerServerUrl(newBaseUrl); + + if (_onEndpointChanged != null) { + await _onEndpointChanged(newBaseUrl); + } + } } diff --git a/lib/network/endpoint_failover_interceptor.dart b/lib/network/endpoint_failover_interceptor.dart new file mode 100644 index 00000000..71c683da --- /dev/null +++ b/lib/network/endpoint_failover_interceptor.dart @@ -0,0 +1,177 @@ +import 'package:dio/dio.dart'; + +import '../utils/app_logger.dart'; + +/// Maintains the list of endpoints we can cycle through when one fails. +class EndpointFailoverManager { + EndpointFailoverManager(List urls) { + _setEndpoints(urls); + } + + late List _endpoints; + int _currentIndex = 0; + + List get endpoints => List.unmodifiable(_endpoints); + + String get current => _endpoints[_currentIndex]; + + bool get hasFallback => _currentIndex < _endpoints.length - 1; + + /// Move to the next endpoint, returning its URL or null if exhausted. + String? moveToNext() { + if (!hasFallback) return null; + _currentIndex++; + return _endpoints[_currentIndex]; + } + + /// Replace the endpoint list and optionally set the active endpoint. + void reset( + List urls, { + String? currentBaseUrl, + }) { + _setEndpoints(urls); + if (currentBaseUrl != null) { + final index = _endpoints.indexOf(currentBaseUrl); + _currentIndex = index >= 0 ? index : 0; + } else { + _currentIndex = 0; + } + } + + void _setEndpoints(List urls) { + final sanitized = []; + final seen = {}; + for (final url in urls) { + if (url.isEmpty || seen.contains(url)) continue; + seen.add(url); + sanitized.add(url); + } + if (sanitized.isEmpty) { + throw ArgumentError('At least one endpoint is required'); + } + _endpoints = sanitized; + _currentIndex = _currentIndex.clamp(0, _endpoints.length - 1); + } +} + +/// Dio interceptor that retries failed requests on the next available endpoint. +class EndpointFailoverInterceptor extends Interceptor { + EndpointFailoverInterceptor({ + required Dio dio, + required this.endpointManager, + required Future Function(String newBaseUrl) onEndpointSwitch, + }) : _dio = dio, + _onEndpointSwitch = onEndpointSwitch; + + final Dio _dio; + final EndpointFailoverManager endpointManager; + final Future Function(String newBaseUrl) _onEndpointSwitch; + bool _isSwitching = false; + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + if (_isSwitching || + !_shouldAttemptFailover(err) || + !endpointManager.hasFallback) { + handler.next(err); + return; + } + + final failedEndpoint = endpointManager.current; + appLogger.w( + 'Endpoint request failed, evaluating failover', + error: { + 'endpoint': failedEndpoint, + 'type': err.type.name, + 'statusCode': err.response?.statusCode, + }, + stackTrace: err.stackTrace, + ); + + final nextBaseUrl = endpointManager.moveToNext(); + if (nextBaseUrl == null) { + appLogger.w( + 'Endpoint failure but no fallback endpoints remain', + error: {'failedEndpoint': failedEndpoint}, + ); + handler.next(err); + return; + } + + _isSwitching = true; + try { + appLogger.i( + 'Switching Plex endpoint after request failure', + error: { + 'from': failedEndpoint, + 'to': nextBaseUrl, + 'path': err.requestOptions.path, + }, + ); + await _onEndpointSwitch(nextBaseUrl); + final response = await _retryRequest(err.requestOptions); + appLogger.i( + 'Endpoint failover retry succeeded', + error: {'newEndpoint': nextBaseUrl}, + ); + handler.resolve(response); + } on DioException catch (dioError) { + appLogger.w( + 'Endpoint failover retry failed', + error: { + 'newEndpoint': nextBaseUrl, + 'type': dioError.type.name, + 'statusCode': dioError.response?.statusCode, + }, + stackTrace: dioError.stackTrace, + ); + handler.next(dioError); + } catch (_) { + handler.next(err); + } finally { + _isSwitching = false; + } + } + + bool _shouldAttemptFailover(DioException error) { + if (error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.sendTimeout || + error.type == DioExceptionType.connectionError) { + return true; + } + + if (error.type == DioExceptionType.badResponse) { + final statusCode = error.response?.statusCode ?? 0; + return statusCode >= 500; + } + + return false; + } + + Future> _retryRequest(RequestOptions requestOptions) { + final options = Options( + method: requestOptions.method, + headers: requestOptions.headers, + responseType: requestOptions.responseType, + contentType: requestOptions.contentType, + followRedirects: requestOptions.followRedirects, + receiveDataWhenStatusError: requestOptions.receiveDataWhenStatusError, + validateStatus: requestOptions.validateStatus, + sendTimeout: requestOptions.sendTimeout, + receiveTimeout: requestOptions.receiveTimeout, + extra: requestOptions.extra, + listFormat: requestOptions.listFormat, + ); + + return _dio.request( + requestOptions.path, + data: requestOptions.data, + queryParameters: requestOptions.queryParameters, + options: options, + cancelToken: requestOptions.cancelToken, + onSendProgress: requestOptions.onSendProgress, + onReceiveProgress: requestOptions.onReceiveProgress, + ); + } +} diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 7c6ec57c..a380cc9c 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -6,6 +6,7 @@ import '../client/plex_client.dart'; import '../models/plex_user_profile.dart'; import '../models/plex_home.dart'; import '../models/user_switch_response.dart'; +import '../utils/app_logger.dart'; class PlexAuthService { static const String _appName = 'Plezy'; @@ -240,8 +241,14 @@ class _ConnectionCandidate { final PlexConnection connection; final String url; final bool isPlexDirectUri; + final bool isHttps; - _ConnectionCandidate(this.connection, this.url, this.isPlexDirectUri); + _ConnectionCandidate( + this.connection, + this.url, + this.isPlexDirectUri, + this.isHttps, + ); } /// Represents a Plex Media Server @@ -391,74 +398,116 @@ class PlexServer { /// Priority: local > remote > relay, then HTTPS > HTTP, then lowest latency /// Tests both plex.direct URI and direct IP for each connection /// HTTPS connections are tested first, with HTTP as fallback - Stream findBestWorkingConnection() async* { - if (connections.isEmpty) return; + Stream findBestWorkingConnection({ + String? preferredUri, + }) async* { + if (connections.isEmpty) { + appLogger.w('No connections available for server discovery'); + return; + } - // Create candidates: test both uri and directUrl for each connection - // Separate HTTPS and HTTP candidates to prioritize HTTPS first - final httpsCandidates = <_ConnectionCandidate>[]; - final httpCandidates = <_ConnectionCandidate>[]; + const preferredTimeout = Duration(seconds: 2); + const raceTimeout = Duration(seconds: 4); - for (final connection in connections) { - final uriCandidate = _ConnectionCandidate( - connection, - connection.uri, - true, - ); - final directCandidate = _ConnectionCandidate( - connection, - connection.directUrl, - false, - ); + final candidates = _buildPrioritizedCandidates(); + if (candidates.isEmpty) { + appLogger.w('No connection candidates generated for server discovery'); + return; + } - if (connection.protocol == 'https') { - httpsCandidates.add(uriCandidate); - httpsCandidates.add(directCandidate); - } else { - httpCandidates.add(uriCandidate); - httpCandidates.add(directCandidate); + final totalCandidates = candidates.length; + appLogger.d( + 'Starting server connection discovery', + error: { + 'preferred': preferredUri, + 'candidateCount': totalCandidates, + }, + ); + + _ConnectionCandidate? firstCandidate; + + // Fast-path: if we have a cached working URI, probe it with a short timeout + if (preferredUri != null) { + final cachedCandidate = _candidateForUrl(preferredUri); + if (cachedCandidate != null) { + appLogger.d( + 'Testing cached endpoint before running full race', + error: {'uri': preferredUri}, + ); + final result = await PlexClient.testConnectionWithLatency( + cachedCandidate.url, + accessToken, + timeout: preferredTimeout, + ); + + if (result.success) { + appLogger.i( + 'Cached endpoint succeeded, using immediately', + error: {'uri': preferredUri}, + ); + firstCandidate = cachedCandidate; + } else { + appLogger.w( + 'Cached endpoint failed, falling back to candidate race', + error: {'uri': preferredUri}, + ); + } } } - // Combine candidates with HTTPS first, then HTTP - final candidates = [...httpsCandidates, ...httpCandidates]; - - // Phase 1: Race to find first working connection - final completer = Completer<_ConnectionCandidate?>(); - _ConnectionCandidate? firstCandidate; - int completedTests = 0; - - // Start testing all candidates simultaneously - for (final candidate in candidates) { - PlexClient.testConnectionWithLatency(candidate.url, accessToken).then(( - result, - ) { - completedTests++; - - // If this is the first successful connection, emit it immediately - if (result.success && !completer.isCompleted) { - completer.complete(candidate); - } - - // If all tests complete without success, complete with null - if (completedTests == candidates.length && !completer.isCompleted) { - completer.complete(null); - } - }); - } - - // Wait for and emit the first successful connection - firstCandidate = await completer.future; + // If no cached candidate or it failed, race candidates to find first success if (firstCandidate == null) { - return; // No working connections found + final completer = Completer<_ConnectionCandidate?>(); + int completedTests = 0; + + appLogger.d( + 'Running connection race to find first working endpoint', + error: {'candidateCount': totalCandidates}, + ); + + for (final candidate in candidates) { + PlexClient + .testConnectionWithLatency( + candidate.url, + accessToken, + timeout: raceTimeout, + ) + .then((result) { + completedTests++; + + if (result.success && !completer.isCompleted) { + completer.complete(candidate); + } + + if (completedTests == candidates.length && !completer.isCompleted) { + completer.complete(null); + } + }); + } + + firstCandidate = await completer.future; + if (firstCandidate == null) { + appLogger.e('No working server connections after race'); + return; // No working connections found + } + appLogger.i( + 'Connection race found first working endpoint', + error: { + 'uri': firstCandidate.url, + 'type': firstCandidate.connection.displayType, + }, + ); } - // Update the connection object to use the working URL final firstConnection = _updateConnectionUrl( firstCandidate.connection, firstCandidate.url, ); yield firstConnection; + appLogger.d( + 'Emitted first working connection, continuing latency tests in background', + error: {'uri': firstConnection.uri}, + ); // Phase 2: Continue testing in background to find best connection // Test each candidate 2-3 times and average the latency @@ -480,20 +529,39 @@ class PlexServer { // If no connections succeeded, we're done if (candidateResults.isEmpty) { + appLogger.w('Latency sweep found no additional working endpoints'); return; } + appLogger.d( + 'Completed latency sweep for server connections', + error: {'successfulCandidates': candidateResults.length}, + ); + // Find the best connection considering priority, latency, and URL type final bestCandidate = _selectBestCandidateWithLatency(candidateResults); // Emit the best connection if it's different from the first one if (bestCandidate != null) { + final upgradedCandidate = + await _upgradeCandidateToHttpsIfPossible(bestCandidate) ?? + bestCandidate; + final bestConnection = _updateConnectionUrl( - bestCandidate.connection, - bestCandidate.url, + upgradedCandidate.connection, + upgradedCandidate.url, ); if (bestConnection.uri != firstConnection.uri) { + appLogger.i( + 'Latency sweep selected better endpoint', + error: {'uri': bestConnection.uri}, + ); yield bestConnection; + } else { + appLogger.d( + 'Latency sweep confirmed initial endpoint is optimal', + error: {'uri': bestConnection.uri}, + ); } } } @@ -517,6 +585,234 @@ class PlexServer { ); } + _ConnectionCandidate? _candidateForUrl(String url) { + for (final connection in connections) { + final httpUrl = connection.httpDirectUrl; + if (httpUrl == url) { + return _ConnectionCandidate(connection, httpUrl, false, false); + } + + final uri = connection.uri; + if (uri == url) { + final isHttps = uri.startsWith('https://'); + final parsedHost = Uri.tryParse(uri)?.host ?? ''; + final isPlexDirect = + parsedHost.toLowerCase().contains('plex.direct'); + return _ConnectionCandidate(connection, uri, isPlexDirect, isHttps); + } + } + return null; + } + + List<_ConnectionCandidate> _buildPrioritizedCandidates({ + Set? excludeUrls, + }) { + final seen = {}; + if (excludeUrls != null) { + seen.addAll(excludeUrls); + } + + final httpsLocal = <_ConnectionCandidate>[]; + final httpsRemote = <_ConnectionCandidate>[]; + final httpsRelay = <_ConnectionCandidate>[]; + final httpLocal = <_ConnectionCandidate>[]; + final httpRemote = <_ConnectionCandidate>[]; + final httpRelay = <_ConnectionCandidate>[]; + + List<_ConnectionCandidate> bucketFor( + PlexConnection connection, + bool isHttps, + ) { + if (isHttps) { + if (connection.relay) return httpsRelay; + if (connection.local) return httpsLocal; + return httpsRemote; + } else { + if (connection.relay) return httpRelay; + if (connection.local) return httpLocal; + return httpRemote; + } + } + + void addCandidate( + PlexConnection connection, + String url, + bool isPlexDirectUri, + bool isHttps, + ) { + if (url.isEmpty || seen.contains(url)) { + return; + } + seen.add(url); + bucketFor(connection, isHttps).add( + _ConnectionCandidate(connection, url, isPlexDirectUri, isHttps), + ); + } + + for (final connection in connections) { + addCandidate( + connection, + connection.httpDirectUrl, + false, + false, + ); + } + + return [ + ...httpsLocal, + ...httpsRemote, + ...httpsRelay, + ...httpLocal, + ...httpRemote, + ...httpRelay, + ]; + } + + List prioritizedEndpointUrls({String? preferredFirst}) { + final urls = []; + final exclude = {}; + + if (preferredFirst != null && preferredFirst.isNotEmpty) { + urls.add(preferredFirst); + exclude.add(preferredFirst); + } + + final candidates = _buildPrioritizedCandidates(excludeUrls: exclude); + urls.addAll(candidates.map((candidate) => candidate.url)); + return urls; + } + + Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible( + _ConnectionCandidate candidate, + ) async { + final currentUrl = candidate.url; + if (currentUrl.startsWith('https://')) { + return null; + } + + late final String httpsUrl; + bool resultingIsPlexDirect = candidate.isPlexDirectUri; + + if (candidate.isPlexDirectUri) { + if (!currentUrl.startsWith('http://')) { + return null; + } + httpsUrl = currentUrl.replaceFirst('http://', 'https://'); + } else { + // Raw IP endpoints can't present HTTPS certificates—prefer their plex.direct alias. + final plexDirectUri = candidate.connection.uri; + if (plexDirectUri.isEmpty) { + return null; + } + + if (plexDirectUri.startsWith('https://')) { + httpsUrl = plexDirectUri; + } else if (plexDirectUri.startsWith('http://')) { + httpsUrl = plexDirectUri.replaceFirst('http://', 'https://'); + } else { + return null; + } + + final upgradedHost = Uri.tryParse(httpsUrl)?.host; + if (upgradedHost == null || + !upgradedHost.toLowerCase().endsWith('.plex.direct')) { + appLogger.d( + 'Skipping HTTPS upgrade for raw IP candidate: no plex.direct alias available', + error: {'candidate': currentUrl, 'target': httpsUrl}, + ); + return null; + } + resultingIsPlexDirect = true; + } + + if (httpsUrl == currentUrl) { + return null; + } + + appLogger.d( + 'Attempting HTTPS upgrade for candidate endpoint', + error: {'from': currentUrl, 'to': httpsUrl}, + ); + + final result = await PlexClient.testConnectionWithLatency( + httpsUrl, + accessToken, + timeout: const Duration(seconds: 4), + ); + + if (!result.success) { + appLogger.w( + 'HTTPS upgrade failed, staying on HTTP candidate', + error: {'url': currentUrl}, + ); + return null; + } + + appLogger.i( + 'HTTPS upgrade succeeded for candidate endpoint', + error: {'httpsUrl': httpsUrl}, + ); + + final httpsConnection = PlexConnection( + protocol: 'https', + address: candidate.connection.address, + port: candidate.connection.port, + uri: httpsUrl, + local: candidate.connection.local, + relay: candidate.connection.relay, + ipv6: candidate.connection.ipv6, + ); + + return _ConnectionCandidate( + httpsConnection, + httpsUrl, + resultingIsPlexDirect, + true, + ); + } + + Future upgradeConnectionToHttps( + PlexConnection current, + ) async { + if (current.uri.startsWith('https://')) { + return current; + } + + final baseConnection = _findMatchingBaseConnection(current); + if (baseConnection == null) { + return null; + } + + final candidate = _ConnectionCandidate( + baseConnection, + current.uri, + current.uri.contains('.plex.direct'), + current.uri.startsWith('https://'), + ); + final upgradedCandidate = + await _upgradeCandidateToHttpsIfPossible(candidate); + if (upgradedCandidate == null) { + return null; + } + return _updateConnectionUrl( + upgradedCandidate.connection, + upgradedCandidate.url, + ); + } + + PlexConnection? _findMatchingBaseConnection(PlexConnection connection) { + for (final base in connections) { + final sameAddress = base.address == connection.address; + final samePort = base.port == connection.port; + final sameLocal = base.local == connection.local; + final sameRelay = base.relay == connection.relay; + if (sameAddress && samePort && sameLocal && sameRelay) { + return base; + } + } + return null; + } + /// Select the best candidate considering priority, latency, and URL type preference _ConnectionCandidate? _selectBestCandidateWithLatency( Map<_ConnectionCandidate, ConnectionTestResult> results, @@ -550,8 +846,8 @@ class PlexServer { if (latencyCompare != 0) return latencyCompare; // If latencies are equal, prefer HTTPS over HTTP - final aIsHttps = a.key.connection.protocol == 'https'; - final bIsHttps = b.key.connection.protocol == 'https'; + final aIsHttps = a.key.isHttps; + final bIsHttps = b.key.isHttps; if (aIsHttps && !bIsHttps) return -1; if (!aIsHttps && bIsHttps) return 1; @@ -641,6 +937,13 @@ class PlexConnection { /// This bypasses plex.direct DNS and connects directly to the IP String get directUrl => '$protocol://$address:$port'; + /// Always return an HTTP URL that points directly at the IP/port combo. + String get httpDirectUrl { + final needsBrackets = address.contains(':') && !address.startsWith('['); + final safeAddress = needsBrackets ? '[$address]' : address; + return 'http://$safeAddress:$port'; + } + String get displayType { if (relay) return 'Relay'; if (local) return 'Local'; diff --git a/lib/services/server_connection_service.dart b/lib/services/server_connection_service.dart index 32569b25..5a3dbea2 100644 --- a/lib/services/server_connection_service.dart +++ b/lib/services/server_connection_service.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; + import 'plex_auth_service.dart'; import 'storage_service.dart'; import '../client/plex_client.dart'; @@ -19,6 +23,12 @@ class ServerConnectionResult { /// Service for handling optimized server connections /// Implements fast-first connection with background optimization class ServerConnectionService { + static StreamSubscription>? + _connectivitySubscription; + static Future? _activeOptimization; + static PlexServer? _activeServer; + static PlexClient? _activeClient; + /// Connect to a Plex server with optimized connection testing /// /// Returns immediately with first working connection, then continues @@ -39,86 +49,115 @@ class ServerConnectionService { bool fetchUserProfile = false, void Function(String message)? onProgress, }) async { - PlexConnection? firstConnection; final storage = await StorageService.getInstance(); + final connectionStream = + server.findBestWorkingConnection().asBroadcastStream(); + PlexClient? client; + + final optimizationSubscription = connectionStream.skip(1).listen( + (connection) async { + await _handleOptimizedConnection( + connection: connection, + storage: storage, + server: server, + client: client, + reason: 'initial_latency_sweep', + ); + }, + onError: (error, stackTrace) { + appLogger.w( + 'Background connection optimization error', + error: error, + stackTrace: stackTrace, + ); + }, + ); + try { - // Listen to the connection stream for progressive connection testing - await for (final connection in server.findBestWorkingConnection()) { - if (firstConnection == null) { - // First emission - use this connection immediately - firstConnection = connection; + final connection = await connectionStream.first; - if (onProgress != null) { - onProgress('Connected to ${connection.displayType} endpoint'); - } + if (onProgress != null) { + onProgress('Connected to ${connection.displayType} endpoint'); + } - // Save server information to storage - await storage.saveServerData(server.toJson()); - await storage.saveServerUrl(connection.uri); - await storage.saveServerAccessToken(server.accessToken); + // Save server information to storage + await storage.saveServerData(server.toJson()); + await storage.saveServerUrl(connection.uri); + await storage.saveServerAccessToken(server.accessToken); - // Save plex token if provided - if (plexToken != null) { - await storage.savePlexToken(plexToken); - } + // Save plex token if provided + if (plexToken != null) { + await storage.savePlexToken(plexToken); + } - // Create client with working connection - final config = await PlexConfig.create( - baseUrl: connection.uri, - token: server.accessToken, - clientIdentifier: clientIdentifier, + // Create client with working connection + final prioritizedEndpoints = server.prioritizedEndpointUrls( + preferredFirst: connection.uri, + ); + final config = await PlexConfig.create( + baseUrl: connection.uri, + token: server.accessToken, + clientIdentifier: clientIdentifier, + ); + client = PlexClient( + config, + prioritizedEndpoints: prioritizedEndpoints, + onEndpointChanged: (newUrl) async { + await storage.saveServerUrl(newUrl); + appLogger.i( + 'Updated stored server URL after failover', + error: newUrl, ); - final client = PlexClient(config); + }, + ); - // Verify server is accessible if requested - if (verifyServer) { - try { - await client.getServerIdentity(); - } catch (e) { - appLogger.w('Server identity verification failed', error: e); - await storage.clearCredentials(); - return ServerConnectionResult( - error: 'Server is not accessible: $e', - ); - } - } - - // Fetch user profile if requested - PlexUserProfile? userProfile; - if (fetchUserProfile && plexToken != null) { - userProfile = await _fetchUserProfile(plexToken); - } - - // Return success result - // Note: Stream continues in background to find better connection + // Verify server is accessible if requested + if (verifyServer) { + try { + await client.getServerIdentity(); + } catch (e) { + await optimizationSubscription.cancel(); + appLogger.w('Server identity verification failed', error: e); + await storage.clearCredentials(); return ServerConnectionResult( - client: client, - userProfile: userProfile, - ); - } else { - // Second emission - better connection found - // Update stored connection seamlessly for future app launches - await storage.saveServerUrl(connection.uri); - appLogger.d( - 'Switched to better connection: ${connection.displayType} (${connection.uri})', + error: 'Server is not accessible: $e', ); } } - // Handle case where no connections were found - if (firstConnection == null) { - return ServerConnectionResult( - error: 'No working connections found for this server', - ); + // Fetch user profile if requested + PlexUserProfile? userProfile; + if (fetchUserProfile && plexToken != null) { + userProfile = await _fetchUserProfile(plexToken); } - // Should never reach here due to return in the stream loop + // Return success result while optimization continues in background + _activeServer = server; + _activeClient = client; + _startConnectivityMonitoring(server); + return ServerConnectionResult( - error: 'Unexpected error in connection flow', + client: client, + userProfile: userProfile, + ); + } on StateError catch (e, stackTrace) { + await optimizationSubscription.cancel(); + appLogger.e( + 'No working connections found for this server', + error: e, + stackTrace: stackTrace, + ); + return ServerConnectionResult( + error: 'No working connections found for this server', + ); + } catch (e, stackTrace) { + await optimizationSubscription.cancel(); + appLogger.e( + 'Error connecting to server', + error: e, + stackTrace: stackTrace, ); - } catch (e) { - appLogger.e('Error connecting to server', error: e); return ServerConnectionResult(error: 'Connection failed: $e'); } } @@ -148,4 +187,148 @@ class ServerConnectionService { return null; } } + + static void _startConnectivityMonitoring(PlexServer server) { + _connectivitySubscription?.cancel(); + final connectivity = Connectivity(); + _connectivitySubscription = connectivity.onConnectivityChanged.listen( + (results) { + final status = + results.isNotEmpty ? results.first : ConnectivityResult.none; + if (status == ConnectivityResult.none) { + appLogger.w( + 'Connectivity lost, pausing optimization until network returns', + ); + return; + } + appLogger.d( + 'Connectivity change detected, triggering endpoint optimization', + error: { + 'status': status.name, + 'interfaces': results.map((r) => r.name).toList(), + }, + ); + _activeServer = server; + _triggerReoptimization(reason: 'connectivity:${status.name}'); + }, + onError: (error, stackTrace) { + appLogger.w( + 'Connectivity listener error', + error: error, + stackTrace: stackTrace, + ); + }, + ); + } + + static void _triggerReoptimization({required String reason}) { + if (_activeServer == null) { + appLogger.d( + 'Optimization trigger ignored because there is no active server', + error: {'reason': reason}, + ); + return; + } + + if (_activeOptimization != null) { + appLogger.d( + 'Optimization already running, skipping new trigger', + error: {'reason': reason}, + ); + return; + } + + _activeOptimization = _runOptimization( + server: _activeServer!, + client: _activeClient, + reason: reason, + ).whenComplete(() { + _activeOptimization = null; + }); + } + + static Future _runOptimization({ + required PlexServer server, + required PlexClient? client, + required String reason, + }) async { + final storage = await StorageService.getInstance(); + try { + appLogger.d( + 'Starting background connection optimization run', + error: {'reason': reason}, + ); + await for (final connection in server.findBestWorkingConnection()) { + await _handleOptimizedConnection( + connection: connection, + storage: storage, + server: server, + client: client, + reason: reason, + ); + } + } catch (e, stackTrace) { + appLogger.w( + 'Background connection optimization failed', + error: e, + stackTrace: stackTrace, + ); + } + } + + static Future _handleOptimizedConnection({ + required PlexConnection connection, + required StorageService storage, + required PlexServer server, + required PlexClient? client, + required String reason, + }) async { + final previousUrl = storage.getServerUrl(); + final isNewEndpoint = previousUrl != connection.uri; + + await storage.saveServerUrl(connection.uri); + appLogger.d( + 'Evaluated optimized endpoint candidate', + error: { + 'uri': connection.uri, + 'displayType': connection.displayType, + 'reason': reason, + 'isNewEndpoint': isNewEndpoint, + }, + ); + + if (client != null) { + final prioritizedEndpoints = server.prioritizedEndpointUrls( + preferredFirst: connection.uri, + ); + await client.updateEndpointPreferences( + prioritizedEndpoints, + switchToFirst: isNewEndpoint, + ); + if (isNewEndpoint) { + appLogger.i( + 'Active client switched to optimized endpoint', + error: {'uri': connection.uri, 'reason': reason}, + ); + } + } else if (isNewEndpoint) { + appLogger.i( + 'Stored optimized endpoint for future sessions', + error: {'uri': connection.uri, 'reason': reason}, + ); + } + + if (isNewEndpoint && !connection.uri.startsWith('https://')) { + final upgraded = await server.upgradeConnectionToHttps(connection); + if (upgraded != null && upgraded.uri != connection.uri) { + await _handleOptimizedConnection( + connection: upgraded, + storage: storage, + server: server, + client: client, + reason: '$reason:https-upgrade', + ); + } + } + } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index aa156acf..a6e9b89c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import connectivity_plus import hotkey_manager_macos import macos_window_utils import media_kit_libs_macos_video @@ -21,6 +22,7 @@ import wakelock_plus import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin")) MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 106c8339..5f91f49b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,6 +185,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" convert: dependency: transitive description: @@ -632,6 +648,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" octo_image: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5e3b7f22..31042248 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: qr_flutter: ^4.1.0 slang: ^3.31.2 slang_flutter: ^3.31.0 + connectivity_plus: ^6.0.5 os_media_controls: git: url: https://github.com/edde746/os-media-controls diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 4e0884f2..e59aa9c2 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -16,6 +17,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); HotkeyManagerWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index adfebedf..0c647b45 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus hotkey_manager_windows media_kit_libs_windows_video media_kit_video From b06609a829b0f1f7d613033abb0c575b7d4b1576 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Nov 2025 01:03:23 +0100 Subject: [PATCH 17/25] fix: android mpv hashes --- pubspec.lock | 32 ++++++++++++++++---------------- pubspec.yaml | 12 ++++++------ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 5f91f49b..7d033286 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -556,8 +556,8 @@ packages: dependency: "direct main" description: path: media_kit - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" @@ -565,8 +565,8 @@ packages: dependency: "direct overridden" description: path: "libs/android/media_kit_libs_android_video" - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.3.9" @@ -574,8 +574,8 @@ packages: dependency: "direct overridden" description: path: "libs/ios/media_kit_libs_ios_video" - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.1.4" @@ -583,8 +583,8 @@ packages: dependency: transitive description: path: "libs/linux/media_kit_libs_linux" - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" @@ -592,8 +592,8 @@ packages: dependency: "direct overridden" description: path: "libs/macos/media_kit_libs_macos_video" - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.1.5" @@ -601,8 +601,8 @@ packages: dependency: "direct main" description: path: "libs/universal/media_kit_libs_video" - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.0.7" @@ -610,8 +610,8 @@ packages: dependency: transitive description: path: "libs/windows/media_kit_libs_windows_video" - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.0.12" @@ -619,8 +619,8 @@ packages: dependency: "direct main" description: path: media_kit_video - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index 31042248..1de465c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,32 +38,32 @@ dependency_overrides: media_kit: git: url: https://github.com/edde746/media-kit - ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + ref: de5c4e23722999b63ac9fbd648019a2288028721 path: media_kit media_kit_video: git: url: https://github.com/edde746/media-kit - ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + ref: de5c4e23722999b63ac9fbd648019a2288028721 path: media_kit_video media_kit_libs_video: git: url: https://github.com/edde746/media-kit - ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + ref: de5c4e23722999b63ac9fbd648019a2288028721 path: libs/universal/media_kit_libs_video media_kit_libs_macos_video: git: url: https://github.com/edde746/media-kit - ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + ref: de5c4e23722999b63ac9fbd648019a2288028721 path: libs/macos/media_kit_libs_macos_video media_kit_libs_ios_video: git: url: https://github.com/edde746/media-kit - ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + ref: de5c4e23722999b63ac9fbd648019a2288028721 path: libs/ios/media_kit_libs_ios_video media_kit_libs_android_video: git: url: https://github.com/edde746/media-kit - ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + ref: de5c4e23722999b63ac9fbd648019a2288028721 path: libs/android/media_kit_libs_android_video dev_dependencies: From a38513db56a573fb33f64d0e3845aeb0162259f0 Mon Sep 17 00:00:00 2001 From: Jovan Date: Sat, 15 Nov 2025 08:08:03 +0800 Subject: [PATCH 18/25] fix code --- lib/i18n/strings_zh.i18n.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 6fb78497..23ae4ed1 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -99,7 +99,7 @@ "largeSkipDuration": "长跳过时长", "secondsUnit": "${seconds} 秒", "defaultSleepTimer": "默认睡眠定时器", - "minutesUnit": "在 ${minutes} 分钟", + "minutesUnit": "${minutes} 分钟", "unwatchedOnly": "仅未观看", "unwatchedOnlyDescription": "随机队列中仅包含未观看的剧集", "shuffleOrderNavigation": "随机顺序导航", @@ -256,7 +256,7 @@ "switchServer": "切换服务器", "logout": "登出", "noContentAvailable": "没有可用内容", - "addMediaToLibraries": "请向的媒体库添加一些媒体", + "addMediaToLibraries": "请向你的媒体库添加一些媒体", "continueWatching": "继续观看", "recentlyAdded": "最近添加", "play": "播放", From efa7f1f724e703f1b5a8b5327b91b458d240479a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Nov 2025 01:10:26 +0100 Subject: [PATCH 19/25] chore: generate strings --- lib/i18n/strings.g.dart | 1878 +++++++++++++++++++++++++++++- lib/screens/settings_screen.dart | 4 + 2 files changed, 1878 insertions(+), 4 deletions(-) diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 3ce44476..fd030098 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -3,10 +3,10 @@ /// Original: lib/i18n /// To regenerate, run: `dart run slang` /// -/// Locales: 4 -/// Strings: 1264 (316 per locale) +/// Locales: 6 +/// Strings: 1894 (315 per locale) /// -/// Built on 2025-11-14 at 21:16 UTC +/// Built on 2025-11-15 at 00:10 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -26,9 +26,11 @@ const AppLocale _baseLocale = AppLocale.en; /// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check enum AppLocale with BaseAppLocale { en(languageCode: 'en', build: Translations.build), + de(languageCode: 'de', build: _StringsDe.build), it(languageCode: 'it', build: _StringsIt.build), nl(languageCode: 'nl', build: _StringsNl.build), - sv(languageCode: 'sv', build: _StringsSv.build); + sv(languageCode: 'sv', build: _StringsSv.build), + zh(languageCode: 'zh', build: _StringsZh.build); const AppLocale({required this.languageCode, this.scriptCode, this.countryCode, required this.build}); // ignore: unused_element @@ -738,6 +740,618 @@ class _StringsNavigationEn { String get settings => 'Settings'; } +// Path: +class _StringsDe implements Translations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + _StringsDe.build({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = TranslationMetadata( + locale: AppLocale.de, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ) { + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + @override dynamic operator[](String key) => $meta.getTranslation(key); + + @override late final _StringsDe _root = this; // ignore: unused_field + + // Translations + @override late final _StringsAppDe app = _StringsAppDe._(_root); + @override late final _StringsAuthDe auth = _StringsAuthDe._(_root); + @override late final _StringsCommonDe common = _StringsCommonDe._(_root); + @override late final _StringsScreensDe screens = _StringsScreensDe._(_root); + @override late final _StringsUpdateDe update = _StringsUpdateDe._(_root); + @override late final _StringsSettingsDe settings = _StringsSettingsDe._(_root); + @override late final _StringsSearchDe search = _StringsSearchDe._(_root); + @override late final _StringsHotkeysDe hotkeys = _StringsHotkeysDe._(_root); + @override late final _StringsPinEntryDe pinEntry = _StringsPinEntryDe._(_root); + @override late final _StringsFileInfoDe fileInfo = _StringsFileInfoDe._(_root); + @override late final _StringsMediaMenuDe mediaMenu = _StringsMediaMenuDe._(_root); + @override late final _StringsTooltipsDe tooltips = _StringsTooltipsDe._(_root); + @override late final _StringsVideoControlsDe videoControls = _StringsVideoControlsDe._(_root); + @override late final _StringsUserStatusDe userStatus = _StringsUserStatusDe._(_root); + @override late final _StringsMessagesDe messages = _StringsMessagesDe._(_root); + @override late final _StringsProfileDe profile = _StringsProfileDe._(_root); + @override late final _StringsSubtitlingStylingDe subtitlingStyling = _StringsSubtitlingStylingDe._(_root); + @override late final _StringsDialogDe dialog = _StringsDialogDe._(_root); + @override late final _StringsDiscoverDe discover = _StringsDiscoverDe._(_root); + @override late final _StringsErrorsDe errors = _StringsErrorsDe._(_root); + @override late final _StringsLibrariesDe libraries = _StringsLibrariesDe._(_root); + @override late final _StringsAboutDe about = _StringsAboutDe._(_root); + @override late final _StringsServerSelectionDe serverSelection = _StringsServerSelectionDe._(_root); + @override late final _StringsHubDetailDe hubDetail = _StringsHubDetailDe._(_root); + @override late final _StringsLogsDe logs = _StringsLogsDe._(_root); + @override late final _StringsLicensesDe licenses = _StringsLicensesDe._(_root); + @override late final _StringsNavigationDe navigation = _StringsNavigationDe._(_root); +} + +// Path: app +class _StringsAppDe implements _StringsAppEn { + _StringsAppDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Plezy'; + @override String get loading => 'Lädt...'; +} + +// Path: auth +class _StringsAuthDe implements _StringsAuthEn { + _StringsAuthDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get signInWithPlex => 'Mit Plex anmelden'; + @override String get showQRCode => 'QR-Code anzeigen'; + @override String get cancel => 'Abbrechen'; + @override String get authenticate => 'Authentifizieren'; + @override String get retry => 'Erneut versuchen'; + @override String get debugEnterToken => 'Debug: Plex-Token eingeben'; + @override String get plexTokenLabel => 'Plex-Auth-Token'; + @override String get plexTokenHint => 'Plex.tv-Token eingeben'; + @override String get authenticationTimeout => 'Authentifizierung abgelaufen. Bitte erneut versuchen.'; + @override String get scanQRCodeInstruction => 'Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.'; + @override String get waitingForAuth => 'Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen.'; +} + +// Path: common +class _StringsCommonDe implements _StringsCommonEn { + _StringsCommonDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get cancel => 'Abbrechen'; + @override String get save => 'Speichern'; + @override String get close => 'Schließen'; + @override String get clear => 'Leeren'; + @override String get reset => 'Zurücksetzen'; + @override String get later => 'Später'; + @override String get submit => 'Senden'; + @override String get confirm => 'Bestätigen'; + @override String get retry => 'Erneut versuchen'; + @override String get playNow => 'Jetzt abspielen'; + @override String get logout => 'Abmelden'; + @override String get online => 'Online'; + @override String get offline => 'Offline'; + @override String get owned => 'Eigentum'; + @override String get shared => 'Geteilt'; + @override String get current => 'AKTUELL'; + @override String get unknown => 'Unbekannt'; + @override String get refresh => 'Aktualisieren'; + @override String get yes => 'Ja'; + @override String get no => 'Nein'; + @override String get server => 'Server'; +} + +// Path: screens +class _StringsScreensDe implements _StringsScreensEn { + _StringsScreensDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get licenses => 'Lizenzen'; + @override String get selectServer => 'Server auswählen'; + @override String get switchProfile => 'Profil wechseln'; + @override String get subtitleStyling => 'Untertitel-Stil'; + @override String get search => 'Suche'; + @override String get logs => 'Protokolle'; +} + +// Path: update +class _StringsUpdateDe implements _StringsUpdateEn { + _StringsUpdateDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get available => 'Update verfügbar'; + @override String versionAvailable({required Object version}) => 'Version ${version} ist verfügbar'; + @override String currentVersion({required Object version}) => 'Aktuell: ${version}'; + @override String get skipVersion => 'Diese Version überspringen'; + @override String get viewRelease => 'Release anzeigen'; + @override String get latestVersion => 'Aktuellste Version installiert'; + @override String get checkFailed => 'Fehler bei der Updateprüfung'; +} + +// Path: settings +class _StringsSettingsDe implements _StringsSettingsEn { + _StringsSettingsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Einstellungen'; + @override String get language => 'Sprache'; + @override String get theme => 'Theme'; + @override String get appearance => 'Darstellung'; + @override String get videoPlayback => 'Videowiedergabe'; + @override String get shufflePlay => 'Zufallswiedergabe'; + @override String get advanced => 'Erweitert'; + @override String get useSeasonPostersDescription => 'Staffelposter statt Serienposter für Episoden anzeigen'; + @override String get showHeroSectionDescription => 'Bereich mit empfohlenen Inhalten auf der Startseite anzeigen'; + @override String get secondsLabel => 'Sekunden'; + @override String get minutesLabel => 'Minuten'; + @override String get secondsShort => 's'; + @override String get minutesShort => 'm'; + @override String durationHint({required Object min, required Object max}) => 'Dauer eingeben (${min}-${max})'; + @override String get systemTheme => 'System'; + @override String get systemThemeDescription => 'Systemeinstellungen folgen'; + @override String get lightTheme => 'Hell'; + @override String get darkTheme => 'Dunkel'; + @override String get libraryDensity => 'Bibliotheksdichte'; + @override String get compact => 'Kompakt'; + @override String get compactDescription => 'Kleinere Karten, mehr Elemente sichtbar'; + @override String get normal => 'Normal'; + @override String get normalDescription => 'Standardgröße'; + @override String get comfortable => 'Großzügig'; + @override String get comfortableDescription => 'Größere Karten, weniger Elemente sichtbar'; + @override String get viewMode => 'Ansichtsmodus'; + @override String get gridView => 'Raster'; + @override String get gridViewDescription => 'Elemente im Raster anzeigen'; + @override String get listView => 'Liste'; + @override String get listViewDescription => 'Elemente in Listenansicht anzeigen'; + @override String get useSeasonPosters => 'Staffelposter verwenden'; + @override String get showHeroSection => 'Hero-Bereich anzeigen'; + @override String get hardwareDecoding => 'Hardware-Decodierung'; + @override String get hardwareDecodingDescription => 'Hardwarebeschleunigung verwenden, wenn verfügbar'; + @override String get bufferSize => 'Puffergröße'; + @override String bufferSizeMB({required Object size}) => '${size}MB'; + @override String get subtitleStyling => 'Untertitel-Stil'; + @override String get subtitleStylingDescription => 'Aussehen von Untertiteln anpassen'; + @override String get smallSkipDuration => 'Kleine Sprungdauer'; + @override String get largeSkipDuration => 'Große Sprungdauer'; + @override String secondsUnit({required Object seconds}) => '${seconds} Sekunden'; + @override String get defaultSleepTimer => 'Standard-Sleep-Timer'; + @override String minutesUnit({required Object minutes}) => '${minutes} Minuten'; + @override String get rememberTrackSelections => 'Spurauswahl pro Serie/Film merken'; + @override String get rememberTrackSelectionsDescription => 'Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert'; + @override String get unwatchedOnly => 'Nur ungesehene'; + @override String get unwatchedOnlyDescription => 'Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen'; + @override String get shuffleOrderNavigation => 'Navigation der Shuffle-Reihenfolge'; + @override String get shuffleOrderNavigationDescription => 'Weiter/Zurück folgt der zufälligen Reihenfolge'; + @override String get loopShuffleQueue => 'Shuffle-Warteschlange wiederholen'; + @override String get loopShuffleQueueDescription => 'Warteschlange neu starten, wenn das Ende erreicht ist'; + @override String get videoPlayerControls => 'Videoplayer-Steuerung'; + @override String get keyboardShortcuts => 'Tastenkürzel'; + @override String get keyboardShortcutsDescription => 'Tastenkürzel anpassen'; + @override String get debugLogging => 'Debug-Protokollierung'; + @override String get debugLoggingDescription => 'Detaillierte Protokolle zur Fehleranalyse aktivieren'; + @override String get viewLogs => 'Protokolle anzeigen'; + @override String get viewLogsDescription => 'App-Protokolle anzeigen'; + @override String get clearCache => 'Cache löschen'; + @override String get clearCacheDescription => 'Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.'; + @override String get clearCacheSuccess => 'Cache erfolgreich gelöscht'; + @override String get resetSettings => 'Einstellungen zurücksetzen'; + @override String get resetSettingsDescription => 'Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.'; + @override String get resetSettingsSuccess => 'Einstellungen erfolgreich zurückgesetzt'; + @override String get shortcutsReset => 'Tastenkürzel auf Standard zurückgesetzt'; + @override String get about => 'Über'; + @override String get aboutDescription => 'App-Informationen und Lizenzen'; + @override String get updates => 'Updates'; + @override String get updateAvailable => 'Update verfügbar'; + @override String get checkForUpdates => 'Nach Updates suchen'; + @override String get validationErrorEnterNumber => 'Bitte eine gültige Zahl eingeben'; + @override String validationErrorDuration({required Object min, required Object max, required Object unit}) => 'Dauer muss zwischen ${min} und ${max} ${unit} liegen'; + @override String shortcutAlreadyAssigned({required Object action}) => 'Tastenkürzel bereits zugewiesen an ${action}'; + @override String shortcutUpdated({required Object action}) => 'Tastenkürzel aktualisiert für ${action}'; +} + +// Path: search +class _StringsSearchDe implements _StringsSearchEn { + _StringsSearchDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get hint => 'Filme, Serien, Musik suchen...'; + @override String get tryDifferentTerm => 'Anderen Suchbegriff versuchen'; + @override String get searchYourMedia => 'In den eigenen Medien suchen'; + @override String get enterTitleActorOrKeyword => 'Titel, Schauspieler oder Stichwort eingeben'; +} + +// Path: hotkeys +class _StringsHotkeysDe implements _StringsHotkeysEn { + _StringsHotkeysDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String setShortcutFor({required Object actionName}) => 'Tastenkürzel festlegen für ${actionName}'; + @override String get clearShortcut => 'Kürzel löschen'; +} + +// Path: pinEntry +class _StringsPinEntryDe implements _StringsPinEntryEn { + _StringsPinEntryDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get enterPin => 'PIN eingeben'; + @override String get showPin => 'PIN anzeigen'; + @override String get hidePin => 'PIN verbergen'; +} + +// Path: fileInfo +class _StringsFileInfoDe implements _StringsFileInfoEn { + _StringsFileInfoDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Dateiinfo'; + @override String get video => 'Video'; + @override String get audio => 'Audio'; + @override String get file => 'Datei'; + @override String get advanced => 'Erweitert'; + @override String get codec => 'Codec'; + @override String get resolution => 'Auflösung'; + @override String get bitrate => 'Bitrate'; + @override String get frameRate => 'Bildrate'; + @override String get aspectRatio => 'Seitenverhältnis'; + @override String get profile => 'Profil'; + @override String get bitDepth => 'Farbtiefe'; + @override String get colorSpace => 'Farbraum'; + @override String get colorRange => 'Farbbereich'; + @override String get colorPrimaries => 'Primärfarben'; + @override String get chromaSubsampling => 'Chroma-Subsampling'; + @override String get channels => 'Kanäle'; + @override String get path => 'Pfad'; + @override String get size => 'Größe'; + @override String get container => 'Container'; + @override String get duration => 'Dauer'; + @override String get optimizedForStreaming => 'Für Streaming optimiert'; + @override String get has64bitOffsets => '64-Bit-Offsets'; +} + +// Path: mediaMenu +class _StringsMediaMenuDe implements _StringsMediaMenuEn { + _StringsMediaMenuDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get markAsWatched => 'Als gesehen markieren'; + @override String get markAsUnwatched => 'Als ungesehen markieren'; + @override String get removeFromContinueWatching => 'Aus ‚Weiterschauen‘ entfernen'; + @override String get goToSeries => 'Zur Serie'; + @override String get goToSeason => 'Zur Staffel'; + @override String get shufflePlay => 'Zufallswiedergabe'; + @override String get fileInfo => 'Dateiinfo'; +} + +// Path: tooltips +class _StringsTooltipsDe implements _StringsTooltipsEn { + _StringsTooltipsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get shufflePlay => 'Zufallswiedergabe'; + @override String get markAsWatched => 'Als gesehen markieren'; + @override String get markAsUnwatched => 'Als ungesehen markieren'; +} + +// Path: videoControls +class _StringsVideoControlsDe implements _StringsVideoControlsEn { + _StringsVideoControlsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get audioLabel => 'Audio'; + @override String get subtitlesLabel => 'Untertitel'; + @override String get resetToZero => 'Auf 0 ms zurücksetzen'; + @override String addTime({required Object amount, required Object unit}) => '+${amount}${unit}'; + @override String minusTime({required Object amount, required Object unit}) => '-${amount}${unit}'; + @override String playsLater({required Object label}) => '${label} spielt später'; + @override String playsEarlier({required Object label}) => '${label} spielt früher'; + @override String get noOffset => 'Kein Offset'; + @override String get letterbox => 'Letterbox'; + @override String get fillScreen => 'Bild füllen'; + @override String get stretch => 'Strecken'; + @override String get lockRotation => 'Rotation sperren'; + @override String get unlockRotation => 'Rotation entsperren'; +} + +// Path: userStatus +class _StringsUserStatusDe implements _StringsUserStatusEn { + _StringsUserStatusDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get admin => 'Admin'; + @override String get restricted => 'Eingeschränkt'; + @override String get protected => 'Geschützt'; +} + +// Path: messages +class _StringsMessagesDe implements _StringsMessagesEn { + _StringsMessagesDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get markedAsWatched => 'Als gesehen markiert'; + @override String get markedAsUnwatched => 'Als ungesehen markiert'; + @override String get removedFromContinueWatching => 'Aus ‚Weiterschauen‘ entfernt'; + @override String errorLoading({required Object error}) => 'Fehler: ${error}'; + @override String get fileInfoNotAvailable => 'Dateiinfo nicht verfügbar'; + @override String errorLoadingFileInfo({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}'; + @override String get errorLoadingSeries => 'Fehler beim Laden der Serie'; + @override String get errorLoadingSeason => 'Fehler beim Laden der Staffel'; + @override String get musicNotSupported => 'Musikwiedergabe wird noch nicht unterstützt'; + @override String get logsCleared => 'Protokolle gelöscht'; + @override String get logsCopied => 'Protokolle in Zwischenablage kopiert'; + @override String get noLogsAvailable => 'Keine Protokolle verfügbar'; + @override String libraryScanning({required Object title}) => 'Scanne „${title}“...'; + @override String libraryScanStarted({required Object title}) => 'Bibliotheksscan gestartet für „${title}“'; + @override String libraryScanFailed({required Object error}) => 'Fehler beim Scannen der Bibliothek: ${error}'; + @override String metadataRefreshing({required Object title}) => 'Metadaten werden aktualisiert für „${title}“...'; + @override String metadataRefreshStarted({required Object title}) => 'Metadaten-Aktualisierung gestartet für „${title}“'; + @override String metadataRefreshFailed({required Object error}) => 'Metadaten konnten nicht aktualisiert werden: ${error}'; + @override String get noPlexToken => 'Kein Plex-Token gefunden. Bitte erneut anmelden.'; + @override String get logoutConfirm => 'Abmeldung wirklich durchführen?'; + @override String get noSeasonsFound => 'Keine Staffeln gefunden'; + @override String get noEpisodesFound => 'Keine Episoden in der ersten Staffel gefunden'; + @override String get noEpisodesFoundGeneral => 'Keine Episoden gefunden'; + @override String get noResultsFound => 'Keine Ergebnisse gefunden'; + @override String sleepTimerSet({required Object label}) => 'Sleep-Timer gesetzt auf ${label}'; + @override String failedToSwitchProfile({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; +} + +// Path: profile +class _StringsProfileDe implements _StringsProfileEn { + _StringsProfileDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get noUsersAvailable => 'Keine Benutzer verfügbar'; +} + +// Path: subtitlingStyling +class _StringsSubtitlingStylingDe implements _StringsSubtitlingStylingEn { + _StringsSubtitlingStylingDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get stylingOptions => 'Stiloptionen'; + @override String get fontSize => 'Schriftgröße'; + @override String get textColor => 'Textfarbe'; + @override String get borderSize => 'Rahmengröße'; + @override String get borderColor => 'Rahmenfarbe'; + @override String get backgroundOpacity => 'Hintergrunddeckkraft'; + @override String get backgroundColor => 'Hintergrundfarbe'; +} + +// Path: dialog +class _StringsDialogDe implements _StringsDialogEn { + _StringsDialogDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get confirmAction => 'Aktion bestätigen'; + @override String get areYouSure => 'Aktion wirklich ausführen?'; + @override String get cancel => 'Abbrechen'; + @override String get playNow => 'Jetzt abspielen'; +} + +// Path: discover +class _StringsDiscoverDe implements _StringsDiscoverEn { + _StringsDiscoverDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Entdecken'; + @override String get switchProfile => 'Profil wechseln'; + @override String get switchServer => 'Server wechseln'; + @override String get logout => 'Abmelden'; + @override String get noContentAvailable => 'Kein Inhalt verfügbar'; + @override String get addMediaToLibraries => 'Medien zur Bibliothek hinzufügen'; + @override String get continueWatching => 'Weiterschauen'; + @override String get recentlyAdded => 'Kürzlich hinzugefügt'; + @override String get play => 'Abspielen'; + @override String get resume => 'Fortsetzen'; + @override String playEpisode({required Object season, required Object episode}) => 'Spiele S${season}, E${episode}'; + @override String resumeEpisode({required Object season, required Object episode}) => 'Fortsetzen S${season}, E${episode}'; + @override String get pause => 'Pause'; + @override String get overview => 'Übersicht'; + @override String get cast => 'Besetzung'; + @override String episodeCount({required Object count}) => '${count} Episoden'; + @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} gesehen'; + @override String get movie => 'Film'; + @override String get tvShow => 'Serie'; + @override String minutesLeft({required Object minutes}) => '${minutes} Min übrig'; +} + +// Path: errors +class _StringsErrorsDe implements _StringsErrorsEn { + _StringsErrorsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String searchFailed({required Object error}) => 'Suche fehlgeschlagen: ${error}'; + @override String connectionTimeout({required Object context}) => 'Zeitüberschreitung beim Laden von ${context}'; + @override String get connectionFailed => 'Verbindung zum Plex-Server fehlgeschlagen'; + @override String failedToLoad({required Object context, required Object error}) => 'Fehler beim Laden von ${context}: ${error}'; + @override String get noClientAvailable => 'Kein Client verfügbar'; + @override String authenticationFailed({required Object error}) => 'Authentifizierung fehlgeschlagen: ${error}'; + @override String get couldNotLaunchUrl => 'Auth-URL konnte nicht geöffnet werden'; + @override String get pleaseEnterToken => 'Bitte Token eingeben'; + @override String get invalidToken => 'Ungültiges Token'; + @override String failedToVerifyToken({required Object error}) => 'Token-Verifizierung fehlgeschlagen: ${error}'; + @override String failedToSwitchProfile({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + @override String get connectionFailedGeneric => 'Verbindung fehlgeschlagen'; +} + +// Path: libraries +class _StringsLibrariesDe implements _StringsLibrariesEn { + _StringsLibrariesDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Bibliotheken'; + @override String get scanLibraryFiles => 'Bibliotheksdateien scannen'; + @override String get scanLibrary => 'Bibliothek scannen'; + @override String get analyze => 'Analysieren'; + @override String get analyzeLibrary => 'Bibliothek analysieren'; + @override String get refreshMetadata => 'Metadaten aktualisieren'; + @override String get emptyTrash => 'Papierkorb leeren'; + @override String emptyingTrash({required Object title}) => 'Papierkorb für „${title}“ wird geleert...'; + @override String trashEmptied({required Object title}) => 'Papierkorb für „${title}“ geleert'; + @override String failedToEmptyTrash({required Object error}) => 'Papierkorb konnte nicht geleert werden: ${error}'; + @override String analyzing({required Object title}) => 'Analysiere „${title}“...'; + @override String analysisStarted({required Object title}) => 'Analyse gestartet für „${title}“'; + @override String failedToAnalyze({required Object error}) => 'Analyse der Bibliothek fehlgeschlagen: ${error}'; + @override String get noLibrariesFound => 'Keine Bibliotheken gefunden'; + @override String get thisLibraryIsEmpty => 'Diese Bibliothek ist leer'; + @override String get all => 'Alle'; + @override String get clearAll => 'Alle löschen'; + @override String scanLibraryConfirm({required Object title}) => '„${title}“ wirklich scannen?'; + @override String analyzeLibraryConfirm({required Object title}) => '„${title}“ wirklich analysieren?'; + @override String refreshMetadataConfirm({required Object title}) => 'Metadaten für „${title}“ wirklich aktualisieren?'; + @override String emptyTrashConfirm({required Object title}) => 'Papierkorb für „${title}“ wirklich leeren?'; + @override String get manageLibraries => 'Bibliotheken verwalten'; + @override String get sort => 'Sortieren'; + @override String get sortBy => 'Sortieren nach'; + @override String get filters => 'Filter'; + @override String loadingLibraryWithCount({required Object count}) => 'Lade Bibliothek... (${count} Elemente geladen)'; + @override String get confirmActionMessage => 'Aktion wirklich durchführen?'; + @override String get showLibrary => 'Bibliothek anzeigen'; + @override String get hideLibrary => 'Bibliothek ausblenden'; + @override String get libraryOptions => 'Bibliotheksoptionen'; +} + +// Path: about +class _StringsAboutDe implements _StringsAboutEn { + _StringsAboutDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Über'; + @override String get openSourceLicenses => 'Open-Source-Lizenzen'; + @override String versionLabel({required Object version}) => 'Version ${version}'; + @override String get appDescription => 'Ein schöner Plex-Client für Flutter'; + @override String get viewLicensesDescription => 'Lizenzen von Drittanbieter-Bibliotheken anzeigen'; +} + +// Path: serverSelection +class _StringsServerSelectionDe implements _StringsServerSelectionEn { + _StringsServerSelectionDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get connectingToServer => 'Verbinde mit Server...'; + @override String get serverDebugCopied => 'Server-Debugdaten kopiert'; + @override String get copyDebugData => 'Debugdaten kopieren'; + @override String get noServersFound => 'Keine Server gefunden'; + @override String malformedServerData({required Object count}) => '${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.'; + @override String get incompleteServerInfo => 'Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.'; + @override String get incompleteConnectionInfo => 'Serververbindungsinformationen unvollständig. Bitte erneut versuchen.'; + @override String malformedServerInfo({required Object message}) => 'Serverinformationen fehlerhaft: ${message}'; + @override String get networkConnectionFailed => 'Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.'; + @override String get authenticationFailed => 'Authentifizierung fehlgeschlagen. Bitte erneut anmelden.'; + @override String get plexServiceUnavailable => 'Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.'; + @override String failedToLoadServers({required Object error}) => 'Server konnten nicht geladen werden: ${error}'; +} + +// Path: hubDetail +class _StringsHubDetailDe implements _StringsHubDetailEn { + _StringsHubDetailDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Titel'; + @override String get releaseYear => 'Erscheinungsjahr'; + @override String get dateAdded => 'Hinzugefügt am'; + @override String get rating => 'Bewertung'; + @override String get noItemsFound => 'Keine Elemente gefunden'; +} + +// Path: logs +class _StringsLogsDe implements _StringsLogsEn { + _StringsLogsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Protokolle'; + @override String get clearLogs => 'Protokolle löschen'; + @override String get copyLogs => 'Protokolle kopieren'; + @override String get exportLogs => 'Protokolle exportieren'; + @override String get noLogsToShow => 'Keine Protokolle zum Anzeigen'; + @override String get error => 'Fehler:'; + @override String get stackTrace => 'Stacktrace:'; +} + +// Path: licenses +class _StringsLicensesDe implements _StringsLicensesEn { + _StringsLicensesDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get relatedPackages => 'Verwandte Pakete'; + @override String get license => 'Lizenz'; + @override String licenseNumber({required Object number}) => 'Lizenz ${number}'; + @override String licensesCount({required Object count}) => '${count} Lizenzen'; +} + +// Path: navigation +class _StringsNavigationDe implements _StringsNavigationEn { + _StringsNavigationDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get home => 'Start'; + @override String get search => 'Suche'; + @override String get libraries => 'Bibliotheken'; + @override String get settings => 'Einstellungen'; +} + // Path: class _StringsIt implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -2574,6 +3188,616 @@ class _StringsNavigationSv implements _StringsNavigationEn { @override String get settings => 'Inställningar'; } +// Path: +class _StringsZh implements Translations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + _StringsZh.build({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = TranslationMetadata( + locale: AppLocale.zh, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ) { + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + @override dynamic operator[](String key) => $meta.getTranslation(key); + + @override late final _StringsZh _root = this; // ignore: unused_field + + // Translations + @override late final _StringsAppZh app = _StringsAppZh._(_root); + @override late final _StringsAuthZh auth = _StringsAuthZh._(_root); + @override late final _StringsCommonZh common = _StringsCommonZh._(_root); + @override late final _StringsScreensZh screens = _StringsScreensZh._(_root); + @override late final _StringsUpdateZh update = _StringsUpdateZh._(_root); + @override late final _StringsSettingsZh settings = _StringsSettingsZh._(_root); + @override late final _StringsSearchZh search = _StringsSearchZh._(_root); + @override late final _StringsHotkeysZh hotkeys = _StringsHotkeysZh._(_root); + @override late final _StringsPinEntryZh pinEntry = _StringsPinEntryZh._(_root); + @override late final _StringsFileInfoZh fileInfo = _StringsFileInfoZh._(_root); + @override late final _StringsMediaMenuZh mediaMenu = _StringsMediaMenuZh._(_root); + @override late final _StringsTooltipsZh tooltips = _StringsTooltipsZh._(_root); + @override late final _StringsVideoControlsZh videoControls = _StringsVideoControlsZh._(_root); + @override late final _StringsUserStatusZh userStatus = _StringsUserStatusZh._(_root); + @override late final _StringsMessagesZh messages = _StringsMessagesZh._(_root); + @override late final _StringsProfileZh profile = _StringsProfileZh._(_root); + @override late final _StringsSubtitlingStylingZh subtitlingStyling = _StringsSubtitlingStylingZh._(_root); + @override late final _StringsDialogZh dialog = _StringsDialogZh._(_root); + @override late final _StringsDiscoverZh discover = _StringsDiscoverZh._(_root); + @override late final _StringsErrorsZh errors = _StringsErrorsZh._(_root); + @override late final _StringsLibrariesZh libraries = _StringsLibrariesZh._(_root); + @override late final _StringsAboutZh about = _StringsAboutZh._(_root); + @override late final _StringsServerSelectionZh serverSelection = _StringsServerSelectionZh._(_root); + @override late final _StringsHubDetailZh hubDetail = _StringsHubDetailZh._(_root); + @override late final _StringsLogsZh logs = _StringsLogsZh._(_root); + @override late final _StringsLicensesZh licenses = _StringsLicensesZh._(_root); + @override late final _StringsNavigationZh navigation = _StringsNavigationZh._(_root); +} + +// Path: app +class _StringsAppZh implements _StringsAppEn { + _StringsAppZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => 'Plezy'; + @override String get loading => '加载中...'; +} + +// Path: auth +class _StringsAuthZh implements _StringsAuthEn { + _StringsAuthZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get signInWithPlex => '使用 Plex 登录'; + @override String get showQRCode => '显示二维码'; + @override String get cancel => '取消'; + @override String get authenticate => '验证'; + @override String get retry => '重试'; + @override String get debugEnterToken => '调试:输入 Plex Token'; + @override String get plexTokenLabel => 'Plex 授权令牌 (Auth Token)'; + @override String get plexTokenHint => '输入你的 Plex.tv 令牌'; + @override String get authenticationTimeout => '验证超时。请重试。'; + @override String get scanQRCodeInstruction => '请使用已登录 Plex 的设备扫描此二维码进行验证。'; + @override String get waitingForAuth => '等待验证中...\n请在你的浏览器中完成登录。'; +} + +// Path: common +class _StringsCommonZh implements _StringsCommonEn { + _StringsCommonZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get cancel => '取消'; + @override String get save => '保存'; + @override String get close => '关闭'; + @override String get clear => '清除'; + @override String get reset => '重置'; + @override String get later => '稍后'; + @override String get submit => '提交'; + @override String get confirm => '确认'; + @override String get retry => '重试'; + @override String get playNow => '立即播放'; + @override String get logout => '登出'; + @override String get online => '在线'; + @override String get offline => '离线'; + @override String get owned => '拥有'; + @override String get shared => '共享'; + @override String get current => '当前'; + @override String get unknown => '未知'; + @override String get refresh => '刷新'; + @override String get yes => '是'; + @override String get no => '否'; + @override String get server => '服务器'; +} + +// Path: screens +class _StringsScreensZh implements _StringsScreensEn { + _StringsScreensZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get licenses => '许可证'; + @override String get selectServer => '选择服务器'; + @override String get switchProfile => '切换配置文件'; + @override String get subtitleStyling => '字幕样式'; + @override String get search => '搜索'; + @override String get logs => '日志'; +} + +// Path: update +class _StringsUpdateZh implements _StringsUpdateEn { + _StringsUpdateZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get available => '有可用更新'; + @override String versionAvailable({required Object version}) => '版本 ${version} 已发布'; + @override String currentVersion({required Object version}) => '当前版本: ${version}'; + @override String get skipVersion => '跳过此版本'; + @override String get viewRelease => '查看发布详情'; + @override String get latestVersion => '已安装的版本是可用的最新版本'; + @override String get checkFailed => '无法检查更新'; +} + +// Path: settings +class _StringsSettingsZh implements _StringsSettingsEn { + _StringsSettingsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '设置'; + @override String get language => '语言'; + @override String get theme => '主题'; + @override String get appearance => '外观'; + @override String get videoPlayback => '视频播放'; + @override String get shufflePlay => '随机播放'; + @override String get advanced => '高级'; + @override String get useSeasonPostersDescription => '为剧集显示季海报而非剧集海报'; + @override String get showHeroSectionDescription => '在主屏幕上显示精选内容轮播区'; + @override String get secondsLabel => '秒'; + @override String get minutesLabel => '分钟'; + @override String get secondsShort => 's'; + @override String get minutesShort => 'm'; + @override String durationHint({required Object min, required Object max}) => '输入时长 (${min}-${max})'; + @override String get systemTheme => '系统'; + @override String get systemThemeDescription => '跟随系统设置'; + @override String get lightTheme => '浅色'; + @override String get darkTheme => '深色'; + @override String get libraryDensity => '媒体库密度'; + @override String get compact => '紧凑'; + @override String get compactDescription => '卡片更小,显示更多项目'; + @override String get normal => '标准'; + @override String get normalDescription => '默认尺寸'; + @override String get comfortable => '舒适'; + @override String get comfortableDescription => '卡片更大,显示更少项目'; + @override String get viewMode => '视图模式'; + @override String get gridView => '网格视图'; + @override String get gridViewDescription => '以网格布局显示项目'; + @override String get listView => '列表视图'; + @override String get listViewDescription => '以列表布局显示项目'; + @override String get useSeasonPosters => '使用季海报'; + @override String get showHeroSection => '显示主要精选区'; + @override String get hardwareDecoding => '硬件解码'; + @override String get hardwareDecodingDescription => '如果可用,使用硬件加速'; + @override String get bufferSize => '缓冲区大小'; + @override String bufferSizeMB({required Object size}) => '${size}MB'; + @override String get subtitleStyling => '字幕样式'; + @override String get subtitleStylingDescription => '调整字幕外观'; + @override String get smallSkipDuration => '短跳过时长'; + @override String get largeSkipDuration => '长跳过时长'; + @override String secondsUnit({required Object seconds}) => '${seconds} 秒'; + @override String get defaultSleepTimer => '默认睡眠定时器'; + @override String minutesUnit({required Object minutes}) => '${minutes} 分钟'; + @override String get unwatchedOnly => '仅未观看'; + @override String get unwatchedOnlyDescription => '随机队列中仅包含未观看的剧集'; + @override String get shuffleOrderNavigation => '随机顺序导航'; + @override String get shuffleOrderNavigationDescription => '下一集/上一集按钮遵循随机播放顺序'; + @override String get loopShuffleQueue => '循环随机队列'; + @override String get loopShuffleQueueDescription => '在队列结束时重新开始'; + @override String get videoPlayerControls => '视频播放器控制'; + @override String get keyboardShortcuts => '键盘快捷键'; + @override String get keyboardShortcutsDescription => '自定义键盘快捷键'; + @override String get debugLogging => '调试日志'; + @override String get debugLoggingDescription => '启用详细日志记录以便故障排除'; + @override String get viewLogs => '查看日志'; + @override String get viewLogsDescription => '查看应用程序日志'; + @override String get clearCache => '清除缓存'; + @override String get clearCacheDescription => '这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。'; + @override String get clearCacheSuccess => '缓存清除成功'; + @override String get resetSettings => '重置设置'; + @override String get resetSettingsDescription => '这会将所有设置重置为其默认值。此操作无法撤销。'; + @override String get resetSettingsSuccess => '设置重置成功'; + @override String get shortcutsReset => '快捷键已重置为默认值'; + @override String get about => '关于'; + @override String get aboutDescription => '应用程序信息和许可证'; + @override String get updates => '更新'; + @override String get updateAvailable => '有可用更新'; + @override String get checkForUpdates => '检查更新'; + @override String get validationErrorEnterNumber => '请输入一个有效的数字'; + @override String validationErrorDuration({required Object min, required Object max, required Object unit}) => '时长必须介于 ${min} 和 ${max} ${unit} 之间'; + @override String shortcutAlreadyAssigned({required Object action}) => '快捷键已被分配给 ${action}'; + @override String shortcutUpdated({required Object action}) => '快捷键已为 ${action} 更新'; +} + +// Path: search +class _StringsSearchZh implements _StringsSearchEn { + _StringsSearchZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get hint => '搜索电影、系列、音乐...'; + @override String get tryDifferentTerm => '尝试不同的搜索词'; + @override String get searchYourMedia => '搜索媒体'; + @override String get enterTitleActorOrKeyword => '输入标题、演员或关键词'; +} + +// Path: hotkeys +class _StringsHotkeysZh implements _StringsHotkeysEn { + _StringsHotkeysZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String setShortcutFor({required Object actionName}) => '为 ${actionName} 设置快捷键'; + @override String get clearShortcut => '清除快捷键'; +} + +// Path: pinEntry +class _StringsPinEntryZh implements _StringsPinEntryEn { + _StringsPinEntryZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get enterPin => '输入 PIN'; + @override String get showPin => '显示 PIN'; + @override String get hidePin => '隐藏 PIN'; +} + +// Path: fileInfo +class _StringsFileInfoZh implements _StringsFileInfoEn { + _StringsFileInfoZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '文件信息'; + @override String get video => '视频'; + @override String get audio => '音频'; + @override String get file => '文件'; + @override String get advanced => '高级'; + @override String get codec => '编解码器'; + @override String get resolution => '分辨率'; + @override String get bitrate => '比特率'; + @override String get frameRate => '帧率'; + @override String get aspectRatio => '宽高比'; + @override String get profile => '配置文件'; + @override String get bitDepth => '位深度'; + @override String get colorSpace => '色彩空间'; + @override String get colorRange => '色彩范围'; + @override String get colorPrimaries => '颜色原色'; + @override String get chromaSubsampling => '色度子采样'; + @override String get channels => '声道'; + @override String get path => '路径'; + @override String get size => '大小'; + @override String get container => '容器'; + @override String get duration => '时长'; + @override String get optimizedForStreaming => '已优化用于流媒体'; + @override String get has64bitOffsets => '64位偏移量'; +} + +// Path: mediaMenu +class _StringsMediaMenuZh implements _StringsMediaMenuEn { + _StringsMediaMenuZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get markAsWatched => '标记为已观看'; + @override String get markAsUnwatched => '标记为未观看'; + @override String get removeFromContinueWatching => '从继续观看中移除'; + @override String get goToSeries => '转到系列'; + @override String get goToSeason => '转到季'; + @override String get shufflePlay => '随机播放'; + @override String get fileInfo => '文件信息'; +} + +// Path: tooltips +class _StringsTooltipsZh implements _StringsTooltipsEn { + _StringsTooltipsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get shufflePlay => '随机播放'; + @override String get markAsWatched => '标记为已观看'; + @override String get markAsUnwatched => '标记为未观看'; +} + +// Path: videoControls +class _StringsVideoControlsZh implements _StringsVideoControlsEn { + _StringsVideoControlsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get audioLabel => '音频'; + @override String get subtitlesLabel => '字幕'; + @override String get resetToZero => '重置为 0ms'; + @override String addTime({required Object amount, required Object unit}) => '+${amount}${unit}'; + @override String minusTime({required Object amount, required Object unit}) => '-${amount}${unit}'; + @override String playsLater({required Object label}) => '${label} 播放较晚'; + @override String playsEarlier({required Object label}) => '${label} 播放较早'; + @override String get noOffset => '无偏移'; + @override String get letterbox => '信箱模式(Letterbox)'; + @override String get fillScreen => '填充屏幕'; + @override String get stretch => '拉伸'; + @override String get lockRotation => '锁定旋转'; + @override String get unlockRotation => '解锁旋转'; +} + +// Path: userStatus +class _StringsUserStatusZh implements _StringsUserStatusEn { + _StringsUserStatusZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get admin => '管理员'; + @override String get restricted => '受限'; + @override String get protected => '受保护'; +} + +// Path: messages +class _StringsMessagesZh implements _StringsMessagesEn { + _StringsMessagesZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get markedAsWatched => '已标记为已观看'; + @override String get markedAsUnwatched => '已标记为未观看'; + @override String get removedFromContinueWatching => '已从继续观看中移除'; + @override String errorLoading({required Object error}) => '错误: ${error}'; + @override String get fileInfoNotAvailable => '文件信息不可用'; + @override String errorLoadingFileInfo({required Object error}) => '加载文件信息时出错: ${error}'; + @override String get errorLoadingSeries => '加载系列时出错'; + @override String get errorLoadingSeason => '加载季时出错'; + @override String get musicNotSupported => '尚不支持播放音乐'; + @override String get logsCleared => '日志已清除'; + @override String get logsCopied => '日志已复制到剪贴板'; + @override String get noLogsAvailable => '没有可用日志'; + @override String libraryScanning({required Object title}) => '正在扫描 “${title}”...'; + @override String libraryScanStarted({required Object title}) => '已开始扫描 “${title}” 媒体库'; + @override String libraryScanFailed({required Object error}) => '无法扫描媒体库: ${error}'; + @override String metadataRefreshing({required Object title}) => '正在刷新 “${title}” 的元数据...'; + @override String metadataRefreshStarted({required Object title}) => '已开始刷新 “${title}” 的元数据'; + @override String metadataRefreshFailed({required Object error}) => '无法刷新元数据: ${error}'; + @override String get noPlexToken => '未找到 Plex 令牌。请重新登录。'; + @override String get logoutConfirm => '你确定要登出吗?'; + @override String get noSeasonsFound => '未找到季'; + @override String get noEpisodesFound => '在第一季中未找到剧集'; + @override String get noEpisodesFoundGeneral => '未找到剧集'; + @override String get noResultsFound => '未找到结果'; + @override String sleepTimerSet({required Object label}) => '睡眠定时器已设置为 ${label}'; + @override String failedToSwitchProfile({required Object displayName}) => '无法切换到 ${displayName}'; +} + +// Path: profile +class _StringsProfileZh implements _StringsProfileEn { + _StringsProfileZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get noUsersAvailable => '没有可用用户'; +} + +// Path: subtitlingStyling +class _StringsSubtitlingStylingZh implements _StringsSubtitlingStylingEn { + _StringsSubtitlingStylingZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get stylingOptions => '样式选项'; + @override String get fontSize => '字号'; + @override String get textColor => '文本颜色'; + @override String get borderSize => '边框大小'; + @override String get borderColor => '边框颜色'; + @override String get backgroundOpacity => '背景不透明度'; + @override String get backgroundColor => '背景颜色'; +} + +// Path: dialog +class _StringsDialogZh implements _StringsDialogEn { + _StringsDialogZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get confirmAction => '确认操作'; + @override String get areYouSure => '确定要执行此操作吗?'; + @override String get cancel => '取消'; + @override String get playNow => '立即播放'; +} + +// Path: discover +class _StringsDiscoverZh implements _StringsDiscoverEn { + _StringsDiscoverZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '发现'; + @override String get switchProfile => '切换配置文件'; + @override String get switchServer => '切换服务器'; + @override String get logout => '登出'; + @override String get noContentAvailable => '没有可用内容'; + @override String get addMediaToLibraries => '请向你的媒体库添加一些媒体'; + @override String get continueWatching => '继续观看'; + @override String get recentlyAdded => '最近添加'; + @override String get play => '播放'; + @override String get resume => '继续'; + @override String playEpisode({required Object season, required Object episode}) => '播放 S${season}, E${episode}'; + @override String resumeEpisode({required Object season, required Object episode}) => '继续 S${season}, E${episode}'; + @override String get pause => '暂停'; + @override String get overview => '概述'; + @override String get cast => '演员表'; + @override String episodeCount({required Object count}) => '${count} 集'; + @override String watchedProgress({required Object watched, required Object total}) => '已观看 ${watched}/${total} 集'; + @override String get movie => '电影'; + @override String get tvShow => '电视剧'; + @override String minutesLeft({required Object minutes}) => '剩余 ${minutes} 分钟'; +} + +// Path: errors +class _StringsErrorsZh implements _StringsErrorsEn { + _StringsErrorsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String searchFailed({required Object error}) => '搜索失败: ${error}'; + @override String connectionTimeout({required Object context}) => '加载 ${context} 时连接超时'; + @override String get connectionFailed => '无法连接到 Plex 服务器'; + @override String failedToLoad({required Object context, required Object error}) => '无法加载 ${context}: ${error}'; + @override String get noClientAvailable => '没有可用客户端'; + @override String authenticationFailed({required Object error}) => '验证失败: ${error}'; + @override String get couldNotLaunchUrl => '无法打开授权 URL'; + @override String get pleaseEnterToken => '请输入一个令牌'; + @override String get invalidToken => '令牌无效'; + @override String failedToVerifyToken({required Object error}) => '无法验证令牌: ${error}'; + @override String failedToSwitchProfile({required Object displayName}) => '无法切换到 ${displayName}'; + @override String get connectionFailedGeneric => '连接失败'; +} + +// Path: libraries +class _StringsLibrariesZh implements _StringsLibrariesEn { + _StringsLibrariesZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '媒体库'; + @override String get scanLibraryFiles => '扫描媒体库文件'; + @override String get scanLibrary => '扫描媒体库'; + @override String get analyze => '分析'; + @override String get analyzeLibrary => '分析媒体库'; + @override String get refreshMetadata => '刷新元数据'; + @override String get emptyTrash => '清空回收站'; + @override String emptyingTrash({required Object title}) => '正在清空 “${title}” 的回收站...'; + @override String trashEmptied({required Object title}) => '已清空 “${title}” 的回收站'; + @override String failedToEmptyTrash({required Object error}) => '无法清空回收站: ${error}'; + @override String analyzing({required Object title}) => '正在分析 “${title}”...'; + @override String analysisStarted({required Object title}) => '已开始分析 “${title}”'; + @override String failedToAnalyze({required Object error}) => '无法分析媒体库: ${error}'; + @override String get noLibrariesFound => '未找到媒体库'; + @override String get thisLibraryIsEmpty => '此媒体库为空'; + @override String get all => '全部'; + @override String get clearAll => '全部清除'; + @override String scanLibraryConfirm({required Object title}) => '确定要扫描 “${title}” 吗?'; + @override String analyzeLibraryConfirm({required Object title}) => '确定要分析 “${title}” 吗?'; + @override String refreshMetadataConfirm({required Object title}) => '确定要刷新 “${title}” 的元数据吗?'; + @override String emptyTrashConfirm({required Object title}) => '确定要清空 “${title}” 的回收站吗?'; + @override String get manageLibraries => '管理媒体库'; + @override String get sort => '排序'; + @override String get sortBy => '排序依据'; + @override String get filters => '筛选器'; + @override String loadingLibraryWithCount({required Object count}) => '正在加载媒体库... (已加载 ${count} 个项目)'; + @override String get confirmActionMessage => '确定要执行此操作吗?'; + @override String get showLibrary => '显示媒体库'; + @override String get hideLibrary => '隐藏媒体库'; + @override String get libraryOptions => '媒体库选项'; +} + +// Path: about +class _StringsAboutZh implements _StringsAboutEn { + _StringsAboutZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '关于'; + @override String get openSourceLicenses => '开源许可证'; + @override String versionLabel({required Object version}) => '版本 ${version}'; + @override String get appDescription => '一款精美的 Flutter Plex 客户端'; + @override String get viewLicensesDescription => '查看第三方库的许可证'; +} + +// Path: serverSelection +class _StringsServerSelectionZh implements _StringsServerSelectionEn { + _StringsServerSelectionZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get connectingToServer => '正在连接服务器...'; + @override String get serverDebugCopied => '服务器调试数据已复制到剪贴板'; + @override String get copyDebugData => '复制调试数据'; + @override String get noServersFound => '未找到服务器'; + @override String malformedServerData({required Object count}) => '找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。'; + @override String get incompleteServerInfo => '某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。'; + @override String get incompleteConnectionInfo => '服务器连接信息不完整。请重试。'; + @override String malformedServerInfo({required Object message}) => '服务器信息格式错误: ${message}'; + @override String get networkConnectionFailed => '网络连接失败。请检查你的网络连接并重试。'; + @override String get authenticationFailed => '验证失败。请重新登录。'; + @override String get plexServiceUnavailable => 'Plex 服务不可用。请稍后重试。'; + @override String failedToLoadServers({required Object error}) => '无法加载服务器: ${error}'; +} + +// Path: hubDetail +class _StringsHubDetailZh implements _StringsHubDetailEn { + _StringsHubDetailZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '标题'; + @override String get releaseYear => '发行年份'; + @override String get dateAdded => '添加日期'; + @override String get rating => '评分'; + @override String get noItemsFound => '未找到项目'; +} + +// Path: logs +class _StringsLogsZh implements _StringsLogsEn { + _StringsLogsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '日志'; + @override String get clearLogs => '清除日志'; + @override String get copyLogs => '复制日志'; + @override String get exportLogs => '导出日志'; + @override String get noLogsToShow => '没有可显示的日志'; + @override String get error => '错误:'; + @override String get stackTrace => '堆栈跟踪 (Stack Trace):'; +} + +// Path: licenses +class _StringsLicensesZh implements _StringsLicensesEn { + _StringsLicensesZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get relatedPackages => '相关软件包'; + @override String get license => '许可证'; + @override String licenseNumber({required Object number}) => '许可证 ${number}'; + @override String licensesCount({required Object count}) => '${count} 个许可证'; +} + +// Path: navigation +class _StringsNavigationZh implements _StringsNavigationEn { + _StringsNavigationZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get home => '主页'; + @override String get search => '搜索'; + @override String get libraries => '媒体库'; + @override String get settings => '设置'; +} + /// Flat map(s) containing all translations. /// Only for edge cases! For simple maps, use the map function of this library. @@ -2901,6 +4125,330 @@ extension on Translations { } } +extension on _StringsDe { + dynamic _flatMapFunction(String path) { + switch (path) { + case 'app.title': return 'Plezy'; + case 'app.loading': return 'Lädt...'; + case 'auth.signInWithPlex': return 'Mit Plex anmelden'; + case 'auth.showQRCode': return 'QR-Code anzeigen'; + case 'auth.cancel': return 'Abbrechen'; + case 'auth.authenticate': return 'Authentifizieren'; + case 'auth.retry': return 'Erneut versuchen'; + case 'auth.debugEnterToken': return 'Debug: Plex-Token eingeben'; + case 'auth.plexTokenLabel': return 'Plex-Auth-Token'; + case 'auth.plexTokenHint': return 'Plex.tv-Token eingeben'; + case 'auth.authenticationTimeout': return 'Authentifizierung abgelaufen. Bitte erneut versuchen.'; + case 'auth.scanQRCodeInstruction': return 'Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.'; + case 'auth.waitingForAuth': return 'Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen.'; + case 'common.cancel': return 'Abbrechen'; + case 'common.save': return 'Speichern'; + case 'common.close': return 'Schließen'; + case 'common.clear': return 'Leeren'; + case 'common.reset': return 'Zurücksetzen'; + case 'common.later': return 'Später'; + case 'common.submit': return 'Senden'; + case 'common.confirm': return 'Bestätigen'; + case 'common.retry': return 'Erneut versuchen'; + case 'common.playNow': return 'Jetzt abspielen'; + case 'common.logout': return 'Abmelden'; + case 'common.online': return 'Online'; + case 'common.offline': return 'Offline'; + case 'common.owned': return 'Eigentum'; + case 'common.shared': return 'Geteilt'; + case 'common.current': return 'AKTUELL'; + case 'common.unknown': return 'Unbekannt'; + case 'common.refresh': return 'Aktualisieren'; + case 'common.yes': return 'Ja'; + case 'common.no': return 'Nein'; + case 'common.server': return 'Server'; + case 'screens.licenses': return 'Lizenzen'; + case 'screens.selectServer': return 'Server auswählen'; + case 'screens.switchProfile': return 'Profil wechseln'; + case 'screens.subtitleStyling': return 'Untertitel-Stil'; + case 'screens.search': return 'Suche'; + case 'screens.logs': return 'Protokolle'; + case 'update.available': return 'Update verfügbar'; + case 'update.versionAvailable': return ({required Object version}) => 'Version ${version} ist verfügbar'; + case 'update.currentVersion': return ({required Object version}) => 'Aktuell: ${version}'; + case 'update.skipVersion': return 'Diese Version überspringen'; + case 'update.viewRelease': return 'Release anzeigen'; + case 'update.latestVersion': return 'Aktuellste Version installiert'; + case 'update.checkFailed': return 'Fehler bei der Updateprüfung'; + case 'settings.title': return 'Einstellungen'; + case 'settings.language': return 'Sprache'; + case 'settings.theme': return 'Theme'; + case 'settings.appearance': return 'Darstellung'; + case 'settings.videoPlayback': return 'Videowiedergabe'; + case 'settings.shufflePlay': return 'Zufallswiedergabe'; + case 'settings.advanced': return 'Erweitert'; + case 'settings.useSeasonPostersDescription': return 'Staffelposter statt Serienposter für Episoden anzeigen'; + case 'settings.showHeroSectionDescription': return 'Bereich mit empfohlenen Inhalten auf der Startseite anzeigen'; + case 'settings.secondsLabel': return 'Sekunden'; + case 'settings.minutesLabel': return 'Minuten'; + case 'settings.secondsShort': return 's'; + case 'settings.minutesShort': return 'm'; + case 'settings.durationHint': return ({required Object min, required Object max}) => 'Dauer eingeben (${min}-${max})'; + case 'settings.systemTheme': return 'System'; + case 'settings.systemThemeDescription': return 'Systemeinstellungen folgen'; + case 'settings.lightTheme': return 'Hell'; + case 'settings.darkTheme': return 'Dunkel'; + case 'settings.libraryDensity': return 'Bibliotheksdichte'; + case 'settings.compact': return 'Kompakt'; + case 'settings.compactDescription': return 'Kleinere Karten, mehr Elemente sichtbar'; + case 'settings.normal': return 'Normal'; + case 'settings.normalDescription': return 'Standardgröße'; + case 'settings.comfortable': return 'Großzügig'; + case 'settings.comfortableDescription': return 'Größere Karten, weniger Elemente sichtbar'; + case 'settings.viewMode': return 'Ansichtsmodus'; + case 'settings.gridView': return 'Raster'; + case 'settings.gridViewDescription': return 'Elemente im Raster anzeigen'; + case 'settings.listView': return 'Liste'; + case 'settings.listViewDescription': return 'Elemente in Listenansicht anzeigen'; + case 'settings.useSeasonPosters': return 'Staffelposter verwenden'; + case 'settings.showHeroSection': return 'Hero-Bereich anzeigen'; + case 'settings.hardwareDecoding': return 'Hardware-Decodierung'; + case 'settings.hardwareDecodingDescription': return 'Hardwarebeschleunigung verwenden, wenn verfügbar'; + case 'settings.bufferSize': return 'Puffergröße'; + case 'settings.bufferSizeMB': return ({required Object size}) => '${size}MB'; + case 'settings.subtitleStyling': return 'Untertitel-Stil'; + case 'settings.subtitleStylingDescription': return 'Aussehen von Untertiteln anpassen'; + case 'settings.smallSkipDuration': return 'Kleine Sprungdauer'; + case 'settings.largeSkipDuration': return 'Große Sprungdauer'; + case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} Sekunden'; + case 'settings.defaultSleepTimer': return 'Standard-Sleep-Timer'; + case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} Minuten'; + case 'settings.rememberTrackSelections': return 'Spurauswahl pro Serie/Film merken'; + case 'settings.rememberTrackSelectionsDescription': return 'Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert'; + case 'settings.unwatchedOnly': return 'Nur ungesehene'; + case 'settings.unwatchedOnlyDescription': return 'Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen'; + case 'settings.shuffleOrderNavigation': return 'Navigation der Shuffle-Reihenfolge'; + case 'settings.shuffleOrderNavigationDescription': return 'Weiter/Zurück folgt der zufälligen Reihenfolge'; + case 'settings.loopShuffleQueue': return 'Shuffle-Warteschlange wiederholen'; + case 'settings.loopShuffleQueueDescription': return 'Warteschlange neu starten, wenn das Ende erreicht ist'; + case 'settings.videoPlayerControls': return 'Videoplayer-Steuerung'; + case 'settings.keyboardShortcuts': return 'Tastenkürzel'; + case 'settings.keyboardShortcutsDescription': return 'Tastenkürzel anpassen'; + case 'settings.debugLogging': return 'Debug-Protokollierung'; + case 'settings.debugLoggingDescription': return 'Detaillierte Protokolle zur Fehleranalyse aktivieren'; + case 'settings.viewLogs': return 'Protokolle anzeigen'; + case 'settings.viewLogsDescription': return 'App-Protokolle anzeigen'; + case 'settings.clearCache': return 'Cache löschen'; + case 'settings.clearCacheDescription': return 'Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.'; + case 'settings.clearCacheSuccess': return 'Cache erfolgreich gelöscht'; + case 'settings.resetSettings': return 'Einstellungen zurücksetzen'; + case 'settings.resetSettingsDescription': return 'Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.'; + case 'settings.resetSettingsSuccess': return 'Einstellungen erfolgreich zurückgesetzt'; + case 'settings.shortcutsReset': return 'Tastenkürzel auf Standard zurückgesetzt'; + case 'settings.about': return 'Über'; + case 'settings.aboutDescription': return 'App-Informationen und Lizenzen'; + case 'settings.updates': return 'Updates'; + case 'settings.updateAvailable': return 'Update verfügbar'; + case 'settings.checkForUpdates': return 'Nach Updates suchen'; + case 'settings.validationErrorEnterNumber': return 'Bitte eine gültige Zahl eingeben'; + case 'settings.validationErrorDuration': return ({required Object min, required Object max, required Object unit}) => 'Dauer muss zwischen ${min} und ${max} ${unit} liegen'; + case 'settings.shortcutAlreadyAssigned': return ({required Object action}) => 'Tastenkürzel bereits zugewiesen an ${action}'; + case 'settings.shortcutUpdated': return ({required Object action}) => 'Tastenkürzel aktualisiert für ${action}'; + case 'search.hint': return 'Filme, Serien, Musik suchen...'; + case 'search.tryDifferentTerm': return 'Anderen Suchbegriff versuchen'; + case 'search.searchYourMedia': return 'In den eigenen Medien suchen'; + case 'search.enterTitleActorOrKeyword': return 'Titel, Schauspieler oder Stichwort eingeben'; + case 'hotkeys.setShortcutFor': return ({required Object actionName}) => 'Tastenkürzel festlegen für ${actionName}'; + case 'hotkeys.clearShortcut': return 'Kürzel löschen'; + case 'pinEntry.enterPin': return 'PIN eingeben'; + case 'pinEntry.showPin': return 'PIN anzeigen'; + case 'pinEntry.hidePin': return 'PIN verbergen'; + case 'fileInfo.title': return 'Dateiinfo'; + case 'fileInfo.video': return 'Video'; + case 'fileInfo.audio': return 'Audio'; + case 'fileInfo.file': return 'Datei'; + case 'fileInfo.advanced': return 'Erweitert'; + case 'fileInfo.codec': return 'Codec'; + case 'fileInfo.resolution': return 'Auflösung'; + case 'fileInfo.bitrate': return 'Bitrate'; + case 'fileInfo.frameRate': return 'Bildrate'; + case 'fileInfo.aspectRatio': return 'Seitenverhältnis'; + case 'fileInfo.profile': return 'Profil'; + case 'fileInfo.bitDepth': return 'Farbtiefe'; + case 'fileInfo.colorSpace': return 'Farbraum'; + case 'fileInfo.colorRange': return 'Farbbereich'; + case 'fileInfo.colorPrimaries': return 'Primärfarben'; + case 'fileInfo.chromaSubsampling': return 'Chroma-Subsampling'; + case 'fileInfo.channels': return 'Kanäle'; + case 'fileInfo.path': return 'Pfad'; + case 'fileInfo.size': return 'Größe'; + case 'fileInfo.container': return 'Container'; + case 'fileInfo.duration': return 'Dauer'; + case 'fileInfo.optimizedForStreaming': return 'Für Streaming optimiert'; + case 'fileInfo.has64bitOffsets': return '64-Bit-Offsets'; + case 'mediaMenu.markAsWatched': return 'Als gesehen markieren'; + case 'mediaMenu.markAsUnwatched': return 'Als ungesehen markieren'; + case 'mediaMenu.removeFromContinueWatching': return 'Aus ‚Weiterschauen‘ entfernen'; + case 'mediaMenu.goToSeries': return 'Zur Serie'; + case 'mediaMenu.goToSeason': return 'Zur Staffel'; + case 'mediaMenu.shufflePlay': return 'Zufallswiedergabe'; + case 'mediaMenu.fileInfo': return 'Dateiinfo'; + case 'tooltips.shufflePlay': return 'Zufallswiedergabe'; + case 'tooltips.markAsWatched': return 'Als gesehen markieren'; + case 'tooltips.markAsUnwatched': return 'Als ungesehen markieren'; + case 'videoControls.audioLabel': return 'Audio'; + case 'videoControls.subtitlesLabel': return 'Untertitel'; + case 'videoControls.resetToZero': return 'Auf 0 ms zurücksetzen'; + case 'videoControls.addTime': return ({required Object amount, required Object unit}) => '+${amount}${unit}'; + case 'videoControls.minusTime': return ({required Object amount, required Object unit}) => '-${amount}${unit}'; + case 'videoControls.playsLater': return ({required Object label}) => '${label} spielt später'; + case 'videoControls.playsEarlier': return ({required Object label}) => '${label} spielt früher'; + case 'videoControls.noOffset': return 'Kein Offset'; + case 'videoControls.letterbox': return 'Letterbox'; + case 'videoControls.fillScreen': return 'Bild füllen'; + case 'videoControls.stretch': return 'Strecken'; + case 'videoControls.lockRotation': return 'Rotation sperren'; + case 'videoControls.unlockRotation': return 'Rotation entsperren'; + case 'userStatus.admin': return 'Admin'; + case 'userStatus.restricted': return 'Eingeschränkt'; + case 'userStatus.protected': return 'Geschützt'; + case 'messages.markedAsWatched': return 'Als gesehen markiert'; + case 'messages.markedAsUnwatched': return 'Als ungesehen markiert'; + case 'messages.removedFromContinueWatching': return 'Aus ‚Weiterschauen‘ entfernt'; + case 'messages.errorLoading': return ({required Object error}) => 'Fehler: ${error}'; + case 'messages.fileInfoNotAvailable': return 'Dateiinfo nicht verfügbar'; + case 'messages.errorLoadingFileInfo': return ({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}'; + case 'messages.errorLoadingSeries': return 'Fehler beim Laden der Serie'; + case 'messages.errorLoadingSeason': return 'Fehler beim Laden der Staffel'; + case 'messages.musicNotSupported': return 'Musikwiedergabe wird noch nicht unterstützt'; + case 'messages.logsCleared': return 'Protokolle gelöscht'; + case 'messages.logsCopied': return 'Protokolle in Zwischenablage kopiert'; + case 'messages.noLogsAvailable': return 'Keine Protokolle verfügbar'; + case 'messages.libraryScanning': return ({required Object title}) => 'Scanne „${title}“...'; + case 'messages.libraryScanStarted': return ({required Object title}) => 'Bibliotheksscan gestartet für „${title}“'; + case 'messages.libraryScanFailed': return ({required Object error}) => 'Fehler beim Scannen der Bibliothek: ${error}'; + case 'messages.metadataRefreshing': return ({required Object title}) => 'Metadaten werden aktualisiert für „${title}“...'; + case 'messages.metadataRefreshStarted': return ({required Object title}) => 'Metadaten-Aktualisierung gestartet für „${title}“'; + case 'messages.metadataRefreshFailed': return ({required Object error}) => 'Metadaten konnten nicht aktualisiert werden: ${error}'; + case 'messages.noPlexToken': return 'Kein Plex-Token gefunden. Bitte erneut anmelden.'; + case 'messages.logoutConfirm': return 'Abmeldung wirklich durchführen?'; + case 'messages.noSeasonsFound': return 'Keine Staffeln gefunden'; + case 'messages.noEpisodesFound': return 'Keine Episoden in der ersten Staffel gefunden'; + case 'messages.noEpisodesFoundGeneral': return 'Keine Episoden gefunden'; + case 'messages.noResultsFound': return 'Keine Ergebnisse gefunden'; + case 'messages.sleepTimerSet': return ({required Object label}) => 'Sleep-Timer gesetzt auf ${label}'; + case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + case 'profile.noUsersAvailable': return 'Keine Benutzer verfügbar'; + case 'subtitlingStyling.stylingOptions': return 'Stiloptionen'; + case 'subtitlingStyling.fontSize': return 'Schriftgröße'; + case 'subtitlingStyling.textColor': return 'Textfarbe'; + case 'subtitlingStyling.borderSize': return 'Rahmengröße'; + case 'subtitlingStyling.borderColor': return 'Rahmenfarbe'; + case 'subtitlingStyling.backgroundOpacity': return 'Hintergrunddeckkraft'; + case 'subtitlingStyling.backgroundColor': return 'Hintergrundfarbe'; + case 'dialog.confirmAction': return 'Aktion bestätigen'; + case 'dialog.areYouSure': return 'Aktion wirklich ausführen?'; + case 'dialog.cancel': return 'Abbrechen'; + case 'dialog.playNow': return 'Jetzt abspielen'; + case 'discover.title': return 'Entdecken'; + case 'discover.switchProfile': return 'Profil wechseln'; + case 'discover.switchServer': return 'Server wechseln'; + case 'discover.logout': return 'Abmelden'; + case 'discover.noContentAvailable': return 'Kein Inhalt verfügbar'; + case 'discover.addMediaToLibraries': return 'Medien zur Bibliothek hinzufügen'; + case 'discover.continueWatching': return 'Weiterschauen'; + case 'discover.recentlyAdded': return 'Kürzlich hinzugefügt'; + case 'discover.play': return 'Abspielen'; + case 'discover.resume': return 'Fortsetzen'; + case 'discover.playEpisode': return ({required Object season, required Object episode}) => 'Spiele S${season}, E${episode}'; + case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => 'Fortsetzen S${season}, E${episode}'; + case 'discover.pause': return 'Pause'; + case 'discover.overview': return 'Übersicht'; + case 'discover.cast': return 'Besetzung'; + case 'discover.episodeCount': return ({required Object count}) => '${count} Episoden'; + case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} gesehen'; + case 'discover.movie': return 'Film'; + case 'discover.tvShow': return 'Serie'; + case 'discover.minutesLeft': return ({required Object minutes}) => '${minutes} Min übrig'; + case 'errors.searchFailed': return ({required Object error}) => 'Suche fehlgeschlagen: ${error}'; + case 'errors.connectionTimeout': return ({required Object context}) => 'Zeitüberschreitung beim Laden von ${context}'; + case 'errors.connectionFailed': return 'Verbindung zum Plex-Server fehlgeschlagen'; + case 'errors.failedToLoad': return ({required Object context, required Object error}) => 'Fehler beim Laden von ${context}: ${error}'; + case 'errors.noClientAvailable': return 'Kein Client verfügbar'; + case 'errors.authenticationFailed': return ({required Object error}) => 'Authentifizierung fehlgeschlagen: ${error}'; + case 'errors.couldNotLaunchUrl': return 'Auth-URL konnte nicht geöffnet werden'; + case 'errors.pleaseEnterToken': return 'Bitte Token eingeben'; + case 'errors.invalidToken': return 'Ungültiges Token'; + case 'errors.failedToVerifyToken': return ({required Object error}) => 'Token-Verifizierung fehlgeschlagen: ${error}'; + case 'errors.failedToSwitchProfile': return ({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + case 'errors.connectionFailedGeneric': return 'Verbindung fehlgeschlagen'; + case 'libraries.title': return 'Bibliotheken'; + case 'libraries.scanLibraryFiles': return 'Bibliotheksdateien scannen'; + case 'libraries.scanLibrary': return 'Bibliothek scannen'; + case 'libraries.analyze': return 'Analysieren'; + case 'libraries.analyzeLibrary': return 'Bibliothek analysieren'; + case 'libraries.refreshMetadata': return 'Metadaten aktualisieren'; + case 'libraries.emptyTrash': return 'Papierkorb leeren'; + case 'libraries.emptyingTrash': return ({required Object title}) => 'Papierkorb für „${title}“ wird geleert...'; + case 'libraries.trashEmptied': return ({required Object title}) => 'Papierkorb für „${title}“ geleert'; + case 'libraries.failedToEmptyTrash': return ({required Object error}) => 'Papierkorb konnte nicht geleert werden: ${error}'; + case 'libraries.analyzing': return ({required Object title}) => 'Analysiere „${title}“...'; + case 'libraries.analysisStarted': return ({required Object title}) => 'Analyse gestartet für „${title}“'; + case 'libraries.failedToAnalyze': return ({required Object error}) => 'Analyse der Bibliothek fehlgeschlagen: ${error}'; + case 'libraries.noLibrariesFound': return 'Keine Bibliotheken gefunden'; + case 'libraries.thisLibraryIsEmpty': return 'Diese Bibliothek ist leer'; + case 'libraries.all': return 'Alle'; + case 'libraries.clearAll': return 'Alle löschen'; + case 'libraries.scanLibraryConfirm': return ({required Object title}) => '„${title}“ wirklich scannen?'; + case 'libraries.analyzeLibraryConfirm': return ({required Object title}) => '„${title}“ wirklich analysieren?'; + case 'libraries.refreshMetadataConfirm': return ({required Object title}) => 'Metadaten für „${title}“ wirklich aktualisieren?'; + case 'libraries.emptyTrashConfirm': return ({required Object title}) => 'Papierkorb für „${title}“ wirklich leeren?'; + case 'libraries.manageLibraries': return 'Bibliotheken verwalten'; + case 'libraries.sort': return 'Sortieren'; + case 'libraries.sortBy': return 'Sortieren nach'; + case 'libraries.filters': return 'Filter'; + case 'libraries.loadingLibraryWithCount': return ({required Object count}) => 'Lade Bibliothek... (${count} Elemente geladen)'; + case 'libraries.confirmActionMessage': return 'Aktion wirklich durchführen?'; + case 'libraries.showLibrary': return 'Bibliothek anzeigen'; + case 'libraries.hideLibrary': return 'Bibliothek ausblenden'; + case 'libraries.libraryOptions': return 'Bibliotheksoptionen'; + case 'about.title': return 'Über'; + case 'about.openSourceLicenses': return 'Open-Source-Lizenzen'; + case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; + case 'about.appDescription': return 'Ein schöner Plex-Client für Flutter'; + case 'about.viewLicensesDescription': return 'Lizenzen von Drittanbieter-Bibliotheken anzeigen'; + case 'serverSelection.connectingToServer': return 'Verbinde mit Server...'; + case 'serverSelection.serverDebugCopied': return 'Server-Debugdaten kopiert'; + case 'serverSelection.copyDebugData': return 'Debugdaten kopieren'; + case 'serverSelection.noServersFound': return 'Keine Server gefunden'; + case 'serverSelection.malformedServerData': return ({required Object count}) => '${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.'; + case 'serverSelection.incompleteServerInfo': return 'Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.'; + case 'serverSelection.incompleteConnectionInfo': return 'Serververbindungsinformationen unvollständig. Bitte erneut versuchen.'; + case 'serverSelection.malformedServerInfo': return ({required Object message}) => 'Serverinformationen fehlerhaft: ${message}'; + case 'serverSelection.networkConnectionFailed': return 'Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.'; + case 'serverSelection.authenticationFailed': return 'Authentifizierung fehlgeschlagen. Bitte erneut anmelden.'; + case 'serverSelection.plexServiceUnavailable': return 'Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.'; + case 'serverSelection.failedToLoadServers': return ({required Object error}) => 'Server konnten nicht geladen werden: ${error}'; + case 'hubDetail.title': return 'Titel'; + case 'hubDetail.releaseYear': return 'Erscheinungsjahr'; + case 'hubDetail.dateAdded': return 'Hinzugefügt am'; + case 'hubDetail.rating': return 'Bewertung'; + case 'hubDetail.noItemsFound': return 'Keine Elemente gefunden'; + case 'logs.title': return 'Protokolle'; + case 'logs.clearLogs': return 'Protokolle löschen'; + case 'logs.copyLogs': return 'Protokolle kopieren'; + case 'logs.exportLogs': return 'Protokolle exportieren'; + case 'logs.noLogsToShow': return 'Keine Protokolle zum Anzeigen'; + case 'logs.error': return 'Fehler:'; + case 'logs.stackTrace': return 'Stacktrace:'; + case 'licenses.relatedPackages': return 'Verwandte Pakete'; + case 'licenses.license': return 'Lizenz'; + case 'licenses.licenseNumber': return ({required Object number}) => 'Lizenz ${number}'; + case 'licenses.licensesCount': return ({required Object count}) => '${count} Lizenzen'; + case 'navigation.home': return 'Start'; + case 'navigation.search': return 'Suche'; + case 'navigation.libraries': return 'Bibliotheken'; + case 'navigation.settings': return 'Einstellungen'; + default: return null; + } + } +} + extension on _StringsIt { dynamic _flatMapFunction(String path) { switch (path) { @@ -3872,3 +5420,325 @@ extension on _StringsSv { } } } + +extension on _StringsZh { + dynamic _flatMapFunction(String path) { + switch (path) { + case 'app.title': return 'Plezy'; + case 'app.loading': return '加载中...'; + case 'auth.signInWithPlex': return '使用 Plex 登录'; + case 'auth.showQRCode': return '显示二维码'; + case 'auth.cancel': return '取消'; + case 'auth.authenticate': return '验证'; + case 'auth.retry': return '重试'; + case 'auth.debugEnterToken': return '调试:输入 Plex Token'; + case 'auth.plexTokenLabel': return 'Plex 授权令牌 (Auth Token)'; + case 'auth.plexTokenHint': return '输入你的 Plex.tv 令牌'; + case 'auth.authenticationTimeout': return '验证超时。请重试。'; + case 'auth.scanQRCodeInstruction': return '请使用已登录 Plex 的设备扫描此二维码进行验证。'; + case 'auth.waitingForAuth': return '等待验证中...\n请在你的浏览器中完成登录。'; + case 'common.cancel': return '取消'; + case 'common.save': return '保存'; + case 'common.close': return '关闭'; + case 'common.clear': return '清除'; + case 'common.reset': return '重置'; + case 'common.later': return '稍后'; + case 'common.submit': return '提交'; + case 'common.confirm': return '确认'; + case 'common.retry': return '重试'; + case 'common.playNow': return '立即播放'; + case 'common.logout': return '登出'; + case 'common.online': return '在线'; + case 'common.offline': return '离线'; + case 'common.owned': return '拥有'; + case 'common.shared': return '共享'; + case 'common.current': return '当前'; + case 'common.unknown': return '未知'; + case 'common.refresh': return '刷新'; + case 'common.yes': return '是'; + case 'common.no': return '否'; + case 'common.server': return '服务器'; + case 'screens.licenses': return '许可证'; + case 'screens.selectServer': return '选择服务器'; + case 'screens.switchProfile': return '切换配置文件'; + case 'screens.subtitleStyling': return '字幕样式'; + case 'screens.search': return '搜索'; + case 'screens.logs': return '日志'; + case 'update.available': return '有可用更新'; + case 'update.versionAvailable': return ({required Object version}) => '版本 ${version} 已发布'; + case 'update.currentVersion': return ({required Object version}) => '当前版本: ${version}'; + case 'update.skipVersion': return '跳过此版本'; + case 'update.viewRelease': return '查看发布详情'; + case 'update.latestVersion': return '已安装的版本是可用的最新版本'; + case 'update.checkFailed': return '无法检查更新'; + case 'settings.title': return '设置'; + case 'settings.language': return '语言'; + case 'settings.theme': return '主题'; + case 'settings.appearance': return '外观'; + case 'settings.videoPlayback': return '视频播放'; + case 'settings.shufflePlay': return '随机播放'; + case 'settings.advanced': return '高级'; + case 'settings.useSeasonPostersDescription': return '为剧集显示季海报而非剧集海报'; + case 'settings.showHeroSectionDescription': return '在主屏幕上显示精选内容轮播区'; + case 'settings.secondsLabel': return '秒'; + case 'settings.minutesLabel': return '分钟'; + case 'settings.secondsShort': return 's'; + case 'settings.minutesShort': return 'm'; + case 'settings.durationHint': return ({required Object min, required Object max}) => '输入时长 (${min}-${max})'; + case 'settings.systemTheme': return '系统'; + case 'settings.systemThemeDescription': return '跟随系统设置'; + case 'settings.lightTheme': return '浅色'; + case 'settings.darkTheme': return '深色'; + case 'settings.libraryDensity': return '媒体库密度'; + case 'settings.compact': return '紧凑'; + case 'settings.compactDescription': return '卡片更小,显示更多项目'; + case 'settings.normal': return '标准'; + case 'settings.normalDescription': return '默认尺寸'; + case 'settings.comfortable': return '舒适'; + case 'settings.comfortableDescription': return '卡片更大,显示更少项目'; + case 'settings.viewMode': return '视图模式'; + case 'settings.gridView': return '网格视图'; + case 'settings.gridViewDescription': return '以网格布局显示项目'; + case 'settings.listView': return '列表视图'; + case 'settings.listViewDescription': return '以列表布局显示项目'; + case 'settings.useSeasonPosters': return '使用季海报'; + case 'settings.showHeroSection': return '显示主要精选区'; + case 'settings.hardwareDecoding': return '硬件解码'; + case 'settings.hardwareDecodingDescription': return '如果可用,使用硬件加速'; + case 'settings.bufferSize': return '缓冲区大小'; + case 'settings.bufferSizeMB': return ({required Object size}) => '${size}MB'; + case 'settings.subtitleStyling': return '字幕样式'; + case 'settings.subtitleStylingDescription': return '调整字幕外观'; + case 'settings.smallSkipDuration': return '短跳过时长'; + case 'settings.largeSkipDuration': return '长跳过时长'; + case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} 秒'; + case 'settings.defaultSleepTimer': return '默认睡眠定时器'; + case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} 分钟'; + case 'settings.unwatchedOnly': return '仅未观看'; + case 'settings.unwatchedOnlyDescription': return '随机队列中仅包含未观看的剧集'; + case 'settings.shuffleOrderNavigation': return '随机顺序导航'; + case 'settings.shuffleOrderNavigationDescription': return '下一集/上一集按钮遵循随机播放顺序'; + case 'settings.loopShuffleQueue': return '循环随机队列'; + case 'settings.loopShuffleQueueDescription': return '在队列结束时重新开始'; + case 'settings.videoPlayerControls': return '视频播放器控制'; + case 'settings.keyboardShortcuts': return '键盘快捷键'; + case 'settings.keyboardShortcutsDescription': return '自定义键盘快捷键'; + case 'settings.debugLogging': return '调试日志'; + case 'settings.debugLoggingDescription': return '启用详细日志记录以便故障排除'; + case 'settings.viewLogs': return '查看日志'; + case 'settings.viewLogsDescription': return '查看应用程序日志'; + case 'settings.clearCache': return '清除缓存'; + case 'settings.clearCacheDescription': return '这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。'; + case 'settings.clearCacheSuccess': return '缓存清除成功'; + case 'settings.resetSettings': return '重置设置'; + case 'settings.resetSettingsDescription': return '这会将所有设置重置为其默认值。此操作无法撤销。'; + case 'settings.resetSettingsSuccess': return '设置重置成功'; + case 'settings.shortcutsReset': return '快捷键已重置为默认值'; + case 'settings.about': return '关于'; + case 'settings.aboutDescription': return '应用程序信息和许可证'; + case 'settings.updates': return '更新'; + case 'settings.updateAvailable': return '有可用更新'; + case 'settings.checkForUpdates': return '检查更新'; + case 'settings.validationErrorEnterNumber': return '请输入一个有效的数字'; + case 'settings.validationErrorDuration': return ({required Object min, required Object max, required Object unit}) => '时长必须介于 ${min} 和 ${max} ${unit} 之间'; + case 'settings.shortcutAlreadyAssigned': return ({required Object action}) => '快捷键已被分配给 ${action}'; + case 'settings.shortcutUpdated': return ({required Object action}) => '快捷键已为 ${action} 更新'; + case 'search.hint': return '搜索电影、系列、音乐...'; + case 'search.tryDifferentTerm': return '尝试不同的搜索词'; + case 'search.searchYourMedia': return '搜索媒体'; + case 'search.enterTitleActorOrKeyword': return '输入标题、演员或关键词'; + case 'hotkeys.setShortcutFor': return ({required Object actionName}) => '为 ${actionName} 设置快捷键'; + case 'hotkeys.clearShortcut': return '清除快捷键'; + case 'pinEntry.enterPin': return '输入 PIN'; + case 'pinEntry.showPin': return '显示 PIN'; + case 'pinEntry.hidePin': return '隐藏 PIN'; + case 'fileInfo.title': return '文件信息'; + case 'fileInfo.video': return '视频'; + case 'fileInfo.audio': return '音频'; + case 'fileInfo.file': return '文件'; + case 'fileInfo.advanced': return '高级'; + case 'fileInfo.codec': return '编解码器'; + case 'fileInfo.resolution': return '分辨率'; + case 'fileInfo.bitrate': return '比特率'; + case 'fileInfo.frameRate': return '帧率'; + case 'fileInfo.aspectRatio': return '宽高比'; + case 'fileInfo.profile': return '配置文件'; + case 'fileInfo.bitDepth': return '位深度'; + case 'fileInfo.colorSpace': return '色彩空间'; + case 'fileInfo.colorRange': return '色彩范围'; + case 'fileInfo.colorPrimaries': return '颜色原色'; + case 'fileInfo.chromaSubsampling': return '色度子采样'; + case 'fileInfo.channels': return '声道'; + case 'fileInfo.path': return '路径'; + case 'fileInfo.size': return '大小'; + case 'fileInfo.container': return '容器'; + case 'fileInfo.duration': return '时长'; + case 'fileInfo.optimizedForStreaming': return '已优化用于流媒体'; + case 'fileInfo.has64bitOffsets': return '64位偏移量'; + case 'mediaMenu.markAsWatched': return '标记为已观看'; + case 'mediaMenu.markAsUnwatched': return '标记为未观看'; + case 'mediaMenu.removeFromContinueWatching': return '从继续观看中移除'; + case 'mediaMenu.goToSeries': return '转到系列'; + case 'mediaMenu.goToSeason': return '转到季'; + case 'mediaMenu.shufflePlay': return '随机播放'; + case 'mediaMenu.fileInfo': return '文件信息'; + case 'tooltips.shufflePlay': return '随机播放'; + case 'tooltips.markAsWatched': return '标记为已观看'; + case 'tooltips.markAsUnwatched': return '标记为未观看'; + case 'videoControls.audioLabel': return '音频'; + case 'videoControls.subtitlesLabel': return '字幕'; + case 'videoControls.resetToZero': return '重置为 0ms'; + case 'videoControls.addTime': return ({required Object amount, required Object unit}) => '+${amount}${unit}'; + case 'videoControls.minusTime': return ({required Object amount, required Object unit}) => '-${amount}${unit}'; + case 'videoControls.playsLater': return ({required Object label}) => '${label} 播放较晚'; + case 'videoControls.playsEarlier': return ({required Object label}) => '${label} 播放较早'; + case 'videoControls.noOffset': return '无偏移'; + case 'videoControls.letterbox': return '信箱模式(Letterbox)'; + case 'videoControls.fillScreen': return '填充屏幕'; + case 'videoControls.stretch': return '拉伸'; + case 'videoControls.lockRotation': return '锁定旋转'; + case 'videoControls.unlockRotation': return '解锁旋转'; + case 'userStatus.admin': return '管理员'; + case 'userStatus.restricted': return '受限'; + case 'userStatus.protected': return '受保护'; + case 'messages.markedAsWatched': return '已标记为已观看'; + case 'messages.markedAsUnwatched': return '已标记为未观看'; + case 'messages.removedFromContinueWatching': return '已从继续观看中移除'; + case 'messages.errorLoading': return ({required Object error}) => '错误: ${error}'; + case 'messages.fileInfoNotAvailable': return '文件信息不可用'; + case 'messages.errorLoadingFileInfo': return ({required Object error}) => '加载文件信息时出错: ${error}'; + case 'messages.errorLoadingSeries': return '加载系列时出错'; + case 'messages.errorLoadingSeason': return '加载季时出错'; + case 'messages.musicNotSupported': return '尚不支持播放音乐'; + case 'messages.logsCleared': return '日志已清除'; + case 'messages.logsCopied': return '日志已复制到剪贴板'; + case 'messages.noLogsAvailable': return '没有可用日志'; + case 'messages.libraryScanning': return ({required Object title}) => '正在扫描 “${title}”...'; + case 'messages.libraryScanStarted': return ({required Object title}) => '已开始扫描 “${title}” 媒体库'; + case 'messages.libraryScanFailed': return ({required Object error}) => '无法扫描媒体库: ${error}'; + case 'messages.metadataRefreshing': return ({required Object title}) => '正在刷新 “${title}” 的元数据...'; + case 'messages.metadataRefreshStarted': return ({required Object title}) => '已开始刷新 “${title}” 的元数据'; + case 'messages.metadataRefreshFailed': return ({required Object error}) => '无法刷新元数据: ${error}'; + case 'messages.noPlexToken': return '未找到 Plex 令牌。请重新登录。'; + case 'messages.logoutConfirm': return '你确定要登出吗?'; + case 'messages.noSeasonsFound': return '未找到季'; + case 'messages.noEpisodesFound': return '在第一季中未找到剧集'; + case 'messages.noEpisodesFoundGeneral': return '未找到剧集'; + case 'messages.noResultsFound': return '未找到结果'; + case 'messages.sleepTimerSet': return ({required Object label}) => '睡眠定时器已设置为 ${label}'; + case 'messages.failedToSwitchProfile': return ({required Object displayName}) => '无法切换到 ${displayName}'; + case 'profile.noUsersAvailable': return '没有可用用户'; + case 'subtitlingStyling.stylingOptions': return '样式选项'; + case 'subtitlingStyling.fontSize': return '字号'; + case 'subtitlingStyling.textColor': return '文本颜色'; + case 'subtitlingStyling.borderSize': return '边框大小'; + case 'subtitlingStyling.borderColor': return '边框颜色'; + case 'subtitlingStyling.backgroundOpacity': return '背景不透明度'; + case 'subtitlingStyling.backgroundColor': return '背景颜色'; + case 'dialog.confirmAction': return '确认操作'; + case 'dialog.areYouSure': return '确定要执行此操作吗?'; + case 'dialog.cancel': return '取消'; + case 'dialog.playNow': return '立即播放'; + case 'discover.title': return '发现'; + case 'discover.switchProfile': return '切换配置文件'; + case 'discover.switchServer': return '切换服务器'; + case 'discover.logout': return '登出'; + case 'discover.noContentAvailable': return '没有可用内容'; + case 'discover.addMediaToLibraries': return '请向你的媒体库添加一些媒体'; + case 'discover.continueWatching': return '继续观看'; + case 'discover.recentlyAdded': return '最近添加'; + case 'discover.play': return '播放'; + case 'discover.resume': return '继续'; + case 'discover.playEpisode': return ({required Object season, required Object episode}) => '播放 S${season}, E${episode}'; + case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => '继续 S${season}, E${episode}'; + case 'discover.pause': return '暂停'; + case 'discover.overview': return '概述'; + case 'discover.cast': return '演员表'; + case 'discover.episodeCount': return ({required Object count}) => '${count} 集'; + case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '已观看 ${watched}/${total} 集'; + case 'discover.movie': return '电影'; + case 'discover.tvShow': return '电视剧'; + case 'discover.minutesLeft': return ({required Object minutes}) => '剩余 ${minutes} 分钟'; + case 'errors.searchFailed': return ({required Object error}) => '搜索失败: ${error}'; + case 'errors.connectionTimeout': return ({required Object context}) => '加载 ${context} 时连接超时'; + case 'errors.connectionFailed': return '无法连接到 Plex 服务器'; + case 'errors.failedToLoad': return ({required Object context, required Object error}) => '无法加载 ${context}: ${error}'; + case 'errors.noClientAvailable': return '没有可用客户端'; + case 'errors.authenticationFailed': return ({required Object error}) => '验证失败: ${error}'; + case 'errors.couldNotLaunchUrl': return '无法打开授权 URL'; + case 'errors.pleaseEnterToken': return '请输入一个令牌'; + case 'errors.invalidToken': return '令牌无效'; + case 'errors.failedToVerifyToken': return ({required Object error}) => '无法验证令牌: ${error}'; + case 'errors.failedToSwitchProfile': return ({required Object displayName}) => '无法切换到 ${displayName}'; + case 'errors.connectionFailedGeneric': return '连接失败'; + case 'libraries.title': return '媒体库'; + case 'libraries.scanLibraryFiles': return '扫描媒体库文件'; + case 'libraries.scanLibrary': return '扫描媒体库'; + case 'libraries.analyze': return '分析'; + case 'libraries.analyzeLibrary': return '分析媒体库'; + case 'libraries.refreshMetadata': return '刷新元数据'; + case 'libraries.emptyTrash': return '清空回收站'; + case 'libraries.emptyingTrash': return ({required Object title}) => '正在清空 “${title}” 的回收站...'; + case 'libraries.trashEmptied': return ({required Object title}) => '已清空 “${title}” 的回收站'; + case 'libraries.failedToEmptyTrash': return ({required Object error}) => '无法清空回收站: ${error}'; + case 'libraries.analyzing': return ({required Object title}) => '正在分析 “${title}”...'; + case 'libraries.analysisStarted': return ({required Object title}) => '已开始分析 “${title}”'; + case 'libraries.failedToAnalyze': return ({required Object error}) => '无法分析媒体库: ${error}'; + case 'libraries.noLibrariesFound': return '未找到媒体库'; + case 'libraries.thisLibraryIsEmpty': return '此媒体库为空'; + case 'libraries.all': return '全部'; + case 'libraries.clearAll': return '全部清除'; + case 'libraries.scanLibraryConfirm': return ({required Object title}) => '确定要扫描 “${title}” 吗?'; + case 'libraries.analyzeLibraryConfirm': return ({required Object title}) => '确定要分析 “${title}” 吗?'; + case 'libraries.refreshMetadataConfirm': return ({required Object title}) => '确定要刷新 “${title}” 的元数据吗?'; + case 'libraries.emptyTrashConfirm': return ({required Object title}) => '确定要清空 “${title}” 的回收站吗?'; + case 'libraries.manageLibraries': return '管理媒体库'; + case 'libraries.sort': return '排序'; + case 'libraries.sortBy': return '排序依据'; + case 'libraries.filters': return '筛选器'; + case 'libraries.loadingLibraryWithCount': return ({required Object count}) => '正在加载媒体库... (已加载 ${count} 个项目)'; + case 'libraries.confirmActionMessage': return '确定要执行此操作吗?'; + case 'libraries.showLibrary': return '显示媒体库'; + case 'libraries.hideLibrary': return '隐藏媒体库'; + case 'libraries.libraryOptions': return '媒体库选项'; + case 'about.title': return '关于'; + case 'about.openSourceLicenses': return '开源许可证'; + case 'about.versionLabel': return ({required Object version}) => '版本 ${version}'; + case 'about.appDescription': return '一款精美的 Flutter Plex 客户端'; + case 'about.viewLicensesDescription': return '查看第三方库的许可证'; + case 'serverSelection.connectingToServer': return '正在连接服务器...'; + case 'serverSelection.serverDebugCopied': return '服务器调试数据已复制到剪贴板'; + case 'serverSelection.copyDebugData': return '复制调试数据'; + case 'serverSelection.noServersFound': return '未找到服务器'; + case 'serverSelection.malformedServerData': return ({required Object count}) => '找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。'; + case 'serverSelection.incompleteServerInfo': return '某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。'; + case 'serverSelection.incompleteConnectionInfo': return '服务器连接信息不完整。请重试。'; + case 'serverSelection.malformedServerInfo': return ({required Object message}) => '服务器信息格式错误: ${message}'; + case 'serverSelection.networkConnectionFailed': return '网络连接失败。请检查你的网络连接并重试。'; + case 'serverSelection.authenticationFailed': return '验证失败。请重新登录。'; + case 'serverSelection.plexServiceUnavailable': return 'Plex 服务不可用。请稍后重试。'; + case 'serverSelection.failedToLoadServers': return ({required Object error}) => '无法加载服务器: ${error}'; + case 'hubDetail.title': return '标题'; + case 'hubDetail.releaseYear': return '发行年份'; + case 'hubDetail.dateAdded': return '添加日期'; + case 'hubDetail.rating': return '评分'; + case 'hubDetail.noItemsFound': return '未找到项目'; + case 'logs.title': return '日志'; + case 'logs.clearLogs': return '清除日志'; + case 'logs.copyLogs': return '复制日志'; + case 'logs.exportLogs': return '导出日志'; + case 'logs.noLogsToShow': return '没有可显示的日志'; + case 'logs.error': return '错误:'; + case 'logs.stackTrace': return '堆栈跟踪 (Stack Trace):'; + case 'licenses.relatedPackages': return '相关软件包'; + case 'licenses.license': return '许可证'; + case 'licenses.licenseNumber': return ({required Object number}) => '许可证 ${number}'; + case 'licenses.licensesCount': return ({required Object count}) => '${count} 个许可证'; + case 'navigation.home': return '主页'; + case 'navigation.search': return '搜索'; + case 'navigation.libraries': return '媒体库'; + case 'navigation.settings': return '设置'; + default: return null; + } + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9a909197..88cc672b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -885,6 +885,10 @@ class _SettingsScreenState extends State { return 'Italiano'; case AppLocale.nl: return 'Nederlands'; + case AppLocale.de: + return 'Deutsch'; + case AppLocale.zh: + return '中文'; } } From ed478d1ed0b261088abeb31fef9f8786bd0449b1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Nov 2025 01:13:09 +0100 Subject: [PATCH 20/25] i18n(zh): translate 2 new strings --- lib/i18n/strings.g.dart | 8 ++++++-- lib/i18n/strings_zh.i18n.json | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index fd030098..61e360cf 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 6 -/// Strings: 1894 (315 per locale) +/// Strings: 1896 (316 per locale) /// -/// Built on 2025-11-15 at 00:10 UTC +/// Built on 2025-11-15 at 00:12 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -3383,6 +3383,8 @@ class _StringsSettingsZh implements _StringsSettingsEn { @override String secondsUnit({required Object seconds}) => '${seconds} 秒'; @override String get defaultSleepTimer => '默认睡眠定时器'; @override String minutesUnit({required Object minutes}) => '${minutes} 分钟'; + @override String get rememberTrackSelections => '记住每个剧集/电影的音轨选择'; + @override String get rememberTrackSelectionsDescription => '在播放过程中更改音轨时自动保存音频和字幕语言偏好'; @override String get unwatchedOnly => '仅未观看'; @override String get unwatchedOnlyDescription => '随机队列中仅包含未观看的剧集'; @override String get shuffleOrderNavigation => '随机顺序导航'; @@ -5514,6 +5516,8 @@ extension on _StringsZh { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} 秒'; case 'settings.defaultSleepTimer': return '默认睡眠定时器'; case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} 分钟'; + case 'settings.rememberTrackSelections': return '记住每个剧集/电影的音轨选择'; + case 'settings.rememberTrackSelectionsDescription': return '在播放过程中更改音轨时自动保存音频和字幕语言偏好'; case 'settings.unwatchedOnly': return '仅未观看'; case 'settings.unwatchedOnlyDescription': return '随机队列中仅包含未观看的剧集'; case 'settings.shuffleOrderNavigation': return '随机顺序导航'; diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 23ae4ed1..34eae804 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} 秒", "defaultSleepTimer": "默认睡眠定时器", "minutesUnit": "${minutes} 分钟", + "rememberTrackSelections": "记住每个剧集/电影的音轨选择", + "rememberTrackSelectionsDescription": "在播放过程中更改音轨时自动保存音频和字幕语言偏好", "unwatchedOnly": "仅未观看", "unwatchedOnlyDescription": "随机队列中仅包含未观看的剧集", "shuffleOrderNavigation": "随机顺序导航", From 232459017560e2fcabe537988b174aed245c79bb Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Nov 2025 04:07:08 +0100 Subject: [PATCH 21/25] feat: playlists --- lib/client/plex_client.dart | 273 ++++++++++- lib/config/plex_config.dart | 6 + lib/i18n/strings.g.dart | 472 +++++++++++++++++- lib/i18n/strings.i18n.json | 36 ++ lib/i18n/strings_de.i18n.json | 36 ++ lib/i18n/strings_it.i18n.json | 36 ++ lib/i18n/strings_nl.i18n.json | 36 ++ lib/i18n/strings_sv.i18n.json | 36 ++ lib/i18n/strings_zh.i18n.json | 36 ++ lib/models/plex_metadata.dart | 4 + lib/models/plex_metadata.g.dart | 2 + lib/models/plex_playlist.dart | 66 +++ lib/models/plex_playlist.g.dart | 48 ++ lib/providers/playback_state_provider.dart | 102 ++-- lib/screens/libraries_screen.dart | 13 + lib/screens/playlist_detail_screen.dart | 517 ++++++++++++++++++++ lib/screens/playlists_screen.dart | 404 +++++++++++++++ lib/screens/video_player_screen.dart | 44 +- lib/services/server_connection_service.dart | 12 + lib/widgets/media_context_menu.dart | 215 ++++++++ lib/widgets/playlist_item_card.dart | 199 ++++++++ macos/Podfile.lock | 6 + 22 files changed, 2539 insertions(+), 60 deletions(-) create mode 100644 lib/models/plex_playlist.dart create mode 100644 lib/models/plex_playlist.g.dart create mode 100644 lib/screens/playlist_detail_screen.dart create mode 100644 lib/screens/playlists_screen.dart create mode 100644 lib/widgets/playlist_item_card.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index b01e62f3..0b31a98a 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -10,6 +10,7 @@ import '../models/plex_library.dart'; import '../models/plex_media_info.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_playlist.dart'; import '../models/plex_sort.dart'; import '../models/plex_video_playback_data.dart'; import '../network/endpoint_failover_interceptor.dart'; @@ -43,11 +44,11 @@ class PlexClient { this.config, { List? prioritizedEndpoints, Future Function(String newBaseUrl)? onEndpointChanged, - }) : _endpointManager = (prioritizedEndpoints != null && - prioritizedEndpoints.isNotEmpty) - ? EndpointFailoverManager(prioritizedEndpoints) - : null, - _onEndpointChanged = onEndpointChanged { + }) : _endpointManager = + (prioritizedEndpoints != null && prioritizedEndpoints.isNotEmpty) + ? EndpointFailoverManager(prioritizedEndpoints) + : null, + _onEndpointChanged = onEndpointChanged { LogRedactionManager.registerServerUrl(config.baseUrl); LogRedactionManager.registerToken(config.token); @@ -104,12 +105,10 @@ class PlexClient { return; } - final targetBaseUrl = - switchToFirst ? prioritizedEndpoints.first : config.baseUrl; - _endpointManager.reset( - prioritizedEndpoints, - currentBaseUrl: targetBaseUrl, - ); + final targetBaseUrl = switchToFirst + ? prioritizedEndpoints.first + : config.baseUrl; + _endpointManager.reset(prioritizedEndpoints, currentBaseUrl: targetBaseUrl); if (switchToFirst && targetBaseUrl != config.baseUrl) { await _handleEndpointSwitch(targetBaseUrl); @@ -304,6 +303,30 @@ class PlexClient { return _extractSingleMetadata(response); } + /// Get the server's machine identifier + Future getMachineIdentifier() async { + try { + final response = await _dio.get('/'); + final container = _getMediaContainer(response); + if (container == null) return null; + return container['machineIdentifier'] as String?; + } catch (e) { + appLogger.e('Failed to get machine identifier', error: e); + return null; + } + } + + /// Build a proper metadata URI for adding to playlists + /// Returns URI in format: server://{machineId}/com.plexapp.plugins.library/library/metadata/{ratingKey} + Future buildMetadataUri(String ratingKey) async { + // Use cached machine identifier from config if available + final machineId = config.machineIdentifier ?? await getMachineIdentifier(); + if (machineId == null) { + throw Exception('Could not get server machine identifier'); + } + return 'server://$machineId/com.plexapp.plugins.library/library/metadata/$ratingKey'; + } + /// Get metadata by rating key with images (includes clearLogo and OnDeck) Future> getMetadataWithImagesAndOnDeck( String ratingKey, @@ -1233,6 +1256,234 @@ class PlexClient { } } + /// Get all playlists + /// Filters by playlistType=video by default + /// Set smart to true/false to filter smart playlists, or null for all + Future> getPlaylists({ + String playlistType = 'video', + bool? smart, + }) async { + try { + final queryParams = {'playlistType': playlistType}; + if (smart != null) { + queryParams['smart'] = smart ? '1' : '0'; + } + + final response = await _dio.get( + '/playlists', + queryParameters: queryParams, + ); + final container = _getMediaContainer(response); + + if (container == null || container['Metadata'] == null) { + return []; + } + + final List metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return []; + } + + return metadata + .map((item) => PlexPlaylist.fromJson(item as Map)) + .toList(); + } catch (e) { + appLogger.e('Failed to get playlists: $e'); + return []; + } + } + + /// Get playlist metadata by playlist ID + /// Returns the playlist details (not the items) + Future getPlaylistMetadata(String playlistId) async { + try { + final response = await _dio.get('/playlists/$playlistId'); + final container = _getMediaContainer(response); + + if (container == null || container['Metadata'] == null) { + return null; + } + + final List metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return null; + } + + return PlexPlaylist.fromJson(metadata.first as Map); + } catch (e) { + appLogger.e('Failed to get playlist metadata: $e'); + return null; + } + } + + /// Create a new playlist + /// [title] - Name of the playlist + /// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234") + /// [playQueueId] - Optional play queue ID to create playlist from + Future createPlaylist({ + required String title, + String? uri, + int? playQueueId, + }) async { + try { + final queryParams = { + 'type': 'video', + 'title': title, + 'smart': '0', + }; + + if (uri != null) { + queryParams['uri'] = uri; + } + if (playQueueId != null) { + queryParams['playQueueID'] = playQueueId.toString(); + } + + final response = await _dio.post( + '/playlists', + queryParameters: queryParams, + ); + final container = _getMediaContainer(response); + + if (container == null || container['Metadata'] == null) { + return null; + } + + final List metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return null; + } + + return PlexPlaylist.fromJson(metadata.first as Map); + } catch (e) { + appLogger.e('Failed to create playlist: $e'); + return null; + } + } + + /// Delete a playlist + Future deletePlaylist(String playlistId) async { + try { + await _dio.delete('/playlists/$playlistId'); + return true; + } catch (e) { + appLogger.e('Failed to delete playlist: $e'); + return false; + } + } + + /// Add items to a playlist + /// [playlistId] - The playlist to add items to + /// [uri] - Comma-separated list of item URIs to add + Future addToPlaylist({ + required String playlistId, + required String uri, + }) async { + try { + appLogger.d( + 'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}', + ); + final response = await _dio.put( + '/playlists/$playlistId/items', + queryParameters: {'uri': uri}, + ); + appLogger.d('Add to playlist response status: ${response.statusCode}'); + return response.statusCode == 200; + } catch (e) { + appLogger.e('Failed to add to playlist', error: e); + return false; + } + } + + /// Remove an item from a playlist + /// [playlistId] - The playlist to remove from + /// [playlistItemId] - The playlist item ID to remove (from the item's playlistItemID field) + Future removeFromPlaylist({ + required String playlistId, + required String playlistItemId, + }) async { + try { + await _dio.delete('/playlists/$playlistId/items/$playlistItemId'); + return true; + } catch (e) { + appLogger.e('Failed to remove from playlist: $e'); + return false; + } + } + + /// Move a playlist item to a new position + /// Only works with non-smart playlists + /// [playlistId] - The playlist rating key + /// [playlistItemId] - The playlist item ID to move + /// [afterPlaylistItemId] - Move the item after this playlist item ID (0 = move to top) + Future movePlaylistItem({ + required String playlistId, + required int playlistItemId, + required int afterPlaylistItemId, + }) async { + try { + appLogger.d( + 'Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId', + ); + await _dio.put( + '/playlists/$playlistId/items/$playlistItemId/move', + queryParameters: {'after': afterPlaylistItemId}, + ); + appLogger.d('Successfully moved playlist item'); + return true; + } catch (e) { + appLogger.e('Failed to move playlist item', error: e); + return false; + } + } + + /// Clear all items from a playlist + Future clearPlaylist(String playlistId) async { + try { + await _dio.delete('/playlists/$playlistId/items'); + return true; + } catch (e) { + appLogger.e('Failed to clear playlist: $e'); + return false; + } + } + + /// Update playlist metadata (e.g., title, summary) + /// Uses the same metadata editing mechanism as other items + Future updatePlaylist({ + required String playlistId, + String? title, + String? summary, + }) async { + try { + final queryParams = { + 'type': 'playlist', + 'id': playlistId, + }; + + if (title != null) { + queryParams['title.value'] = title; + queryParams['title.locked'] = '1'; + } + if (summary != null) { + queryParams['summary.value'] = summary; + queryParams['summary.locked'] = '1'; + } + + await _dio.put( + '/library/metadata/$playlistId', + queryParameters: queryParams, + ); + return true; + } catch (e) { + appLogger.e('Failed to update playlist: $e'); + return false; + } + } + // ============================================================================ // Library Management Methods // ============================================================================ diff --git a/lib/config/plex_config.dart b/lib/config/plex_config.dart index 08675587..5d53b1a9 100644 --- a/lib/config/plex_config.dart +++ b/lib/config/plex_config.dart @@ -9,6 +9,7 @@ class PlexConfig { final String platform; final String? device; final bool acceptJson; + final String? machineIdentifier; PlexConfig({ required this.baseUrl, @@ -19,6 +20,7 @@ class PlexConfig { this.platform = 'Flutter', this.device, this.acceptJson = true, + this.machineIdentifier, }); static Future create({ @@ -29,6 +31,7 @@ class PlexConfig { String? platform, String? device, bool acceptJson = true, + String? machineIdentifier, }) async { final packageInfo = await PackageInfo.fromPlatform(); return PlexConfig( @@ -40,6 +43,7 @@ class PlexConfig { platform: platform ?? 'Flutter', device: device, acceptJson: acceptJson, + machineIdentifier: machineIdentifier, ); } @@ -70,6 +74,7 @@ class PlexConfig { String? platform, String? device, bool? acceptJson, + String? machineIdentifier, }) { return PlexConfig( baseUrl: baseUrl ?? this.baseUrl, @@ -80,6 +85,7 @@ class PlexConfig { platform: platform ?? this.platform, device: device ?? this.device, acceptJson: acceptJson ?? this.acceptJson, + machineIdentifier: machineIdentifier ?? this.machineIdentifier, ); } } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 61e360cf..dbf4b3d0 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 6 -/// Strings: 1896 (316 per locale) +/// Strings: 2100 (350 per locale) /// -/// Built on 2025-11-15 at 00:12 UTC +/// Built on 2025-11-15 at 02:45 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -179,6 +179,7 @@ class Translations implements BaseTranslations { late final _StringsLogsEn logs = _StringsLogsEn._(_root); late final _StringsLicensesEn licenses = _StringsLicensesEn._(_root); late final _StringsNavigationEn navigation = _StringsNavigationEn._(_root); + late final _StringsPlaylistsEn playlists = _StringsPlaylistsEn._(_root); } // Path: app @@ -740,6 +741,49 @@ class _StringsNavigationEn { String get settings => 'Settings'; } +// Path: playlists +class _StringsPlaylistsEn { + _StringsPlaylistsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'Playlists'; + String get noPlaylists => 'No playlists found'; + String get create => 'Create Playlist'; + String get newPlaylist => 'New Playlist'; + String get playlistName => 'Playlist Name'; + String get enterPlaylistName => 'Enter playlist name'; + String get edit => 'Edit Playlist'; + String get delete => 'Delete Playlist'; + String get addTo => 'Add to Playlist'; + String get addItems => 'Add Items'; + String get removeItem => 'Remove from Playlist'; + String get clearPlaylist => 'Clear Playlist'; + String get playAll => 'Play All'; + String get shuffle => 'Shuffle'; + String get smartPlaylist => 'Smart Playlist'; + String get regularPlaylist => 'Regular Playlist'; + String itemCount({required Object count}) => '${count} items'; + String get oneItem => '1 item'; + String get emptyPlaylist => 'This playlist is empty'; + String get deleteConfirm => 'Delete Playlist?'; + String deleteMessage({required Object name}) => 'Are you sure you want to delete "${name}"?'; + String get created => 'Playlist created'; + String get updated => 'Playlist updated'; + String get deleted => 'Playlist deleted'; + String get itemAdded => 'Added to playlist'; + String get itemRemoved => 'Removed from playlist'; + String get selectPlaylist => 'Select Playlist'; + String get createNewPlaylist => 'Create New Playlist'; + String get errorCreating => 'Failed to create playlist'; + String get errorDeleting => 'Failed to delete playlist'; + String get errorLoading => 'Failed to load playlists'; + String get errorAdding => 'Failed to add to playlist'; + String get errorReordering => 'Failed to reorder playlist item'; + String get errorRemoving => 'Failed to remove from playlist'; +} + // Path: class _StringsDe implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -791,6 +835,7 @@ class _StringsDe implements Translations { @override late final _StringsLogsDe logs = _StringsLogsDe._(_root); @override late final _StringsLicensesDe licenses = _StringsLicensesDe._(_root); @override late final _StringsNavigationDe navigation = _StringsNavigationDe._(_root); + @override late final _StringsPlaylistsDe playlists = _StringsPlaylistsDe._(_root); } // Path: app @@ -1352,6 +1397,49 @@ class _StringsNavigationDe implements _StringsNavigationEn { @override String get settings => 'Einstellungen'; } +// Path: playlists +class _StringsPlaylistsDe implements _StringsPlaylistsEn { + _StringsPlaylistsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Playlists'; + @override String get noPlaylists => 'Keine Playlists gefunden'; + @override String get create => 'Playlist erstellen'; + @override String get newPlaylist => 'Neue Playlist'; + @override String get playlistName => 'Playlist-Name'; + @override String get enterPlaylistName => 'Playlist-Namen eingeben'; + @override String get edit => 'Playlist bearbeiten'; + @override String get delete => 'Playlist löschen'; + @override String get addTo => 'Zur Playlist hinzufügen'; + @override String get addItems => 'Elemente hinzufügen'; + @override String get removeItem => 'Aus Playlist entfernen'; + @override String get clearPlaylist => 'Playlist leeren'; + @override String get playAll => 'Alle abspielen'; + @override String get shuffle => 'Zufällig'; + @override String get smartPlaylist => 'Intelligente Playlist'; + @override String get regularPlaylist => 'Normale Playlist'; + @override String itemCount({required Object count}) => '${count} Elemente'; + @override String get oneItem => '1 Element'; + @override String get emptyPlaylist => 'Diese Playlist ist leer'; + @override String get deleteConfirm => 'Playlist löschen?'; + @override String deleteMessage({required Object name}) => 'Möchten Sie "${name}" wirklich löschen?'; + @override String get created => 'Playlist erstellt'; + @override String get updated => 'Playlist aktualisiert'; + @override String get deleted => 'Playlist gelöscht'; + @override String get itemAdded => 'Zur Playlist hinzugefügt'; + @override String get itemRemoved => 'Aus Playlist entfernt'; + @override String get selectPlaylist => 'Playlist auswählen'; + @override String get createNewPlaylist => 'Neue Playlist erstellen'; + @override String get errorCreating => 'Fehler beim Erstellen der Playlist'; + @override String get errorDeleting => 'Fehler beim Löschen der Playlist'; + @override String get errorLoading => 'Fehler beim Laden der Playlists'; + @override String get errorAdding => 'Fehler beim Hinzufügen zur Playlist'; + @override String get errorReordering => 'Fehler beim Neuordnen des Playlist-Elements'; + @override String get errorRemoving => 'Fehler beim Entfernen aus der Playlist'; +} + // Path: class _StringsIt implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -1403,6 +1491,7 @@ class _StringsIt implements Translations { @override late final _StringsLogsIt logs = _StringsLogsIt._(_root); @override late final _StringsLicensesIt licenses = _StringsLicensesIt._(_root); @override late final _StringsNavigationIt navigation = _StringsNavigationIt._(_root); + @override late final _StringsPlaylistsIt playlists = _StringsPlaylistsIt._(_root); } // Path: app @@ -1964,6 +2053,49 @@ class _StringsNavigationIt implements _StringsNavigationEn { @override String get settings => 'Impostazioni'; } +// Path: playlists +class _StringsPlaylistsIt implements _StringsPlaylistsEn { + _StringsPlaylistsIt._(this._root); + + @override final _StringsIt _root; // ignore: unused_field + + // Translations + @override String get title => 'Playlist'; + @override String get noPlaylists => 'Nessuna playlist trovata'; + @override String get create => 'Crea playlist'; + @override String get newPlaylist => 'Nuova playlist'; + @override String get playlistName => 'Nome playlist'; + @override String get enterPlaylistName => 'Inserisci nome playlist'; + @override String get edit => 'Modifica playlist'; + @override String get delete => 'Elimina playlist'; + @override String get addTo => 'Aggiungi a playlist'; + @override String get addItems => 'Aggiungi elementi'; + @override String get removeItem => 'Rimuovi da playlist'; + @override String get clearPlaylist => 'Svuota playlist'; + @override String get playAll => 'Riproduci tutto'; + @override String get shuffle => 'Casuale'; + @override String get smartPlaylist => 'Playlist intelligente'; + @override String get regularPlaylist => 'Playlist normale'; + @override String itemCount({required Object count}) => '${count} elementi'; + @override String get oneItem => '1 elemento'; + @override String get emptyPlaylist => 'Questa playlist è vuota'; + @override String get deleteConfirm => 'Eliminare playlist?'; + @override String deleteMessage({required Object name}) => 'Sei sicuro di voler eliminare "${name}"?'; + @override String get created => 'Playlist creata'; + @override String get updated => 'Playlist aggiornata'; + @override String get deleted => 'Playlist eliminata'; + @override String get itemAdded => 'Aggiunto alla playlist'; + @override String get itemRemoved => 'Rimosso dalla playlist'; + @override String get selectPlaylist => 'Seleziona playlist'; + @override String get createNewPlaylist => 'Crea nuova playlist'; + @override String get errorCreating => 'Errore durante la creazione della playlist'; + @override String get errorDeleting => 'Errore durante l\'eliminazione della playlist'; + @override String get errorLoading => 'Errore durante il caricamento delle playlist'; + @override String get errorAdding => 'Errore durante l\'aggiunta alla playlist'; + @override String get errorReordering => 'Errore durante il riordino dell\'elemento della playlist'; + @override String get errorRemoving => 'Errore durante la rimozione dalla playlist'; +} + // Path: class _StringsNl implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -2015,6 +2147,7 @@ class _StringsNl implements Translations { @override late final _StringsLogsNl logs = _StringsLogsNl._(_root); @override late final _StringsLicensesNl licenses = _StringsLicensesNl._(_root); @override late final _StringsNavigationNl navigation = _StringsNavigationNl._(_root); + @override late final _StringsPlaylistsNl playlists = _StringsPlaylistsNl._(_root); } // Path: app @@ -2576,6 +2709,49 @@ class _StringsNavigationNl implements _StringsNavigationEn { @override String get settings => 'Instellingen'; } +// Path: playlists +class _StringsPlaylistsNl implements _StringsPlaylistsEn { + _StringsPlaylistsNl._(this._root); + + @override final _StringsNl _root; // ignore: unused_field + + // Translations + @override String get title => 'Afspeellijsten'; + @override String get noPlaylists => 'Geen afspeellijsten gevonden'; + @override String get create => 'Afspeellijst maken'; + @override String get newPlaylist => 'Nieuwe afspeellijst'; + @override String get playlistName => 'Naam afspeellijst'; + @override String get enterPlaylistName => 'Voer naam afspeellijst in'; + @override String get edit => 'Afspeellijst bewerken'; + @override String get delete => 'Afspeellijst verwijderen'; + @override String get addTo => 'Toevoegen aan afspeellijst'; + @override String get addItems => 'Items toevoegen'; + @override String get removeItem => 'Verwijderen uit afspeellijst'; + @override String get clearPlaylist => 'Afspeellijst wissen'; + @override String get playAll => 'Alles afspelen'; + @override String get shuffle => 'Shuffle'; + @override String get smartPlaylist => 'Slimme afspeellijst'; + @override String get regularPlaylist => 'Normale afspeellijst'; + @override String itemCount({required Object count}) => '${count} items'; + @override String get oneItem => '1 item'; + @override String get emptyPlaylist => 'Deze afspeellijst is leeg'; + @override String get deleteConfirm => 'Afspeellijst verwijderen?'; + @override String deleteMessage({required Object name}) => 'Weet je zeker dat je "${name}" wilt verwijderen?'; + @override String get created => 'Afspeellijst gemaakt'; + @override String get updated => 'Afspeellijst bijgewerkt'; + @override String get deleted => 'Afspeellijst verwijderd'; + @override String get itemAdded => 'Toegevoegd aan afspeellijst'; + @override String get itemRemoved => 'Verwijderd uit afspeellijst'; + @override String get selectPlaylist => 'Selecteer afspeellijst'; + @override String get createNewPlaylist => 'Nieuwe afspeellijst maken'; + @override String get errorCreating => 'Fout bij maken afspeellijst'; + @override String get errorDeleting => 'Fout bij verwijderen afspeellijst'; + @override String get errorLoading => 'Fout bij laden afspeellijsten'; + @override String get errorAdding => 'Fout bij toevoegen aan afspeellijst'; + @override String get errorReordering => 'Fout bij herschikken van afspeellijstitem'; + @override String get errorRemoving => 'Fout bij verwijderen uit afspeellijst'; +} + // Path: class _StringsSv implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -2627,6 +2803,7 @@ class _StringsSv implements Translations { @override late final _StringsLogsSv logs = _StringsLogsSv._(_root); @override late final _StringsLicensesSv licenses = _StringsLicensesSv._(_root); @override late final _StringsNavigationSv navigation = _StringsNavigationSv._(_root); + @override late final _StringsPlaylistsSv playlists = _StringsPlaylistsSv._(_root); } // Path: app @@ -3188,6 +3365,49 @@ class _StringsNavigationSv implements _StringsNavigationEn { @override String get settings => 'Inställningar'; } +// Path: playlists +class _StringsPlaylistsSv implements _StringsPlaylistsEn { + _StringsPlaylistsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Spellistor'; + @override String get noPlaylists => 'Inga spellistor hittades'; + @override String get create => 'Skapa spellista'; + @override String get newPlaylist => 'Ny spellista'; + @override String get playlistName => 'Spellistans namn'; + @override String get enterPlaylistName => 'Ange spellistans namn'; + @override String get edit => 'Redigera spellista'; + @override String get delete => 'Ta bort spellista'; + @override String get addTo => 'Lägg till i spellista'; + @override String get addItems => 'Lägg till objekt'; + @override String get removeItem => 'Ta bort från spellista'; + @override String get clearPlaylist => 'Rensa spellista'; + @override String get playAll => 'Spela alla'; + @override String get shuffle => 'Blanda'; + @override String get smartPlaylist => 'Smart spellista'; + @override String get regularPlaylist => 'Vanlig spellista'; + @override String itemCount({required Object count}) => '${count} objekt'; + @override String get oneItem => '1 objekt'; + @override String get emptyPlaylist => 'Denna spellista är tom'; + @override String get deleteConfirm => 'Ta bort spellista?'; + @override String deleteMessage({required Object name}) => 'Är du säker på att du vill ta bort "${name}"?'; + @override String get created => 'Spellista skapad'; + @override String get updated => 'Spellista uppdaterad'; + @override String get deleted => 'Spellista borttagen'; + @override String get itemAdded => 'Tillagd i spellista'; + @override String get itemRemoved => 'Borttagen från spellista'; + @override String get selectPlaylist => 'Välj spellista'; + @override String get createNewPlaylist => 'Skapa ny spellista'; + @override String get errorCreating => 'Det gick inte att skapa spellista'; + @override String get errorDeleting => 'Det gick inte att ta bort spellista'; + @override String get errorLoading => 'Det gick inte att ladda spellistor'; + @override String get errorAdding => 'Det gick inte att lägga till i spellista'; + @override String get errorReordering => 'Det gick inte att omordna spellisteobjekt'; + @override String get errorRemoving => 'Det gick inte att ta bort från spellista'; +} + // Path: class _StringsZh implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -3239,6 +3459,7 @@ class _StringsZh implements Translations { @override late final _StringsLogsZh logs = _StringsLogsZh._(_root); @override late final _StringsLicensesZh licenses = _StringsLicensesZh._(_root); @override late final _StringsNavigationZh navigation = _StringsNavigationZh._(_root); + @override late final _StringsPlaylistsZh playlists = _StringsPlaylistsZh._(_root); } // Path: app @@ -3800,6 +4021,49 @@ class _StringsNavigationZh implements _StringsNavigationEn { @override String get settings => '设置'; } +// Path: playlists +class _StringsPlaylistsZh implements _StringsPlaylistsEn { + _StringsPlaylistsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '播放列表'; + @override String get noPlaylists => '未找到播放列表'; + @override String get create => '创建播放列表'; + @override String get newPlaylist => '新播放列表'; + @override String get playlistName => '播放列表名称'; + @override String get enterPlaylistName => '输入播放列表名称'; + @override String get edit => '编辑播放列表'; + @override String get delete => '删除播放列表'; + @override String get addTo => '添加到播放列表'; + @override String get addItems => '添加项目'; + @override String get removeItem => '从播放列表中移除'; + @override String get clearPlaylist => '清空播放列表'; + @override String get playAll => '全部播放'; + @override String get shuffle => '随机播放'; + @override String get smartPlaylist => '智能播放列表'; + @override String get regularPlaylist => '普通播放列表'; + @override String itemCount({required Object count}) => '${count} 个项目'; + @override String get oneItem => '1 个项目'; + @override String get emptyPlaylist => '此播放列表为空'; + @override String get deleteConfirm => '删除播放列表?'; + @override String deleteMessage({required Object name}) => '确定要删除 "${name}" 吗?'; + @override String get created => '播放列表已创建'; + @override String get updated => '播放列表已更新'; + @override String get deleted => '播放列表已删除'; + @override String get itemAdded => '已添加到播放列表'; + @override String get itemRemoved => '已从播放列表中移除'; + @override String get selectPlaylist => '选择播放列表'; + @override String get createNewPlaylist => '创建新播放列表'; + @override String get errorCreating => '创建播放列表失败'; + @override String get errorDeleting => '删除播放列表失败'; + @override String get errorLoading => '加载播放列表失败'; + @override String get errorAdding => '添加到播放列表失败'; + @override String get errorReordering => '重新排序播放列表项目失败'; + @override String get errorRemoving => '从播放列表中移除失败'; +} + /// Flat map(s) containing all translations. /// Only for edge cases! For simple maps, use the map function of this library. @@ -4122,6 +4386,40 @@ extension on Translations { case 'navigation.search': return 'Search'; case 'navigation.libraries': return 'Libraries'; case 'navigation.settings': return 'Settings'; + case 'playlists.title': return 'Playlists'; + case 'playlists.noPlaylists': return 'No playlists found'; + case 'playlists.create': return 'Create Playlist'; + case 'playlists.newPlaylist': return 'New Playlist'; + case 'playlists.playlistName': return 'Playlist Name'; + case 'playlists.enterPlaylistName': return 'Enter playlist name'; + case 'playlists.edit': return 'Edit Playlist'; + case 'playlists.delete': return 'Delete Playlist'; + case 'playlists.addTo': return 'Add to Playlist'; + case 'playlists.addItems': return 'Add Items'; + case 'playlists.removeItem': return 'Remove from Playlist'; + case 'playlists.clearPlaylist': return 'Clear Playlist'; + case 'playlists.playAll': return 'Play All'; + case 'playlists.shuffle': return 'Shuffle'; + case 'playlists.smartPlaylist': return 'Smart Playlist'; + case 'playlists.regularPlaylist': return 'Regular Playlist'; + case 'playlists.itemCount': return ({required Object count}) => '${count} items'; + case 'playlists.oneItem': return '1 item'; + case 'playlists.emptyPlaylist': return 'This playlist is empty'; + case 'playlists.deleteConfirm': return 'Delete Playlist?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Are you sure you want to delete "${name}"?'; + case 'playlists.created': return 'Playlist created'; + case 'playlists.updated': return 'Playlist updated'; + case 'playlists.deleted': return 'Playlist deleted'; + case 'playlists.itemAdded': return 'Added to playlist'; + case 'playlists.itemRemoved': return 'Removed from playlist'; + case 'playlists.selectPlaylist': return 'Select Playlist'; + case 'playlists.createNewPlaylist': return 'Create New Playlist'; + case 'playlists.errorCreating': return 'Failed to create playlist'; + case 'playlists.errorDeleting': return 'Failed to delete playlist'; + case 'playlists.errorLoading': return 'Failed to load playlists'; + case 'playlists.errorAdding': return 'Failed to add to playlist'; + case 'playlists.errorReordering': return 'Failed to reorder playlist item'; + case 'playlists.errorRemoving': return 'Failed to remove from playlist'; default: return null; } } @@ -4446,6 +4744,40 @@ extension on _StringsDe { case 'navigation.search': return 'Suche'; case 'navigation.libraries': return 'Bibliotheken'; case 'navigation.settings': return 'Einstellungen'; + case 'playlists.title': return 'Playlists'; + case 'playlists.noPlaylists': return 'Keine Playlists gefunden'; + case 'playlists.create': return 'Playlist erstellen'; + case 'playlists.newPlaylist': return 'Neue Playlist'; + case 'playlists.playlistName': return 'Playlist-Name'; + case 'playlists.enterPlaylistName': return 'Playlist-Namen eingeben'; + case 'playlists.edit': return 'Playlist bearbeiten'; + case 'playlists.delete': return 'Playlist löschen'; + case 'playlists.addTo': return 'Zur Playlist hinzufügen'; + case 'playlists.addItems': return 'Elemente hinzufügen'; + case 'playlists.removeItem': return 'Aus Playlist entfernen'; + case 'playlists.clearPlaylist': return 'Playlist leeren'; + case 'playlists.playAll': return 'Alle abspielen'; + case 'playlists.shuffle': return 'Zufällig'; + case 'playlists.smartPlaylist': return 'Intelligente Playlist'; + case 'playlists.regularPlaylist': return 'Normale Playlist'; + case 'playlists.itemCount': return ({required Object count}) => '${count} Elemente'; + case 'playlists.oneItem': return '1 Element'; + case 'playlists.emptyPlaylist': return 'Diese Playlist ist leer'; + case 'playlists.deleteConfirm': return 'Playlist löschen?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Möchten Sie "${name}" wirklich löschen?'; + case 'playlists.created': return 'Playlist erstellt'; + case 'playlists.updated': return 'Playlist aktualisiert'; + case 'playlists.deleted': return 'Playlist gelöscht'; + case 'playlists.itemAdded': return 'Zur Playlist hinzugefügt'; + case 'playlists.itemRemoved': return 'Aus Playlist entfernt'; + case 'playlists.selectPlaylist': return 'Playlist auswählen'; + case 'playlists.createNewPlaylist': return 'Neue Playlist erstellen'; + case 'playlists.errorCreating': return 'Fehler beim Erstellen der Playlist'; + case 'playlists.errorDeleting': return 'Fehler beim Löschen der Playlist'; + case 'playlists.errorLoading': return 'Fehler beim Laden der Playlists'; + case 'playlists.errorAdding': return 'Fehler beim Hinzufügen zur Playlist'; + case 'playlists.errorReordering': return 'Fehler beim Neuordnen des Playlist-Elements'; + case 'playlists.errorRemoving': return 'Fehler beim Entfernen aus der Playlist'; default: return null; } } @@ -4770,6 +5102,40 @@ extension on _StringsIt { case 'navigation.search': return 'Cerca'; case 'navigation.libraries': return 'Librerie'; case 'navigation.settings': return 'Impostazioni'; + case 'playlists.title': return 'Playlist'; + case 'playlists.noPlaylists': return 'Nessuna playlist trovata'; + case 'playlists.create': return 'Crea playlist'; + case 'playlists.newPlaylist': return 'Nuova playlist'; + case 'playlists.playlistName': return 'Nome playlist'; + case 'playlists.enterPlaylistName': return 'Inserisci nome playlist'; + case 'playlists.edit': return 'Modifica playlist'; + case 'playlists.delete': return 'Elimina playlist'; + case 'playlists.addTo': return 'Aggiungi a playlist'; + case 'playlists.addItems': return 'Aggiungi elementi'; + case 'playlists.removeItem': return 'Rimuovi da playlist'; + case 'playlists.clearPlaylist': return 'Svuota playlist'; + case 'playlists.playAll': return 'Riproduci tutto'; + case 'playlists.shuffle': return 'Casuale'; + case 'playlists.smartPlaylist': return 'Playlist intelligente'; + case 'playlists.regularPlaylist': return 'Playlist normale'; + case 'playlists.itemCount': return ({required Object count}) => '${count} elementi'; + case 'playlists.oneItem': return '1 elemento'; + case 'playlists.emptyPlaylist': return 'Questa playlist è vuota'; + case 'playlists.deleteConfirm': return 'Eliminare playlist?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Sei sicuro di voler eliminare "${name}"?'; + case 'playlists.created': return 'Playlist creata'; + case 'playlists.updated': return 'Playlist aggiornata'; + case 'playlists.deleted': return 'Playlist eliminata'; + case 'playlists.itemAdded': return 'Aggiunto alla playlist'; + case 'playlists.itemRemoved': return 'Rimosso dalla playlist'; + case 'playlists.selectPlaylist': return 'Seleziona playlist'; + case 'playlists.createNewPlaylist': return 'Crea nuova playlist'; + case 'playlists.errorCreating': return 'Errore durante la creazione della playlist'; + case 'playlists.errorDeleting': return 'Errore durante l\'eliminazione della playlist'; + case 'playlists.errorLoading': return 'Errore durante il caricamento delle playlist'; + case 'playlists.errorAdding': return 'Errore durante l\'aggiunta alla playlist'; + case 'playlists.errorReordering': return 'Errore durante il riordino dell\'elemento della playlist'; + case 'playlists.errorRemoving': return 'Errore durante la rimozione dalla playlist'; default: return null; } } @@ -5094,6 +5460,40 @@ extension on _StringsNl { case 'navigation.search': return 'Zoeken'; case 'navigation.libraries': return 'Bibliotheken'; case 'navigation.settings': return 'Instellingen'; + case 'playlists.title': return 'Afspeellijsten'; + case 'playlists.noPlaylists': return 'Geen afspeellijsten gevonden'; + case 'playlists.create': return 'Afspeellijst maken'; + case 'playlists.newPlaylist': return 'Nieuwe afspeellijst'; + case 'playlists.playlistName': return 'Naam afspeellijst'; + case 'playlists.enterPlaylistName': return 'Voer naam afspeellijst in'; + case 'playlists.edit': return 'Afspeellijst bewerken'; + case 'playlists.delete': return 'Afspeellijst verwijderen'; + case 'playlists.addTo': return 'Toevoegen aan afspeellijst'; + case 'playlists.addItems': return 'Items toevoegen'; + case 'playlists.removeItem': return 'Verwijderen uit afspeellijst'; + case 'playlists.clearPlaylist': return 'Afspeellijst wissen'; + case 'playlists.playAll': return 'Alles afspelen'; + case 'playlists.shuffle': return 'Shuffle'; + case 'playlists.smartPlaylist': return 'Slimme afspeellijst'; + case 'playlists.regularPlaylist': return 'Normale afspeellijst'; + case 'playlists.itemCount': return ({required Object count}) => '${count} items'; + case 'playlists.oneItem': return '1 item'; + case 'playlists.emptyPlaylist': return 'Deze afspeellijst is leeg'; + case 'playlists.deleteConfirm': return 'Afspeellijst verwijderen?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Weet je zeker dat je "${name}" wilt verwijderen?'; + case 'playlists.created': return 'Afspeellijst gemaakt'; + case 'playlists.updated': return 'Afspeellijst bijgewerkt'; + case 'playlists.deleted': return 'Afspeellijst verwijderd'; + case 'playlists.itemAdded': return 'Toegevoegd aan afspeellijst'; + case 'playlists.itemRemoved': return 'Verwijderd uit afspeellijst'; + case 'playlists.selectPlaylist': return 'Selecteer afspeellijst'; + case 'playlists.createNewPlaylist': return 'Nieuwe afspeellijst maken'; + case 'playlists.errorCreating': return 'Fout bij maken afspeellijst'; + case 'playlists.errorDeleting': return 'Fout bij verwijderen afspeellijst'; + case 'playlists.errorLoading': return 'Fout bij laden afspeellijsten'; + case 'playlists.errorAdding': return 'Fout bij toevoegen aan afspeellijst'; + case 'playlists.errorReordering': return 'Fout bij herschikken van afspeellijstitem'; + case 'playlists.errorRemoving': return 'Fout bij verwijderen uit afspeellijst'; default: return null; } } @@ -5418,6 +5818,40 @@ extension on _StringsSv { case 'navigation.search': return 'Sök'; case 'navigation.libraries': return 'Bibliotek'; case 'navigation.settings': return 'Inställningar'; + case 'playlists.title': return 'Spellistor'; + case 'playlists.noPlaylists': return 'Inga spellistor hittades'; + case 'playlists.create': return 'Skapa spellista'; + case 'playlists.newPlaylist': return 'Ny spellista'; + case 'playlists.playlistName': return 'Spellistans namn'; + case 'playlists.enterPlaylistName': return 'Ange spellistans namn'; + case 'playlists.edit': return 'Redigera spellista'; + case 'playlists.delete': return 'Ta bort spellista'; + case 'playlists.addTo': return 'Lägg till i spellista'; + case 'playlists.addItems': return 'Lägg till objekt'; + case 'playlists.removeItem': return 'Ta bort från spellista'; + case 'playlists.clearPlaylist': return 'Rensa spellista'; + case 'playlists.playAll': return 'Spela alla'; + case 'playlists.shuffle': return 'Blanda'; + case 'playlists.smartPlaylist': return 'Smart spellista'; + case 'playlists.regularPlaylist': return 'Vanlig spellista'; + case 'playlists.itemCount': return ({required Object count}) => '${count} objekt'; + case 'playlists.oneItem': return '1 objekt'; + case 'playlists.emptyPlaylist': return 'Denna spellista är tom'; + case 'playlists.deleteConfirm': return 'Ta bort spellista?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Är du säker på att du vill ta bort "${name}"?'; + case 'playlists.created': return 'Spellista skapad'; + case 'playlists.updated': return 'Spellista uppdaterad'; + case 'playlists.deleted': return 'Spellista borttagen'; + case 'playlists.itemAdded': return 'Tillagd i spellista'; + case 'playlists.itemRemoved': return 'Borttagen från spellista'; + case 'playlists.selectPlaylist': return 'Välj spellista'; + case 'playlists.createNewPlaylist': return 'Skapa ny spellista'; + case 'playlists.errorCreating': return 'Det gick inte att skapa spellista'; + case 'playlists.errorDeleting': return 'Det gick inte att ta bort spellista'; + case 'playlists.errorLoading': return 'Det gick inte att ladda spellistor'; + case 'playlists.errorAdding': return 'Det gick inte att lägga till i spellista'; + case 'playlists.errorReordering': return 'Det gick inte att omordna spellisteobjekt'; + case 'playlists.errorRemoving': return 'Det gick inte att ta bort från spellista'; default: return null; } } @@ -5742,6 +6176,40 @@ extension on _StringsZh { case 'navigation.search': return '搜索'; case 'navigation.libraries': return '媒体库'; case 'navigation.settings': return '设置'; + case 'playlists.title': return '播放列表'; + case 'playlists.noPlaylists': return '未找到播放列表'; + case 'playlists.create': return '创建播放列表'; + case 'playlists.newPlaylist': return '新播放列表'; + case 'playlists.playlistName': return '播放列表名称'; + case 'playlists.enterPlaylistName': return '输入播放列表名称'; + case 'playlists.edit': return '编辑播放列表'; + case 'playlists.delete': return '删除播放列表'; + case 'playlists.addTo': return '添加到播放列表'; + case 'playlists.addItems': return '添加项目'; + case 'playlists.removeItem': return '从播放列表中移除'; + case 'playlists.clearPlaylist': return '清空播放列表'; + case 'playlists.playAll': return '全部播放'; + case 'playlists.shuffle': return '随机播放'; + case 'playlists.smartPlaylist': return '智能播放列表'; + case 'playlists.regularPlaylist': return '普通播放列表'; + case 'playlists.itemCount': return ({required Object count}) => '${count} 个项目'; + case 'playlists.oneItem': return '1 个项目'; + case 'playlists.emptyPlaylist': return '此播放列表为空'; + case 'playlists.deleteConfirm': return '删除播放列表?'; + case 'playlists.deleteMessage': return ({required Object name}) => '确定要删除 "${name}" 吗?'; + case 'playlists.created': return '播放列表已创建'; + case 'playlists.updated': return '播放列表已更新'; + case 'playlists.deleted': return '播放列表已删除'; + case 'playlists.itemAdded': return '已添加到播放列表'; + case 'playlists.itemRemoved': return '已从播放列表中移除'; + case 'playlists.selectPlaylist': return '选择播放列表'; + case 'playlists.createNewPlaylist': return '创建新播放列表'; + case 'playlists.errorCreating': return '创建播放列表失败'; + case 'playlists.errorDeleting': return '删除播放列表失败'; + case 'playlists.errorLoading': return '加载播放列表失败'; + case 'playlists.errorAdding': return '添加到播放列表失败'; + case 'playlists.errorReordering': return '重新排序播放列表项目失败'; + case 'playlists.errorRemoving': return '从播放列表中移除失败'; default: return null; } } diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 2d250cbd..9712e83b 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -368,5 +368,41 @@ "search": "Search", "libraries": "Libraries", "settings": "Settings" + }, + "playlists": { + "title": "Playlists", + "noPlaylists": "No playlists found", + "create": "Create Playlist", + "newPlaylist": "New Playlist", + "playlistName": "Playlist Name", + "enterPlaylistName": "Enter playlist name", + "edit": "Edit Playlist", + "delete": "Delete Playlist", + "addTo": "Add to Playlist", + "addItems": "Add Items", + "removeItem": "Remove from Playlist", + "clearPlaylist": "Clear Playlist", + "playAll": "Play All", + "shuffle": "Shuffle", + "smartPlaylist": "Smart Playlist", + "regularPlaylist": "Regular Playlist", + "itemCount": "${count} items", + "oneItem": "1 item", + "emptyPlaylist": "This playlist is empty", + "deleteConfirm": "Delete Playlist?", + "deleteMessage": "Are you sure you want to delete \"${name}\"?", + "created": "Playlist created", + "updated": "Playlist updated", + "deleted": "Playlist deleted", + "itemAdded": "Added to playlist", + "itemRemoved": "Removed from playlist", + "selectPlaylist": "Select Playlist", + "createNewPlaylist": "Create New Playlist", + "errorCreating": "Failed to create playlist", + "errorDeleting": "Failed to delete playlist", + "errorLoading": "Failed to load playlists", + "errorAdding": "Failed to add to playlist", + "errorReordering": "Failed to reorder playlist item", + "errorRemoving": "Failed to remove from playlist" } } diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index 176effea..fa5c662c 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -368,5 +368,41 @@ "search": "Suche", "libraries": "Bibliotheken", "settings": "Einstellungen" + }, + "playlists": { + "title": "Playlists", + "noPlaylists": "Keine Playlists gefunden", + "create": "Playlist erstellen", + "newPlaylist": "Neue Playlist", + "playlistName": "Playlist-Name", + "enterPlaylistName": "Playlist-Namen eingeben", + "edit": "Playlist bearbeiten", + "delete": "Playlist löschen", + "addTo": "Zur Playlist hinzufügen", + "addItems": "Elemente hinzufügen", + "removeItem": "Aus Playlist entfernen", + "clearPlaylist": "Playlist leeren", + "playAll": "Alle abspielen", + "shuffle": "Zufällig", + "smartPlaylist": "Intelligente Playlist", + "regularPlaylist": "Normale Playlist", + "itemCount": "${count} Elemente", + "oneItem": "1 Element", + "emptyPlaylist": "Diese Playlist ist leer", + "deleteConfirm": "Playlist löschen?", + "deleteMessage": "Möchten Sie \"${name}\" wirklich löschen?", + "created": "Playlist erstellt", + "updated": "Playlist aktualisiert", + "deleted": "Playlist gelöscht", + "itemAdded": "Zur Playlist hinzugefügt", + "itemRemoved": "Aus Playlist entfernt", + "selectPlaylist": "Playlist auswählen", + "createNewPlaylist": "Neue Playlist erstellen", + "errorCreating": "Fehler beim Erstellen der Playlist", + "errorDeleting": "Fehler beim Löschen der Playlist", + "errorLoading": "Fehler beim Laden der Playlists", + "errorAdding": "Fehler beim Hinzufügen zur Playlist", + "errorReordering": "Fehler beim Neuordnen des Playlist-Elements", + "errorRemoving": "Fehler beim Entfernen aus der Playlist" } } diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index b206b80d..8672f423 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -368,5 +368,41 @@ "search": "Cerca", "libraries": "Librerie", "settings": "Impostazioni" + }, + "playlists": { + "title": "Playlist", + "noPlaylists": "Nessuna playlist trovata", + "create": "Crea playlist", + "newPlaylist": "Nuova playlist", + "playlistName": "Nome playlist", + "enterPlaylistName": "Inserisci nome playlist", + "edit": "Modifica playlist", + "delete": "Elimina playlist", + "addTo": "Aggiungi a playlist", + "addItems": "Aggiungi elementi", + "removeItem": "Rimuovi da playlist", + "clearPlaylist": "Svuota playlist", + "playAll": "Riproduci tutto", + "shuffle": "Casuale", + "smartPlaylist": "Playlist intelligente", + "regularPlaylist": "Playlist normale", + "itemCount": "${count} elementi", + "oneItem": "1 elemento", + "emptyPlaylist": "Questa playlist è vuota", + "deleteConfirm": "Eliminare playlist?", + "deleteMessage": "Sei sicuro di voler eliminare \"${name}\"?", + "created": "Playlist creata", + "updated": "Playlist aggiornata", + "deleted": "Playlist eliminata", + "itemAdded": "Aggiunto alla playlist", + "itemRemoved": "Rimosso dalla playlist", + "selectPlaylist": "Seleziona playlist", + "createNewPlaylist": "Crea nuova playlist", + "errorCreating": "Errore durante la creazione della playlist", + "errorDeleting": "Errore durante l'eliminazione della playlist", + "errorLoading": "Errore durante il caricamento delle playlist", + "errorAdding": "Errore durante l'aggiunta alla playlist", + "errorReordering": "Errore durante il riordino dell'elemento della playlist", + "errorRemoving": "Errore durante la rimozione dalla playlist" } } diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index ec1e906f..04640b3f 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -368,5 +368,41 @@ "search": "Zoeken", "libraries": "Bibliotheken", "settings": "Instellingen" + }, + "playlists": { + "title": "Afspeellijsten", + "noPlaylists": "Geen afspeellijsten gevonden", + "create": "Afspeellijst maken", + "newPlaylist": "Nieuwe afspeellijst", + "playlistName": "Naam afspeellijst", + "enterPlaylistName": "Voer naam afspeellijst in", + "edit": "Afspeellijst bewerken", + "delete": "Afspeellijst verwijderen", + "addTo": "Toevoegen aan afspeellijst", + "addItems": "Items toevoegen", + "removeItem": "Verwijderen uit afspeellijst", + "clearPlaylist": "Afspeellijst wissen", + "playAll": "Alles afspelen", + "shuffle": "Shuffle", + "smartPlaylist": "Slimme afspeellijst", + "regularPlaylist": "Normale afspeellijst", + "itemCount": "${count} items", + "oneItem": "1 item", + "emptyPlaylist": "Deze afspeellijst is leeg", + "deleteConfirm": "Afspeellijst verwijderen?", + "deleteMessage": "Weet je zeker dat je \"${name}\" wilt verwijderen?", + "created": "Afspeellijst gemaakt", + "updated": "Afspeellijst bijgewerkt", + "deleted": "Afspeellijst verwijderd", + "itemAdded": "Toegevoegd aan afspeellijst", + "itemRemoved": "Verwijderd uit afspeellijst", + "selectPlaylist": "Selecteer afspeellijst", + "createNewPlaylist": "Nieuwe afspeellijst maken", + "errorCreating": "Fout bij maken afspeellijst", + "errorDeleting": "Fout bij verwijderen afspeellijst", + "errorLoading": "Fout bij laden afspeellijsten", + "errorAdding": "Fout bij toevoegen aan afspeellijst", + "errorReordering": "Fout bij herschikken van afspeellijstitem", + "errorRemoving": "Fout bij verwijderen uit afspeellijst" } } diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index e118824d..54f72356 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -368,5 +368,41 @@ "search": "Sök", "libraries": "Bibliotek", "settings": "Inställningar" + }, + "playlists": { + "title": "Spellistor", + "noPlaylists": "Inga spellistor hittades", + "create": "Skapa spellista", + "newPlaylist": "Ny spellista", + "playlistName": "Spellistans namn", + "enterPlaylistName": "Ange spellistans namn", + "edit": "Redigera spellista", + "delete": "Ta bort spellista", + "addTo": "Lägg till i spellista", + "addItems": "Lägg till objekt", + "removeItem": "Ta bort från spellista", + "clearPlaylist": "Rensa spellista", + "playAll": "Spela alla", + "shuffle": "Blanda", + "smartPlaylist": "Smart spellista", + "regularPlaylist": "Vanlig spellista", + "itemCount": "${count} objekt", + "oneItem": "1 objekt", + "emptyPlaylist": "Denna spellista är tom", + "deleteConfirm": "Ta bort spellista?", + "deleteMessage": "Är du säker på att du vill ta bort \"${name}\"?", + "created": "Spellista skapad", + "updated": "Spellista uppdaterad", + "deleted": "Spellista borttagen", + "itemAdded": "Tillagd i spellista", + "itemRemoved": "Borttagen från spellista", + "selectPlaylist": "Välj spellista", + "createNewPlaylist": "Skapa ny spellista", + "errorCreating": "Det gick inte att skapa spellista", + "errorDeleting": "Det gick inte att ta bort spellista", + "errorLoading": "Det gick inte att ladda spellistor", + "errorAdding": "Det gick inte att lägga till i spellista", + "errorReordering": "Det gick inte att omordna spellisteobjekt", + "errorRemoving": "Det gick inte att ta bort från spellista" } } diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 34eae804..705ae2ee 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -368,5 +368,41 @@ "search": "搜索", "libraries": "媒体库", "settings": "设置" + }, + "playlists": { + "title": "播放列表", + "noPlaylists": "未找到播放列表", + "create": "创建播放列表", + "newPlaylist": "新播放列表", + "playlistName": "播放列表名称", + "enterPlaylistName": "输入播放列表名称", + "edit": "编辑播放列表", + "delete": "删除播放列表", + "addTo": "添加到播放列表", + "addItems": "添加项目", + "removeItem": "从播放列表中移除", + "clearPlaylist": "清空播放列表", + "playAll": "全部播放", + "shuffle": "随机播放", + "smartPlaylist": "智能播放列表", + "regularPlaylist": "普通播放列表", + "itemCount": "${count} 个项目", + "oneItem": "1 个项目", + "emptyPlaylist": "此播放列表为空", + "deleteConfirm": "删除播放列表?", + "deleteMessage": "确定要删除 \"${name}\" 吗?", + "created": "播放列表已创建", + "updated": "播放列表已更新", + "deleted": "播放列表已删除", + "itemAdded": "已添加到播放列表", + "itemRemoved": "已从播放列表中移除", + "selectPlaylist": "选择播放列表", + "createNewPlaylist": "创建新播放列表", + "errorCreating": "创建播放列表失败", + "errorDeleting": "删除播放列表失败", + "errorLoading": "加载播放列表失败", + "errorAdding": "添加到播放列表失败", + "errorReordering": "重新排序播放列表项目失败", + "errorRemoving": "从播放列表中移除失败" } } diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 4a667cf8..52233ed1 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -40,6 +40,7 @@ class PlexMetadata { final List? role; // Cast members final String? audioLanguage; // Per-media preferred audio language final String? subtitleLanguage; // Per-media preferred subtitle language + final int? playlistItemID; // Playlist item ID (for dumb playlists only) // Transient field for clear logo (extracted from Image array) String? _clearLogo; @@ -79,6 +80,7 @@ class PlexMetadata { this.role, this.audioLanguage, this.subtitleLanguage, + this.playlistItemID, }); /// Create a copy of this metadata with optional field overrides @@ -116,6 +118,7 @@ class PlexMetadata { List? role, String? audioLanguage, String? subtitleLanguage, + int? playlistItemID, }) { final copy = PlexMetadata( ratingKey: ratingKey ?? this.ratingKey, @@ -151,6 +154,7 @@ class PlexMetadata { role: role ?? this.role, audioLanguage: audioLanguage ?? this.audioLanguage, subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, + playlistItemID: playlistItemID ?? this.playlistItemID, ); // Preserve clearLogo copy._clearLogo = _clearLogo; diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index 9d6de3a1..a9f262ea 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -42,6 +42,7 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( .toList(), audioLanguage: json['audioLanguage'] as String?, subtitleLanguage: json['subtitleLanguage'] as String?, + playlistItemID: (json['playlistItemID'] as num?)?.toInt(), ); Map _$PlexMetadataToJson(PlexMetadata instance) => @@ -79,4 +80,5 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 'Role': instance.role, 'audioLanguage': instance.audioLanguage, 'subtitleLanguage': instance.subtitleLanguage, + 'playlistItemID': instance.playlistItemID, }; diff --git a/lib/models/plex_playlist.dart b/lib/models/plex_playlist.dart new file mode 100644 index 00000000..d47944f7 --- /dev/null +++ b/lib/models/plex_playlist.dart @@ -0,0 +1,66 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'plex_playlist.g.dart'; + +@JsonSerializable() +class PlexPlaylist { + final String ratingKey; + final String key; + final String type; // "playlist" + final String title; + final String? summary; + final bool smart; + final String playlistType; // video, audio, photo + final int? duration; + final int? leafCount; // Number of items in playlist + final String? composite; // Composite thumbnail image + final int? addedAt; + final int? updatedAt; + final int? lastViewedAt; + final int? viewCount; + final String? content; // For smart playlists - generator URI + final String? guid; + final String? thumb; + + PlexPlaylist({ + required this.ratingKey, + required this.key, + required this.type, + required this.title, + this.summary, + required this.smart, + required this.playlistType, + this.duration, + this.leafCount, + this.composite, + this.addedAt, + this.updatedAt, + this.lastViewedAt, + this.viewCount, + this.content, + this.guid, + this.thumb, + }); + + /// Helper to get display image (composite or thumb) + String? get displayImage => composite ?? thumb; + + /// Helper to get formatted duration + String? get formattedDuration { + if (duration == null) return null; + final hours = duration! ~/ 3600000; + final minutes = (duration! % 3600000) ~/ 60000; + if (hours > 0) { + return '${hours}h ${minutes}m'; + } + return '${minutes}m'; + } + + /// Helper to determine if playlist is editable + bool get isEditable => !smart; + + factory PlexPlaylist.fromJson(Map json) => + _$PlexPlaylistFromJson(json); + + Map toJson() => _$PlexPlaylistToJson(this); +} diff --git a/lib/models/plex_playlist.g.dart b/lib/models/plex_playlist.g.dart new file mode 100644 index 00000000..96647865 --- /dev/null +++ b/lib/models/plex_playlist.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_playlist.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexPlaylist _$PlexPlaylistFromJson(Map json) => PlexPlaylist( + ratingKey: json['ratingKey'] as String, + key: json['key'] as String, + type: json['type'] as String, + title: json['title'] as String, + summary: json['summary'] as String?, + smart: json['smart'] as bool, + playlistType: json['playlistType'] as String, + duration: (json['duration'] as num?)?.toInt(), + leafCount: (json['leafCount'] as num?)?.toInt(), + composite: json['composite'] as String?, + addedAt: (json['addedAt'] as num?)?.toInt(), + updatedAt: (json['updatedAt'] as num?)?.toInt(), + lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(), + viewCount: (json['viewCount'] as num?)?.toInt(), + content: json['content'] as String?, + guid: json['guid'] as String?, + thumb: json['thumb'] as String?, +); + +Map _$PlexPlaylistToJson(PlexPlaylist instance) => + { + 'ratingKey': instance.ratingKey, + 'key': instance.key, + 'type': instance.type, + 'title': instance.title, + 'summary': instance.summary, + 'smart': instance.smart, + 'playlistType': instance.playlistType, + 'duration': instance.duration, + 'leafCount': instance.leafCount, + 'composite': instance.composite, + 'addedAt': instance.addedAt, + 'updatedAt': instance.updatedAt, + 'lastViewedAt': instance.lastViewedAt, + 'viewCount': instance.viewCount, + 'content': instance.content, + 'guid': instance.guid, + 'thumb': instance.thumb, + }; diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 721345af..c13d7c3f 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -1,98 +1,126 @@ import 'package:flutter/foundation.dart'; import '../models/plex_metadata.dart'; -/// Manages shuffle playback state for TV shows and seasons. +/// Playback mode types +enum PlaybackMode { + none, // No active playback queue + sequential, // Normal episode-to-episode playback (uses Plex API) + shufflePlay, // Shuffle play for shows/seasons + playlist, // Playlist playback (ordered or shuffled) +} + +/// Manages playback state for TV shows, seasons, and playlists. /// This provider is session-only and does not persist across app restarts. class PlaybackStateProvider with ChangeNotifier { - List _shuffleQueue = []; - String? - _shuffleContextKey; // The show/season ratingKey for this shuffle session + List _queue = []; + String? _contextKey; // The show/season/playlist ratingKey for this session int _currentIndex = 0; + PlaybackMode _playbackMode = PlaybackMode.none; + + /// Current playback mode + PlaybackMode get playbackMode => _playbackMode; /// Whether shuffle mode is currently active - bool get isShuffleActive => _shuffleQueue.isNotEmpty; + bool get isShuffleActive => _playbackMode == PlaybackMode.shufflePlay; - /// The context key (show or season ratingKey) for the current shuffle session - String? get shuffleContextKey => _shuffleContextKey; + /// Whether playlist mode is currently active + bool get isPlaylistActive => _playbackMode == PlaybackMode.playlist; + + /// Whether any queue-based playback is active + bool get isQueueActive => _queue.isNotEmpty && _playbackMode != PlaybackMode.none; + + /// The context key (show/season/playlist ratingKey) for the current session + String? get shuffleContextKey => _contextKey; /// Sets a new shuffle queue and starts shuffle mode void setShuffleQueue(List episodes, String contextKey) { - _shuffleQueue = List.from(episodes); - _shuffleContextKey = contextKey; + _queue = List.from(episodes); + _contextKey = contextKey; _currentIndex = 0; + _playbackMode = PlaybackMode.shufflePlay; notifyListeners(); } - /// Gets the next episode in the shuffle queue. - /// Returns null if queue is exhausted or current episode is not in queue. + /// Sets a playback queue for playlist playback (ordered, not shuffled) + void setPlaybackQueue(List items, String contextKey) { + _queue = List.from(items); + _contextKey = contextKey; + _currentIndex = 0; + _playbackMode = PlaybackMode.playlist; + notifyListeners(); + } + + /// Gets the next item in the playback queue. + /// Returns null if queue is exhausted or current item is not in queue. /// [loopQueue] - If true, restart from beginning when queue is exhausted PlexMetadata? getNextEpisode( - String currentEpisodeKey, { + String currentItemKey, { bool loopQueue = false, }) { - if (_shuffleQueue.isEmpty) return null; + if (_queue.isEmpty) return null; - // Find current episode in queue - final currentIndex = _shuffleQueue.indexWhere( - (ep) => ep.ratingKey == currentEpisodeKey, + // Find current item in queue + final currentIndex = _queue.indexWhere( + (item) => item.ratingKey == currentItemKey, ); if (currentIndex == -1) { - // Current episode not in queue, clear shuffle + // Current item not in queue, clear queue clearShuffle(); return null; } - // Check if there's a next episode - if (currentIndex + 1 >= _shuffleQueue.length) { + // Check if there's a next item + if (currentIndex + 1 >= _queue.length) { // Queue exhausted - if (loopQueue && _shuffleQueue.isNotEmpty) { + if (loopQueue && _queue.isNotEmpty) { // Loop back to beginning _currentIndex = 0; - return _shuffleQueue[_currentIndex]; + return _queue[_currentIndex]; } return null; } _currentIndex = currentIndex + 1; - return _shuffleQueue[_currentIndex]; + return _queue[_currentIndex]; } - /// Gets the previous episode in the shuffle queue. - /// Returns null if at the beginning of the queue or current episode is not in queue. - PlexMetadata? getPreviousEpisode(String currentEpisodeKey) { - if (_shuffleQueue.isEmpty) return null; + /// Gets the previous item in the playback queue. + /// Returns null if at the beginning of the queue or current item is not in queue. + PlexMetadata? getPreviousEpisode(String currentItemKey) { + if (_queue.isEmpty) return null; - // Find current episode in queue - final currentIndex = _shuffleQueue.indexWhere( - (ep) => ep.ratingKey == currentEpisodeKey, + // Find current item in queue + final currentIndex = _queue.indexWhere( + (item) => item.ratingKey == currentItemKey, ); if (currentIndex == -1) { - // Current episode not in queue + // Current item not in queue return null; } - // Check if there's a previous episode + // Check if there's a previous item if (currentIndex <= 0) { // At the beginning of queue return null; } _currentIndex = currentIndex - 1; - return _shuffleQueue[_currentIndex]; + return _queue[_currentIndex]; } - /// Clears the shuffle queue and exits shuffle mode + /// Clears the playback queue and exits queue mode void clearShuffle() { - _shuffleQueue = []; - _shuffleContextKey = null; + _queue = []; + _contextKey = null; _currentIndex = 0; + _playbackMode = PlaybackMode.none; notifyListeners(); } - /// Gets the total number of episodes in the current shuffle queue - int get queueLength => _shuffleQueue.length; + /// Gets the total number of items in the current playback queue + int get queueLength => _queue.length; /// Gets the current position in the queue (1-indexed) int get currentPosition => _currentIndex + 1; diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index b7a65fd2..cf5a912e 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -21,6 +21,7 @@ import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; +import 'playlists_screen.dart'; class LibrariesScreen extends StatefulWidget { const LibrariesScreen({super.key}); @@ -1125,6 +1126,18 @@ class _LibrariesScreenState extends State ], ], ), + floatingActionButton: FloatingActionButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PlaylistsScreen(), + ), + ); + }, + tooltip: t.playlists.title, + child: const Icon(Icons.playlist_play), + ), ); } diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart new file mode 100644 index 00000000..c96af4ec --- /dev/null +++ b/lib/screens/playlist_detail_screen.dart @@ -0,0 +1,517 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; +import '../models/plex_playlist.dart'; +import '../models/plex_metadata.dart'; +import '../providers/settings_provider.dart'; +import '../providers/playback_state_provider.dart'; +import '../services/settings_service.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; +import '../utils/video_player_navigation.dart'; +import '../widgets/media_card.dart'; +import '../widgets/playlist_item_card.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../mixins/refreshable.dart'; +import '../mixins/item_updatable.dart'; +import '../i18n/strings.g.dart'; + +/// Screen to display the contents of a playlist +class PlaylistDetailScreen extends StatefulWidget { + final PlexPlaylist playlist; + + const PlaylistDetailScreen({super.key, required this.playlist}); + + @override + State createState() => _PlaylistDetailScreenState(); +} + +class _PlaylistDetailScreenState extends State + with Refreshable, ItemUpdatable { + @override + PlexClient get client => context.clientSafe; + + List _items = []; + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadPlaylistItems(); + } + + Future _loadPlaylistItems() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final items = await client.getPlaylist(widget.playlist.ratingKey); + + setState(() { + _items = items; + _isLoading = false; + }); + + appLogger.d('Loaded ${items.length} items for playlist: ${widget.playlist.title}'); + } catch (e) { + appLogger.e('Failed to load playlist items', error: e); + setState(() { + _errorMessage = 'Failed to load playlist items: ${e.toString()}'; + _isLoading = false; + }); + } + } + + Future _deletePlaylist() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.playlists.deleteConfirm), + content: Text(t.playlists.deleteMessage(name: widget.playlist.title)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(t.playlists.delete), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final success = await client.deletePlaylist(widget.playlist.ratingKey); + + if (mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.deleted)), + ); + Navigator.pop(context); // Return to playlists screen + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorDeleting)), + ); + } + } + } + } + + Future _onReorder(int oldIndex, int newIndex) async { + // Adjust newIndex if moving down in the list + if (newIndex > oldIndex) { + newIndex--; + } + + // Can't reorder if indices are the same + if (oldIndex == newIndex) return; + + final movedItem = _items[oldIndex]; + + // Check if item has playlistItemID (required for reordering) + if (movedItem.playlistItemID == null) { + appLogger.e('Cannot reorder: item missing playlistItemID'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorReordering)), + ); + } + return; + } + + // Determine the "after" item ID + // If moving to position 0, afterPlaylistItemId should be 0 (move to top) + // Otherwise, use the playlistItemID of the item before the new position + final int afterPlaylistItemId; + if (newIndex == 0) { + afterPlaylistItemId = 0; // Move to top + } else { + final afterItem = _items[newIndex - 1]; + if (afterItem.playlistItemID == null) { + appLogger.e('Cannot reorder: after item missing playlistItemID'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorReordering)), + ); + } + return; + } + afterPlaylistItemId = afterItem.playlistItemID!; + } + + appLogger.d('Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)'); + + // Optimistically update UI + setState(() { + final item = _items.removeAt(oldIndex); + _items.insert(newIndex, item); + }); + + // Call API to persist the change + final success = await client.movePlaylistItem( + playlistId: widget.playlist.ratingKey, + playlistItemId: movedItem.playlistItemID!, + afterPlaylistItemId: afterPlaylistItemId, + ); + + if (!success) { + // Revert on failure + appLogger.e('Failed to reorder playlist item, reverting UI'); + if (mounted) { + setState(() { + final item = _items.removeAt(newIndex); + _items.insert(oldIndex, item); + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorReordering)), + ); + } + } + } + + Future _removeItem(int index) async { + final item = _items[index]; + + // Check if item has playlistItemID (required for removal) + if (item.playlistItemID == null) { + appLogger.e('Cannot remove: item missing playlistItemID'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorRemoving)), + ); + } + return; + } + + appLogger.d('Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist'); + + // Optimistically update UI + setState(() { + _items.removeAt(index); + }); + + // Call API to persist the change + final success = await client.removeFromPlaylist( + playlistId: widget.playlist.ratingKey, + playlistItemId: item.playlistItemID.toString(), + ); + + if (mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.itemRemoved)), + ); + } else { + // Revert on failure + appLogger.e('Failed to remove playlist item, reverting UI'); + setState(() { + _items.insert(index, item); + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorRemoving)), + ); + } + } + } + + @override + void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + final index = _items.indexWhere((item) => item.ratingKey == ratingKey); + if (index != -1) { + _items[index] = updatedMetadata; + } + } + + @override + void refresh() { + _loadPlaylistItems(); + } + + Future _playPlaylist() async { + if (_items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.emptyPlaylist)), + ); + } + return; + } + + final playbackState = context.read(); + + // Set the playlist items as the playback queue (in order, not shuffled) + playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey); + + // Navigate to the first item + if (mounted) { + await navigateToVideoPlayer(context, metadata: _items.first); + } + } + + Future _shufflePlayPlaylist() async { + if (_items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.emptyPlaylist)), + ); + } + return; + } + + final playbackState = context.read(); + + // Shuffle the items + final shuffledItems = List.from(_items)..shuffle(); + + // Set the shuffled playlist items as the playback queue (playlist mode, not shuffle mode) + playbackState.setPlaybackQueue(shuffledItems, widget.playlist.ratingKey); + + // Navigate to the first shuffled item + if (mounted) { + await navigateToVideoPlayer(context, metadata: shuffledItems.first); + } + } + + Future _playFromItem(int index) async { + if (_items.isEmpty || index < 0 || index >= _items.length) return; + + final playbackState = context.read(); + + // Set the full playlist as playback queue (in order) + playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey); + + // Start playing from the clicked item + if (mounted) { + await navigateToVideoPlayer(context, metadata: _items[index]); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + CustomAppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.playlist.title, + style: const TextStyle(fontSize: 16), + ), + if (widget.playlist.smart) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.auto_awesome, + size: 12, + color: Colors.blue[300], + ), + const SizedBox(width: 4), + Text( + t.playlists.smartPlaylist, + style: TextStyle( + fontSize: 11, + color: Colors.blue[300], + fontWeight: FontWeight.normal, + ), + ), + ], + ), + ], + ), + pinned: true, + actions: [ + // Play button + if (_items.isNotEmpty) + IconButton( + icon: const Icon(Icons.play_arrow), + tooltip: t.discover.play, + onPressed: _playPlaylist, + ), + // Shuffle button + if (_items.isNotEmpty) + IconButton( + icon: const Icon(Icons.shuffle), + tooltip: t.playlists.shuffle, + onPressed: _shufflePlayPlaylist, + ), + // Delete button for non-smart playlists + if (!widget.playlist.smart) + IconButton( + icon: const Icon(Icons.delete), + tooltip: t.playlists.delete, + onPressed: _deletePlaylist, + color: Colors.red, + ), + ], + ), + if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPlaylistItems, + child: Text(t.common.retry), + ), + ], + ), + ), + ) + else if (_items.isEmpty && _isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_items.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.playlist_play, + size: 64, + color: Colors.grey, + ), + const SizedBox(height: 16), + Text( + t.playlists.emptyPlaylist, + style: const TextStyle(fontSize: 16, color: Colors.grey), + ), + ], + ), + ), + ) + else if (widget.playlist.smart) + // Smart playlists: Use grid view (cannot be reordered) + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + delegate: SliverChildBuilderDelegate((context, index) { + return MediaCard( + item: _items[index], + onRefresh: updateItem, + ); + }, childCount: _items.length), + ), + ) + else + // Regular playlists: Use reorderable list view + SliverReorderableList( + itemBuilder: (context, index) { + final item = _items[index]; + return PlaylistItemCard( + key: ValueKey(item.playlistItemID ?? item.ratingKey), + item: item, + index: index, + onRemove: () => _removeItem(index), + onTap: () => _playFromItem(index), + canReorder: !widget.playlist.smart, + ); + }, + itemCount: _items.length, + onReorder: _onReorder, + ), + ], + ), + ); + } + + double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { + final screenWidth = MediaQuery.of(context).size.width; + final padding = 16.0; + final availableWidth = screenWidth - padding; + + if (screenWidth >= 900) { + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(120, maxItemWidth); + } else if (screenWidth >= 600) { + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 4.5; + maxItemWidth = 220; + break; + case LibraryDensity.normal: + divisor = 5.5; + maxItemWidth = 180; + break; + case LibraryDensity.compact: + divisor = 7.0; + maxItemWidth = 140; + break; + } + + return (availableWidth / divisor).clamp(100, maxItemWidth); + } else { + double divisor; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 2.2; + break; + case LibraryDensity.normal: + divisor = 2.8; + break; + case LibraryDensity.compact: + divisor = 3.5; + break; + } + + return availableWidth / divisor; + } + } +} diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart new file mode 100644 index 00000000..82fad9f0 --- /dev/null +++ b/lib/screens/playlists_screen.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; +import '../models/plex_playlist.dart'; +import '../providers/settings_provider.dart'; +import '../services/settings_service.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../mixins/refreshable.dart'; +import '../i18n/strings.g.dart'; +import 'playlist_detail_screen.dart'; + +/// Screen to display all video playlists +class PlaylistsScreen extends StatefulWidget { + const PlaylistsScreen({super.key}); + + @override + State createState() => _PlaylistsScreenState(); +} + +class _PlaylistsScreenState extends State with Refreshable { + PlexClient get client => context.clientSafe; + + List _playlists = []; + bool _isLoading = false; + String? _errorMessage; + bool? _filterSmart; + + @override + void initState() { + super.initState(); + _loadPlaylists(); + } + + Future _loadPlaylists() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final playlists = await client.getPlaylists( + playlistType: 'video', + smart: _filterSmart, + ); + + setState(() { + _playlists = playlists; + _isLoading = false; + }); + + appLogger.d('Loaded ${playlists.length} playlists'); + } catch (e) { + appLogger.e('Failed to load playlists', error: e); + setState(() { + _errorMessage = 'Failed to load playlists: ${e.toString()}'; + _isLoading = false; + }); + } + } + + void _toggleSmartFilter() { + setState(() { + if (_filterSmart == null) { + _filterSmart = true; // Show only smart + } else if (_filterSmart == true) { + _filterSmart = false; // Show only regular + } else { + _filterSmart = null; // Show all + } + }); + _loadPlaylists(); + } + + String _getFilterLabel() { + if (_filterSmart == null) return 'All'; + if (_filterSmart == true) return 'Smart'; + return 'Regular'; + } + + @override + void refresh() { + _loadPlaylists(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + CustomAppBar( + title: Text(t.playlists.title), + pinned: true, + actions: [ + TextButton.icon( + icon: const Icon(Icons.filter_list), + label: Text(_getFilterLabel()), + onPressed: _toggleSmartFilter, + ), + ], + ), + if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPlaylists, + child: Text(t.common.retry), + ), + ], + ), + ), + ) + else if (_playlists.isEmpty && _isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_playlists.isEmpty) + SliverFillRemaining( + child: Center(child: Text(t.playlists.noPlaylists)), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + delegate: SliverChildBuilderDelegate((context, index) { + return _PlaylistCard( + playlist: _playlists[index], + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaylistDetailScreen( + playlist: _playlists[index], + ), + ), + ).then((_) => _loadPlaylists()); // Refresh on return + }, + onDeleted: _loadPlaylists, + ); + }, childCount: _playlists.length), + ), + ), + ], + ), + ); + } + + double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { + final screenWidth = MediaQuery.of(context).size.width; + final padding = 16.0; + final availableWidth = screenWidth - padding; + + if (screenWidth >= 900) { + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(120, maxItemWidth); + } else if (screenWidth >= 600) { + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 4.5; + maxItemWidth = 220; + break; + case LibraryDensity.normal: + divisor = 5.5; + maxItemWidth = 180; + break; + case LibraryDensity.compact: + divisor = 7.0; + maxItemWidth = 140; + break; + } + + return (availableWidth / divisor).clamp(100, maxItemWidth); + } else { + double divisor; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 2.2; + break; + case LibraryDensity.normal: + divisor = 2.8; + break; + case LibraryDensity.compact: + divisor = 3.5; + break; + } + + return availableWidth / divisor; + } + } +} + +/// Widget to display a single playlist card +class _PlaylistCard extends StatelessWidget { + final PlexPlaylist playlist; + final VoidCallback onTap; + final VoidCallback onDeleted; + + const _PlaylistCard({ + required this.playlist, + required this.onTap, + required this.onDeleted, + }); + + Future _showDeleteDialog(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.playlists.deleteConfirm), + content: Text(t.playlists.deleteMessage(name: playlist.title)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(t.playlists.delete), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + final client = context.clientSafe; + final success = await client.deletePlaylist(playlist.ratingKey); + + if (context.mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.deleted)), + ); + onDeleted(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorDeleting)), + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + final client = context.clientSafe; + final imageUrl = playlist.displayImage != null + ? client.getThumbnailUrl(playlist.displayImage!) + : null; + + return Card( + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.all(4), + child: InkWell( + onTap: onTap, + onLongPress: () => _showDeleteDialog(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Playlist image + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + if (imageUrl != null) + Image.network( + imageUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildPlaceholder(); + }, + ) + else + _buildPlaceholder(), + // Smart playlist indicator + if (playlist.smart) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.9), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon( + Icons.auto_awesome, + size: 12, + color: Colors.white, + ), + ), + ), + ], + ), + ), + // Playlist info + Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + playlist.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.playlist_play, + size: 14, + color: Colors.grey[600], + ), + const SizedBox(width: 4), + Text( + playlist.leafCount != null && playlist.leafCount! > 0 + ? (playlist.leafCount == 1 + ? t.playlists.oneItem + : t.playlists.itemCount(count: playlist.leafCount!)) + : t.playlists.emptyPlaylist, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildPlaceholder() { + return Container( + color: Colors.grey[850], + child: const Center( + child: Icon( + Icons.playlist_play, + size: 48, + color: Colors.grey, + ), + ), + ); + } +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 6f40aed7..06098ccb 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -284,10 +284,6 @@ class VideoPlayerScreenState extends State } Future _loadAdjacentEpisodes() async { - if (widget.metadata.type.toLowerCase() != 'episode') { - return; - } - try { final clientProvider = context.plexClient; final client = clientProvider.client; @@ -299,8 +295,25 @@ class VideoPlayerScreenState extends State PlexMetadata? next; PlexMetadata? previous; + // Check if playlist mode is active (takes priority) + if (playbackState.isPlaylistActive) { + // For playlists, always use the queue regardless of item type + // Playlists can contain both movies and episodes + next = playbackState.getNextEpisode( + widget.metadata.ratingKey, + loopQueue: false, // Don't loop playlists by default + ); + previous = playbackState.getPreviousEpisode( + widget.metadata.ratingKey, + ); + } // Check if shuffle mode is active - if (playbackState.isShuffleActive) { + else if (playbackState.isShuffleActive) { + // Only works for episodes in shuffle mode + if (widget.metadata.type.toLowerCase() != 'episode') { + return; + } + // Get settings final shuffleOrderNavigation = settingsProvider.shuffleOrderNavigation; final loopQueue = settingsProvider.shuffleLoopQueue; @@ -319,7 +332,14 @@ class VideoPlayerScreenState extends State next = await client.findAdjacentEpisode(widget.metadata, 1); previous = await client.findAdjacentEpisode(widget.metadata, -1); } - } else { + } + // Normal sequential playback + else { + // Only works for episodes in sequential mode + if (widget.metadata.type.toLowerCase() != 'episode') { + return; + } + // Use normal sequential episode loading next = await client.findAdjacentEpisode(widget.metadata, 1); previous = await client.findAdjacentEpisode(widget.metadata, -1); @@ -1483,16 +1503,20 @@ class VideoPlayerScreenState extends State } }); - // Enable/disable next/previous track controls based on content type + // Enable/disable next/previous track controls based on content type and playback mode + final playbackState = context.read(); final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; - if (isEpisode) { - // Enable next/previous track controls for episodes + final isInPlaylist = playbackState.isPlaylistActive; + + // Enable controls for episodes OR playlist items + if (isEpisode || isInPlaylist) { + // Enable next/previous track controls for episodes and playlist items await OsMediaControls.enableControls([ MediaControl.next, MediaControl.previous, ]); } else { - // Disable next/previous track controls for movies + // Disable next/previous track controls for standalone movies await OsMediaControls.disableControls([ MediaControl.next, MediaControl.previous, diff --git a/lib/services/server_connection_service.dart b/lib/services/server_connection_service.dart index 5a3dbea2..c760039c 100644 --- a/lib/services/server_connection_service.dart +++ b/lib/services/server_connection_service.dart @@ -112,6 +112,18 @@ class ServerConnectionService { }, ); + // Fetch machine identifier and cache it in config + try { + final machineId = await client.getMachineIdentifier(); + if (machineId != null) { + client.config = config.copyWith(machineIdentifier: machineId); + appLogger.d('Cached machine identifier: $machineId'); + } + } catch (e) { + appLogger.w('Failed to fetch machine identifier', error: e); + // Continue without it - buildMetadataUri will fallback to fetching it + } + // Verify server is accessible if requested if (verifyServer) { try { diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index a186469d..68e6c39b 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,7 +1,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_playlist.dart'; import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../widgets/file_info_bottom_sheet.dart'; @@ -143,6 +145,17 @@ class _MediaContextMenuState extends State { ); } + // Add to Playlist (for episodes, movies, shows, and seasons) + if (itemType == 'episode' || itemType == 'movie' || itemType == 'show' || itemType == 'season') { + menuActions.add( + _MenuAction( + value: 'add_to_playlist', + icon: Icons.playlist_add, + label: t.playlists.addTo, + ), + ); + } + String? selected; if (useBottomSheet) { @@ -296,6 +309,10 @@ class _MediaContextMenuState extends State { await _showFileInfo(context); break; + case 'add_to_playlist': + await _showAddToPlaylistDialog(context); + break; + case 'shuffle_play': await handleShufflePlay(context, widget.metadata); break; @@ -412,6 +429,100 @@ class _MediaContextMenuState extends State { } } + /// Show dialog to select playlist and add item + Future _showAddToPlaylistDialog(BuildContext context) async { + final client = context.client; + if (client == null) return; + + try { + final itemType = widget.metadata.type.toLowerCase(); + + // Load playlists + final playlists = await client.getPlaylists(playlistType: 'video'); + + if (!context.mounted) return; + + // Show dialog to select playlist or create new + final result = await showDialog( + context: context, + builder: (context) => _PlaylistSelectionDialog( + playlists: playlists, + ), + ); + + if (result == null || !context.mounted) return; + + // Build URI for the item (works for all types: movies, episodes, seasons, shows) + // For seasons/shows, the Plex API should automatically expand to include all episodes + final itemUri = await client.buildMetadataUri(widget.metadata.ratingKey); + appLogger.d('Built URI for $itemType: $itemUri'); + + if (result == '_create_new') { + // Create new playlist flow + final playlistName = await showDialog( + context: context, + builder: (context) => _CreatePlaylistDialog(), + ); + + if (playlistName == null || playlistName.isEmpty || !context.mounted) { + return; + } + + // Create playlist with the item(s) + appLogger.d('Creating playlist "$playlistName" with URI length: ${itemUri.length}'); + final newPlaylist = await client.createPlaylist( + title: playlistName, + uri: itemUri, + ); + + if (context.mounted) { + if (newPlaylist != null) { + appLogger.d('Successfully created playlist: ${newPlaylist.title}'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.created)), + ); + } else { + appLogger.e('Failed to create playlist - API returned null'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorCreating)), + ); + } + } + } else { + // Add to existing playlist + appLogger.d('Adding to playlist $result with URI: $itemUri'); + final success = await client.addToPlaylist( + playlistId: result, + uri: itemUri, + ); + + if (context.mounted) { + if (success) { + appLogger.d('Successfully added item(s) to playlist $result'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.itemAdded)), + ); + } else { + appLogger.e('Failed to add item(s) to playlist $result - API returned false'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorAdding)), + ); + } + } + } + } catch (e, stackTrace) { + appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${t.playlists.errorLoading}: ${e.toString()}'), + duration: const Duration(seconds: 5), + ), + ); + } + } + } + @override Widget build(BuildContext context) { return GestureDetector( @@ -424,3 +535,107 @@ class _MediaContextMenuState extends State { ); } } + +/// Dialog to select a playlist or create a new one +class _PlaylistSelectionDialog extends StatelessWidget { + final List playlists; + + const _PlaylistSelectionDialog({required this.playlists}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(t.playlists.selectPlaylist), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: playlists.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + // Create new playlist option (always shown first) + return ListTile( + leading: const Icon(Icons.add), + title: Text(t.playlists.createNewPlaylist), + onTap: () => Navigator.pop(context, '_create_new'), + ); + } + + final playlist = playlists[index - 1]; + return ListTile( + leading: playlist.smart + ? const Icon(Icons.auto_awesome) + : const Icon(Icons.playlist_play), + title: Text(playlist.title), + subtitle: playlist.leafCount != null + ? Text(playlist.leafCount == 1 + ? t.playlists.oneItem + : t.playlists.itemCount(count: playlist.leafCount!)) + : null, + onTap: playlist.smart + ? null // Disable smart playlists + : () => Navigator.pop(context, playlist.ratingKey), + enabled: !playlist.smart, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + ], + ); + } +} + +/// Dialog to create a new playlist +class _CreatePlaylistDialog extends StatefulWidget { + @override + State<_CreatePlaylistDialog> createState() => _CreatePlaylistDialogState(); +} + +class _CreatePlaylistDialogState extends State<_CreatePlaylistDialog> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(t.playlists.create), + content: TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + labelText: t.playlists.playlistName, + hintText: t.playlists.enterPlaylistName, + ), + onSubmitted: (value) { + if (value.isNotEmpty) { + Navigator.pop(context, value); + } + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () { + if (_controller.text.isNotEmpty) { + Navigator.pop(context, _controller.text); + } + }, + child: Text(t.common.save), + ), + ], + ); + } +} diff --git a/lib/widgets/playlist_item_card.dart b/lib/widgets/playlist_item_card.dart new file mode 100644 index 00000000..0cbb404b --- /dev/null +++ b/lib/widgets/playlist_item_card.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../models/plex_metadata.dart'; +import '../providers/plex_client_provider.dart'; +import '../i18n/strings.g.dart'; + +/// Custom list item widget for playlist items +/// Shows drag handle, poster, title/metadata, duration, and remove button +class PlaylistItemCard extends StatelessWidget { + final PlexMetadata item; + final int index; + final VoidCallback onRemove; + final VoidCallback? onTap; + final bool canReorder; // Whether drag handle should be shown + + const PlaylistItemCard({ + super.key, + required this.item, + required this.index, + required this.onRemove, + this.onTap, + this.canReorder = true, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + // Drag handle (if reorderable) + if (canReorder) + ReorderableDragStartListener( + index: index, + child: const Padding( + padding: EdgeInsets.only(right: 12), + child: Icon( + Icons.drag_indicator, + color: Colors.grey, + ), + ), + ), + + // Poster thumbnail + _buildPosterImage(context), + + const SizedBox(width: 12), + + // Title and metadata + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Title + Text( + item.displayTitle, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 4), + + // Subtitle (episode info or type) + Text( + _buildSubtitle(), + style: TextStyle( + fontSize: 13, + color: Colors.grey[400], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + // Progress indicator if partially watched + if (item.viewOffset != null && item.duration != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: LinearProgressIndicator( + value: item.viewOffset! / item.duration!, + backgroundColor: Colors.grey[800], + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + minHeight: 3, + ), + ), + ], + ), + ), + + const SizedBox(width: 12), + + // Duration + if (item.duration != null) + Text( + _formatDuration(item.duration!), + style: TextStyle( + fontSize: 13, + color: Colors.grey[400], + ), + ), + + const SizedBox(width: 8), + + // Remove button + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: onRemove, + tooltip: t.playlists.removeItem, + color: Colors.grey[400], + ), + ], + ), + ), + ), + ); + } + + Widget _buildPosterImage(BuildContext context) { + final posterUrl = item.posterThumb(); + if (posterUrl != null) { + return Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return _buildPlaceholder(); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: client.getThumbnailUrl(posterUrl), + width: 60, + height: 90, + fit: BoxFit.cover, + placeholder: (context, url) => _buildPlaceholder(), + errorWidget: (context, url, error) => _buildPlaceholder(), + ), + ); + }, + ); + } + return _buildPlaceholder(); + } + + Widget _buildPlaceholder() { + return Container( + width: 60, + height: 90, + decoration: BoxDecoration( + color: Colors.grey[850], + borderRadius: BorderRadius.circular(6), + ), + child: const Icon(Icons.movie, color: Colors.grey, size: 24), + ); + } + + String _buildSubtitle() { + final itemType = item.type.toLowerCase(); + + if (itemType == 'episode') { + // For episodes, show "S#E# - Episode Title" + final season = item.parentIndex; + final episode = item.index; + if (season != null && episode != null) { + return 'S${season}E$episode${item.displaySubtitle != null ? ' - ${item.displaySubtitle}' : ''}'; + } + return item.displaySubtitle ?? t.discover.tvShow; + } else if (itemType == 'movie') { + // For movies, show year + return item.year?.toString() ?? t.discover.movie; + } + + // Default to type + return item.type; + } + + String _formatDuration(int milliseconds) { + final duration = Duration(milliseconds: milliseconds); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m'; + } else { + return '${minutes}m'; + } + } +} diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 3035b5b1..c77fe217 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - connectivity_plus (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - HotKey (0.2.1) - hotkey_manager_macos (0.0.1): @@ -36,6 +38,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`) - macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`) @@ -57,6 +60,8 @@ SPEC REPOS: - HotKey EXTERNAL SOURCES: + connectivity_plus: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos FlutterMacOS: :path: Flutter/ephemeral hotkey_manager_macos: @@ -89,6 +94,7 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: + connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe From f14ed7d762b383b321192e15657ca41033194978 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Nov 2025 04:08:57 +0100 Subject: [PATCH 22/25] chore: format --- .../endpoint_failover_interceptor.dart | 9 +-- lib/providers/playback_state_provider.dart | 3 +- lib/screens/libraries_screen.dart | 4 +- lib/screens/playlist_detail_screen.dart | 77 ++++++++++--------- lib/screens/playlists_screen.dart | 34 ++++---- lib/screens/video_player_screen.dart | 8 +- lib/services/plex_auth_service.dart | 35 ++++----- lib/services/server_connection_service.dart | 72 ++++++++--------- lib/utils/log_redaction_manager.dart | 13 ++-- lib/widgets/media_context_menu.dart | 53 ++++++++----- lib/widgets/playlist_item_card.dart | 15 +--- 11 files changed, 155 insertions(+), 168 deletions(-) diff --git a/lib/network/endpoint_failover_interceptor.dart b/lib/network/endpoint_failover_interceptor.dart index 71c683da..e0ecd011 100644 --- a/lib/network/endpoint_failover_interceptor.dart +++ b/lib/network/endpoint_failover_interceptor.dart @@ -25,10 +25,7 @@ class EndpointFailoverManager { } /// Replace the endpoint list and optionally set the active endpoint. - void reset( - List urls, { - String? currentBaseUrl, - }) { + void reset(List urls, {String? currentBaseUrl}) { _setEndpoints(urls); if (currentBaseUrl != null) { final index = _endpoints.indexOf(currentBaseUrl); @@ -60,8 +57,8 @@ class EndpointFailoverInterceptor extends Interceptor { required Dio dio, required this.endpointManager, required Future Function(String newBaseUrl) onEndpointSwitch, - }) : _dio = dio, - _onEndpointSwitch = onEndpointSwitch; + }) : _dio = dio, + _onEndpointSwitch = onEndpointSwitch; final Dio _dio; final EndpointFailoverManager endpointManager; diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index c13d7c3f..b994af6c 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -27,7 +27,8 @@ class PlaybackStateProvider with ChangeNotifier { bool get isPlaylistActive => _playbackMode == PlaybackMode.playlist; /// Whether any queue-based playback is active - bool get isQueueActive => _queue.isNotEmpty && _playbackMode != PlaybackMode.none; + bool get isQueueActive => + _queue.isNotEmpty && _playbackMode != PlaybackMode.none; /// The context key (show/season/playlist ratingKey) for the current session String? get shuffleContextKey => _contextKey; diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index cf5a912e..ff02bde8 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -1130,9 +1130,7 @@ class _LibrariesScreenState extends State onPressed: () { Navigator.push( context, - MaterialPageRoute( - builder: (context) => const PlaylistsScreen(), - ), + MaterialPageRoute(builder: (context) => const PlaylistsScreen()), ); }, tooltip: t.playlists.title, diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart index c96af4ec..bcd25c25 100644 --- a/lib/screens/playlist_detail_screen.dart +++ b/lib/screens/playlist_detail_screen.dart @@ -61,7 +61,9 @@ class _PlaylistDetailScreenState extends State _isLoading = false; }); - appLogger.d('Loaded ${items.length} items for playlist: ${widget.playlist.title}'); + appLogger.d( + 'Loaded ${items.length} items for playlist: ${widget.playlist.title}', + ); } catch (e) { appLogger.e('Failed to load playlist items', error: e); setState(() { @@ -96,14 +98,14 @@ class _PlaylistDetailScreenState extends State if (mounted) { if (success) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.deleted)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.deleted))); Navigator.pop(context); // Return to playlists screen } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorDeleting)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting))); } } } @@ -124,9 +126,9 @@ class _PlaylistDetailScreenState extends State if (movedItem.playlistItemID == null) { appLogger.e('Cannot reorder: item missing playlistItemID'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorReordering)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering))); } return; } @@ -142,16 +144,18 @@ class _PlaylistDetailScreenState extends State if (afterItem.playlistItemID == null) { appLogger.e('Cannot reorder: after item missing playlistItemID'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorReordering)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering))); } return; } afterPlaylistItemId = afterItem.playlistItemID!; } - appLogger.d('Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)'); + appLogger.d( + 'Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)', + ); // Optimistically update UI setState(() { @@ -175,9 +179,9 @@ class _PlaylistDetailScreenState extends State _items.insert(oldIndex, item); }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorReordering)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering))); } } } @@ -189,14 +193,16 @@ class _PlaylistDetailScreenState extends State if (item.playlistItemID == null) { appLogger.e('Cannot remove: item missing playlistItemID'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorRemoving)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving))); } return; } - appLogger.d('Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist'); + appLogger.d( + 'Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist', + ); // Optimistically update UI setState(() { @@ -211,9 +217,9 @@ class _PlaylistDetailScreenState extends State if (mounted) { if (success) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.itemRemoved)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.itemRemoved))); } else { // Revert on failure appLogger.e('Failed to remove playlist item, reverting UI'); @@ -221,9 +227,9 @@ class _PlaylistDetailScreenState extends State _items.insert(index, item); }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorRemoving)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving))); } } } @@ -244,9 +250,9 @@ class _PlaylistDetailScreenState extends State Future _playPlaylist() async { if (_items.isEmpty) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.emptyPlaylist)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist))); } return; } @@ -265,9 +271,9 @@ class _PlaylistDetailScreenState extends State Future _shufflePlayPlaylist() async { if (_items.isEmpty) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.emptyPlaylist)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist))); } return; } @@ -422,10 +428,7 @@ class _PlaylistDetailScreenState extends State mainAxisSpacing: 0, ), delegate: SliverChildBuilderDelegate((context, index) { - return MediaCard( - item: _items[index], - onRefresh: updateItem, - ); + return MediaCard(item: _items[index], onRefresh: updateItem); }, childCount: _items.length), ), ) diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart index 82fad9f0..16166ef8 100644 --- a/lib/screens/playlists_screen.dart +++ b/lib/screens/playlists_screen.dart @@ -156,9 +156,8 @@ class _PlaylistsScreenState extends State with Refreshable { Navigator.push( context, MaterialPageRoute( - builder: (context) => PlaylistDetailScreen( - playlist: _playlists[index], - ), + builder: (context) => + PlaylistDetailScreen(playlist: _playlists[index]), ), ).then((_) => _loadPlaylists()); // Refresh on return }, @@ -275,14 +274,14 @@ class _PlaylistCard extends StatelessWidget { if (context.mounted) { if (success) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.deleted)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.deleted))); onDeleted(); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorDeleting)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting))); } } } @@ -370,13 +369,12 @@ class _PlaylistCard extends StatelessWidget { Text( playlist.leafCount != null && playlist.leafCount! > 0 ? (playlist.leafCount == 1 - ? t.playlists.oneItem - : t.playlists.itemCount(count: playlist.leafCount!)) + ? t.playlists.oneItem + : t.playlists.itemCount( + count: playlist.leafCount!, + )) : t.playlists.emptyPlaylist, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ), @@ -393,11 +391,7 @@ class _PlaylistCard extends StatelessWidget { return Container( color: Colors.grey[850], child: const Center( - child: Icon( - Icons.playlist_play, - size: 48, - color: Colors.grey, - ), + child: Icon(Icons.playlist_play, size: 48, color: Colors.grey), ), ); } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 06098ccb..0eb7a339 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -135,7 +135,9 @@ class VideoPlayerScreenState extends State // Clear media controls when app goes to background or screen locks // (we don't support background playback) OsMediaControls.clear(); - appLogger.d('Media controls cleared due to app lifecycle state: $state'); + appLogger.d( + 'Media controls cleared due to app lifecycle state: $state', + ); break; case AppLifecycleState.resumed: // Restore media controls when app is resumed @@ -303,9 +305,7 @@ class VideoPlayerScreenState extends State widget.metadata.ratingKey, loopQueue: false, // Don't loop playlists by default ); - previous = playbackState.getPreviousEpisode( - widget.metadata.ratingKey, - ); + previous = playbackState.getPreviousEpisode(widget.metadata.ratingKey); } // Check if shuffle mode is active else if (playbackState.isShuffleActive) { diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index a380cc9c..f649a9ee 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -418,10 +418,7 @@ class PlexServer { final totalCandidates = candidates.length; appLogger.d( 'Starting server connection discovery', - error: { - 'preferred': preferredUri, - 'candidateCount': totalCandidates, - }, + error: {'preferred': preferredUri, 'candidateCount': totalCandidates}, ); _ConnectionCandidate? firstCandidate; @@ -466,13 +463,11 @@ class PlexServer { ); for (final candidate in candidates) { - PlexClient - .testConnectionWithLatency( + PlexClient.testConnectionWithLatency( candidate.url, accessToken, timeout: raceTimeout, - ) - .then((result) { + ).then((result) { completedTests++; if (result.success && !completer.isCompleted) { @@ -545,7 +540,7 @@ class PlexServer { if (bestCandidate != null) { final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(bestCandidate) ?? - bestCandidate; + bestCandidate; final bestConnection = _updateConnectionUrl( upgradedCandidate.connection, @@ -596,8 +591,7 @@ class PlexServer { if (uri == url) { final isHttps = uri.startsWith('https://'); final parsedHost = Uri.tryParse(uri)?.host ?? ''; - final isPlexDirect = - parsedHost.toLowerCase().contains('plex.direct'); + final isPlexDirect = parsedHost.toLowerCase().contains('plex.direct'); return _ConnectionCandidate(connection, uri, isPlexDirect, isHttps); } } @@ -644,18 +638,14 @@ class PlexServer { return; } seen.add(url); - bucketFor(connection, isHttps).add( - _ConnectionCandidate(connection, url, isPlexDirectUri, isHttps), - ); + bucketFor( + connection, + isHttps, + ).add(_ConnectionCandidate(connection, url, isPlexDirectUri, isHttps)); } for (final connection in connections) { - addCandidate( - connection, - connection.httpDirectUrl, - false, - false, - ); + addCandidate(connection, connection.httpDirectUrl, false, false); } return [ @@ -789,8 +779,9 @@ class PlexServer { current.uri.contains('.plex.direct'), current.uri.startsWith('https://'), ); - final upgradedCandidate = - await _upgradeCandidateToHttpsIfPossible(candidate); + final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible( + candidate, + ); if (upgradedCandidate == null) { return null; } diff --git a/lib/services/server_connection_service.dart b/lib/services/server_connection_service.dart index c760039c..5f7d23f1 100644 --- a/lib/services/server_connection_service.dart +++ b/lib/services/server_connection_service.dart @@ -24,7 +24,7 @@ class ServerConnectionResult { /// Implements fast-first connection with background optimization class ServerConnectionService { static StreamSubscription>? - _connectivitySubscription; + _connectivitySubscription; static Future? _activeOptimization; static PlexServer? _activeServer; static PlexClient? _activeClient; @@ -51,28 +51,31 @@ class ServerConnectionService { }) async { final storage = await StorageService.getInstance(); - final connectionStream = - server.findBestWorkingConnection().asBroadcastStream(); + final connectionStream = server + .findBestWorkingConnection() + .asBroadcastStream(); PlexClient? client; - final optimizationSubscription = connectionStream.skip(1).listen( - (connection) async { - await _handleOptimizedConnection( - connection: connection, - storage: storage, - server: server, - client: client, - reason: 'initial_latency_sweep', + final optimizationSubscription = connectionStream + .skip(1) + .listen( + (connection) async { + await _handleOptimizedConnection( + connection: connection, + storage: storage, + server: server, + client: client, + reason: 'initial_latency_sweep', + ); + }, + onError: (error, stackTrace) { + appLogger.w( + 'Background connection optimization error', + error: error, + stackTrace: stackTrace, + ); + }, ); - }, - onError: (error, stackTrace) { - appLogger.w( - 'Background connection optimization error', - error: error, - stackTrace: stackTrace, - ); - }, - ); try { final connection = await connectionStream.first; @@ -132,9 +135,7 @@ class ServerConnectionService { await optimizationSubscription.cancel(); appLogger.w('Server identity verification failed', error: e); await storage.clearCredentials(); - return ServerConnectionResult( - error: 'Server is not accessible: $e', - ); + return ServerConnectionResult(error: 'Server is not accessible: $e'); } } @@ -149,10 +150,7 @@ class ServerConnectionService { _activeClient = client; _startConnectivityMonitoring(server); - return ServerConnectionResult( - client: client, - userProfile: userProfile, - ); + return ServerConnectionResult(client: client, userProfile: userProfile); } on StateError catch (e, stackTrace) { await optimizationSubscription.cancel(); appLogger.e( @@ -205,8 +203,9 @@ class ServerConnectionService { final connectivity = Connectivity(); _connectivitySubscription = connectivity.onConnectivityChanged.listen( (results) { - final status = - results.isNotEmpty ? results.first : ConnectivityResult.none; + final status = results.isNotEmpty + ? results.first + : ConnectivityResult.none; if (status == ConnectivityResult.none) { appLogger.w( 'Connectivity lost, pausing optimization until network returns', @@ -250,13 +249,14 @@ class ServerConnectionService { return; } - _activeOptimization = _runOptimization( - server: _activeServer!, - client: _activeClient, - reason: reason, - ).whenComplete(() { - _activeOptimization = null; - }); + _activeOptimization = + _runOptimization( + server: _activeServer!, + client: _activeClient, + reason: reason, + ).whenComplete(() { + _activeOptimization = null; + }); } static Future _runOptimization({ diff --git a/lib/utils/log_redaction_manager.dart b/lib/utils/log_redaction_manager.dart index f0aff940..15437c9e 100644 --- a/lib/utils/log_redaction_manager.dart +++ b/lib/utils/log_redaction_manager.dart @@ -2,10 +2,10 @@ class LogRedactionManager { static final Set _tokens = {}; static final Set _urls = {}; static final Set _customValues = {}; - static final RegExp _ipv4Pattern = - RegExp(r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b'); - static final RegExp _ipv4HostPattern = - RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$'); + static final RegExp _ipv4Pattern = RegExp( + r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b', + ); + static final RegExp _ipv4HostPattern = RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$'); /// Register a server access token or Plex.tv token for redaction. static void registerToken(String? token) { @@ -129,8 +129,9 @@ class LogRedactionManager { ? (url.length / 2).ceil() : startPreviewLength; final remainingForEnd = url.length - startLength; - final endLength = - remainingForEnd <= endPreviewLength ? remainingForEnd : endPreviewLength; + final endLength = remainingForEnd <= endPreviewLength + ? remainingForEnd + : endPreviewLength; final start = url.substring(0, startLength); if (endLength <= 0) { diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 68e6c39b..66c72186 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -146,7 +146,10 @@ class _MediaContextMenuState extends State { } // Add to Playlist (for episodes, movies, shows, and seasons) - if (itemType == 'episode' || itemType == 'movie' || itemType == 'show' || itemType == 'season') { + if (itemType == 'episode' || + itemType == 'movie' || + itemType == 'show' || + itemType == 'season') { menuActions.add( _MenuAction( value: 'add_to_playlist', @@ -445,9 +448,7 @@ class _MediaContextMenuState extends State { // Show dialog to select playlist or create new final result = await showDialog( context: context, - builder: (context) => _PlaylistSelectionDialog( - playlists: playlists, - ), + builder: (context) => _PlaylistSelectionDialog(playlists: playlists), ); if (result == null || !context.mounted) return; @@ -469,7 +470,9 @@ class _MediaContextMenuState extends State { } // Create playlist with the item(s) - appLogger.d('Creating playlist "$playlistName" with URI length: ${itemUri.length}'); + appLogger.d( + 'Creating playlist "$playlistName" with URI length: ${itemUri.length}', + ); final newPlaylist = await client.createPlaylist( title: playlistName, uri: itemUri, @@ -478,14 +481,14 @@ class _MediaContextMenuState extends State { if (context.mounted) { if (newPlaylist != null) { appLogger.d('Successfully created playlist: ${newPlaylist.title}'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.created)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.created))); } else { appLogger.e('Failed to create playlist - API returned null'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorCreating)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorCreating))); } } } else { @@ -499,19 +502,25 @@ class _MediaContextMenuState extends State { if (context.mounted) { if (success) { appLogger.d('Successfully added item(s) to playlist $result'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.itemAdded)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.itemAdded))); } else { - appLogger.e('Failed to add item(s) to playlist $result - API returned false'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorAdding)), + appLogger.e( + 'Failed to add item(s) to playlist $result - API returned false', ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorAdding))); } } } } catch (e, stackTrace) { - appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace); + appLogger.e( + 'Error in add to playlist flow', + error: e, + stackTrace: stackTrace, + ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -568,9 +577,11 @@ class _PlaylistSelectionDialog extends StatelessWidget { : const Icon(Icons.playlist_play), title: Text(playlist.title), subtitle: playlist.leafCount != null - ? Text(playlist.leafCount == 1 - ? t.playlists.oneItem - : t.playlists.itemCount(count: playlist.leafCount!)) + ? Text( + playlist.leafCount == 1 + ? t.playlists.oneItem + : t.playlists.itemCount(count: playlist.leafCount!), + ) : null, onTap: playlist.smart ? null // Disable smart playlists diff --git a/lib/widgets/playlist_item_card.dart b/lib/widgets/playlist_item_card.dart index 0cbb404b..234ae989 100644 --- a/lib/widgets/playlist_item_card.dart +++ b/lib/widgets/playlist_item_card.dart @@ -39,10 +39,7 @@ class PlaylistItemCard extends StatelessWidget { index: index, child: const Padding( padding: EdgeInsets.only(right: 12), - child: Icon( - Icons.drag_indicator, - color: Colors.grey, - ), + child: Icon(Icons.drag_indicator, color: Colors.grey), ), ), @@ -73,10 +70,7 @@ class PlaylistItemCard extends StatelessWidget { // Subtitle (episode info or type) Text( _buildSubtitle(), - style: TextStyle( - fontSize: 13, - color: Colors.grey[400], - ), + style: TextStyle(fontSize: 13, color: Colors.grey[400]), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -104,10 +98,7 @@ class PlaylistItemCard extends StatelessWidget { if (item.duration != null) Text( _formatDuration(item.duration!), - style: TextStyle( - fontSize: 13, - color: Colors.grey[400], - ), + style: TextStyle(fontSize: 13, color: Colors.grey[400]), ), const SizedBox(width: 8), From 8ee2b7cfe13ecff05fc40d6d8aeb695734fb0402 Mon Sep 17 00:00:00 2001 From: Jamal <15986930+jamal2362@users.noreply.github.com> Date: Sat, 15 Nov 2025 12:51:57 +0100 Subject: [PATCH 23/25] Added more missing translations --- lib/i18n/strings.g.dart | 276 ++++++++++++++++---------- lib/i18n/strings.i18n.json | 7 +- lib/i18n/strings_de.i18n.json | 113 ++++++----- lib/i18n/strings_it.i18n.json | 7 +- lib/i18n/strings_nl.i18n.json | 7 +- lib/i18n/strings_sv.i18n.json | 7 +- lib/i18n/strings_zh.i18n.json | 7 +- lib/screens/media_detail_screen.dart | 6 +- lib/screens/season_detail_screen.dart | 2 +- lib/widgets/profile_list_tile.dart | 42 ++-- 10 files changed, 289 insertions(+), 185 deletions(-) diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index dbf4b3d0..d95497f8 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 6 -/// Strings: 2100 (350 per locale) +/// Strings: 2130 (355 per locale) /// -/// Built on 2025-11-15 at 02:45 UTC +/// Built on 2025-11-15 at 11:48 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -485,6 +485,7 @@ class _StringsUserStatusEn { String get admin => 'Admin'; String get restricted => 'Restricted'; String get protected => 'Protected'; + String get current => 'CURRENT'; } // Path: messages @@ -583,6 +584,10 @@ class _StringsDiscoverEn { String get pause => 'Pause'; String get overview => 'Overview'; String get cast => 'Cast'; + String get seasons => 'Seasons'; + String get studio => 'Studio'; + String get rating => 'Rating'; + String get watched => 'Watched'; String episodeCount({required Object count}) => '${count} episodes'; String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} watched'; String get movie => 'Movie'; @@ -889,7 +894,7 @@ class _StringsCommonDe implements _StringsCommonEn { @override String get logout => 'Abmelden'; @override String get online => 'Online'; @override String get offline => 'Offline'; - @override String get owned => 'Eigentum'; + @override String get owned => 'Besitzer'; @override String get shared => 'Geteilt'; @override String get current => 'AKTUELL'; @override String get unknown => 'Unbekannt'; @@ -939,7 +944,7 @@ class _StringsSettingsDe implements _StringsSettingsEn { // Translations @override String get title => 'Einstellungen'; @override String get language => 'Sprache'; - @override String get theme => 'Theme'; + @override String get theme => 'Design'; @override String get appearance => 'Darstellung'; @override String get videoPlayback => 'Videowiedergabe'; @override String get shufflePlay => 'Zufallswiedergabe'; @@ -955,7 +960,7 @@ class _StringsSettingsDe implements _StringsSettingsEn { @override String get systemThemeDescription => 'Systemeinstellungen folgen'; @override String get lightTheme => 'Hell'; @override String get darkTheme => 'Dunkel'; - @override String get libraryDensity => 'Bibliotheksdichte'; + @override String get libraryDensity => 'Mediathekdichte'; @override String get compact => 'Kompakt'; @override String get compactDescription => 'Kleinere Karten, mehr Elemente sichtbar'; @override String get normal => 'Normal'; @@ -970,7 +975,7 @@ class _StringsSettingsDe implements _StringsSettingsEn { @override String get useSeasonPosters => 'Staffelposter verwenden'; @override String get showHeroSection => 'Hero-Bereich anzeigen'; @override String get hardwareDecoding => 'Hardware-Decodierung'; - @override String get hardwareDecodingDescription => 'Hardwarebeschleunigung verwenden, wenn verfügbar'; + @override String get hardwareDecodingDescription => 'Hardwarebeschleunigung verwenden, sofern verfügbar'; @override String get bufferSize => 'Puffergröße'; @override String bufferSizeMB({required Object size}) => '${size}MB'; @override String get subtitleStyling => 'Untertitel-Stil'; @@ -1138,9 +1143,10 @@ class _StringsUserStatusDe implements _StringsUserStatusEn { @override final _StringsDe _root; // ignore: unused_field // Translations - @override String get admin => 'Admin'; + @override String get admin => 'Eigentümer'; @override String get restricted => 'Eingeschränkt'; @override String get protected => 'Geschützt'; + @override String get current => 'AKTUELL'; } // Path: messages @@ -1163,8 +1169,8 @@ class _StringsMessagesDe implements _StringsMessagesEn { @override String get logsCopied => 'Protokolle in Zwischenablage kopiert'; @override String get noLogsAvailable => 'Keine Protokolle verfügbar'; @override String libraryScanning({required Object title}) => 'Scanne „${title}“...'; - @override String libraryScanStarted({required Object title}) => 'Bibliotheksscan gestartet für „${title}“'; - @override String libraryScanFailed({required Object error}) => 'Fehler beim Scannen der Bibliothek: ${error}'; + @override String libraryScanStarted({required Object title}) => 'Mediathekscan gestartet für „${title}“'; + @override String libraryScanFailed({required Object error}) => 'Fehler beim Scannen der Mediathek: ${error}'; @override String metadataRefreshing({required Object title}) => 'Metadaten werden aktualisiert für „${title}“...'; @override String metadataRefreshStarted({required Object title}) => 'Metadaten-Aktualisierung gestartet für „${title}“'; @override String metadataRefreshFailed({required Object error}) => 'Metadaten konnten nicht aktualisiert werden: ${error}'; @@ -1229,18 +1235,22 @@ class _StringsDiscoverDe implements _StringsDiscoverEn { @override String get switchServer => 'Server wechseln'; @override String get logout => 'Abmelden'; @override String get noContentAvailable => 'Kein Inhalt verfügbar'; - @override String get addMediaToLibraries => 'Medien zur Bibliothek hinzufügen'; + @override String get addMediaToLibraries => 'Medien zur Mediathek hinzufügen'; @override String get continueWatching => 'Weiterschauen'; @override String get recentlyAdded => 'Kürzlich hinzugefügt'; @override String get play => 'Abspielen'; @override String get resume => 'Fortsetzen'; - @override String playEpisode({required Object season, required Object episode}) => 'Spiele S${season}, E${episode}'; - @override String resumeEpisode({required Object season, required Object episode}) => 'Fortsetzen S${season}, E${episode}'; + @override String playEpisode({required Object season, required Object episode}) => 'S${season}, E${episode} - Abspielen'; + @override String resumeEpisode({required Object season, required Object episode}) => 'S${season}, E${episode} - Fortsetzen'; @override String get pause => 'Pause'; @override String get overview => 'Übersicht'; @override String get cast => 'Besetzung'; + @override String get seasons => 'Staffeln'; + @override String get studio => 'Studio'; + @override String get rating => 'Altersfreigabe'; + @override String get watched => 'Gesehen'; @override String episodeCount({required Object count}) => '${count} Episoden'; - @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} gesehen'; + @override String watchedProgress({required Object watched, required Object total}) => '${watched} von ${total} gesehen'; @override String get movie => 'Film'; @override String get tvShow => 'Serie'; @override String minutesLeft({required Object minutes}) => '${minutes} Min übrig'; @@ -1274,11 +1284,11 @@ class _StringsLibrariesDe implements _StringsLibrariesEn { @override final _StringsDe _root; // ignore: unused_field // Translations - @override String get title => 'Bibliotheken'; - @override String get scanLibraryFiles => 'Bibliotheksdateien scannen'; - @override String get scanLibrary => 'Bibliothek scannen'; + @override String get title => 'Mediatheken'; + @override String get scanLibraryFiles => 'Mediatheksdateien scannen'; + @override String get scanLibrary => 'Mediathek scannen'; @override String get analyze => 'Analysieren'; - @override String get analyzeLibrary => 'Bibliothek analysieren'; + @override String get analyzeLibrary => 'Mediathek analysieren'; @override String get refreshMetadata => 'Metadaten aktualisieren'; @override String get emptyTrash => 'Papierkorb leeren'; @override String emptyingTrash({required Object title}) => 'Papierkorb für „${title}“ wird geleert...'; @@ -1286,24 +1296,24 @@ class _StringsLibrariesDe implements _StringsLibrariesEn { @override String failedToEmptyTrash({required Object error}) => 'Papierkorb konnte nicht geleert werden: ${error}'; @override String analyzing({required Object title}) => 'Analysiere „${title}“...'; @override String analysisStarted({required Object title}) => 'Analyse gestartet für „${title}“'; - @override String failedToAnalyze({required Object error}) => 'Analyse der Bibliothek fehlgeschlagen: ${error}'; - @override String get noLibrariesFound => 'Keine Bibliotheken gefunden'; - @override String get thisLibraryIsEmpty => 'Diese Bibliothek ist leer'; + @override String failedToAnalyze({required Object error}) => 'Analyse der Mediathek fehlgeschlagen: ${error}'; + @override String get noLibrariesFound => 'Keine Mediatheken gefunden'; + @override String get thisLibraryIsEmpty => 'Diese Mediathek ist leer'; @override String get all => 'Alle'; @override String get clearAll => 'Alle löschen'; @override String scanLibraryConfirm({required Object title}) => '„${title}“ wirklich scannen?'; @override String analyzeLibraryConfirm({required Object title}) => '„${title}“ wirklich analysieren?'; @override String refreshMetadataConfirm({required Object title}) => 'Metadaten für „${title}“ wirklich aktualisieren?'; @override String emptyTrashConfirm({required Object title}) => 'Papierkorb für „${title}“ wirklich leeren?'; - @override String get manageLibraries => 'Bibliotheken verwalten'; + @override String get manageLibraries => 'Mediatheken verwalten'; @override String get sort => 'Sortieren'; @override String get sortBy => 'Sortieren nach'; @override String get filters => 'Filter'; - @override String loadingLibraryWithCount({required Object count}) => 'Lade Bibliothek... (${count} Elemente geladen)'; + @override String loadingLibraryWithCount({required Object count}) => 'Lade Mediathek... (${count} Elemente geladen)'; @override String get confirmActionMessage => 'Aktion wirklich durchführen?'; - @override String get showLibrary => 'Bibliothek anzeigen'; - @override String get hideLibrary => 'Bibliothek ausblenden'; - @override String get libraryOptions => 'Bibliotheksoptionen'; + @override String get showLibrary => 'Mediathek anzeigen'; + @override String get hideLibrary => 'Mediathek ausblenden'; + @override String get libraryOptions => 'Mediatheksoptionen'; } // Path: about @@ -1393,7 +1403,7 @@ class _StringsNavigationDe implements _StringsNavigationEn { // Translations @override String get home => 'Start'; @override String get search => 'Suche'; - @override String get libraries => 'Bibliotheken'; + @override String get libraries => 'Mediatheken'; @override String get settings => 'Einstellungen'; } @@ -1404,40 +1414,40 @@ class _StringsPlaylistsDe implements _StringsPlaylistsEn { @override final _StringsDe _root; // ignore: unused_field // Translations - @override String get title => 'Playlists'; - @override String get noPlaylists => 'Keine Playlists gefunden'; - @override String get create => 'Playlist erstellen'; - @override String get newPlaylist => 'Neue Playlist'; - @override String get playlistName => 'Playlist-Name'; - @override String get enterPlaylistName => 'Playlist-Namen eingeben'; - @override String get edit => 'Playlist bearbeiten'; - @override String get delete => 'Playlist löschen'; - @override String get addTo => 'Zur Playlist hinzufügen'; + @override String get title => 'Wiedergabelisten'; + @override String get noPlaylists => 'Keine Wiedergabelisten gefunden'; + @override String get create => 'Wiedergabeliste erstellen'; + @override String get newPlaylist => 'Neue Wiedergabeliste'; + @override String get playlistName => 'Name der Wiedergabeliste'; + @override String get enterPlaylistName => 'Name der Wiedergabeliste eingeben'; + @override String get edit => 'Wiedergabeliste bearbeiten'; + @override String get delete => 'Wiedergabeliste löschen'; + @override String get addTo => 'Zur Wiedergabeliste hinzufügen'; @override String get addItems => 'Elemente hinzufügen'; - @override String get removeItem => 'Aus Playlist entfernen'; - @override String get clearPlaylist => 'Playlist leeren'; + @override String get removeItem => 'Aus Wiedergabeliste entfernen'; + @override String get clearPlaylist => 'Wiedergabeliste leeren'; @override String get playAll => 'Alle abspielen'; @override String get shuffle => 'Zufällig'; - @override String get smartPlaylist => 'Intelligente Playlist'; - @override String get regularPlaylist => 'Normale Playlist'; + @override String get smartPlaylist => 'Intelligente Wiedergabeliste'; + @override String get regularPlaylist => 'Normale Wiedergabeliste'; @override String itemCount({required Object count}) => '${count} Elemente'; @override String get oneItem => '1 Element'; - @override String get emptyPlaylist => 'Diese Playlist ist leer'; - @override String get deleteConfirm => 'Playlist löschen?'; - @override String deleteMessage({required Object name}) => 'Möchten Sie "${name}" wirklich löschen?'; - @override String get created => 'Playlist erstellt'; - @override String get updated => 'Playlist aktualisiert'; - @override String get deleted => 'Playlist gelöscht'; - @override String get itemAdded => 'Zur Playlist hinzugefügt'; - @override String get itemRemoved => 'Aus Playlist entfernt'; - @override String get selectPlaylist => 'Playlist auswählen'; - @override String get createNewPlaylist => 'Neue Playlist erstellen'; - @override String get errorCreating => 'Fehler beim Erstellen der Playlist'; - @override String get errorDeleting => 'Fehler beim Löschen der Playlist'; - @override String get errorLoading => 'Fehler beim Laden der Playlists'; - @override String get errorAdding => 'Fehler beim Hinzufügen zur Playlist'; - @override String get errorReordering => 'Fehler beim Neuordnen des Playlist-Elements'; - @override String get errorRemoving => 'Fehler beim Entfernen aus der Playlist'; + @override String get emptyPlaylist => 'Diese Wiedergabeliste ist leer'; + @override String get deleteConfirm => 'Wiedergabeliste löschen?'; + @override String deleteMessage({required Object name}) => 'Soll "${name}" wirklich gelöscht werden?'; + @override String get created => 'Wiedergabeliste erstellt'; + @override String get updated => 'Wiedergabeliste aktualisiert'; + @override String get deleted => 'Wiedergabeliste gelöscht'; + @override String get itemAdded => 'Zur Wiedergabeliste hinzugefügt'; + @override String get itemRemoved => 'Aus Wiedergabeliste entfernt'; + @override String get selectPlaylist => 'Wiedergabeliste auswählen'; + @override String get createNewPlaylist => 'Neue Wiedergabeliste erstellen'; + @override String get errorCreating => 'Wiedergabeliste konnte nicht erstellt werden'; + @override String get errorDeleting => 'Wiedergabeliste konnte nicht gelöscht werden'; + @override String get errorLoading => 'Wiedergabelisten konnten nicht geladen werden'; + @override String get errorAdding => 'Konnte nicht zur Wiedergabeliste hinzugefügt werden'; + @override String get errorReordering => 'Element der Wiedergabeliste konnte nicht neu geordnet werden'; + @override String get errorRemoving => 'Konnte nicht aus der Wiedergabeliste entfernt werden'; } // Path: @@ -1797,6 +1807,7 @@ class _StringsUserStatusIt implements _StringsUserStatusEn { @override String get admin => 'Admin'; @override String get restricted => 'Limitato'; @override String get protected => 'Protetto'; + @override String get current => 'ATTUALE'; } // Path: messages @@ -1895,6 +1906,10 @@ class _StringsDiscoverIt implements _StringsDiscoverEn { @override String get pause => 'Pausa'; @override String get overview => 'Panoramica'; @override String get cast => 'Cast'; + @override String get seasons => 'Stagioni'; + @override String get studio => 'Studio'; + @override String get rating => 'Classificazione'; + @override String get watched => 'Guardato'; @override String episodeCount({required Object count}) => '${count} episodi'; @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} guardati'; @override String get movie => 'Film'; @@ -2453,6 +2468,7 @@ class _StringsUserStatusNl implements _StringsUserStatusEn { @override String get admin => 'Beheerder'; @override String get restricted => 'Beperkt'; @override String get protected => 'Beschermd'; + @override String get current => 'HUIDIG'; } // Path: messages @@ -2551,6 +2567,10 @@ class _StringsDiscoverNl implements _StringsDiscoverEn { @override String get pause => 'Pauzeren'; @override String get overview => 'Overzicht'; @override String get cast => 'Cast'; + @override String get seasons => 'Seizoenen'; + @override String get studio => 'Studio'; + @override String get rating => 'Leeftijd'; + @override String get watched => 'Bekeken'; @override String episodeCount({required Object count}) => '${count} afleveringen'; @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} gekeken'; @override String get movie => 'Film'; @@ -3109,6 +3129,7 @@ class _StringsUserStatusSv implements _StringsUserStatusEn { @override String get admin => 'Admin'; @override String get restricted => 'Begränsad'; @override String get protected => 'Skyddad'; + @override String get current => 'NUVARANDE'; } // Path: messages @@ -3207,6 +3228,10 @@ class _StringsDiscoverSv implements _StringsDiscoverEn { @override String get pause => 'Pausa'; @override String get overview => 'Översikt'; @override String get cast => 'Rollbesättning'; + @override String get seasons => 'Säsonger'; + @override String get studio => 'Studio'; + @override String get rating => 'Åldersgräns'; + @override String get watched => 'Tittad'; @override String episodeCount({required Object count}) => '${count} avsnitt'; @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} sedda'; @override String get movie => 'Film'; @@ -3765,6 +3790,7 @@ class _StringsUserStatusZh implements _StringsUserStatusEn { @override String get admin => '管理员'; @override String get restricted => '受限'; @override String get protected => '受保护'; + @override String get current => '当前'; } // Path: messages @@ -3863,6 +3889,10 @@ class _StringsDiscoverZh implements _StringsDiscoverEn { @override String get pause => '暂停'; @override String get overview => '概述'; @override String get cast => '演员表'; + @override String get seasons => '季数'; + @override String get studio => '制作公司'; + @override String get rating => '年龄分级'; + @override String get watched => '已观看'; @override String episodeCount({required Object count}) => '${count} 集'; @override String watchedProgress({required Object watched, required Object total}) => '已观看 ${watched}/${total} 集'; @override String get movie => '电影'; @@ -4249,6 +4279,7 @@ extension on Translations { case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Restricted'; case 'userStatus.protected': return 'Protected'; + case 'userStatus.current': return 'CURRENT'; case 'messages.markedAsWatched': return 'Marked as watched'; case 'messages.markedAsUnwatched': return 'Marked as unwatched'; case 'messages.removedFromContinueWatching': return 'Removed from Continue Watching'; @@ -4302,6 +4333,10 @@ extension on Translations { case 'discover.pause': return 'Pause'; case 'discover.overview': return 'Overview'; case 'discover.cast': return 'Cast'; + case 'discover.seasons': return 'Seasons'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Rating'; + case 'discover.watched': return 'Watched'; case 'discover.episodeCount': return ({required Object count}) => '${count} episodes'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} watched'; case 'discover.movie': return 'Movie'; @@ -4454,7 +4489,7 @@ extension on _StringsDe { case 'common.logout': return 'Abmelden'; case 'common.online': return 'Online'; case 'common.offline': return 'Offline'; - case 'common.owned': return 'Eigentum'; + case 'common.owned': return 'Besitzer'; case 'common.shared': return 'Geteilt'; case 'common.current': return 'AKTUELL'; case 'common.unknown': return 'Unbekannt'; @@ -4477,7 +4512,7 @@ extension on _StringsDe { case 'update.checkFailed': return 'Fehler bei der Updateprüfung'; case 'settings.title': return 'Einstellungen'; case 'settings.language': return 'Sprache'; - case 'settings.theme': return 'Theme'; + case 'settings.theme': return 'Design'; case 'settings.appearance': return 'Darstellung'; case 'settings.videoPlayback': return 'Videowiedergabe'; case 'settings.shufflePlay': return 'Zufallswiedergabe'; @@ -4493,7 +4528,7 @@ extension on _StringsDe { case 'settings.systemThemeDescription': return 'Systemeinstellungen folgen'; case 'settings.lightTheme': return 'Hell'; case 'settings.darkTheme': return 'Dunkel'; - case 'settings.libraryDensity': return 'Bibliotheksdichte'; + case 'settings.libraryDensity': return 'Mediathekdichte'; case 'settings.compact': return 'Kompakt'; case 'settings.compactDescription': return 'Kleinere Karten, mehr Elemente sichtbar'; case 'settings.normal': return 'Normal'; @@ -4508,7 +4543,7 @@ extension on _StringsDe { case 'settings.useSeasonPosters': return 'Staffelposter verwenden'; case 'settings.showHeroSection': return 'Hero-Bereich anzeigen'; case 'settings.hardwareDecoding': return 'Hardware-Decodierung'; - case 'settings.hardwareDecodingDescription': return 'Hardwarebeschleunigung verwenden, wenn verfügbar'; + case 'settings.hardwareDecodingDescription': return 'Hardwarebeschleunigung verwenden, sofern verfügbar'; case 'settings.bufferSize': return 'Puffergröße'; case 'settings.bufferSizeMB': return ({required Object size}) => '${size}MB'; case 'settings.subtitleStyling': return 'Untertitel-Stil'; @@ -4604,9 +4639,10 @@ extension on _StringsDe { case 'videoControls.stretch': return 'Strecken'; case 'videoControls.lockRotation': return 'Rotation sperren'; case 'videoControls.unlockRotation': return 'Rotation entsperren'; - case 'userStatus.admin': return 'Admin'; + case 'userStatus.admin': return 'Eigentümer'; case 'userStatus.restricted': return 'Eingeschränkt'; case 'userStatus.protected': return 'Geschützt'; + case 'userStatus.current': return 'AKTUELL'; case 'messages.markedAsWatched': return 'Als gesehen markiert'; case 'messages.markedAsUnwatched': return 'Als ungesehen markiert'; case 'messages.removedFromContinueWatching': return 'Aus ‚Weiterschauen‘ entfernt'; @@ -4620,8 +4656,8 @@ extension on _StringsDe { case 'messages.logsCopied': return 'Protokolle in Zwischenablage kopiert'; case 'messages.noLogsAvailable': return 'Keine Protokolle verfügbar'; case 'messages.libraryScanning': return ({required Object title}) => 'Scanne „${title}“...'; - case 'messages.libraryScanStarted': return ({required Object title}) => 'Bibliotheksscan gestartet für „${title}“'; - case 'messages.libraryScanFailed': return ({required Object error}) => 'Fehler beim Scannen der Bibliothek: ${error}'; + case 'messages.libraryScanStarted': return ({required Object title}) => 'Mediathekscan gestartet für „${title}“'; + case 'messages.libraryScanFailed': return ({required Object error}) => 'Fehler beim Scannen der Mediathek: ${error}'; case 'messages.metadataRefreshing': return ({required Object title}) => 'Metadaten werden aktualisiert für „${title}“...'; case 'messages.metadataRefreshStarted': return ({required Object title}) => 'Metadaten-Aktualisierung gestartet für „${title}“'; case 'messages.metadataRefreshFailed': return ({required Object error}) => 'Metadaten konnten nicht aktualisiert werden: ${error}'; @@ -4650,18 +4686,22 @@ extension on _StringsDe { case 'discover.switchServer': return 'Server wechseln'; case 'discover.logout': return 'Abmelden'; case 'discover.noContentAvailable': return 'Kein Inhalt verfügbar'; - case 'discover.addMediaToLibraries': return 'Medien zur Bibliothek hinzufügen'; + case 'discover.addMediaToLibraries': return 'Medien zur Mediathek hinzufügen'; case 'discover.continueWatching': return 'Weiterschauen'; case 'discover.recentlyAdded': return 'Kürzlich hinzugefügt'; case 'discover.play': return 'Abspielen'; case 'discover.resume': return 'Fortsetzen'; - case 'discover.playEpisode': return ({required Object season, required Object episode}) => 'Spiele S${season}, E${episode}'; - case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => 'Fortsetzen S${season}, E${episode}'; + case 'discover.playEpisode': return ({required Object season, required Object episode}) => 'S${season}, E${episode} - Abspielen'; + case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => 'S${season}, E${episode} - Fortsetzen'; case 'discover.pause': return 'Pause'; case 'discover.overview': return 'Übersicht'; case 'discover.cast': return 'Besetzung'; + case 'discover.seasons': return 'Staffeln'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Altersfreigabe'; + case 'discover.watched': return 'Gesehen'; case 'discover.episodeCount': return ({required Object count}) => '${count} Episoden'; - case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} gesehen'; + case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched} von ${total} gesehen'; case 'discover.movie': return 'Film'; case 'discover.tvShow': return 'Serie'; case 'discover.minutesLeft': return ({required Object minutes}) => '${minutes} Min übrig'; @@ -4677,11 +4717,11 @@ extension on _StringsDe { case 'errors.failedToVerifyToken': return ({required Object error}) => 'Token-Verifizierung fehlgeschlagen: ${error}'; case 'errors.failedToSwitchProfile': return ({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; case 'errors.connectionFailedGeneric': return 'Verbindung fehlgeschlagen'; - case 'libraries.title': return 'Bibliotheken'; - case 'libraries.scanLibraryFiles': return 'Bibliotheksdateien scannen'; - case 'libraries.scanLibrary': return 'Bibliothek scannen'; + case 'libraries.title': return 'Mediatheken'; + case 'libraries.scanLibraryFiles': return 'Mediatheksdateien scannen'; + case 'libraries.scanLibrary': return 'Mediathek scannen'; case 'libraries.analyze': return 'Analysieren'; - case 'libraries.analyzeLibrary': return 'Bibliothek analysieren'; + case 'libraries.analyzeLibrary': return 'Mediathek analysieren'; case 'libraries.refreshMetadata': return 'Metadaten aktualisieren'; case 'libraries.emptyTrash': return 'Papierkorb leeren'; case 'libraries.emptyingTrash': return ({required Object title}) => 'Papierkorb für „${title}“ wird geleert...'; @@ -4689,24 +4729,24 @@ extension on _StringsDe { case 'libraries.failedToEmptyTrash': return ({required Object error}) => 'Papierkorb konnte nicht geleert werden: ${error}'; case 'libraries.analyzing': return ({required Object title}) => 'Analysiere „${title}“...'; case 'libraries.analysisStarted': return ({required Object title}) => 'Analyse gestartet für „${title}“'; - case 'libraries.failedToAnalyze': return ({required Object error}) => 'Analyse der Bibliothek fehlgeschlagen: ${error}'; - case 'libraries.noLibrariesFound': return 'Keine Bibliotheken gefunden'; - case 'libraries.thisLibraryIsEmpty': return 'Diese Bibliothek ist leer'; + case 'libraries.failedToAnalyze': return ({required Object error}) => 'Analyse der Mediathek fehlgeschlagen: ${error}'; + case 'libraries.noLibrariesFound': return 'Keine Mediatheken gefunden'; + case 'libraries.thisLibraryIsEmpty': return 'Diese Mediathek ist leer'; case 'libraries.all': return 'Alle'; case 'libraries.clearAll': return 'Alle löschen'; case 'libraries.scanLibraryConfirm': return ({required Object title}) => '„${title}“ wirklich scannen?'; case 'libraries.analyzeLibraryConfirm': return ({required Object title}) => '„${title}“ wirklich analysieren?'; case 'libraries.refreshMetadataConfirm': return ({required Object title}) => 'Metadaten für „${title}“ wirklich aktualisieren?'; case 'libraries.emptyTrashConfirm': return ({required Object title}) => 'Papierkorb für „${title}“ wirklich leeren?'; - case 'libraries.manageLibraries': return 'Bibliotheken verwalten'; + case 'libraries.manageLibraries': return 'Mediatheken verwalten'; case 'libraries.sort': return 'Sortieren'; case 'libraries.sortBy': return 'Sortieren nach'; case 'libraries.filters': return 'Filter'; - case 'libraries.loadingLibraryWithCount': return ({required Object count}) => 'Lade Bibliothek... (${count} Elemente geladen)'; + case 'libraries.loadingLibraryWithCount': return ({required Object count}) => 'Lade Mediathek... (${count} Elemente geladen)'; case 'libraries.confirmActionMessage': return 'Aktion wirklich durchführen?'; - case 'libraries.showLibrary': return 'Bibliothek anzeigen'; - case 'libraries.hideLibrary': return 'Bibliothek ausblenden'; - case 'libraries.libraryOptions': return 'Bibliotheksoptionen'; + case 'libraries.showLibrary': return 'Mediathek anzeigen'; + case 'libraries.hideLibrary': return 'Mediathek ausblenden'; + case 'libraries.libraryOptions': return 'Mediatheksoptionen'; case 'about.title': return 'Über'; case 'about.openSourceLicenses': return 'Open-Source-Lizenzen'; case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; @@ -4742,42 +4782,42 @@ extension on _StringsDe { case 'licenses.licensesCount': return ({required Object count}) => '${count} Lizenzen'; case 'navigation.home': return 'Start'; case 'navigation.search': return 'Suche'; - case 'navigation.libraries': return 'Bibliotheken'; + case 'navigation.libraries': return 'Mediatheken'; case 'navigation.settings': return 'Einstellungen'; - case 'playlists.title': return 'Playlists'; - case 'playlists.noPlaylists': return 'Keine Playlists gefunden'; - case 'playlists.create': return 'Playlist erstellen'; - case 'playlists.newPlaylist': return 'Neue Playlist'; - case 'playlists.playlistName': return 'Playlist-Name'; - case 'playlists.enterPlaylistName': return 'Playlist-Namen eingeben'; - case 'playlists.edit': return 'Playlist bearbeiten'; - case 'playlists.delete': return 'Playlist löschen'; - case 'playlists.addTo': return 'Zur Playlist hinzufügen'; + case 'playlists.title': return 'Wiedergabelisten'; + case 'playlists.noPlaylists': return 'Keine Wiedergabelisten gefunden'; + case 'playlists.create': return 'Wiedergabeliste erstellen'; + case 'playlists.newPlaylist': return 'Neue Wiedergabeliste'; + case 'playlists.playlistName': return 'Name der Wiedergabeliste'; + case 'playlists.enterPlaylistName': return 'Name der Wiedergabeliste eingeben'; + case 'playlists.edit': return 'Wiedergabeliste bearbeiten'; + case 'playlists.delete': return 'Wiedergabeliste löschen'; + case 'playlists.addTo': return 'Zur Wiedergabeliste hinzufügen'; case 'playlists.addItems': return 'Elemente hinzufügen'; - case 'playlists.removeItem': return 'Aus Playlist entfernen'; - case 'playlists.clearPlaylist': return 'Playlist leeren'; + case 'playlists.removeItem': return 'Aus Wiedergabeliste entfernen'; + case 'playlists.clearPlaylist': return 'Wiedergabeliste leeren'; case 'playlists.playAll': return 'Alle abspielen'; case 'playlists.shuffle': return 'Zufällig'; - case 'playlists.smartPlaylist': return 'Intelligente Playlist'; - case 'playlists.regularPlaylist': return 'Normale Playlist'; + case 'playlists.smartPlaylist': return 'Intelligente Wiedergabeliste'; + case 'playlists.regularPlaylist': return 'Normale Wiedergabeliste'; case 'playlists.itemCount': return ({required Object count}) => '${count} Elemente'; case 'playlists.oneItem': return '1 Element'; - case 'playlists.emptyPlaylist': return 'Diese Playlist ist leer'; - case 'playlists.deleteConfirm': return 'Playlist löschen?'; - case 'playlists.deleteMessage': return ({required Object name}) => 'Möchten Sie "${name}" wirklich löschen?'; - case 'playlists.created': return 'Playlist erstellt'; - case 'playlists.updated': return 'Playlist aktualisiert'; - case 'playlists.deleted': return 'Playlist gelöscht'; - case 'playlists.itemAdded': return 'Zur Playlist hinzugefügt'; - case 'playlists.itemRemoved': return 'Aus Playlist entfernt'; - case 'playlists.selectPlaylist': return 'Playlist auswählen'; - case 'playlists.createNewPlaylist': return 'Neue Playlist erstellen'; - case 'playlists.errorCreating': return 'Fehler beim Erstellen der Playlist'; - case 'playlists.errorDeleting': return 'Fehler beim Löschen der Playlist'; - case 'playlists.errorLoading': return 'Fehler beim Laden der Playlists'; - case 'playlists.errorAdding': return 'Fehler beim Hinzufügen zur Playlist'; - case 'playlists.errorReordering': return 'Fehler beim Neuordnen des Playlist-Elements'; - case 'playlists.errorRemoving': return 'Fehler beim Entfernen aus der Playlist'; + case 'playlists.emptyPlaylist': return 'Diese Wiedergabeliste ist leer'; + case 'playlists.deleteConfirm': return 'Wiedergabeliste löschen?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Soll "${name}" wirklich gelöscht werden?'; + case 'playlists.created': return 'Wiedergabeliste erstellt'; + case 'playlists.updated': return 'Wiedergabeliste aktualisiert'; + case 'playlists.deleted': return 'Wiedergabeliste gelöscht'; + case 'playlists.itemAdded': return 'Zur Wiedergabeliste hinzugefügt'; + case 'playlists.itemRemoved': return 'Aus Wiedergabeliste entfernt'; + case 'playlists.selectPlaylist': return 'Wiedergabeliste auswählen'; + case 'playlists.createNewPlaylist': return 'Neue Wiedergabeliste erstellen'; + case 'playlists.errorCreating': return 'Wiedergabeliste konnte nicht erstellt werden'; + case 'playlists.errorDeleting': return 'Wiedergabeliste konnte nicht gelöscht werden'; + case 'playlists.errorLoading': return 'Wiedergabelisten konnten nicht geladen werden'; + case 'playlists.errorAdding': return 'Konnte nicht zur Wiedergabeliste hinzugefügt werden'; + case 'playlists.errorReordering': return 'Element der Wiedergabeliste konnte nicht neu geordnet werden'; + case 'playlists.errorRemoving': return 'Konnte nicht aus der Wiedergabeliste entfernt werden'; default: return null; } } @@ -4965,6 +5005,7 @@ extension on _StringsIt { case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Limitato'; case 'userStatus.protected': return 'Protetto'; + case 'userStatus.current': return 'ATTUALE'; case 'messages.markedAsWatched': return 'Segna come visto'; case 'messages.markedAsUnwatched': return 'Segna come non visto'; case 'messages.removedFromContinueWatching': return 'Rimosso da Continua a guardare'; @@ -5018,6 +5059,10 @@ extension on _StringsIt { case 'discover.pause': return 'Pausa'; case 'discover.overview': return 'Panoramica'; case 'discover.cast': return 'Cast'; + case 'discover.seasons': return 'Stagioni'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Classificazione'; + case 'discover.watched': return 'Guardato'; case 'discover.episodeCount': return ({required Object count}) => '${count} episodi'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} guardati'; case 'discover.movie': return 'Film'; @@ -5323,6 +5368,7 @@ extension on _StringsNl { case 'userStatus.admin': return 'Beheerder'; case 'userStatus.restricted': return 'Beperkt'; case 'userStatus.protected': return 'Beschermd'; + case 'userStatus.current': return 'HUIDIG'; case 'messages.markedAsWatched': return 'Gemarkeerd als gekeken'; case 'messages.markedAsUnwatched': return 'Gemarkeerd als ongekeken'; case 'messages.removedFromContinueWatching': return 'Verwijderd uit Doorgaan met kijken'; @@ -5376,6 +5422,10 @@ extension on _StringsNl { case 'discover.pause': return 'Pauzeren'; case 'discover.overview': return 'Overzicht'; case 'discover.cast': return 'Cast'; + case 'discover.seasons': return 'Seizoenen'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Leeftijd'; + case 'discover.watched': return 'Bekeken'; case 'discover.episodeCount': return ({required Object count}) => '${count} afleveringen'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} gekeken'; case 'discover.movie': return 'Film'; @@ -5681,6 +5731,7 @@ extension on _StringsSv { case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Begränsad'; case 'userStatus.protected': return 'Skyddad'; + case 'userStatus.current': return 'NUVARANDE'; case 'messages.markedAsWatched': return 'Markerad som sedd'; case 'messages.markedAsUnwatched': return 'Markerad som osedd'; case 'messages.removedFromContinueWatching': return 'Borttagen från Fortsätt titta'; @@ -5734,6 +5785,10 @@ extension on _StringsSv { case 'discover.pause': return 'Pausa'; case 'discover.overview': return 'Översikt'; case 'discover.cast': return 'Rollbesättning'; + case 'discover.seasons': return 'Säsonger'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Åldersgräns'; + case 'discover.watched': return 'Tittad'; case 'discover.episodeCount': return ({required Object count}) => '${count} avsnitt'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} sedda'; case 'discover.movie': return 'Film'; @@ -6039,6 +6094,7 @@ extension on _StringsZh { case 'userStatus.admin': return '管理员'; case 'userStatus.restricted': return '受限'; case 'userStatus.protected': return '受保护'; + case 'userStatus.current': return '当前'; case 'messages.markedAsWatched': return '已标记为已观看'; case 'messages.markedAsUnwatched': return '已标记为未观看'; case 'messages.removedFromContinueWatching': return '已从继续观看中移除'; @@ -6092,6 +6148,10 @@ extension on _StringsZh { case 'discover.pause': return '暂停'; case 'discover.overview': return '概述'; case 'discover.cast': return '演员表'; + case 'discover.seasons': return '季数'; + case 'discover.studio': return '制作公司'; + case 'discover.rating': return '年龄分级'; + case 'discover.watched': return '已观看'; case 'discover.episodeCount': return ({required Object count}) => '${count} 集'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '已观看 ${watched}/${total} 集'; case 'discover.movie': return '电影'; diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 9712e83b..e57bc0fe 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -204,7 +204,8 @@ "userStatus": { "admin": "Admin", "restricted": "Restricted", - "protected": "Protected" + "protected": "Protected", + "current": "CURRENT" }, "messages": { "markedAsWatched": "Marked as watched", @@ -268,6 +269,10 @@ "pause": "Pause", "overview": "Overview", "cast": "Cast", + "seasons": "Seasons", + "studio": "Studio", + "rating": "Rating", + "watched": "Watched", "episodeCount": "${count} episodes", "watchedProgress": "${watched}/${total} watched", "movie": "Movie", diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index fa5c662c..e78c5411 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -30,7 +30,7 @@ "logout": "Abmelden", "online": "Online", "offline": "Offline", - "owned": "Eigentum", + "owned": "Besitzer", "shared": "Geteilt", "current": "AKTUELL", "unknown": "Unbekannt", @@ -59,7 +59,7 @@ "settings": { "title": "Einstellungen", "language": "Sprache", - "theme": "Theme", + "theme": "Design", "appearance": "Darstellung", "videoPlayback": "Videowiedergabe", "shufflePlay": "Zufallswiedergabe", @@ -75,7 +75,7 @@ "systemThemeDescription": "Systemeinstellungen folgen", "lightTheme": "Hell", "darkTheme": "Dunkel", - "libraryDensity": "Bibliotheksdichte", + "libraryDensity": "Mediathekdichte", "compact": "Kompakt", "compactDescription": "Kleinere Karten, mehr Elemente sichtbar", "normal": "Normal", @@ -90,7 +90,7 @@ "useSeasonPosters": "Staffelposter verwenden", "showHeroSection": "Hero-Bereich anzeigen", "hardwareDecoding": "Hardware-Decodierung", - "hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, wenn verfügbar", + "hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, sofern verfügbar", "bufferSize": "Puffergröße", "bufferSizeMB": "${size}MB", "subtitleStyling": "Untertitel-Stil", @@ -202,9 +202,10 @@ "unlockRotation": "Rotation entsperren" }, "userStatus": { - "admin": "Admin", + "admin": "Eigentümer", "restricted": "Eingeschränkt", - "protected": "Geschützt" + "protected": "Geschützt", + "current": "AKTUELL" }, "messages": { "markedAsWatched": "Als gesehen markiert", @@ -220,8 +221,8 @@ "logsCopied": "Protokolle in Zwischenablage kopiert", "noLogsAvailable": "Keine Protokolle verfügbar", "libraryScanning": "Scanne „${title}“...", - "libraryScanStarted": "Bibliotheksscan gestartet für „${title}“", - "libraryScanFailed": "Fehler beim Scannen der Bibliothek: ${error}", + "libraryScanStarted": "Mediathekscan gestartet für „${title}“", + "libraryScanFailed": "Fehler beim Scannen der Mediathek: ${error}", "metadataRefreshing": "Metadaten werden aktualisiert für „${title}“...", "metadataRefreshStarted": "Metadaten-Aktualisierung gestartet für „${title}“", "metadataRefreshFailed": "Metadaten konnten nicht aktualisiert werden: ${error}", @@ -258,18 +259,22 @@ "switchServer": "Server wechseln", "logout": "Abmelden", "noContentAvailable": "Kein Inhalt verfügbar", - "addMediaToLibraries": "Medien zur Bibliothek hinzufügen", + "addMediaToLibraries": "Medien zur Mediathek hinzufügen", "continueWatching": "Weiterschauen", "recentlyAdded": "Kürzlich hinzugefügt", "play": "Abspielen", "resume": "Fortsetzen", - "playEpisode": "Spiele S${season}, E${episode}", - "resumeEpisode": "Fortsetzen S${season}, E${episode}", + "playEpisode": "S${season}, E${episode} - Abspielen", + "resumeEpisode": "S${season}, E${episode} - Fortsetzen", "pause": "Pause", "overview": "Übersicht", "cast": "Besetzung", + "seasons": "Staffeln", + "studio": "Studio", + "rating": "Altersfreigabe", + "watched": "Gesehen", "episodeCount": "${count} Episoden", - "watchedProgress": "${watched}/${total} gesehen", + "watchedProgress": "${watched} von ${total} gesehen", "movie": "Film", "tvShow": "Serie", "minutesLeft": "${minutes} Min übrig" @@ -289,11 +294,11 @@ "connectionFailedGeneric": "Verbindung fehlgeschlagen" }, "libraries": { - "title": "Bibliotheken", - "scanLibraryFiles": "Bibliotheksdateien scannen", - "scanLibrary": "Bibliothek scannen", + "title": "Mediatheken", + "scanLibraryFiles": "Mediatheksdateien scannen", + "scanLibrary": "Mediathek scannen", "analyze": "Analysieren", - "analyzeLibrary": "Bibliothek analysieren", + "analyzeLibrary": "Mediathek analysieren", "refreshMetadata": "Metadaten aktualisieren", "emptyTrash": "Papierkorb leeren", "emptyingTrash": "Papierkorb für „${title}“ wird geleert...", @@ -301,24 +306,24 @@ "failedToEmptyTrash": "Papierkorb konnte nicht geleert werden: ${error}", "analyzing": "Analysiere „${title}“...", "analysisStarted": "Analyse gestartet für „${title}“", - "failedToAnalyze": "Analyse der Bibliothek fehlgeschlagen: ${error}", - "noLibrariesFound": "Keine Bibliotheken gefunden", - "thisLibraryIsEmpty": "Diese Bibliothek ist leer", + "failedToAnalyze": "Analyse der Mediathek fehlgeschlagen: ${error}", + "noLibrariesFound": "Keine Mediatheken gefunden", + "thisLibraryIsEmpty": "Diese Mediathek ist leer", "all": "Alle", "clearAll": "Alle löschen", "scanLibraryConfirm": "„${title}“ wirklich scannen?", "analyzeLibraryConfirm": "„${title}“ wirklich analysieren?", "refreshMetadataConfirm": "Metadaten für „${title}“ wirklich aktualisieren?", "emptyTrashConfirm": "Papierkorb für „${title}“ wirklich leeren?", - "manageLibraries": "Bibliotheken verwalten", + "manageLibraries": "Mediatheken verwalten", "sort": "Sortieren", "sortBy": "Sortieren nach", "filters": "Filter", - "loadingLibraryWithCount": "Lade Bibliothek... (${count} Elemente geladen)", + "loadingLibraryWithCount": "Lade Mediathek... (${count} Elemente geladen)", "confirmActionMessage": "Aktion wirklich durchführen?", - "showLibrary": "Bibliothek anzeigen", - "hideLibrary": "Bibliothek ausblenden", - "libraryOptions": "Bibliotheksoptionen" + "showLibrary": "Mediathek anzeigen", + "hideLibrary": "Mediathek ausblenden", + "libraryOptions": "Mediatheksoptionen" }, "about": { "title": "Über", @@ -366,43 +371,43 @@ "navigation": { "home": "Start", "search": "Suche", - "libraries": "Bibliotheken", + "libraries": "Mediatheken", "settings": "Einstellungen" }, "playlists": { - "title": "Playlists", - "noPlaylists": "Keine Playlists gefunden", - "create": "Playlist erstellen", - "newPlaylist": "Neue Playlist", - "playlistName": "Playlist-Name", - "enterPlaylistName": "Playlist-Namen eingeben", - "edit": "Playlist bearbeiten", - "delete": "Playlist löschen", - "addTo": "Zur Playlist hinzufügen", + "title": "Wiedergabelisten", + "noPlaylists": "Keine Wiedergabelisten gefunden", + "create": "Wiedergabeliste erstellen", + "newPlaylist": "Neue Wiedergabeliste", + "playlistName": "Name der Wiedergabeliste", + "enterPlaylistName": "Name der Wiedergabeliste eingeben", + "edit": "Wiedergabeliste bearbeiten", + "delete": "Wiedergabeliste löschen", + "addTo": "Zur Wiedergabeliste hinzufügen", "addItems": "Elemente hinzufügen", - "removeItem": "Aus Playlist entfernen", - "clearPlaylist": "Playlist leeren", + "removeItem": "Aus Wiedergabeliste entfernen", + "clearPlaylist": "Wiedergabeliste leeren", "playAll": "Alle abspielen", "shuffle": "Zufällig", - "smartPlaylist": "Intelligente Playlist", - "regularPlaylist": "Normale Playlist", + "smartPlaylist": "Intelligente Wiedergabeliste", + "regularPlaylist": "Normale Wiedergabeliste", "itemCount": "${count} Elemente", "oneItem": "1 Element", - "emptyPlaylist": "Diese Playlist ist leer", - "deleteConfirm": "Playlist löschen?", - "deleteMessage": "Möchten Sie \"${name}\" wirklich löschen?", - "created": "Playlist erstellt", - "updated": "Playlist aktualisiert", - "deleted": "Playlist gelöscht", - "itemAdded": "Zur Playlist hinzugefügt", - "itemRemoved": "Aus Playlist entfernt", - "selectPlaylist": "Playlist auswählen", - "createNewPlaylist": "Neue Playlist erstellen", - "errorCreating": "Fehler beim Erstellen der Playlist", - "errorDeleting": "Fehler beim Löschen der Playlist", - "errorLoading": "Fehler beim Laden der Playlists", - "errorAdding": "Fehler beim Hinzufügen zur Playlist", - "errorReordering": "Fehler beim Neuordnen des Playlist-Elements", - "errorRemoving": "Fehler beim Entfernen aus der Playlist" + "emptyPlaylist": "Diese Wiedergabeliste ist leer", + "deleteConfirm": "Wiedergabeliste löschen?", + "deleteMessage": "Soll \"${name}\" wirklich gelöscht werden?", + "created": "Wiedergabeliste erstellt", + "updated": "Wiedergabeliste aktualisiert", + "deleted": "Wiedergabeliste gelöscht", + "itemAdded": "Zur Wiedergabeliste hinzugefügt", + "itemRemoved": "Aus Wiedergabeliste entfernt", + "selectPlaylist": "Wiedergabeliste auswählen", + "createNewPlaylist": "Neue Wiedergabeliste erstellen", + "errorCreating": "Wiedergabeliste konnte nicht erstellt werden", + "errorDeleting": "Wiedergabeliste konnte nicht gelöscht werden", + "errorLoading": "Wiedergabelisten konnten nicht geladen werden", + "errorAdding": "Konnte nicht zur Wiedergabeliste hinzugefügt werden", + "errorReordering": "Element der Wiedergabeliste konnte nicht neu geordnet werden", + "errorRemoving": "Konnte nicht aus der Wiedergabeliste entfernt werden" } } diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index 8672f423..04a05e14 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -204,7 +204,8 @@ "userStatus": { "admin": "Admin", "restricted": "Limitato", - "protected": "Protetto" + "protected": "Protetto", + "current": "ATTUALE" }, "messages": { "markedAsWatched": "Segna come visto", @@ -268,6 +269,10 @@ "pause": "Pausa", "overview": "Panoramica", "cast": "Cast", + "seasons": "Stagioni", + "studio": "Studio", + "rating": "Classificazione", + "watched": "Guardato", "episodeCount": "${count} episodi", "watchedProgress": "${watched}/${total} guardati", "movie": "Film", diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index 04640b3f..bced748e 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -204,7 +204,8 @@ "userStatus": { "admin": "Beheerder", "restricted": "Beperkt", - "protected": "Beschermd" + "protected": "Beschermd", + "current": "HUIDIG" }, "messages": { "markedAsWatched": "Gemarkeerd als gekeken", @@ -268,6 +269,10 @@ "pause": "Pauzeren", "overview": "Overzicht", "cast": "Cast", + "seasons": "Seizoenen", + "studio": "Studio", + "rating": "Leeftijd", + "watched": "Bekeken", "episodeCount": "${count} afleveringen", "watchedProgress": "${watched}/${total} gekeken", "movie": "Film", diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index 54f72356..b97094d9 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -204,7 +204,8 @@ "userStatus": { "admin": "Admin", "restricted": "Begränsad", - "protected": "Skyddad" + "protected": "Skyddad", + "current": "NUVARANDE" }, "messages": { "markedAsWatched": "Markerad som sedd", @@ -268,6 +269,10 @@ "pause": "Pausa", "overview": "Översikt", "cast": "Rollbesättning", + "seasons": "Säsonger", + "studio": "Studio", + "rating": "Åldersgräns", + "watched": "Tittad", "episodeCount": "${count} avsnitt", "watchedProgress": "${watched}/${total} sedda", "movie": "Film", diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 705ae2ee..8daa0990 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -204,7 +204,8 @@ "userStatus": { "admin": "管理员", "restricted": "受限", - "protected": "受保护" + "protected": "受保护", + "current": "当前" }, "messages": { "markedAsWatched": "已标记为已观看", @@ -268,6 +269,10 @@ "pause": "暂停", "overview": "概述", "cast": "演员表", + "seasons": "季数", + "studio": "制作公司", + "rating": "年龄分级", + "watched": "已观看", "episodeCount": "${count} 集", "watchedProgress": "已观看 ${watched}/${total} 集", "movie": "电影", diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 9831062e..0b1afff6 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -768,7 +768,7 @@ class _MediaDetailScreenState extends State { // Seasons (for TV shows) if (isShow) ...[ Text( - 'Seasons', + t.discover.seasons, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), @@ -930,12 +930,12 @@ class _MediaDetailScreenState extends State { // Additional info if (metadata.studio != null) ...[ - _buildInfoRow('Studio', metadata.studio!), + _buildInfoRow(t.discover.studio, metadata.studio!), const SizedBox(height: 12), ], if (metadata.contentRating != null) ...[ _buildInfoRow( - 'Rating', + t.discover.rating, formatContentRating(metadata.contentRating!), ), const SizedBox(height: 12), diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 24995170..137816d0 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -350,7 +350,7 @@ class _SeasonDetailScreenState extends State ), ), Text( - 'Watched ✓', + '${t.discover.watched} ✓', style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: tokens(context).textMuted, diff --git a/lib/widgets/profile_list_tile.dart b/lib/widgets/profile_list_tile.dart index ca806763..9712653f 100644 --- a/lib/widgets/profile_list_tile.dart +++ b/lib/widgets/profile_list_tile.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import '../i18n/strings.g.dart'; import '../models/plex_home_user.dart'; import 'user_avatar_widget.dart'; +enum UserAttribute { admin, restricted, protected } + class ProfileListTile extends StatelessWidget { final PlexHomeUser user; final VoidCallback onTap; @@ -34,7 +37,7 @@ class ProfileListTile extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: Text( - 'CURRENT', + t.userStatus.current, style: TextStyle( fontSize: 10, color: theme.colorScheme.onPrimary, @@ -55,21 +58,21 @@ class ProfileListTile extends StatelessWidget { List _buildUserAttributes(ThemeData theme) { final attributes = []; - final labels = []; + final List userAttributes = []; if (user.isAdminUser) { - labels.add('Admin'); + userAttributes.add(UserAttribute.admin); } if (user.isRestrictedUser && !user.isAdminUser) { - labels.add('Restricted'); + userAttributes.add(UserAttribute.restricted); } if (user.requiresPassword) { - labels.add('Protected'); + userAttributes.add(UserAttribute.protected); } - for (int i = 0; i < labels.length; i++) { + for (int i = 0; i < userAttributes.length; i++) { if (i > 0) { attributes.addAll([ const SizedBox(width: 8), @@ -84,12 +87,14 @@ class ProfileListTile extends StatelessWidget { ]); } + final attribute = userAttributes[i]; + attributes.add( Text( - labels[i], + _getAttributeLabel(attribute), style: TextStyle( fontSize: 12, - color: _getAttributeColor(labels[i], theme), + color: _getAttributeColor(attribute, theme), fontWeight: FontWeight.w500, ), ), @@ -99,16 +104,25 @@ class ProfileListTile extends StatelessWidget { return attributes; } - Color _getAttributeColor(String attribute, ThemeData theme) { + String _getAttributeLabel(UserAttribute attribute) { switch (attribute) { - case 'Admin': + case UserAttribute.admin: + return t.userStatus.admin; + case UserAttribute.restricted: + return t.userStatus.restricted; + case UserAttribute.protected: + return t.userStatus.protected; + } + } + + Color _getAttributeColor(UserAttribute attribute, ThemeData theme) { + switch (attribute) { + case UserAttribute.admin: return theme.colorScheme.primary; - case 'Restricted': + case UserAttribute.restricted: return theme.colorScheme.warning ?? Colors.orange; - case 'Protected': + case UserAttribute.protected: return theme.colorScheme.secondary; - default: - return theme.colorScheme.onSurface; } } } From be69d16948e386ae21cb2fe4319340dc10c49feb Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Nov 2025 03:08:56 +0100 Subject: [PATCH 24/25] fix(android): working mpv builds --- lib/screens/video_player_screen.dart | 2 +- pubspec.lock | 32 ++++++++++++++-------------- pubspec.yaml | 12 +++++------ 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 0eb7a339..53fe04ad 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -181,7 +181,7 @@ class VideoPlayerScreenState extends State 'sub-border-color': settingsService.getSubtitleBorderColor(), 'sub-back-color': '#${(settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt().toRadixString(16).padLeft(2, '0').toUpperCase()}${settingsService.getSubtitleBackgroundColor().replaceFirst('#', '')}', - 'sub-ass-override': 'yes', + 'sub-ass-override': 'no', }, ), ); diff --git a/pubspec.lock b/pubspec.lock index 7d033286..f5885bec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -556,8 +556,8 @@ packages: dependency: "direct main" description: path: media_kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" @@ -565,8 +565,8 @@ packages: dependency: "direct overridden" description: path: "libs/android/media_kit_libs_android_video" - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.3.9" @@ -574,8 +574,8 @@ packages: dependency: "direct overridden" description: path: "libs/ios/media_kit_libs_ios_video" - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.1.4" @@ -583,8 +583,8 @@ packages: dependency: transitive description: path: "libs/linux/media_kit_libs_linux" - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" @@ -592,8 +592,8 @@ packages: dependency: "direct overridden" description: path: "libs/macos/media_kit_libs_macos_video" - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.1.5" @@ -601,8 +601,8 @@ packages: dependency: "direct main" description: path: "libs/universal/media_kit_libs_video" - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.0.7" @@ -610,8 +610,8 @@ packages: dependency: transitive description: path: "libs/windows/media_kit_libs_windows_video" - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.0.12" @@ -619,8 +619,8 @@ packages: dependency: "direct main" description: path: media_kit_video - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index 1de465c4..30d4b9a9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,32 +38,32 @@ dependency_overrides: media_kit: git: url: https://github.com/edde746/media-kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 path: media_kit media_kit_video: git: url: https://github.com/edde746/media-kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 path: media_kit_video media_kit_libs_video: git: url: https://github.com/edde746/media-kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 path: libs/universal/media_kit_libs_video media_kit_libs_macos_video: git: url: https://github.com/edde746/media-kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 path: libs/macos/media_kit_libs_macos_video media_kit_libs_ios_video: git: url: https://github.com/edde746/media-kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 path: libs/ios/media_kit_libs_ios_video media_kit_libs_android_video: git: url: https://github.com/edde746/media-kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 path: libs/android/media_kit_libs_android_video dev_dependencies: From d10b882516da31edeb7765d8f064631e40f38a4e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Nov 2025 03:53:38 +0100 Subject: [PATCH 25/25] fix(android): add missing crop effect --- pubspec.lock | 32 ++++++++++++++++---------------- pubspec.yaml | 12 ++++++------ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index f5885bec..e818babd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -556,8 +556,8 @@ packages: dependency: "direct main" description: path: media_kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" @@ -565,8 +565,8 @@ packages: dependency: "direct overridden" description: path: "libs/android/media_kit_libs_android_video" - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.3.9" @@ -574,8 +574,8 @@ packages: dependency: "direct overridden" description: path: "libs/ios/media_kit_libs_ios_video" - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.1.4" @@ -583,8 +583,8 @@ packages: dependency: transitive description: path: "libs/linux/media_kit_libs_linux" - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" @@ -592,8 +592,8 @@ packages: dependency: "direct overridden" description: path: "libs/macos/media_kit_libs_macos_video" - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.1.5" @@ -601,8 +601,8 @@ packages: dependency: "direct main" description: path: "libs/universal/media_kit_libs_video" - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.0.7" @@ -610,8 +610,8 @@ packages: dependency: transitive description: path: "libs/windows/media_kit_libs_windows_video" - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.0.12" @@ -619,8 +619,8 @@ packages: dependency: "direct main" description: path: media_kit_video - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index 30d4b9a9..d6ae92be 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,32 +38,32 @@ dependency_overrides: media_kit: git: url: https://github.com/edde746/media-kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: media_kit media_kit_video: git: url: https://github.com/edde746/media-kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: media_kit_video media_kit_libs_video: git: url: https://github.com/edde746/media-kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: libs/universal/media_kit_libs_video media_kit_libs_macos_video: git: url: https://github.com/edde746/media-kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: libs/macos/media_kit_libs_macos_video media_kit_libs_ios_video: git: url: https://github.com/edde746/media-kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: libs/ios/media_kit_libs_ios_video media_kit_libs_android_video: git: url: https://github.com/edde746/media-kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: libs/android/media_kit_libs_android_video dev_dependencies: