feat: respect per media language preference

This commit is contained in:
edde746
2025-11-14 22:27:12 +01:00
parent 173ab59007
commit a618218828
15 changed files with 407 additions and 71 deletions
+34
View File
@@ -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<bool> setMetadataPreferences(
String ratingKey, {
String? audioLanguage,
String? subtitleLanguage,
}) async {
try {
final queryParams = <String, dynamic>{};
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<List<PlexMetadata>> search(String query, {int limit = 10}) async {
+18 -2
View File
@@ -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';
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+8
View File
@@ -38,6 +38,8 @@ class PlexMetadata {
final int? viewedLeafCount; // Number of watched episodes in a series/season
@JsonKey(name: 'Role')
final List<PlexRole>? 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<PlexRole>? 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;
+4
View File
@@ -40,6 +40,8 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
role: (json['Role'] as List<dynamic>?)
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
.toList(),
audioLanguage: json['audioLanguage'] as String?,
subtitleLanguage: json['subtitleLanguage'] as String?,
);
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
@@ -75,4 +77,6 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
'leafCount': instance.leafCount,
'viewedLeafCount': instance.viewedLeafCount,
'Role': instance.role,
'audioLanguage': instance.audioLanguage,
'subtitleLanguage': instance.subtitleLanguage,
};
+14
View File
@@ -33,6 +33,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
_seekTimeSmall = _settingsService.getSeekTimeSmall();
_seekTimeLarge = _settingsService.getSeekTimeLarge();
_sleepTimerDuration = _settingsService.getSleepTimerDuration();
_rememberTrackSelections = _settingsService.getRememberTrackSelections();
_isLoading = false;
});
}
@@ -264,6 +266,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
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);
},
),
],
),
);
+194 -13
View File
@@ -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<VideoPlayerScreen> {
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<void> processTracks(Tracks tracks) async {
@@ -1102,7 +1131,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
);
}
// 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<VideoPlayerScreen> {
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<VideoPlayerScreen> {
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<VideoPlayerScreen> {
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<VideoPlayerScreen> {
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<VideoPlayerScreen> {
// 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<VideoPlayerScreen> {
await _navigateToEpisode(_previousEpisode!);
}
/// Handle audio track changes from the user - save as per-media preference if enabled
Future<void> _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<PlexClient>();
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<void> _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<PlexClient>();
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<VideoPlayerScreen> {
selectedMediaIndex: widget.selectedMediaIndex,
boxFitMode: _boxFitMode,
onCycleBoxFitMode: _cycleBoxFitMode,
onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
),
);
},
+14
View File
@@ -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<void> setRememberTrackSelections(bool enabled) async {
await _prefs.setBool(_keyRememberTrackSelections, enabled);
}
bool getRememberTrackSelections() {
return _prefs.getBool(_keyRememberTrackSelections) ?? true; // Default: true
}
// Reset all settings to defaults
Future<void> 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(),
};
}
}
+70 -47
View File
@@ -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<PlexAudioTrack> 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 = <String>[];
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<PlexSubtitleTrack> 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);
}
}
@@ -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);
},
);
@@ -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);
},
);
+18 -3
View File
@@ -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<PlexVideoControls>
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(