Files
plezy/lib/services/track_manager.dart
T

458 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import '../mpv/mpv.dart';
import '../models/plex_media_info.dart';
import '../models/plex_metadata.dart';
import '../services/plex_client.dart';
import '../services/settings_service.dart';
import '../services/track_selection_service.dart';
import '../models/plex_user_profile.dart';
import '../utils/app_logger.dart';
import '../utils/content_utils.dart';
import '../utils/language_codes.dart';
import '../utils/track_label_builder.dart';
/// 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;
/// Resolves the Plex API client for the current server.
final PlexClient Function() getClient;
/// Resolves the user's profile settings (may be null during loading).
final PlexUserProfile? 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) ──────────
PlexMetadata metadata;
PlexMediaInfo? mediaInfo;
AudioTrack? preferredAudioTrack;
SubtitleTrack? preferredSubtitleTrack;
SubtitleTrack? preferredSecondarySubtitleTrack;
// ── Internal state ─────────────────────────────────────────────────
bool waitingForExternalSubsTrackSelection = false;
bool _isApplyingTrackSelection = false;
List<SubtitleTrack> _lastExternalSubtitles = const [];
StreamSubscription<Tracks>? _trackLoadingSubscription;
Timer? _subtitleFallbackTimer;
/// Cached external subtitles for re-use after backend fallback.
List<SubtitleTrack> get lastExternalSubtitles => _lastExternalSubtitles;
TrackManager({
required this.player,
required this.isActive,
required this.getClient,
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: false,
);
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 (currentTracks.audio.isNotEmpty || currentTracks.subtitle.isNotEmpty) {
applyTrackSelection();
} else {
_trackLoadingSubscription?.cancel();
_trackLoadingSubscription = player.streams.tracks.listen((tracks) {
if (tracks.audio.isEmpty && tracks.subtitle.isEmpty) return;
_trackLoadingSubscription?.cancel();
_trackLoadingSubscription = null;
applyTrackSelection();
});
}
}
/// 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 ────────────────────────────────────────────────
/// Rating key used for series/movie level language preferences.
String get _preferenceRatingKey {
return metadata.isEpisode ? (metadata.grandparentRatingKey ?? metadata.ratingKey) : metadata.ratingKey;
}
/// Common guard checks for track change handlers.
Future<int?> _guardTrackChange(PlexMediaInfo? info) async {
final settings = await SettingsService.getInstance();
if (!settings.read(SettingsService.rememberTrackSelections)) 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 client = getClient();
final ratingKey = _preferenceRatingKey;
final futures = <Future>[];
if (languageCode != null && (trackType == 'subtitle' || languageCode.isNotEmpty)) {
futures.add(
trackType == 'audio'
? client.setMetadataPreferences(ratingKey, audioLanguage: languageCode)
: client.setMetadataPreferences(ratingKey, subtitleLanguage: languageCode),
);
}
if (streamID != null) {
futures.add(
trackType == 'audio'
? client.selectStreams(partId, audioStreamID: streamID, allParts: true)
: client.selectStreams(partId, subtitleStreamID: streamID, allParts: true),
);
}
await Future.wait(futures);
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 = _iso6391ToPlex6392(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 Plex's 639-2 code (e.g. "fre").
static String? _iso6391ToPlex6392(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;
}
}