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] 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(