From 468d680484cbcbe559171f49c9ae7177c6a41c0e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:05:11 +0200 Subject: [PATCH] fix(player): keep an explicit track choice through the pending automatic pass When a source advertises subtitles the native track list has not produced yet, applyTrackSelectionWhenReady keeps an automatic selection armed for up to thirty seconds. That late pass re-runs TrackSelectionService against the stored preferences, so a track the user picked in the meantime was silently reset. The Maestro codec suites caught it: the English E-AC3 and Japanese DTS-HD flows select an audio track, and fifteen seconds later the deadline puts the preferred language back. Adds explicit user-selection entry points that retire the pending automatic selection first, and routes the sheet callbacks and the remote's cycle shortcuts through them. Subtitles get the same treatment, because the same pass re-selects them. Bumping the generation is sufficient: TrackSelectionService re-checks it in the statement immediately before each select call, and a mutation already in flight was dispatched before the user's and so lands first. --- lib/screens/video_player_screen.dart | 4 +- lib/services/track_manager.dart | 32 ++++++++- test/services/track_manager_test.dart | 99 +++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index f2833174..e11848b5 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1763,11 +1763,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - Future _onAudioTrackChanged(AudioTrack track) async => _trackManager?.onAudioTrackChanged(track); + Future _onAudioTrackChanged(AudioTrack track) async => _trackManager?.onAudioTrackSelectedByUser(track); Future _onSubtitleTrackChanged(SubtitleTrack track, {int? sourceStreamId}) async { _rememberNativeSubtitleSelection(track, sourceStreamId: sourceStreamId); - await _trackManager?.onSubtitleTrackChanged(track, sourceStreamId: sourceStreamId); + await _trackManager?.onSubtitleTrackSelectedByUser(track, sourceStreamId: sourceStreamId); } void _rememberNativeSubtitleSelection(SubtitleTrack track, {int? sourceStreamId}) { diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index b9ecb749..9b75c1b7 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -380,7 +380,7 @@ class TrackManager { final nextIndex = (currentIndex + 1) % tracks.length; final next = tracks[nextIndex]; player.selectSubtitleTrack(next); - onSubtitleTrackChanged(next); + unawaited(onSubtitleTrackSelectedByUser(next)); if (isActive()) { final label = next.id == 'no' @@ -400,7 +400,7 @@ class TrackManager { final nextIndex = (currentIndex + 1) % tracks.length; final next = tracks[nextIndex]; player.selectAudioTrack(next); - onAudioTrackChanged(next); + unawaited(onAudioTrackSelectedByUser(next)); if (isActive()) { final label = @@ -409,6 +409,34 @@ class TrackManager { } } + // ── Explicit user selection ──────────────────────────────────────── + + /// Records an explicit user audio choice. + /// + /// A source that advertises subtitles keeps an automatic selection pending + /// for up to 30 seconds (see [applyTrackSelectionWhenReady]). That late pass + /// re-runs [TrackSelectionService] against the preferences, so it would + /// overwrite whatever the user picked in the meantime. Retiring the pending + /// selection first makes the explicit choice win. + /// + /// The caller has already told the player which track to use, and this does + /// not re-issue that command: the generation bump closes the whole window. + /// `TrackSelectionService` re-checks the generation in the statement right + /// before each `select*Track` call, so no later automatic mutation can be + /// dispatched, and one already in flight was dispatched earlier and so lands + /// before the user's. + Future onAudioTrackSelectedByUser(AudioTrack track) async { + await invalidatePendingSelection(); + await onAudioTrackChanged(track); + } + + /// Records an explicit user subtitle choice, retiring any pending automatic + /// selection for the same reason as [onAudioTrackSelectedByUser]. + Future onSubtitleTrackSelectedByUser(SubtitleTrack track, {int? sourceStreamId}) async { + await invalidatePendingSelection(); + await onSubtitleTrackChanged(track, sourceStreamId: sourceStreamId); + } + // ── Server preference sync ───────────────────────────────────────── /// Handle audio track changes — save stream selection and language preference. diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index 88e1509e..ab6a5cbd 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -808,6 +808,105 @@ void main() { }); }); + // ============================================================ + // Explicit user selection vs. the pending automatic pass + // ============================================================ + + group('explicit user selection', () { + test('user audio choice survives the advertised-subtitle deadline', () async { + await SettingsService.getInstance(); + + fakeAsync((async) { + const userPick = AudioTrack(id: 'hin', language: 'hin'); + final player = _FakePlayer( + tracks: const Tracks( + audio: [ + AudioTrack(id: 'eng', language: 'eng'), + userPick, + ], + ), + ); + final mgr = _make(player: player, mediaInfo: _mediaInfoWithSubtitles(selected: true)); + + // The advertised subtitle never materializes, so the five-second + // fallback applies the ready audio and keeps the 30-second pass armed. + mgr.applyTrackSelectionWhenReady(); + async.elapse(const Duration(seconds: 5)); + async.flushMicrotasks(); + expect(player.selectedAudio.map((track) => track.id), ['eng']); + + // The user picks a different audio track from the sheet. + player.selectAudioTrack(userPick); + unawaited(mgr.onAudioTrackSelectedByUser(userPick)); + async.flushMicrotasks(); + expect(player.selectedAudio.map((track) => track.id), ['eng', 'hin']); + + // The deadline must not re-run selection and reset that choice. + async.elapse(const Duration(seconds: 25)); + async.flushMicrotasks(); + expect(player.selectedAudio.map((track) => track.id), ['eng', 'hin']); + + mgr.dispose(); + }); + }); + + test('user subtitle choice survives a late native track-list update', () async { + await SettingsService.getInstance(); + + fakeAsync((async) { + const userPick = SubtitleTrack(id: '10', language: 'eng'); + final mediaInfo = MediaSourceInfo( + videoUrl: 'https://example.com/transcode.m3u8', + audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)], + subtitleTracks: [ + MediaSubtitleTrack(id: 10, languageCode: 'eng', selected: false, forced: false), + MediaSubtitleTrack(id: 11, languageCode: 'fre', selected: true, forced: false), + ], + chapters: const [], + ); + final player = _FakePlayer( + tracks: const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + subtitle: [userPick], + ), + ); + final mgr = _make( + player: player, + mediaInfo: mediaInfo, + preferredSubtitleTrack: const SubtitleTrack(id: 'source:11', language: 'fre'), + ); + + // Still waiting for the French subtitle the catalog advertises. + mgr.applyTrackSelectionWhenReady(); + async.elapse(const Duration(seconds: 5)); + async.flushMicrotasks(); + expect(player.selectedSubtitle, isEmpty); + + // The user settles on the English subtitle that is already present. + player.selectSubtitleTrack(userPick); + unawaited(mgr.onSubtitleTrackSelectedByUser(userPick, sourceStreamId: 10)); + async.flushMicrotasks(); + expect(player.selectedSubtitle.map((track) => track.id), ['10']); + + // The late native list must not swap the user onto the French track. + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + subtitle: [ + userPick, + SubtitleTrack(id: '11', language: 'fre'), + ], + ), + ); + async.elapse(const Duration(seconds: 25)); + async.flushMicrotasks(); + expect(player.selectedSubtitle.map((track) => track.id), ['10']); + + mgr.dispose(); + }); + }); + }); + group('applyTrackSelection ownership', () { const audioTracks = [AudioTrack(id: 'audio-en', language: 'eng'), AudioTrack(id: 'audio-ja', language: 'jpn')]; const subtitleTracks = [SubtitleTrack(id: 'sub-en', language: 'eng'), SubtitleTrack(id: 'sub-es', language: 'spa')];