From 2d37d37596f72cf20f74bc688cc80b5d45a1f74d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 20 Mar 2026 03:17:10 +0100 Subject: [PATCH] refactor: extract TrackManager, fix subtitle bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract track lifecycle logic from VideoPlayerScreen (3248→2928 lines) into TrackManager - Parse isDefault/isForced for subtitle tracks in parseTrackList - Emit forced flag from ExoPlayer's emitTrackList - Fix detectSubtitleMimeType failing on URLs with query params - Add missing external_ ID prefix in ExoPlayer addSubtitleTrack - Replace polling loop with stream in selectAndApplyTracks - Handle play() failure in resumeAfterSubtitleLoad - Handle addExternalSubtitles failure in onBackendSwitched --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 122 ++--- .../plezy/exoplayer/ExoPlayerPlugin.kt | 4 +- lib/mpv/player/platform/player_android.dart | 7 +- lib/mpv/player/player.dart | 2 +- lib/mpv/player/player_base.dart | 2 + lib/mpv/player/player_native.dart | 2 +- lib/screens/video_player_screen.dart | 412 ++-------------- lib/services/track_manager.dart | 443 ++++++++++++++++++ lib/services/track_selection_service.dart | 15 +- 9 files changed, 562 insertions(+), 447 deletions(-) create mode 100644 lib/services/track_manager.dart diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 98547497..3b03ebab 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -153,13 +153,13 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // External subtitles added dynamically private val externalSubtitles = mutableListOf() + private val externalSubtitleUris = mutableListOf() private var currentMediaUri: String? = null private var currentHeaders: Map? = null private var currentMediaIsLive: Boolean = false private var currentVisible: Boolean = false private var selectedAudioTrackId: String? = null private var selectedSubtitleTrackId: String? = null - private var selectedExternalSubtitleIndex: Int? = null private val audioTrackGroupMap = mutableMapOf() private val subtitleTrackGroupMap = mutableMapOf() @@ -822,7 +822,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } - // Process subtitle tracks + // Process subtitle tracks (embedded + side-loaded external) Log.d(TAG, "emitTrackList: found ${textGroups.size} subtitle track groups") textGroups.forEachIndexed { groupIndex, group -> val trackGroup = group.mediaTrackGroup @@ -831,7 +831,12 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { subtitleTrackGroupMap[trackId] = trackGroup val isSelected = group.isSelected - Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected") + // Detect external (side-loaded) subtitle by the ID prefix set in open() + val isExternal = format.id?.startsWith("external_") == true + val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null + val externalUri = externalIndex?.takeIf { it in externalSubtitleUris.indices }?.let { externalSubtitleUris[it] } + + Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal") val track = mutableMapOf( "type" to "sub", @@ -840,8 +845,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { "lang" to format.language, "codec" to format.codecs, "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0), + "forced" to (format.selectionFlags and C.SELECTION_FLAG_FORCED != 0), "selected" to isSelected, - "external" to false + "external" to isExternal, + "external-filename" to externalUri ) trackList.add(track) @@ -874,31 +881,12 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { delegate?.onPropertyChange("aid", selectedAudioId) } - val effectiveSubtitleId = when { - selectedExternalSubtitleIndex != null -> "ext_sub_$selectedExternalSubtitleIndex" - selectedSubId != null -> selectedSubId - textGroups.isNotEmpty() || externalSubtitles.isNotEmpty() -> "no" - else -> null - } - if (effectiveSubtitleId != null) { - selectedSubtitleTrackId = effectiveSubtitleId - delegate?.onPropertyChange("sid", effectiveSubtitleId) - } - - // Add external subtitles to track list - externalSubtitles.forEachIndexed { index, subtitle -> - val extTrackId = "ext_sub_$index" - trackList.add(mapOf( - "type" to "sub", - "id" to extTrackId, - "title" to (subtitle.label ?: "External"), - "lang" to subtitle.language, - "codec" to subtitle.mimeType, - "default" to false, - "selected" to (selectedExternalSubtitleIndex == index), - "external" to true, - "external-filename" to subtitle.uri.toString() - )) + if (selectedSubId != null) { + selectedSubtitleTrackId = selectedSubId + delegate?.onPropertyChange("sid", selectedSubId) + } else if (textGroups.isNotEmpty()) { + selectedSubtitleTrackId = "no" + delegate?.onPropertyChange("sid", "no") } delegate?.onPropertyChange("track-list", trackList) @@ -1026,8 +1014,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val mediaItemBuilder = MediaItem.Builder() .setUri(uri) - selectedExternalSubtitleIndex?.takeIf { it in externalSubtitles.indices }?.let { subtitleIndex -> - mediaItemBuilder.setSubtitleConfigurations(listOf(externalSubtitles[subtitleIndex])) + if (externalSubtitles.isNotEmpty()) { + mediaItemBuilder.setSubtitleConfigurations(externalSubtitles.toList()) } return mediaItemBuilder.build() @@ -1173,7 +1161,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // Public API - fun open(uri: String, headers: Map?, startPositionMs: Long, autoPlay: Boolean, isLive: Boolean = false) { + fun open(uri: String, headers: Map?, startPositionMs: Long, autoPlay: Boolean, isLive: Boolean = false, + externalSubtitleList: List>? = null) { if (!isInitialized) return stopFrameWatchdog() @@ -1196,11 +1185,24 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { ) externalSubtitles.clear() + externalSubtitleUris.clear() audioTrackGroupMap.clear() subtitleTrackGroupMap.clear() - selectedExternalSubtitleIndex = null selectedAudioTrackId = null selectedSubtitleTrackId = null + + // Build external subtitle configurations (attached to MediaItem before prepare) + externalSubtitleList?.forEachIndexed { index, sub -> + val subUri = sub["uri"] ?: return@forEachIndexed + val config = MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUri)) + .setId("external_$index") + .setLabel(sub["title"] ?: "External") + .setLanguage(sub["language"]) + .setMimeType(sub["mimeType"] ?: detectSubtitleMimeType(subUri)) + .build() + externalSubtitles.add(config) + externalSubtitleUris.add(subUri) + } tunnelingDisabledForAudioCodec = false tunnelingDisabledForVideoCodec = false currentTunneledPlayback = tunnelingUserEnabled @@ -1295,7 +1297,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val selector = trackSelector ?: return if (trackId == null || trackId == "no") { - selectedExternalSubtitleIndex = null selectedSubtitleTrackId = "no" selector.parameters = selector.buildUponParameters() .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) @@ -1304,18 +1305,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { return } - if (trackId.startsWith("ext_sub_")) { - val index = trackId.removePrefix("ext_sub_").toIntOrNull() ?: return - if (index >= 0 && index < externalSubtitles.size) { - selectedExternalSubtitleIndex = index - selectedSubtitleTrackId = trackId - reloadWithExternalSubtitle(index) - return - } - } - val trackGroup = subtitleTrackGroupMap[trackId] ?: return - selectedExternalSubtitleIndex = null selectedSubtitleTrackId = trackId selector.parameters = selector.buildUponParameters() .setOverrideForType(TrackSelectionOverride(trackGroup, 0)) @@ -1324,48 +1314,33 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { delegate?.onPropertyChange("sid", trackId) } - private fun reloadWithExternalSubtitle(subtitleIndex: Int) { - val uri = currentMediaUri ?: return - val player = exoPlayer ?: return - - val currentPosition = player.currentPosition - val shouldResume = player.playWhenReady - selectedExternalSubtitleIndex = subtitleIndex - selectedSubtitleTrackId = "ext_sub_$subtitleIndex" - val mediaItem = buildMediaItem(uri) - - player.setMediaItem(mediaItem, currentPosition) - player.prepare() - player.playWhenReady = shouldResume - - delegate?.onPropertyChange("sid", "ext_sub_$subtitleIndex") - } - fun addSubtitleTrack(uri: String, title: String?, language: String?, mimeType: String?, select: Boolean) { + val index = externalSubtitles.size val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(uri)) + .setId("external_$index") .setLabel(title ?: "External") .setLanguage(language) .setMimeType(mimeType ?: detectSubtitleMimeType(uri)) - .setSelectionFlags(if (select) C.SELECTION_FLAG_DEFAULT else 0) .build() externalSubtitles.add(subtitleConfig) + externalSubtitleUris.add(uri) - // Emit updated track list + // Note: ExoPlayer won't see these until the media is reloaded. + // On Android, external subs are normally passed at open() time. + // This path is only reached if the Flutter layer calls addSubtitleTrack + // while ExoPlayer (not MPV fallback) is active. emitTrackList() - - if (select) { - selectSubtitleTrack("ext_sub_${externalSubtitles.size - 1}") - } } private fun detectSubtitleMimeType(uri: String): String { - val lowerUri = uri.lowercase() + // Strip query params before checking extension (Plex URLs have ?X-Plex-Token=...) + val path = Uri.parse(uri).path?.lowercase() ?: uri.lowercase() return when { - lowerUri.endsWith(".srt") -> MimeTypes.APPLICATION_SUBRIP - lowerUri.endsWith(".ass") || lowerUri.endsWith(".ssa") -> MimeTypes.TEXT_SSA - lowerUri.endsWith(".vtt") -> MimeTypes.TEXT_VTT - lowerUri.endsWith(".ttml") -> MimeTypes.APPLICATION_TTML + path.endsWith(".srt") -> MimeTypes.APPLICATION_SUBRIP + path.endsWith(".ass") || path.endsWith(".ssa") -> MimeTypes.TEXT_SSA + path.endsWith(".vtt") -> MimeTypes.TEXT_VTT + path.endsWith(".ttml") -> MimeTypes.APPLICATION_TTML else -> MimeTypes.APPLICATION_SUBRIP } } @@ -1600,7 +1575,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { emitSeekable(false, force = true) selectedAudioTrackId = null selectedSubtitleTrackId = null - selectedExternalSubtitleIndex = null audioTrackGroupMap.clear() subtitleTrackGroupMap.clear() exoPlayer?.clearVideoSurface() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index bcff0afa..b8e89bd1 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -214,12 +214,14 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } ?: result.success(null) } + @Suppress("UNCHECKED_CAST") private fun handleOpen(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri") val headers = call.argument>("headers") val startPositionMs = call.argument("startPositionMs")?.toLong() ?: 0L val autoPlay = call.argument("autoPlay") ?: true val isLive = call.argument("isLive") ?: false + val externalSubtitles = call.argument>>("externalSubtitles") if (uri == null) { result.error("INVALID_ARGS", "Missing 'uri'", null) @@ -249,7 +251,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) } } else { - playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive) + playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive, externalSubtitles) } result.success(null) } ?: result.error("NO_ACTIVITY", "Activity not available", null) diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index d32772e3..8731f512 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -89,7 +89,7 @@ class PlayerAndroid extends PlayerBase { // ============================================ @override - Future open(Media media, {bool play = true, bool isLive = false}) async { + Future open(Media media, {bool play = true, bool isLive = false, List? externalSubtitles}) async { if (disposed) return; await _ensureInitialized(); setSeekable(false); @@ -103,6 +103,11 @@ class PlayerAndroid extends PlayerBase { 'startPositionMs': media.start?.inMilliseconds ?? 0, 'autoPlay': play, 'isLive': isLive, + if (externalSubtitles != null && externalSubtitles.isNotEmpty) + 'externalSubtitles': externalSubtitles + .where((s) => s.uri != null) + .map((s) => {'uri': s.uri, 'title': s.title, 'language': s.language}) + .toList(), }); } diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 3fc33759..c2ca4b46 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -61,7 +61,7 @@ abstract class Player { /// /// [media] - The media source to open. /// [play] - Whether to start playback immediately (default: true). - Future open(Media media, {bool play = true, bool isLive = false}); + Future open(Media media, {bool play = true, bool isLive = false, List? externalSubtitles}); /// Start or resume playback. Future play(); diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 89e4498a..1b983ec9 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -414,6 +414,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { title: track['title'] as String?, language: track['lang'] as String?, codec: track['codec'] as String?, + isDefault: track['default'] as bool? ?? false, + isForced: track['forced'] as bool? ?? false, isExternal: track['external'] as bool? ?? false, uri: track['external-filename'] as String?, ), diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 11a52823..badfa3bc 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -91,7 +91,7 @@ class PlayerNative extends PlayerBase { } @override - Future open(Media media, {bool play = true, bool isLive = false}) async { + Future open(Media media, {bool play = true, bool isLive = false, List? externalSubtitles}) async { if (disposed) return; await _ensureInitialized(); setSeekable(false); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index ce2bb848..5774a58d 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -42,7 +42,7 @@ import '../services/playback_progress_tracker.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/settings_service.dart'; import '../services/sleep_timer_service.dart'; -import '../services/track_selection_service.dart'; +import '../services/track_manager.dart'; import '../services/ambient_lighting_service.dart'; import '../services/video_filter_manager.dart'; import '../services/video_pip_manager.dart'; @@ -57,9 +57,7 @@ import '../utils/player_utils.dart'; import '../utils/orientation_helper.dart'; import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; -import '../utils/language_codes.dart'; import '../utils/snackbar_helper.dart'; -import '../utils/track_label_builder.dart'; import '../utils/plex_url_helper.dart'; import '../utils/video_player_navigation.dart'; import '../widgets/overlay_sheet.dart'; @@ -150,10 +148,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin StreamSubscription? _completedSubscription; StreamSubscription? _mediaControlSubscription; StreamSubscription? _bufferingSubscription; - StreamSubscription? _trackLoadingSubscription; StreamSubscription? _positionSubscription; StreamSubscription? _playbackRestartSubscription; StreamSubscription? _backendSwitchedSubscription; + TrackManager? _trackManager; StreamSubscription? _logSubscription; StreamSubscription? _sleepTimerSubscription; StreamSubscription? _mediaControlsPlayingSubscription; @@ -163,10 +161,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin StreamSubscription>? _serverStatusSubscription; bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation bool _isDisposingForNavigation = false; - bool _waitingForExternalSubsTrackSelection = false; - bool _isApplyingTrackSelection = false; bool _isHandlingBack = false; - List _lastExternalSubtitles = const []; BifThumbnailService? _bifService; // Live TV channel navigation @@ -379,29 +374,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - /// Converts a 2-letter code like "fr", "nl", "ca" to a Plex 3-letter code, or returns null if unknown - String? _iso6391ToPlex6392(String? code) { - if (code == null || code.isEmpty) return null; - // Takes the base "fr" from "fr-FR" - final lang = code.split('-').first.toLowerCase(); - - // Use LanguageCodes utility to get variations and find the 639-2 code - try { - final variations = LanguageCodes.getVariations(lang); - // The getVariations method returns all variations including 639-2 codes - // We need to find the 3-letter code from the variations - for (final variation in variations) { - if (variation.length == 3) { - return variation; - } - } - return null; - } catch (e) { - // If LanguageCodes is not initialized or fails, return null - return null; - } - } - Future _initializePlayer() async { try { // Load buffer size from settings @@ -633,10 +605,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin await _applyFrameRateMatching(); } } - if (_waitingForExternalSubsTrackSelection) { - _waitingForExternalSubsTrackSelection = false; - _applyTrackSelection(); - } + _trackManager?.onPlaybackRestart(); }); // Listen to position for completion detection (fallback for unreliable MPV events) @@ -714,29 +683,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - /// Add external subtitle tracks to the player - Future _addExternalSubtitles(List externalSubtitles) async { - if (player == null || externalSubtitles.isEmpty) return; - - appLogger.d('Adding ${externalSubtitles.length} external subtitle(s) to player'); - - for (final subtitleTrack in externalSubtitles) { - if (subtitleTrack.uri == null) continue; - - try { - await player!.addSubtitleTrack( - uri: subtitleTrack.uri!, - title: subtitleTrack.title, - language: subtitleTrack.language, - select: false, // Don't auto-select - ); - appLogger.d('Added external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}'); - } catch (e) { - appLogger.w('Failed to add external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}', error: e); - } - } - } - /// Initialize the service layer Future _initializeServices() async { if (!mounted || player == null) return; @@ -1015,7 +961,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _livePlaybackStartTime = DateTime.now(); await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); - _lastExternalSubtitles = const []; + _trackManager?.cacheExternalSubtitles(const []); if (mounted) { setState(() { @@ -1023,6 +969,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _currentMediaInfo = null; _isPlayerInitialized = true; }); + _trackManager?.mediaInfo = null; } _startLiveTimelineUpdates(); @@ -1091,13 +1038,16 @@ class VideoPlayerScreenState extends State with WidgetsBindin 'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600'); } - // If we have external subtitles, open paused to add them before playback starts. - // This prevents a race condition on Android where adding subtitle tracks - // during active playback can freeze the video decoder (issue #226). final hasExternalSubs = result.externalSubtitles.isNotEmpty; + final isAndroid = Platform.isAndroid; + + // On Android, attach external subs at open time so ExoPlayer discovers + // them in a single prepare() — no media reload needed for selection. + // On other platforms (MPV), external subs are added after open via sub-add. await player!.open( Media(result.videoUrl!, start: resumePosition, headers: plexHeaders), - play: !hasExternalSubs, + play: isAndroid || !hasExternalSubs, + externalSubtitles: isAndroid && hasExternalSubs ? result.externalSubtitles : null, ); // Apply subtitle styling to ExoPlayer native layer (CaptionStyleCompat + libass font scale) @@ -1189,37 +1139,41 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - // Store external subtitles for re-use after backend fallback - _lastExternalSubtitles = result.externalSubtitles; + // Track manager: owns track selection, external subtitle loading, and server sync + _trackManager = TrackManager( + player: player!, + isActive: () => mounted && player != null, + getClient: () => _getClientForMetadata(context), + getProfileSettings: () => context.read().profileSettings, + waitForProfileSettings: _waitForProfileSettingsIfNeeded, + metadata: widget.metadata, + mediaInfo: _currentMediaInfo, + preferredAudioTrack: widget.preferredAudioTrack, + preferredSubtitleTrack: widget.preferredSubtitleTrack, + preferredSecondarySubtitleTrack: widget.preferredSecondarySubtitleTrack, + showMessage: (message, {duration}) { + if (mounted) showAppSnackBar(context, message, duration: duration); + }, + ); - // Add external subtitles while paused, then start playback - if (result.externalSubtitles.isNotEmpty) { + // Store external subtitles for re-use after backend fallback + _trackManager!.cacheExternalSubtitles(result.externalSubtitles); + + // Non-Android with external subs: add after open via sub-add (MPV), + // opened paused to avoid race condition (issue #226) + if (!Platform.isAndroid && result.externalSubtitles.isNotEmpty) { _hasFirstFrame.value = false; - _waitingForExternalSubsTrackSelection = true; + _trackManager!.waitingForExternalSubsTrackSelection = true; try { - await _addExternalSubtitles(result.externalSubtitles); + await _trackManager!.addExternalSubtitles(result.externalSubtitles); } finally { - await _resumeAfterSubtitleLoad(); + await _trackManager!.resumeAfterSubtitleLoad(); } } else { - // Check state first — it's always up to date even if broadcast stream - // events were missed (happens on fast devices where ExoPlayer prepares - // during the await points between open() and here). - // Fall back to stream subscription for slower devices. - 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(); - }); - } + // Android (subs attached at open time) or no external subs: + // apply once tracks are available + _trackManager!.applyTrackSelectionWhenReady(); } } } on PlaybackException catch (e) { @@ -1235,27 +1189,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - /// Resume playback after external subtitles have been loaded (or failed to load). - Future _resumeAfterSubtitleLoad() async { - if (player == null || !mounted) return; - - 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); - } - - // Fallback if playbackRestart doesn't fire - Future.delayed(const Duration(seconds: 3), () { - if (_waitingForExternalSubsTrackSelection && mounted) { - _waitingForExternalSubsTrackSelection = false; - _applyTrackSelection(); - } - }); - } - /// Start playback for offline/downloaded content Future _startOfflinePlayback() async { final downloadProvider = context.read(); @@ -1658,45 +1591,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin _companionRemoteProvider = null; } - void _cycleSubtitleTrack() { - if (player == null) return; - final tracks = player!.state.tracks.subtitle.where((t) => t.id != 'auto').toList(); - if (tracks.isEmpty) return; + void _cycleSubtitleTrack() => _trackManager?.cycleSubtitleTrack(); - final current = player!.state.track.subtitle; - // tracks includes 'no' (off). Find current index and advance. - 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 (mounted) { - final label = next.id == 'no' - ? 'Subtitles: Off' - : 'Subtitles: ${TrackLabelBuilder.buildSubtitleLabel(title: next.title, language: next.language, codec: next.codec, index: nextIndex)}'; - showAppSnackBar(context, label, duration: const Duration(seconds: 1)); - } - } - - void _cycleAudioTrack() { - if (player == null) return; - 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 (mounted) { - final label = - 'Audio: ${TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; - showAppSnackBar(context, label, duration: const Duration(seconds: 1)); - } - } + void _cycleAudioTrack() => _trackManager?.cycleAudioTrack(); Future _toggleFullscreen() async { if (PlatformDetector.isMobile(context)) return; @@ -1812,7 +1709,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _errorSubscription?.cancel(); _mediaControlSubscription?.cancel(); _bufferingSubscription?.cancel(); - _trackLoadingSubscription?.cancel(); + _trackManager?.dispose(); _positionSubscription?.cancel(); _playbackRestartSubscription?.cancel(); _backendSwitchedSubscription?.cancel(); @@ -1991,34 +1888,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Handle notification when native player switched from ExoPlayer to MPV Future _onBackendSwitched() async { - appLogger.i('Player backend switched from ExoPlayer to MPV (native fallback)'); - if (mounted) { showAppSnackBar(context, t.messages.switchingToCompatiblePlayer); } - // Re-add external subtitles to MPV (lost when ExoPlayer was disposed). - // Must await so they're in the track list before we apply selection. - if (_lastExternalSubtitles.isNotEmpty) { - await _addExternalSubtitles(_lastExternalSubtitles); - } - - if (player == null) return; - - // Check state first, fall back to stream subscription (same pattern as _startPlayback) - 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(); - }); - } + await _trackManager?.onBackendSwitched(); } // OS Media Controls Integration @@ -2385,203 +2259,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - /// Apply track selection using the TrackSelectionService - Future _applyTrackSelection() async { - if (!mounted || player == null || _isApplyingTrackSelection) return; + Future _onAudioTrackChanged(AudioTrack track) async => _trackManager?.onAudioTrackChanged(track); - _isApplyingTrackSelection = true; - try { - await _waitForProfileSettingsIfNeeded(); - if (!mounted || player == null) return; + Future _onSubtitleTrackChanged(SubtitleTrack track) async => _trackManager?.onSubtitleTrackChanged(track); - final profileSettings = context.read().profileSettings; - final settingsService = await SettingsService.getInstance(); - if (!mounted || player == null) return; - - final trackService = TrackSelectionService( - player: player!, - profileSettings: profileSettings, - metadata: widget.metadata, - plexMediaInfo: _currentMediaInfo, - ); - - await trackService.selectAndApplyTracks( - preferredAudioTrack: widget.preferredAudioTrack, - preferredSubtitleTrack: widget.preferredSubtitleTrack, - preferredSecondarySubtitleTrack: widget.preferredSecondarySubtitleTrack, - defaultPlaybackSpeed: settingsService.getDefaultPlaybackSpeed(), - onAudioTrackChanged: _onAudioTrackChanged, - onSubtitleTrackChanged: _onSubtitleTrackChanged, - ); - } catch (e) { - appLogger.w('Failed to apply track selection', error: e); - } finally { - _isApplyingTrackSelection = false; - } - } - - /// Rating key used for series/movie level language preferences. - String get _preferenceRatingKey { - return widget.metadata.isEpisode - ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) - : widget.metadata.ratingKey; - } - - /// Common guard checks for track change handlers. - /// Returns the part ID if all checks pass, or null if the change should be skipped. - Future _guardTrackChange() async { - final settings = await SettingsService.getInstance(); - if (!settings.getRememberTrackSelections()) return null; - - if (_currentMediaInfo == null) { - appLogger.w('No media info available, cannot save stream selection'); - return null; - } - - final partId = _currentMediaInfo!.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 _saveTrackPreferences({ - required int partId, - required String trackType, - String? languageCode, - int? streamID, - }) async { - try { - if (!mounted) return; - final client = _getClientForMetadata(context); - final ratingKey = _preferenceRatingKey; - - final futures = []; - - 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({ - required String? mpvLanguage, - required String? mpvTitle, - required List 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; - } - - /// Handle audio track changes from the user - save both stream selection and language preference - Future _onAudioTrackChanged(AudioTrack track) async { - final partId = await _guardTrackChange(); - if (partId == null) return; - - int? streamID = _matchTrackByAttributes( - mpvLanguage: track.language, - mpvTitle: track.title, - plexTracks: _currentMediaInfo!.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, _currentMediaInfo!.audioTracks); - 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 from the user - save both stream selection and language preference - Future _onSubtitleTrackChanged(SubtitleTrack track) async { - final partId = await _guardTrackChange(); - 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 { - languageCode = track.language; - - streamID = _matchTrackByAttributes( - mpvLanguage: track.language, - mpvTitle: track.title, - plexTracks: _currentMediaInfo!.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, _currentMediaInfo!.subtitleTracks); - 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, just preserve for episode navigation - 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. - } + void _onSecondarySubtitleTrackChanged(SubtitleTrack track) => _trackManager?.onSecondarySubtitleTrackChanged(track); /// Set flag to skip orientation restoration when replacing with another video void setReplacingWithVideo() { diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart new file mode 100644 index 00000000..6e5b14fd --- /dev/null +++ b/lib/services/track_manager.dart @@ -0,0 +1,443 @@ +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 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 _lastExternalSubtitles = const []; + StreamSubscription? _trackLoadingSubscription; + + /// Cached external subtitles for re-use after backend fallback. + List 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 externalSubtitles) { + _lastExternalSubtitles = externalSubtitles; + } + + /// Add external subtitle tracks to the player one by one. + Future addExternalSubtitles(List externalSubtitles) async { + if (externalSubtitles.isEmpty) return; + + appLogger.d('Adding ${externalSubtitles.length} external subtitle(s) to player'); + + for (final subtitleTrack in externalSubtitles) { + if (subtitleTrack.uri == null) continue; + + 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 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; + applyTrackSelection(); + return; + } + + // Fallback if playbackRestart doesn't fire + Future.delayed(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 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.getDefaultPlaybackSpeed(), + 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 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 onAudioTrackChanged(AudioTrack track) async { + final partId = await _guardTrackChange(); + if (partId == null) return; + + int? streamID = _matchTrackByAttributes( + mpvLanguage: track.language, + mpvTitle: track.title, + plexTracks: mediaInfo!.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, mediaInfo!.audioTracks); + 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 onSubtitleTrackChanged(SubtitleTrack track) async { + final partId = await _guardTrackChange(); + 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 { + languageCode = track.language; + + streamID = _matchTrackByAttributes( + mpvLanguage: track.language, + mpvTitle: track.title, + plexTracks: mediaInfo!.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, mediaInfo!.subtitleTracks); + 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 _guardTrackChange() async { + final settings = await SettingsService.getInstance(); + if (!settings.getRememberTrackSelections()) return null; + + if (mediaInfo == null) { + appLogger.w('No media info available, cannot save stream selection'); + return null; + } + + final partId = mediaInfo!.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 _saveTrackPreferences({ + required int partId, + required String trackType, + String? languageCode, + int? streamID, + }) async { + try { + if (!isActive()) return; + final client = getClient(); + final ratingKey = _preferenceRatingKey; + + final futures = []; + + 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({ + required String? mpvLanguage, + required String? mpvTitle, + required List 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; + } +} diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 2c6aca92..92f0ac82 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import '../mpv/mpv.dart'; import '../models/plex_media_info.dart'; @@ -640,10 +642,15 @@ class TrackSelectionService { Function(SubtitleTrack)? onSubtitleTrackChanged, }) async { // Wait for tracks to be loaded - int attempts = 0; - while (player.state.tracks.audio.isEmpty && player.state.tracks.subtitle.isEmpty && attempts < 100) { - await Future.delayed(const Duration(milliseconds: 100)); - attempts++; + if (player.state.tracks.audio.isEmpty && player.state.tracks.subtitle.isEmpty) { + try { + await player.streams.tracks + .where((t) => t.audio.isNotEmpty || t.subtitle.isNotEmpty) + .first + .timeout(const Duration(seconds: 10)); + } catch (_) { + // Timeout or stream closed — proceed with whatever state we have + } } if (player.disposed) return;