From c21c5396cdb42dc0457c346e7d469ede22c312dc Mon Sep 17 00:00:00 2001 From: Doezer Date: Wed, 26 Nov 2025 14:08:34 +0100 Subject: [PATCH] fix: save stream selection per media for audio and subtitles (not just language pref) --- lib/client/plex_client.dart | 37 +++++ lib/models/plex_media_info.dart | 3 + lib/screens/video_player_screen.dart | 239 +++++++++++++++++++++++---- 3 files changed, 244 insertions(+), 35 deletions(-) diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index b0185997..810c22f6 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -462,6 +462,42 @@ class PlexClient { } } + /// Select specific audio and subtitle streams for playback + /// This updates which streams are "selected" in the media metadata + /// Uses the part ID from media info for accurate stream selection + Future selectStreams( + int partId, { + int? audioStreamID, + int? subtitleStreamID, + bool allParts = true, + }) async { + try { + final queryParams = {}; + if (audioStreamID != null) { + queryParams['audioStreamID'] = audioStreamID; + } + if (subtitleStreamID != null) { + queryParams['subtitleStreamID'] = subtitleStreamID; + } + if (allParts) { + // If no streams to select, return early + if (queryParams.isEmpty) { + return true; + } + + // Use PUT request on /library/parts/{partId} + final response = await _dio.put( + '/library/parts/$partId', + queryParameters: queryParams, + ); + + return response.statusCode == 200; + } catch (e) { + appLogger.e('Failed to select streams', error: e); + return false; + } + } + /// Search across all libraries using the hub search endpoint /// Only returns movies and shows, filtering out seasons and episodes Future> search(String query, {int limit = 10}) async { @@ -923,6 +959,7 @@ class PlexClient { audioTracks: audioTracks, subtitleTracks: subtitleTracks, chapters: chapters, + partId: part['id'] as int?, ); } } diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index 6e05cf73..9981996c 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -3,13 +3,16 @@ class PlexMediaInfo { final List audioTracks; final List subtitleTracks; final List chapters; + final int? partId; PlexMediaInfo({ required this.videoUrl, required this.audioTracks, required this.subtitleTracks, required this.chapters, + this.partId, }); + int? getPartId() => partId; } /// Mixin for building track labels with a consistent pattern diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index f058a07e..2e7fa072 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'dart:io'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/services.dart' show rootBundle; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:os_media_controls/os_media_controls.dart'; @@ -11,6 +13,7 @@ import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_media_info.dart'; import '../providers/playback_state_provider.dart'; import '../services/episode_navigation_service.dart'; import '../services/media_controls_manager.dart'; @@ -26,6 +29,14 @@ import '../utils/video_player_navigation.dart'; import '../widgets/video_controls/video_controls.dart'; import '../i18n/strings.g.dart'; +Map? _isoLangTable; + +/// Charger la table en mémoire au démarrage du widget (dans initState par exemple) +Future loadIsoTable() async { + final data = await rootBundle.loadString('lib/data/iso_6369_codes.json'); + _isoLangTable = json.decode(data) as Map; +} + class VideoPlayerScreen extends StatefulWidget { final PlexMetadata metadata; final AudioTrack? preferredAudioTrack; @@ -57,6 +68,7 @@ class VideoPlayerScreenState extends State bool _showPlayNextDialog = false; bool _isPhone = false; List _availableVersions = []; + PlexMediaInfo? _currentMediaInfo; StreamSubscription? _logSubscription; StreamSubscription? _errorSubscription; StreamSubscription? _playingSubscription; @@ -90,6 +102,8 @@ class VideoPlayerScreenState extends State void initState() { super.initState(); + loadIsoTable(); + appLogger.d('VideoPlayerScreen initialized for: ${widget.metadata.title}'); if (widget.preferredAudioTrack != null) { appLogger.d( @@ -212,6 +226,15 @@ class VideoPlayerScreenState extends State } } + /// Convertit un code à 2 lettres "fr", "nl", "ca" en code Plex à 3 lettres, ou renvoie null si inconnu + String? _iso6391ToPlex6392(String? code) { + if (code == null || code.isEmpty || _isoLangTable == null) return null; + // Prend la base "fr" de "fr-FR" + final lang = code.split('-').first.toLowerCase(); + final langEntry = _isoLangTable![lang] as Map?; + return langEntry?['639-2'] as String?; + } + Future _initializePlayer() async { try { // Load buffer size from settings @@ -566,6 +589,7 @@ class VideoPlayerScreenState extends State if (mounted) { setState(() { _availableVersions = result.availableVersions.cast(); + _currentMediaInfo = result.mediaInfo; }); // Initialize video filter manager with player and available versions @@ -757,7 +781,7 @@ class VideoPlayerScreenState extends State await _navigateToEpisode(_previousEpisode!); } - /// Handle audio track changes from the user - save as per-media preference if enabled + /// Handle audio track changes from the user - save both stream selection and language preference Future _onAudioTrackChanged(AudioTrack track) async { final settings = await SettingsService.getInstance(); @@ -765,41 +789,101 @@ class VideoPlayerScreenState extends State 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'); + if (_currentMediaInfo == null) { + appLogger.w('No media info available, cannot save stream selection'); + return; + } + final partId = _currentMediaInfo!.getPartId(); + if (partId == null) { + appLogger.w('No part ID available, cannot save stream selection'); return; } - // Determine which ratingKey to use - // For TV shows: use grandparentRatingKey (series level) - // For movies: use ratingKey (movie level) + final languageCode = track.language; + int? streamID; + + // === Matching by attributes === + PlexAudioTrack? matched; + final normalizedTrackLang = _iso6391ToPlex6392(track.language); + + appLogger.d( + 'Normalized media_kit language: ${track.language} -> $normalizedTrackLang', + ); + + for (final plexTrack in _currentMediaInfo!.audioTracks) { + final matchLang = plexTrack.languageCode == normalizedTrackLang; + final matchTitle = (track.title == null || track.title!.isEmpty) + ? true + : (plexTrack.displayTitle == track.title || + plexTrack.title == track.title); + + if (matchLang && matchTitle) { + matched = plexTrack; + appLogger.d('Matched audio by lang/title: streamID ${matched.id}'); + break; + } + } + + if (matched != null) { + streamID = matched.id; + appLogger.d('Matched audio by lang/title: streamID $streamID'); + } else { + appLogger.w('Could not match audio track, using fallback index'); + // Fallback - normally no offset for audio + try { + final trackIndex = int.parse(track.id); + + if (trackIndex >= 0 && + trackIndex < _currentMediaInfo!.audioTracks.length) { + streamID = _currentMediaInfo!.audioTracks[trackIndex].id; + appLogger.d( + 'Using fallback: audio index $trackIndex -> streamID $streamID', + ); + } else { + appLogger.e( + 'Fallback index $trackIndex out of bounds (total: ${_currentMediaInfo!.audioTracks.length})', + ); + } + } catch (e) { + appLogger.e('Failed to parse track index', error: e); + } + } + final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; - final targetRatingKey = isEpisode + final languagePrefRatingKey = 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; - // Use server-specific client for this metadata final client = _getClientForMetadata(context); - await client.setMetadataPreferences( - targetRatingKey, - audioLanguage: languageCode, - ); - appLogger.d('Successfully saved audio language preference'); + + final futures = []; + + // 1. Language preference (series/movie level) + if (languageCode != null && languageCode.isNotEmpty) { + futures.add( + client.setMetadataPreferences( + languagePrefRatingKey, + audioLanguage: languageCode, + ), + ); + } + // 2. Exact stream selection (part level) + if (streamID != null) { + futures.add( + client.selectStreams(partId, audioStreamID: streamID, allParts: true), + ); + } + + await Future.wait(futures); + appLogger.d('Successfully saved audio preferences (language + stream)'); } catch (e) { - appLogger.e('Failed to save audio language preference', error: e); + appLogger.e('Failed to save audio preferences', error: e); } } - /// Handle subtitle track changes from the user - save as per-media preference if enabled + /// Handle subtitle track changes from the user - save both stream selection and language preference Future _onSubtitleTrackChanged(SubtitleTrack track) async { final settings = await SettingsService.getInstance(); @@ -808,42 +892,127 @@ class VideoPlayerScreenState extends State return; } - // Handle "Off" selection + if (_currentMediaInfo == null) { + appLogger.w('No media info available, cannot save stream selection'); + return; + } + + final partId = _currentMediaInfo!.getPartId(); + if (partId == null) { + appLogger.w('No part ID available, cannot save stream selection'); + return; + } + String? languageCode; + int? streamID; + if (track.id == 'no') { languageCode = 'none'; + streamID = 0; appLogger.i('User turned subtitles off, saving preference'); } else { languageCode = track.language; - if (languageCode == null || languageCode.isEmpty) { + + // === Matching by attributes === + PlexSubtitleTrack? matched; + final normalizedTrackLang = _iso6391ToPlex6392(track.language); + + appLogger.d( + 'Normalized media_kit language: ${track.language} -> $normalizedTrackLang', + ); + + for (final plexTrack in _currentMediaInfo!.subtitleTracks) { + final matchLang = plexTrack.languageCode == normalizedTrackLang; + final matchTitle = (track.title == null || track.title!.isEmpty) + ? true + : (plexTrack.displayTitle == track.title || + plexTrack.title == track.title); + + appLogger.d('Comparing with streamID ${plexTrack.id}:'); appLogger.d( - 'Subtitle track has no language code, not saving preference', + ' matchLang: $matchLang (${plexTrack.languageCode} == $normalizedTrackLang)', ); - return; + appLogger.d(' matchTitle: $matchTitle'); + + if (matchLang && matchTitle) { + matched = plexTrack; + appLogger.d(' ✅ MATCHED!'); + break; + } + } + + if (matched != null) { + streamID = matched.id; + appLogger.d('Matched subtitle by lang/title: streamID $streamID'); + } else { + appLogger.w('Could not match subtitle track, using fallback index'); + // Fallback with offset correction + try { + final trackIndex = int.parse(track.id); + + // media kit has a "no" (off) at index 0, so real subtitles start at 1 + // We need to subtract 1 to get the actual index in PlexMediaInfo + final plexIndex = trackIndex > 0 ? trackIndex - 1 : 0; + + if (plexIndex >= 0 && + plexIndex < _currentMediaInfo!.subtitleTracks.length) { + streamID = _currentMediaInfo!.subtitleTracks[plexIndex].id; + appLogger.d( + 'Using fallback: media_kit index $trackIndex -> Plex index $plexIndex -> streamID $streamID', + ); + } else { + appLogger.e( + 'Fallback index $plexIndex out of bounds (total: ${_currentMediaInfo!.subtitleTracks.length})', + ); + } + } catch (e) { + appLogger.e('Failed to parse track index', error: e); + } } } - // Determine which ratingKey to use + // Determine ratingKeys final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; - final targetRatingKey = isEpisode + final languagePrefRatingKey = isEpisode ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) : widget.metadata.ratingKey; appLogger.i( - 'Saving subtitle language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)', + 'Saving subtitle preference: language=$languageCode (ratingKey: $languagePrefRatingKey), streamID=$streamID (partId: $partId)', ); try { if (!mounted) return; - // Use server-specific client for this metadata final client = _getClientForMetadata(context); - await client.setMetadataPreferences( - targetRatingKey, - subtitleLanguage: languageCode, + + final futures = []; + + // 1. Save language preference at series/movie level + if (languageCode != null) { + futures.add( + client.setMetadataPreferences( + languagePrefRatingKey, + subtitleLanguage: languageCode, + ), + ); + } + // 2. Save exact stream selection using part ID + if (streamID != null) { + futures.add( + client.selectStreams( + partId, + subtitleStreamID: streamID, + allParts: true, + ), + ); + } + + await Future.wait(futures); + appLogger.d( + 'Successfully saved subtitle preferences (language + stream)', ); - appLogger.d('Successfully saved subtitle language preference'); } catch (e) { - appLogger.e('Failed to save subtitle language preference', error: e); + appLogger.e('Failed to save subtitle preferences', error: e); } }