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.
This commit is contained in:
edde746
2026-07-26 23:05:53 +02:00
parent 78eedd21d3
commit 468d680484
3 changed files with 131 additions and 4 deletions
+2 -2
View File
@@ -1763,11 +1763,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
Future<void> _onAudioTrackChanged(AudioTrack track) async => _trackManager?.onAudioTrackChanged(track);
Future<void> _onAudioTrackChanged(AudioTrack track) async => _trackManager?.onAudioTrackSelectedByUser(track);
Future<void> _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}) {
+30 -2
View File
@@ -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<void> 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<void> 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.
+99
View File
@@ -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')];