feat: respect per media language preference
This commit is contained in:
@@ -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
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user