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')];