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/subtitle_preference.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 Function({required int partId, required String trackType, required 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 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; SubtitlePreference? preferredSubtitleTrack; SubtitlePreference? preferredSecondarySubtitleTrack; // ── Internal state ───────────────────────────────────────────────── bool waitingForExternalSubsTrackSelection = false; bool _externalSubtitleAddsInFlight = false; bool _isApplyingTrackSelection = false; Completer? _selectionIdleCompleter; Future? _activePlayerMutationDrain; List _lastExternalSubtitles = const []; StreamSubscription? _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 mutation) { final drain = mutation.then((_) {}, 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 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 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 addExternalSubtitles(List externalSubtitles, {Future? 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 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 invalidatePendingSelection() { final activePlayerMutationDrain = _activePlayerMutationDrain; _selectionGeneration++; _trackLoadingSubscription?.cancel(); _trackLoadingSubscription = null; _trackSelectionFallbackTimer?.cancel(); _trackSelectionFallbackTimer = null; return activePlayerMutationDrain ?? Future.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. The deadline pass /// resolves the subtitle from whatever has arrived rather than deferring /// again, so a source the native player never exposes ends as an explicit /// decision instead of silently leaving subtitles untouched. /// /// 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(waitForPendingSource: false)); }); }); } 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. /// /// Pass `waitForPendingSource: false` from a deadline pass so an advertised /// subtitle that never materialized resolves to the best available choice /// instead of deferring forever. Future applyTrackSelection({bool waitForPendingSource = true}) 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(waitForPendingSource: waitForPendingSource); } _isApplyingTrackSelection = true; final idleCompleter = Completer(); _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, waitForPendingSource: waitForPendingSource, ); } 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 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 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. Future 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 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 _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. /// /// A null [streamID] means no server stream could be identified for the /// chosen track. There is no local fallback store, so the choice is simply /// lost — say so instead of reporting a save that never happened. Future _saveTrackPreferences({required int partId, required String trackType, int? streamID}) async { if (streamID == null) { appLogger.w('Not saving $trackType stream selection: no server stream matched the selected track'); return; } 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; } }