From deadbd1be74069ae0b100d13447f2e0e35dbbd8b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:51:58 +0100 Subject: [PATCH] fix: improve subtitle selection --- lib/screens/video_player_screen.dart | 251 +++++++++++++++----- lib/services/track_selection_service.dart | 269 ++++++++++++++++++++++ 2 files changed, 464 insertions(+), 56 deletions(-) create mode 100644 lib/services/track_selection_service.dart diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index fccd4428..87e23d61 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -375,7 +375,7 @@ class _VideoPlayerScreenState extends State { ) { appLogger.d('Audio track selection using user profile'); appLogger.d( - 'Profile settings - autoSelectAudio: ${profile.autoSelectAudio}, defaultAudioLanguage: ${profile.defaultAudioLanguage}', + 'Profile settings - autoSelectAudio: ${profile.autoSelectAudio}, defaultAudioLanguage: ${profile.defaultAudioLanguage}, defaultAudioLanguages: ${profile.defaultAudioLanguages}', ); if (availableTracks.isEmpty || !profile.autoSelectAudio) { @@ -385,31 +385,43 @@ class _VideoPlayerScreenState extends State { return null; } - final preferredLanguage = profile.defaultAudioLanguage; - if (preferredLanguage == null || preferredLanguage.isEmpty) { - appLogger.d('Cannot use profile: No defaultAudioLanguage specified'); + // Build list of preferred languages + final preferredLanguages = []; + if (profile.defaultAudioLanguage != null && profile.defaultAudioLanguage!.isNotEmpty) { + preferredLanguages.add(profile.defaultAudioLanguage!); + } + if (profile.defaultAudioLanguages != null) { + preferredLanguages.addAll(profile.defaultAudioLanguages!); + } + + if (preferredLanguages.isEmpty) { + appLogger.d('Cannot use profile: No defaultAudioLanguage(s) specified'); return null; } - // Get all possible language code variations (e.g., "en" → ["en", "eng"]) - final languageVariations = LanguageCodes.getVariations(preferredLanguage); - appLogger.d( - 'Checking language variations: ${languageVariations.join(", ")}', - ); + appLogger.d('Preferred languages: ${preferredLanguages.join(", ")}'); - // Try to find track matching any language variation - for (var track in availableTracks) { - final trackLang = track.language?.toLowerCase(); - if (trackLang != null && languageVariations.contains(trackLang)) { - appLogger.d( - 'Found audio track matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', - ); - return track; + // Try to find track matching any preferred language + for (final preferredLanguage in preferredLanguages) { + // Get all possible language code variations (e.g., "en" → ["en", "eng"]) + final languageVariations = LanguageCodes.getVariations(preferredLanguage); + appLogger.d( + 'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}', + ); + + for (var track in availableTracks) { + final trackLang = track.language?.toLowerCase(); + if (trackLang != null && languageVariations.contains(trackLang)) { + appLogger.d( + 'Found audio track matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', + ); + return track; + } } } appLogger.d( - 'No audio track found matching profile language "$preferredLanguage" or its variations', + 'No audio track found matching profile languages or their variations', ); return null; } @@ -434,11 +446,12 @@ class _VideoPlayerScreenState extends State { SubtitleTrack? _findSubtitleTrackByProfile( List availableTracks, - PlexUserProfile profile, - ) { + PlexUserProfile profile, { + AudioTrack? selectedAudioTrack, + }) { appLogger.d('Subtitle track selection using user profile'); appLogger.d( - 'Profile settings - autoSelectSubtitle: ${profile.autoSelectSubtitle}, defaultSubtitleLanguage: ${profile.defaultSubtitleLanguage}, defaultSubtitleForced: ${profile.defaultSubtitleForced}', + 'Profile settings - autoSelectSubtitle: ${profile.autoSelectSubtitle}, defaultSubtitleLanguage: ${profile.defaultSubtitleLanguage}, defaultSubtitleLanguages: ${profile.defaultSubtitleLanguages}, defaultSubtitleForced: ${profile.defaultSubtitleForced}, defaultSubtitleAccessibility: ${profile.defaultSubtitleAccessibility}', ); if (availableTracks.isEmpty) { @@ -446,63 +459,183 @@ class _VideoPlayerScreenState extends State { return null; } - // If autoSelectSubtitle is 0, don't select any subtitle - if (!profile.shouldAutoSelectSubtitle) { + // Mode 0: Manually selected - return OFF + if (profile.autoSelectSubtitle == 0) { appLogger.d( - 'Profile specifies no auto-select (autoSelectSubtitle=0) - Subtitles OFF', + 'Profile specifies manual mode (autoSelectSubtitle=0) - Subtitles OFF', ); return SubtitleTrack.no(); } - final preferredLanguage = profile.defaultSubtitleLanguage; - if (preferredLanguage == null || preferredLanguage.isEmpty) { - appLogger.d('Cannot use profile: No defaultSubtitleLanguage specified'); + // Mode 1: Shown with foreign audio + if (profile.autoSelectSubtitle == 1) { + appLogger.d('Profile specifies foreign audio mode (autoSelectSubtitle=1)'); + + // Check if audio language matches user's preferred subtitle language + if (selectedAudioTrack != null && profile.defaultSubtitleLanguage != null) { + final audioLang = selectedAudioTrack.language?.toLowerCase(); + final prefLang = profile.defaultSubtitleLanguage!.toLowerCase(); + final languageVariations = LanguageCodes.getVariations(prefLang); + + appLogger.d('Checking if audio is foreign - audio: $audioLang, preferred subtitle lang: $prefLang'); + + // If audio matches preferred language, no subtitles needed + if (audioLang != null && languageVariations.contains(audioLang)) { + appLogger.d('Audio matches preferred language - Subtitles OFF'); + return SubtitleTrack.no(); + } + appLogger.d('Foreign audio detected - enabling subtitles'); + } + // Foreign audio detected or cannot determine, enable subtitles + } + + // Mode 2: Always enabled (or continuing from mode 1 with foreign audio) + appLogger.d('Selecting subtitle track based on preferences'); + + // Build list of preferred languages + final preferredLanguages = []; + if (profile.defaultSubtitleLanguage != null && profile.defaultSubtitleLanguage!.isNotEmpty) { + preferredLanguages.add(profile.defaultSubtitleLanguage!); + } + if (profile.defaultSubtitleLanguages != null) { + preferredLanguages.addAll(profile.defaultSubtitleLanguages!); + } + + if (preferredLanguages.isEmpty) { + appLogger.d('Cannot use profile: No defaultSubtitleLanguage(s) specified'); return null; } - // Get all possible language code variations (e.g., "en" → ["en", "eng"]) - final languageVariations = LanguageCodes.getVariations(preferredLanguage); - appLogger.d( - 'Checking language variations: ${languageVariations.join(", ")}', - ); + appLogger.d('Preferred languages: ${preferredLanguages.join(", ")}'); - // If defaultSubtitleForced is 1, prefer forced subtitles - if (profile.preferForcedSubtitles) { - appLogger.d('Profile prefers forced subtitles (defaultSubtitleForced=1)'); - // Try to find forced subtitle in preferred language - for (var track in availableTracks) { + // Apply filtering based on preferences + var candidateTracks = availableTracks; + + // Filter by SDH (defaultSubtitleAccessibility: 0-3) + candidateTracks = _filterSubtitlesBySDH(candidateTracks, profile.defaultSubtitleAccessibility); + + // Filter by forced subtitle preference (defaultSubtitleForced: 0-3) + candidateTracks = _filterSubtitlesByForced(candidateTracks, profile.defaultSubtitleForced); + + // If no candidates after filtering, relax filters + if (candidateTracks.isEmpty) { + appLogger.d('No tracks match strict filters, relaxing filters'); + candidateTracks = availableTracks; + } + + // Try to find track matching any preferred language + for (final preferredLanguage in preferredLanguages) { + final languageVariations = LanguageCodes.getVariations(preferredLanguage); + appLogger.d( + 'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}', + ); + + for (var track in candidateTracks) { final trackLang = track.language?.toLowerCase(); - if (trackLang != null && - languageVariations.contains(trackLang) && - track.title?.toLowerCase().contains('forced') == true) { + if (trackLang != null && languageVariations.contains(trackLang)) { appLogger.d( - 'Found forced subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', + 'Found subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', ); return track; } } - appLogger.d( - 'No forced subtitle found in "$preferredLanguage" or its variations, trying regular subtitles', - ); - } - - // Try to find regular subtitle in preferred language - for (var track in availableTracks) { - final trackLang = track.language?.toLowerCase(); - if (trackLang != null && languageVariations.contains(trackLang)) { - appLogger.d( - 'Found subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', - ); - return track; - } } appLogger.d( - 'No subtitle track found matching profile language "$preferredLanguage" or its variations', + 'No subtitle track found matching profile languages or their variations', ); return null; } + /// Filters subtitle tracks based on SDH (Subtitles for Deaf or Hard-of-Hearing) preference + /// + /// Values: + /// - 0: Prefer non-SDH subtitles + /// - 1: Prefer SDH subtitles + /// - 2: Only show SDH subtitles + /// - 3: Only show non-SDH subtitles + List _filterSubtitlesBySDH( + List tracks, + int preference, + ) { + if (preference == 0 || preference == 1) { + // Prefer but don't require + final preferSDH = preference == 1; + final preferred = tracks.where((t) => _isSDH(t) == preferSDH).toList(); + if (preferred.isNotEmpty) { + appLogger.d('Applying SDH preference: ${preferSDH ? "prefer SDH" : "prefer non-SDH"} (${preferred.length} tracks)'); + return preferred; + } + appLogger.d('No tracks match SDH preference, using all tracks'); + return tracks; + } else if (preference == 2) { + // Only SDH + final filtered = tracks.where(_isSDH).toList(); + appLogger.d('Filtering to SDH only (${filtered.length} tracks)'); + return filtered; + } else if (preference == 3) { + // Only non-SDH + final filtered = tracks.where((t) => !_isSDH(t)).toList(); + appLogger.d('Filtering to non-SDH only (${filtered.length} tracks)'); + return filtered; + } + return tracks; + } + + /// Filters subtitle tracks based on forced subtitle preference + /// + /// Values: + /// - 0: Prefer non-forced subtitles + /// - 1: Prefer forced subtitles + /// - 2: Only show forced subtitles + /// - 3: Only show non-forced subtitles + List _filterSubtitlesByForced( + List tracks, + int preference, + ) { + if (preference == 0 || preference == 1) { + // Prefer but don't require + final preferForced = preference == 1; + final preferred = tracks.where((t) => _isForced(t) == preferForced).toList(); + if (preferred.isNotEmpty) { + appLogger.d('Applying forced preference: ${preferForced ? "prefer forced" : "prefer non-forced"} (${preferred.length} tracks)'); + return preferred; + } + appLogger.d('No tracks match forced preference, using all tracks'); + return tracks; + } else if (preference == 2) { + // Only forced + final filtered = tracks.where(_isForced).toList(); + appLogger.d('Filtering to forced only (${filtered.length} tracks)'); + return filtered; + } else if (preference == 3) { + // Only non-forced + final filtered = tracks.where((t) => !_isForced(t)).toList(); + appLogger.d('Filtering to non-forced only (${filtered.length} tracks)'); + return filtered; + } + return tracks; + } + + /// Checks if a subtitle track is SDH (Subtitles for Deaf or Hard-of-Hearing) + /// + /// Since media_kit may not expose this directly, we infer from the title + bool _isSDH(SubtitleTrack track) { + final title = track.title?.toLowerCase() ?? ''; + + // Look for common SDH indicators + return title.contains('sdh') || + title.contains('cc') || + title.contains('hearing impaired') || + title.contains('deaf'); + } + + /// Checks if a subtitle track is forced + bool _isForced(SubtitleTrack track) { + final title = track.title?.toLowerCase() ?? ''; + return title.contains('forced'); + } + void _waitForTracksAndApply() async { // Helper function to process tracks Future processTracks(Tracks tracks) async { @@ -622,9 +755,15 @@ class _VideoPlayerScreenState extends State { profileSettings != null && realSubtitleTracks.isNotEmpty) { appLogger.d('Priority 2: Checking user profile preferences'); + // Get the currently selected audio track + final currentAudioTrack = realAudioTracks.firstWhere( + (t) => t.id == player!.state.track.audio.id, + orElse: () => realAudioTracks.first, + ); subtitleToSelect = _findSubtitleTrackByProfile( realSubtitleTracks, profileSettings, + selectedAudioTrack: currentAudioTrack, ); } else if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) { appLogger.d('Priority 2: No user profile available'); diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart new file mode 100644 index 00000000..11f6df7b --- /dev/null +++ b/lib/services/track_selection_service.dart @@ -0,0 +1,269 @@ +import '../models/plex_media_info.dart'; +import '../models/plex_user_profile.dart'; + +/// Service for selecting audio and subtitle tracks based on user preferences +class TrackSelectionService { + /// Selects the best audio track based on user preferences + /// + /// Returns the selected audio track, or null if no suitable track is found + static PlexAudioTrack? selectAudioTrack( + List tracks, + PlexUserProfile profile, + ) { + if (tracks.isEmpty) return null; + + // If auto-select is disabled, use Plex's selected track + if (!profile.autoSelectAudio) { + return tracks.firstWhere( + (track) => track.selected, + orElse: () => tracks.first, + ); + } + + // Build list of preferred language codes + final preferredLanguages = []; + if (profile.defaultAudioLanguage != null) { + preferredLanguages.add(profile.defaultAudioLanguage!); + } + if (profile.defaultAudioLanguages != null) { + preferredLanguages.addAll(profile.defaultAudioLanguages!); + } + + // If no preferred languages, return first track + if (preferredLanguages.isEmpty) { + return tracks.first; + } + + // Try to find a track matching preferred languages + for (final language in preferredLanguages) { + final matchingTrack = tracks.firstWhere( + (track) => _matchesLanguage(track.languageCode, language), + orElse: () => tracks.first, + ); + if (matchingTrack != tracks.first || + _matchesLanguage(matchingTrack.languageCode, language)) { + return matchingTrack; + } + } + + // Fallback to first track + return tracks.first; + } + + /// Selects the best subtitle track based on user preferences + /// + /// Returns the selected subtitle track, or null if subtitles should be disabled + static PlexSubtitleTrack? selectSubtitleTrack( + List tracks, + PlexUserProfile profile, + PlexAudioTrack? selectedAudioTrack, + ) { + if (tracks.isEmpty) return null; + + // Mode 0: Manually selected - return null to disable subtitles + if (profile.autoSelectSubtitle == 0) { + return null; + } + + // 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) { + final audioLang = selectedAudioTrack.languageCode; + final prefLang = profile.defaultSubtitleLanguage; + + // If audio matches preferred language, no subtitles needed + if (_matchesLanguage(audioLang, prefLang)) { + return null; + } + } + + // Foreign audio detected, enable subtitles + return _findBestSubtitle(tracks, profile); + } + + // Mode 2: Always enabled + if (profile.autoSelectSubtitle == 2) { + return _findBestSubtitle(tracks, profile); + } + + return null; + } + + /// Finds the best subtitle track matching user preferences + static PlexSubtitleTrack? _findBestSubtitle( + List tracks, + PlexUserProfile profile, + ) { + // Build list of preferred language codes + final preferredLanguages = []; + if (profile.defaultSubtitleLanguage != null) { + preferredLanguages.add(profile.defaultSubtitleLanguage!); + } + if (profile.defaultSubtitleLanguages != null) { + preferredLanguages.addAll(profile.defaultSubtitleLanguages!); + } + + // Filter tracks based on preferences + var candidateTracks = tracks; + + // Apply SDH (hearing impaired) filtering + candidateTracks = _filterBySDH(candidateTracks, profile.defaultSubtitleAccessibility); + + // Apply forced subtitle filtering + candidateTracks = _filterByForced(candidateTracks, profile.defaultSubtitleForced); + + // If no candidates after filtering, relax filters + if (candidateTracks.isEmpty) { + candidateTracks = tracks; + } + + // If no preferred languages, return first candidate + if (preferredLanguages.isEmpty) { + return candidateTracks.firstOrNull; + } + + // Try to find a track matching preferred languages + for (final language in preferredLanguages) { + final matchingTrack = candidateTracks.firstWhere( + (track) => _matchesLanguage(track.languageCode, language), + orElse: () => candidateTracks.first, + ); + if (matchingTrack != candidateTracks.first || + _matchesLanguage(matchingTrack.languageCode, language)) { + return matchingTrack; + } + } + + // Fallback to first candidate + return candidateTracks.firstOrNull; + } + + /// Filters subtitle tracks based on SDH (hearing impaired) preference + /// + /// Values: + /// - 0: Prefer non-SDH subtitles + /// - 1: Prefer SDH subtitles + /// - 2: Only show SDH subtitles + /// - 3: Only show non-SDH subtitles + static List _filterBySDH( + List tracks, + int preference, + ) { + if (preference == 0 || preference == 1) { + // Prefer but don't require + final preferSDH = preference == 1; + final preferred = tracks.where((t) => _isSDH(t) == preferSDH).toList(); + return preferred.isNotEmpty ? preferred : tracks; + } else if (preference == 2) { + // Only SDH + return tracks.where(_isSDH).toList(); + } else if (preference == 3) { + // Only non-SDH + return tracks.where((t) => !_isSDH(t)).toList(); + } + return tracks; + } + + /// Filters subtitle tracks based on forced subtitle preference + /// + /// Values: + /// - 0: Prefer non-forced subtitles + /// - 1: Prefer forced subtitles + /// - 2: Only show forced subtitles + /// - 3: Only show non-forced subtitles + static List _filterByForced( + List tracks, + int preference, + ) { + if (preference == 0 || preference == 1) { + // Prefer but don't require + final preferForced = preference == 1; + final preferred = tracks.where((t) => t.forced == preferForced).toList(); + return preferred.isNotEmpty ? preferred : tracks; + } else if (preference == 2) { + // Only forced + return tracks.where((t) => t.forced).toList(); + } else if (preference == 3) { + // Only non-forced + return tracks.where((t) => !t.forced).toList(); + } + return tracks; + } + + /// Checks if a subtitle track is SDH (Subtitles for Deaf or Hard-of-Hearing) + /// + /// Since Plex API may not expose this directly, we infer from the title/displayTitle + static bool _isSDH(PlexSubtitleTrack track) { + final title = track.title?.toLowerCase() ?? ''; + final displayTitle = track.displayTitle?.toLowerCase() ?? ''; + + // 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'); + } + + /// 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) { + if (trackLanguage == null || preferredLanguage == null) { + return false; + } + + final track = trackLanguage.toLowerCase(); + final preferred = preferredLanguage.toLowerCase(); + + // 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', + }; + + // Try mapping preferred to 3-letter and compare + if (languageMap[preferred] == track) return true; + + // Try mapping track to 3-letter and compare with preferred 3-letter + if (languageMap[track] == preferred) return true; + + // 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; + } +}