489 lines
18 KiB
Dart
489 lines
18 KiB
Dart
import 'dart:async';
|
||
|
||
import '../mpv/mpv.dart';
|
||
|
||
import '../media/media_item.dart';
|
||
import '../media/media_item_types.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/language_codes.dart';
|
||
import '../utils/track_label_builder.dart';
|
||
|
||
/// Persists a track choice through Plex's immediate preference endpoints.
|
||
/// Backends that persist through another path (Jellyfin uses playback progress
|
||
/// stream indexes) or lack server-side track preferences leave this null.
|
||
/// [trackType] is `'audio'` or `'subtitle'`.
|
||
typedef TrackPreferencePersister =
|
||
Future<void> Function({
|
||
required String id,
|
||
required int partId,
|
||
required String trackType,
|
||
String? languageCode,
|
||
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 _isApplyingTrackSelection = false;
|
||
List<SubtitleTrack> _lastExternalSubtitles = const [];
|
||
StreamSubscription<Tracks>? _trackLoadingSubscription;
|
||
Timer? _subtitleFallbackTimer;
|
||
Timer? _trackSelectionFallbackTimer;
|
||
|
||
/// Cached external subtitles for re-use after backend fallback.
|
||
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 parallel.
|
||
///
|
||
/// Each sub-add does its own HTTP fetch of the sidecar file, so sequential
|
||
/// adds dominate startup (~170ms × N). Firing them in parallel lets
|
||
/// libavformat's network IO overlap and stops Dart → method channel → native
|
||
/// round-trips from stacking.
|
||
Future<void> addExternalSubtitles(List<SubtitleTrack> externalSubtitles) async {
|
||
if (externalSubtitles.isEmpty) return;
|
||
|
||
appLogger.d('Adding ${externalSubtitles.length} external subtitle(s) to player');
|
||
|
||
await Future.wait(
|
||
externalSubtitles.where((s) => s.uri != null).map((subtitleTrack) async {
|
||
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);
|
||
}
|
||
}),
|
||
);
|
||
}
|
||
|
||
/// 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();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Track selection ────────────────────────────────────────────────
|
||
|
||
/// Apply track selection once tracks are available.
|
||
/// If tracks are not yet loaded, subscribes to the stream.
|
||
void applyTrackSelectionWhenReady() {
|
||
final currentTracks = player.state.tracks;
|
||
if (_tracksReadyForSelection(currentTracks)) {
|
||
applyTrackSelection();
|
||
} else {
|
||
_trackLoadingSubscription?.cancel();
|
||
_trackLoadingSubscription = player.streams.tracks.listen((tracks) {
|
||
if (!_tracksReadyForSelection(tracks)) return;
|
||
|
||
_trackLoadingSubscription?.cancel();
|
||
_trackLoadingSubscription = null;
|
||
_trackSelectionFallbackTimer?.cancel();
|
||
_trackSelectionFallbackTimer = null;
|
||
applyTrackSelection();
|
||
});
|
||
|
||
_trackSelectionFallbackTimer?.cancel();
|
||
_trackSelectionFallbackTimer = Timer(const Duration(seconds: 5), () {
|
||
if (!isActive()) return;
|
||
_trackLoadingSubscription?.cancel();
|
||
_trackLoadingSubscription = null;
|
||
applyTrackSelection();
|
||
});
|
||
}
|
||
}
|
||
|
||
bool _tracksReadyForSelection(Tracks tracks) {
|
||
final hasAnyTracks = tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty;
|
||
if (!hasAnyTracks) return false;
|
||
|
||
final info = mediaInfo;
|
||
if (info == null || tracks.subtitle.isNotEmpty) return true;
|
||
|
||
final expectsSelectedSubtitle = info.subtitleTracks.any((track) => track.selected);
|
||
return !expectsSelectedSubtitle;
|
||
}
|
||
|
||
/// Core track selection: delegates to [TrackSelectionService].
|
||
Future<void> applyTrackSelection() async {
|
||
if (!isActive() || _isApplyingTrackSelection) return;
|
||
|
||
_isApplyingTrackSelection = true;
|
||
try {
|
||
await waitForProfileSettings();
|
||
if (!isActive()) return;
|
||
|
||
final profileSettings = getProfileSettings();
|
||
final settingsService = await SettingsService.getInstance();
|
||
if (!isActive()) return;
|
||
|
||
final trackService = TrackSelectionService(
|
||
player: player,
|
||
profileSettings: profileSettings,
|
||
metadata: metadata,
|
||
plexMediaInfo: mediaInfo,
|
||
);
|
||
|
||
await trackService.selectAndApplyTracks(
|
||
preferredAudioTrack: preferredAudioTrack,
|
||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||
defaultPlaybackSpeed: settingsService.read(SettingsService.defaultPlaybackSpeed),
|
||
onAudioTrackChanged: onAudioTrackChanged,
|
||
onSubtitleTrackChanged: onSubtitleTrackChanged,
|
||
);
|
||
} catch (e) {
|
||
appLogger.w('Failed to apply track selection', error: e);
|
||
} finally {
|
||
_isApplyingTrackSelection = false;
|
||
}
|
||
}
|
||
|
||
/// Called when playbackRestart fires — checks the flag and applies selection.
|
||
void onPlaybackRestart() {
|
||
if (waitingForExternalSubsTrackSelection) {
|
||
waitingForExternalSubsTrackSelection = false;
|
||
applyTrackSelection();
|
||
}
|
||
}
|
||
|
||
// ── Backend fallback ───────────────────────────────────────────────
|
||
|
||
/// Handle ExoPlayer → MPV backend switch: re-add external subs and reapply selection.
|
||
Future<void> onBackendSwitched() async {
|
||
appLogger.i('Player backend switched from ExoPlayer to MPV (native fallback)');
|
||
|
||
if (_lastExternalSubtitles.isNotEmpty) {
|
||
try {
|
||
await addExternalSubtitles(_lastExternalSubtitles);
|
||
} catch (e) {
|
||
appLogger.w('Failed to re-add external subtitles after backend switch', error: e);
|
||
}
|
||
}
|
||
|
||
if (!isActive()) 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);
|
||
onSubtitleTrackChanged(next);
|
||
|
||
if (isActive()) {
|
||
final label = next.id == 'no'
|
||
? 'Subtitles: Off'
|
||
: 'Subtitles: ${TrackLabelBuilder.buildSubtitleLabel(title: next.title, language: next.language, codec: next.codec, index: nextIndex)}';
|
||
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);
|
||
onAudioTrackChanged(next);
|
||
|
||
if (isActive()) {
|
||
final label =
|
||
'Audio: ${TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}';
|
||
showMessage?.call(label, duration: const Duration(seconds: 1));
|
||
}
|
||
}
|
||
|
||
// ── 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;
|
||
|
||
int? streamID = _matchTrackByAttributes(
|
||
mpvLanguage: track.language,
|
||
mpvTitle: track.title,
|
||
plexTracks: info.audioTracks,
|
||
getLanguageCode: (t) => t.languageCode,
|
||
getDisplayTitle: (t) => t.displayTitle,
|
||
getTitle: (t) => t.title,
|
||
getId: (t) => t.id,
|
||
);
|
||
|
||
if (streamID != null) {
|
||
appLogger.d('Matched audio by lang/title: streamID $streamID');
|
||
} else {
|
||
final matchedPlex = findPlexTrackForMpvAudio(track, info.audioTracks, allMpvTracks: player.state.tracks.audio);
|
||
streamID = matchedPlex?.id;
|
||
if (streamID != null) {
|
||
appLogger.d('Matched audio by properties: streamID $streamID');
|
||
} else {
|
||
appLogger.e('Could not match audio track to any Plex track');
|
||
}
|
||
}
|
||
|
||
await _saveTrackPreferences(partId: partId, trackType: 'audio', languageCode: track.language, streamID: streamID);
|
||
}
|
||
|
||
/// Handle subtitle track changes — save stream selection and language preference.
|
||
Future<void> onSubtitleTrackChanged(SubtitleTrack track) async {
|
||
final info = mediaInfo;
|
||
final partId = await _guardTrackChange(info);
|
||
if (partId == null) return;
|
||
|
||
String? languageCode;
|
||
int? streamID;
|
||
|
||
if (track.id == 'no') {
|
||
languageCode = 'none';
|
||
streamID = 0;
|
||
appLogger.i('User turned subtitles off, saving preference');
|
||
} else if (info != null) {
|
||
languageCode = track.language;
|
||
|
||
streamID = _matchTrackByAttributes(
|
||
mpvLanguage: track.language,
|
||
mpvTitle: track.title,
|
||
plexTracks: info.subtitleTracks,
|
||
getLanguageCode: (t) => t.languageCode,
|
||
getDisplayTitle: (t) => t.displayTitle,
|
||
getTitle: (t) => t.title,
|
||
getId: (t) => t.id,
|
||
);
|
||
|
||
if (streamID != null) {
|
||
appLogger.d('Matched subtitle by lang/title: streamID $streamID');
|
||
} else {
|
||
final matchedPlex = findPlexTrackForMpvSubtitle(
|
||
track,
|
||
info.subtitleTracks,
|
||
allMpvTracks: player.state.tracks.subtitle,
|
||
);
|
||
streamID = matchedPlex?.id;
|
||
if (streamID != null) {
|
||
appLogger.d('Matched subtitle by properties: streamID $streamID');
|
||
} else {
|
||
appLogger.e('Could not match subtitle track to any Plex track');
|
||
}
|
||
}
|
||
}
|
||
|
||
await _saveTrackPreferences(partId: partId, trackType: 'subtitle', languageCode: languageCode, 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 ────────────────────────────────────────────────
|
||
|
||
/// Series/movie-level identifier used for language preferences.
|
||
String get _preferenceId {
|
||
return metadata.isEpisode ? (metadata.grandparentId ?? metadata.id) : metadata.id;
|
||
}
|
||
|
||
/// 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 language preference and stream selection to the server.
|
||
Future<void> _saveTrackPreferences({
|
||
required int partId,
|
||
required String trackType,
|
||
String? languageCode,
|
||
int? streamID,
|
||
}) async {
|
||
try {
|
||
if (!isActive()) return;
|
||
final persist = persistTrackPreference;
|
||
if (persist == null) {
|
||
return;
|
||
}
|
||
await persist(
|
||
id: _preferenceId,
|
||
partId: partId,
|
||
trackType: trackType,
|
||
languageCode: languageCode,
|
||
streamID: streamID,
|
||
);
|
||
appLogger.d('Successfully saved $trackType preferences (language + stream)');
|
||
} catch (e) {
|
||
appLogger.e('Failed to save $trackType preferences', error: e);
|
||
}
|
||
}
|
||
|
||
/// Match an mpv track against Plex tracks by language and title.
|
||
int? _matchTrackByAttributes<T>({
|
||
required String? mpvLanguage,
|
||
required String? mpvTitle,
|
||
required List<T> plexTracks,
|
||
required String? Function(T) getLanguageCode,
|
||
required String? Function(T) getDisplayTitle,
|
||
required String? Function(T) getTitle,
|
||
required int Function(T) getId,
|
||
}) {
|
||
final normalizedLang = _iso6391To6392(mpvLanguage);
|
||
|
||
for (final plexTrack in plexTracks) {
|
||
final matchLang = getLanguageCode(plexTrack) == normalizedLang;
|
||
final matchTitle = (mpvTitle == null || mpvTitle.isEmpty)
|
||
? true
|
||
: (getDisplayTitle(plexTrack) == mpvTitle || getTitle(plexTrack) == mpvTitle);
|
||
|
||
if (matchLang && matchTitle) {
|
||
return getId(plexTrack);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Convert ISO 639-1 code (e.g. "fr") to ISO 639-2/B (e.g. "fre"). Plex
|
||
/// streams use the 3-letter form.
|
||
static String? _iso6391To6392(String? code) {
|
||
if (code == null || code.isEmpty) return null;
|
||
final lang = code.split('-').first.toLowerCase();
|
||
|
||
try {
|
||
final variations = LanguageCodes.getVariations(lang);
|
||
for (final variation in variations) {
|
||
if (variation.length == 3) {
|
||
return variation;
|
||
}
|
||
}
|
||
return null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Clean up subscriptions.
|
||
void dispose() {
|
||
_trackLoadingSubscription?.cancel();
|
||
_trackLoadingSubscription = null;
|
||
_subtitleFallbackTimer?.cancel();
|
||
_subtitleFallbackTimer = null;
|
||
_trackSelectionFallbackTimer?.cancel();
|
||
_trackSelectionFallbackTimer = null;
|
||
}
|
||
}
|