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:
@@ -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 {
|
Future<void> _onSubtitleTrackChanged(SubtitleTrack track, {int? sourceStreamId}) async {
|
||||||
_rememberNativeSubtitleSelection(track, sourceStreamId: sourceStreamId);
|
_rememberNativeSubtitleSelection(track, sourceStreamId: sourceStreamId);
|
||||||
await _trackManager?.onSubtitleTrackChanged(track, sourceStreamId: sourceStreamId);
|
await _trackManager?.onSubtitleTrackSelectedByUser(track, sourceStreamId: sourceStreamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _rememberNativeSubtitleSelection(SubtitleTrack track, {int? sourceStreamId}) {
|
void _rememberNativeSubtitleSelection(SubtitleTrack track, {int? sourceStreamId}) {
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ class TrackManager {
|
|||||||
final nextIndex = (currentIndex + 1) % tracks.length;
|
final nextIndex = (currentIndex + 1) % tracks.length;
|
||||||
final next = tracks[nextIndex];
|
final next = tracks[nextIndex];
|
||||||
player.selectSubtitleTrack(next);
|
player.selectSubtitleTrack(next);
|
||||||
onSubtitleTrackChanged(next);
|
unawaited(onSubtitleTrackSelectedByUser(next));
|
||||||
|
|
||||||
if (isActive()) {
|
if (isActive()) {
|
||||||
final label = next.id == 'no'
|
final label = next.id == 'no'
|
||||||
@@ -400,7 +400,7 @@ class TrackManager {
|
|||||||
final nextIndex = (currentIndex + 1) % tracks.length;
|
final nextIndex = (currentIndex + 1) % tracks.length;
|
||||||
final next = tracks[nextIndex];
|
final next = tracks[nextIndex];
|
||||||
player.selectAudioTrack(next);
|
player.selectAudioTrack(next);
|
||||||
onAudioTrackChanged(next);
|
unawaited(onAudioTrackSelectedByUser(next));
|
||||||
|
|
||||||
if (isActive()) {
|
if (isActive()) {
|
||||||
final label =
|
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 ─────────────────────────────────────────
|
// ── Server preference sync ─────────────────────────────────────────
|
||||||
|
|
||||||
/// Handle audio track changes — save stream selection and language preference.
|
/// Handle audio track changes — save stream selection and language preference.
|
||||||
|
|||||||
@@ -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', () {
|
group('applyTrackSelection ownership', () {
|
||||||
const audioTracks = [AudioTrack(id: 'audio-en', language: 'eng'), AudioTrack(id: 'audio-ja', language: 'jpn')];
|
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')];
|
const subtitleTracks = [SubtitleTrack(id: 'sub-en', language: 'eng'), SubtitleTrack(id: 'sub-es', language: 'spa')];
|
||||||
|
|||||||
Reference in New Issue
Block a user