Files
plezy/lib/services/track_manager.dart
T
edde746 d5f7c5d7ac fix(player): skip the deferred track pass when its switch is superseded
Persisting the deferred choice suspends, so the source switch can be
superseded before the pass is armed. Return early when the continuation is
stale, and refuse to arm a disposed or inactive TrackManager at all.

The per-callback generation checks only stopped the work; the subscription
and the five-second timer were still allocated on a manager whose dispose had
already run, so nothing would ever cancel them.
2026-07-27 17:51:07 +02:00

548 lines
22 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import '../mpv/mpv.dart';
import '../media/media_item.dart';
import '../media/media_server_user_profile.dart';
import '../media/media_source_info.dart';
import '../services/settings_service.dart';
import '../services/track_selection_service.dart';
import '../utils/app_logger.dart';
import '../utils/track_label_builder.dart';
/// Persists a track choice for the current part to the server.
/// Backends that persist through another path (Jellyfin uses playback progress
/// stream indexes) or lack server-side stream selection leave this null.
/// [trackType] is `'audio'` or `'subtitle'`.
typedef TrackPreferencePersister =
Future<void> Function({required int partId, required String trackType, int? streamID});
/// Manages track (audio + subtitle) lifecycle: external subtitle loading,
/// automatic track selection, server preference sync, and cycling.
///
/// Follows the same manager pattern as [VideoFilterManager]:
/// constructed with a [Player] + callbacks, mutated via public setters,
/// disposed when the player screen tears down.
class TrackManager {
final Player player;
/// Returns false once the owning widget is unmounted or disposed.
final bool Function() isActive;
/// Optional hook for persisting a track choice to Plex immediately. `null`
/// for backends with a different persistence path (Jellyfin) or no
/// server-side track preferences.
final TrackPreferencePersister? persistTrackPreference;
/// Resolves the user's profile settings (may be null during loading).
final MediaServerUserProfile? Function() getProfileSettings;
/// Waits until profile settings are available (offline path).
final Future<void> Function() waitForProfileSettings;
/// Shows a transient message to the user (e.g., snackbar).
final void Function(String message, {Duration? duration})? showMessage;
// ── Mutable configuration (updated on episode navigation) ──────────
MediaItem metadata;
MediaSourceInfo? mediaInfo;
AudioTrack? preferredAudioTrack;
SubtitleTrack? preferredSubtitleTrack;
SubtitleTrack? preferredSecondarySubtitleTrack;
// ── Internal state ─────────────────────────────────────────────────
bool waitingForExternalSubsTrackSelection = false;
bool _externalSubtitleAddsInFlight = false;
bool _isApplyingTrackSelection = false;
Completer<void>? _selectionIdleCompleter;
Future<void>? _activePlayerMutationDrain;
List<SubtitleTrack> _lastExternalSubtitles = const [];
StreamSubscription<Tracks>? _trackLoadingSubscription;
Timer? _subtitleFallbackTimer;
Timer? _trackSelectionFallbackTimer;
bool _disposed = false;
int _selectionGeneration = 0;
bool get _managerIsActive => !_disposed && isActive();
bool _isSelectionCurrent(int generation) => _managerIsActive && generation == _selectionGeneration;
void _trackDispatchedPlayerMutation(Future<void> mutation) {
final drain = mutation.then<void>((_) {}, onError: (Object _, StackTrace _) {});
_activePlayerMutationDrain = drain;
unawaited(
drain.then((_) {
if (identical(_activePlayerMutationDrain, drain)) {
_activePlayerMutationDrain = null;
}
}),
);
}
/// Cached external subtitles for re-use after backend fallback.
@visibleForTesting
List<SubtitleTrack> get lastExternalSubtitles => _lastExternalSubtitles;
TrackManager({
required this.player,
required this.isActive,
this.persistTrackPreference,
required this.getProfileSettings,
required this.waitForProfileSettings,
required this.metadata,
this.mediaInfo,
this.preferredAudioTrack,
this.preferredSubtitleTrack,
this.preferredSecondarySubtitleTrack,
this.showMessage,
});
// ── External subtitles ─────────────────────────────────────────────
/// Cache external subtitles for backend fallback recovery.
void cacheExternalSubtitles(List<SubtitleTrack> externalSubtitles) {
_lastExternalSubtitles = externalSubtitles;
}
/// Add external subtitle tracks to the player in metadata order.
///
/// MPV assigns subtitle track IDs in completion order, so parallel sub-adds
/// make the track list nondeterministic. Keep this ordered for the fallback
/// paths that cannot attach sidecars through loadfile.
Future<void> addExternalSubtitles(List<SubtitleTrack> externalSubtitles, {Future<void>? waitUntilReady}) async {
if (externalSubtitles.isEmpty) return;
_externalSubtitleAddsInFlight = true;
try {
if (waitUntilReady != null) {
try {
await waitUntilReady;
} catch (e) {
appLogger.w('Continuing external subtitle load after readiness wait failed', error: e);
}
if (!isActive()) return;
}
appLogger.d('Adding ${externalSubtitles.length} external subtitle(s) to player');
for (final subtitleTrack in externalSubtitles.where((s) => s.uri != null)) {
try {
await player.addSubtitleTrack(
uri: subtitleTrack.uri!,
title: subtitleTrack.title,
language: subtitleTrack.language,
select: subtitleTrack.isDefault,
);
appLogger.d('Added external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}');
} catch (e) {
appLogger.w('Failed to add external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}', error: e);
}
}
} finally {
_externalSubtitleAddsInFlight = false;
}
}
/// Resume playback after external subtitles have been loaded (or failed).
/// Sets up a 3-second fallback in case playbackRestart doesn't fire.
Future<void> resumeAfterSubtitleLoad() async {
if (!isActive()) return;
try {
await player.play();
final pos = player.state.position;
try {
await player.seek(pos.inMilliseconds > 0 ? pos : Duration.zero);
} catch (e) {
appLogger.w('Non-critical seek after subtitle load failed', error: e);
}
} catch (e) {
// play() failed — clear the flag immediately since playbackRestart won't fire
appLogger.w('Resume after subtitle load failed, applying track selection directly', error: e);
waitingForExternalSubsTrackSelection = false;
unawaited(applyTrackSelection());
return;
}
// Fallback if playbackRestart doesn't fire
_subtitleFallbackTimer?.cancel();
_subtitleFallbackTimer = Timer(const Duration(seconds: 3), () {
if (waitingForExternalSubsTrackSelection && isActive()) {
waitingForExternalSubsTrackSelection = false;
applyTrackSelection();
}
});
}
/// Invalidates every pending automatic selection before the player is
/// reused for another media generation and returns a bounded drain for the
/// native player mutation already in flight at invalidation time.
///
/// The returned future does not wait for profile/track readiness or include
/// mutations started by a later generation. Reload callers can await it
/// immediately before replacement media is opened, ensuring an
/// already-dispatched native audio, subtitle, or rate mutation cannot land
/// on that replacement. Disposal deliberately ignores the drain so teardown
/// is never held by a native command.
Future<void> invalidatePendingSelection() {
final activePlayerMutationDrain = _activePlayerMutationDrain;
_selectionGeneration++;
_trackLoadingSubscription?.cancel();
_trackLoadingSubscription = null;
_trackSelectionFallbackTimer?.cancel();
_trackSelectionFallbackTimer = null;
return activePlayerMutationDrain ?? Future<void>.value();
}
// ── Track selection ────────────────────────────────────────────────
/// Apply track selection once tracks are available.
///
/// The five-second fallback applies any ready audio/rate settings, but a
/// source that advertises subtitles keeps listening for their late native
/// track-list update. The listener has a separate hard deadline and every
/// callback is scoped to the current media generation.
///
/// Callers may arm this after an `await`, so a manager disposed or
/// deactivated in the meantime must not subscribe or start a timer: nothing
/// would ever cancel them. The generation checks inside each callback only
/// stop the work, not the allocation.
void applyTrackSelectionWhenReady() {
if (!_managerIsActive) return;
final selectionGeneration = _selectionGeneration;
bool selectionIsCurrent() => _isSelectionCurrent(selectionGeneration);
final currentTracks = player.state.tracks;
if (_tracksReadyForSelection(currentTracks)) {
unawaited(applyTrackSelection());
return;
}
_trackLoadingSubscription?.cancel();
_trackLoadingSubscription = player.streams.tracks.listen((tracks) {
if (!selectionIsCurrent() || !_tracksReadyForSelection(tracks)) return;
_trackLoadingSubscription?.cancel();
_trackLoadingSubscription = null;
_trackSelectionFallbackTimer?.cancel();
_trackSelectionFallbackTimer = null;
unawaited(applyTrackSelection());
});
_trackSelectionFallbackTimer?.cancel();
_trackSelectionFallbackTimer = Timer(const Duration(seconds: 5), () {
if (!selectionIsCurrent()) return;
final tracks = player.state.tracks;
final waitingForAdvertisedSubtitles =
mediaInfo?.subtitleTracks.isNotEmpty == true && !_tracksReadyForSelection(tracks);
if (!waitingForAdvertisedSubtitles) {
_trackLoadingSubscription?.cancel();
_trackLoadingSubscription = null;
_trackSelectionFallbackTimer = null;
unawaited(applyTrackSelection());
return;
}
appLogger.w(
'Native subtitle tracks are still pending after 5 seconds; applying ready track settings and continuing to wait',
);
unawaited(applyTrackSelection());
_trackSelectionFallbackTimer = Timer(const Duration(seconds: 25), () {
if (!selectionIsCurrent()) return;
_trackLoadingSubscription?.cancel();
_trackLoadingSubscription = null;
_trackSelectionFallbackTimer = null;
if (!_tracksReadyForSelection(player.state.tracks)) {
appLogger.w('Advertised native subtitle selection did not resolve before the 30-second deadline');
}
unawaited(applyTrackSelection());
});
});
}
bool _tracksReadyForSelection(Tracks tracks) {
final hasAnyTracks = tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty;
if (!hasAnyTracks) return false;
final realAudioTracks = tracks.audio
.where((track) => track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id)
.toList(growable: false);
final realSubtitleTracks = tracks.subtitle
.where((track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id)
.toList(growable: false);
final service = TrackSelectionService(metadata: metadata, plexMediaInfo: mediaInfo);
final selectedAudioTrack = service.selectAudioTrack(realAudioTracks, preferredAudioTrack)?.track;
// Selection owns the catalog-completeness decision. A null subtitle result
// is the only state in which a requested source track can still arrive.
return service.selectSubtitleTrack(realSubtitleTracks, preferredSubtitleTrack, selectedAudioTrack) != null;
}
/// Core track selection: delegates to [TrackSelectionService]. Returns
/// whether every player mutation completed for this still-active owner.
Future<bool> applyTrackSelection() async {
final selectionGeneration = _selectionGeneration;
bool selectionIsActive() => _isSelectionCurrent(selectionGeneration);
if (!selectionIsActive()) return false;
if (_isApplyingTrackSelection) {
// A later track-list event can make a same-generation selection materially
// different (notably when subtitles arrive while the five-second audio/rate
// fallback is still applying). Queue one pass after the current mutation
// chain rather than dropping that event.
final activeSelectionDone = _selectionIdleCompleter?.future;
if (activeSelectionDone == null) return false;
await activeSelectionDone;
if (!selectionIsActive()) return false;
return applyTrackSelection();
}
_isApplyingTrackSelection = true;
final idleCompleter = Completer<void>();
_selectionIdleCompleter = idleCompleter;
try {
await waitForProfileSettings();
if (!selectionIsActive()) return false;
final profileSettings = getProfileSettings();
final settingsService = await SettingsService.getInstance();
if (!selectionIsActive()) return false;
final trackService = TrackSelectionService(
player: player,
profileSettings: profileSettings,
metadata: metadata,
plexMediaInfo: mediaInfo,
);
return await trackService.selectAndApplyTracks(
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
defaultPlaybackSpeed: settingsService.read(SettingsService.defaultPlaybackSpeed),
onAudioTrackChanged: onAudioTrackChanged,
onSubtitleTrackChanged: onSubtitleTrackChanged,
isActive: selectionIsActive,
onPlayerMutationDispatched: _trackDispatchedPlayerMutation,
);
} catch (e) {
appLogger.w('Failed to apply track selection', error: e);
return false;
} finally {
_isApplyingTrackSelection = false;
if (identical(_selectionIdleCompleter, idleCompleter)) {
_selectionIdleCompleter = null;
idleCompleter.complete();
}
}
}
/// Called when playbackRestart fires — checks the flag and applies selection.
void onPlaybackRestart() {
if (waitingForExternalSubsTrackSelection) {
if (_externalSubtitleAddsInFlight) return;
waitingForExternalSubsTrackSelection = false;
applyTrackSelection();
}
}
// ── Backend fallback ───────────────────────────────────────────────
/// Handle ExoPlayer → MPV backend switch: re-add external subs and reapply selection.
Future<void> onBackendSwitched() async {
final pendingSelection = _selectionIdleCompleter?.future;
final playerMutationDrain = invalidatePendingSelection();
if (pendingSelection != null) await pendingSelection;
await playerMutationDrain;
if (!_managerIsActive) return;
appLogger.i('Player backend switched from ExoPlayer to MPV (native fallback)');
if (_lastExternalSubtitles.isNotEmpty && !player.attachesExternalSubtitlesAtOpen) {
try {
await addExternalSubtitles(_lastExternalSubtitles);
} catch (e) {
appLogger.w('Failed to re-add external subtitles after backend switch', error: e);
}
}
if (!_managerIsActive) return;
applyTrackSelectionWhenReady();
}
// ── Track cycling (remote/keyboard shortcuts) ──────────────────────
/// Cycle to the next subtitle track and save the preference.
void cycleSubtitleTrack() {
final tracks = player.state.tracks.subtitle.where((t) => t.id != 'auto').toList();
if (tracks.isEmpty) return;
final current = player.state.track.subtitle;
final currentIndex = tracks.indexWhere((t) => t.id == current?.id);
final nextIndex = (currentIndex + 1) % tracks.length;
final next = tracks[nextIndex];
player.selectSubtitleTrack(next);
unawaited(onSubtitleTrackSelectedByUser(next));
if (isActive()) {
final label = next.id == 'no'
? 'Subtitles: Off'
: 'Subtitles: ${TrackLabelBuilder.subtitleLabel(title: next.title, language: next.language, codec: next.codec, forced: next.isForced, index: nextIndex).joined}';
showMessage?.call(label, duration: const Duration(seconds: 1));
}
}
/// Cycle to the next audio track and save the preference.
void cycleAudioTrack() {
final tracks = player.state.tracks.audio.where((t) => t.id != 'auto' && t.id != 'no').toList();
if (tracks.length <= 1) return;
final current = player.state.track.audio;
final currentIndex = tracks.indexWhere((t) => t.id == current?.id);
final nextIndex = (currentIndex + 1) % tracks.length;
final next = tracks[nextIndex];
player.selectAudioTrack(next);
unawaited(onAudioTrackSelectedByUser(next));
if (isActive()) {
final label =
'Audio: ${TrackLabelBuilder.audioLabel(title: next.title, language: next.language, codec: next.codec, channels: next.channelsCount, index: nextIndex).joined}';
showMessage?.call(label, duration: const Duration(seconds: 1));
}
}
// ── 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.
Future<void> onAudioTrackChanged(AudioTrack track) async {
final info = mediaInfo;
final partId = await _guardTrackChange(info);
if (partId == null || info == null) return;
final matchedPlex = findPlexTrackForMpvAudio(track, info.audioTracks, allMpvTracks: player.state.tracks.audio);
final streamID = matchedPlex?.id;
if (streamID != null) {
appLogger.d('Matched audio to streamID $streamID');
} else {
appLogger.e('Could not match audio track to any Plex track');
}
await _saveTrackPreferences(partId: partId, trackType: 'audio', streamID: streamID);
}
/// Handle subtitle track changes — save stream selection and language preference.
Future<void> onSubtitleTrackChanged(SubtitleTrack track, {int? sourceStreamId}) async {
final info = mediaInfo;
final partId = await _guardTrackChange(info);
if (partId == null) return;
int? streamID;
if (track.id == 'no') {
streamID = 0;
appLogger.i('User turned subtitles off, saving preference');
} else if (sourceStreamId != null) {
streamID = sourceStreamId;
appLogger.d('Using authoritative subtitle streamID $streamID');
} else if (info != null) {
final matchedPlex = findPlexTrackForMpvSubtitle(
track,
info.subtitleTracks,
allMpvTracks: player.state.tracks.subtitle,
);
streamID = matchedPlex?.id;
if (streamID != null) {
appLogger.d('Matched subtitle to streamID $streamID');
} else {
appLogger.e('Could not match subtitle track to any Plex track');
}
}
await _saveTrackPreferences(partId: partId, trackType: 'subtitle', streamID: streamID);
}
/// Handle secondary subtitle track changes — no server save needed.
void onSecondarySubtitleTrackChanged(SubtitleTrack track) {
// Secondary subtitle preference is carried via player.state.track.secondarySubtitle
// which is automatically read during episode navigation. No additional state needed.
}
// ── Private helpers ────────────────────────────────────────────────
/// Common guard checks for track change handlers.
Future<int?> _guardTrackChange(MediaSourceInfo? info) async {
final settings = await SettingsService.getInstance();
if (!settings.read(SettingsService.rememberTrackSelections)) return null;
if (persistTrackPreference == null) return null;
if (info == null) {
appLogger.w('No media info available, cannot save stream selection');
return null;
}
final partId = info.getPartId();
if (partId == null) {
appLogger.w('No part ID available, cannot save stream selection');
}
return partId;
}
/// Save the stream selection for the current part to the server.
Future<void> _saveTrackPreferences({required int partId, required String trackType, int? streamID}) async {
try {
if (!isActive()) return;
final persist = persistTrackPreference;
if (persist == null) {
return;
}
await persist(partId: partId, trackType: trackType, streamID: streamID);
appLogger.d('Successfully saved $trackType stream selection');
} catch (e) {
appLogger.e('Failed to save $trackType stream selection', error: e);
}
}
/// Clean up subscriptions.
void dispose() {
if (_disposed) return;
_disposed = true;
invalidatePendingSelection();
_externalSubtitleAddsInFlight = false;
_subtitleFallbackTimer?.cancel();
_subtitleFallbackTimer = null;
}
}