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 51b6b0d6..855f5d11 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 @@ -27,6 +27,7 @@ class ExoPlayerPlugin : private const val TAG = "ExoPlayerPlugin" private const val METHOD_CHANNEL = "com.plezy/exo_player" private const val EVENT_CHANNEL = "com.plezy/exo_player/events" + private const val MPV_FALLBACK_SWITCH_TIMEOUT_MS = 15_000L } private lateinit var methodChannel: MethodChannel @@ -36,6 +37,7 @@ class ExoPlayerPlugin : private var mpvCore: MpvPlayerCore? = null // MPV fallback player private var usingMpvFallback: Boolean = false private var fallbackInProgress: Boolean = false + private var pendingMpvFallbackSwitchGeneration: Int? = null private var activity: Activity? = null private var activityBinding: ActivityPluginBinding? = null @@ -93,6 +95,7 @@ class ExoPlayerPlugin : mpvCore = null usingMpvFallback = false fallbackInProgress = false + clearPendingMpvFallbackSwitch() pendingMpvProperties.clear() activity = null activityBinding = null @@ -108,6 +111,7 @@ class ExoPlayerPlugin : override fun onDetachedFromActivityForConfigChanges() { sessionGeneration++ fallbackInProgress = false + clearPendingMpvFallbackSwitch() activity = null activityBinding = null Log.d(TAG, "Detached from activity for config changes") @@ -210,6 +214,7 @@ class ExoPlayerPlugin : mpvCore = null usingMpvFallback = false fallbackInProgress = false + clearPendingMpvFallbackSwitch() } try { @@ -246,6 +251,7 @@ class ExoPlayerPlugin : mpvCore = null usingMpvFallback = false fallbackInProgress = false + clearPendingMpvFallbackSwitch() pendingMpvProperties.clear() Log.d(TAG, "Disposed") result.success(null) @@ -747,13 +753,44 @@ class ExoPlayerPlugin : mainHandler.post { eventSink?.success(listOf(propId, value)) } } - override fun onEvent(name: String, data: Map?) { + private fun eventPayload(name: String, data: Map? = null): Map { val event = mutableMapOf( "type" to "event", "name" to name ) data?.let { event["data"] = it } - mainHandler.post { eventSink?.success(event) } + return event + } + + override fun onEvent(name: String, data: Map?) { + val pendingFallbackSwitch = + name == "file-loaded" && pendingMpvFallbackSwitchGeneration == sessionGeneration && usingMpvFallback + if (pendingFallbackSwitch) { + clearPendingMpvFallbackSwitch() + } + + val event = eventPayload(name, data) + mainHandler.post { + eventSink?.success(event) + if (pendingFallbackSwitch) { + eventSink?.success(eventPayload("backend-switched")) + } + } + } + + private fun armPendingMpvFallbackSwitch(generation: Int) { + pendingMpvFallbackSwitchGeneration = generation + mainHandler.postDelayed({ + if (pendingMpvFallbackSwitchGeneration == generation && sessionGeneration == generation && usingMpvFallback) { + Log.w(TAG, "Timed out waiting for MPV fallback file-loaded before backend-switched") + clearPendingMpvFallbackSwitch() + onEvent("backend-switched", null) + } + }, MPV_FALLBACK_SWITCH_TIMEOUT_MS) + } + + private fun clearPendingMpvFallbackSwitch() { + pendingMpvFallbackSwitchGeneration = null } /** @@ -806,6 +843,7 @@ class ExoPlayerPlugin : if (mpvCore !== core) { core.dispose() fallbackInProgress = false + clearPendingMpvFallbackSwitch() return } // Configure basic MPV properties for Plex playback @@ -840,6 +878,7 @@ class ExoPlayerPlugin : options.add("http-header-fields-append=$key: $value") } val optionsStr = options.joinToString(",") + armPendingMpvFallbackSwitch(sessionGeneration) core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) // On GPUs without compute shaders, MPV can't do dynamic peak detection @@ -858,11 +897,6 @@ class ExoPlayerPlugin : // Request audio focus core.requestAudioFocus() - // Emit backend-switched event on main thread - activity?.runOnUiThread { - onEvent("backend-switched", null) - } - Log.i(TAG, "Successfully switched to MPV fallback") } @@ -900,17 +934,20 @@ class ExoPlayerPlugin : mpvCore?.dispose() mpvCore = null usingMpvFallback = false // Clear before handoff + clearPendingMpvFallbackSwitch() val generation = sessionGeneration Handler(Looper.getMainLooper()).post { if (generation != sessionGeneration) { fallbackInProgress = false + clearPendingMpvFallbackSwitch() return@post } val act = activity if (act == null) { fallbackInProgress = false + clearPendingMpvFallbackSwitch() return@post } @@ -927,6 +964,7 @@ class ExoPlayerPlugin : mpvCore = null } fallbackInProgress = false + clearPendingMpvFallbackSwitch() return@initialize } if (!success) { @@ -935,6 +973,7 @@ class ExoPlayerPlugin : mpvCore = null } fallbackInProgress = false + clearPendingMpvFallbackSwitch() Log.e(TAG, "Failed to initialize MPV fallback") onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: $errorMessage")) return@initialize @@ -947,12 +986,14 @@ class ExoPlayerPlugin : } } catch (e: Exception) { fallbackInProgress = false + clearPendingMpvFallbackSwitch() Log.e(TAG, "Failed to switch to MPV fallback", e) onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}")) } } } catch (e: Exception) { fallbackInProgress = false + clearPendingMpvFallbackSwitch() Log.e(TAG, "Failed to switch to MPV fallback", e) onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}")) } diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 511de009..23ea3aca 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -388,6 +388,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { case 'file-loaded': _state = _state.copyWith(completed: false); completedController.add(false); + fileLoadedController.add(null); break; case 'playback-restart': diff --git a/lib/mpv/player/player_stream_controllers.dart b/lib/mpv/player/player_stream_controllers.dart index 741d79cd..baa5daa4 100644 --- a/lib/mpv/player/player_stream_controllers.dart +++ b/lib/mpv/player/player_stream_controllers.dart @@ -21,6 +21,7 @@ mixin PlayerStreamControllersMixin { final audioDevicesController = StreamController>.broadcast(); final bufferRangesController = StreamController>.broadcast(); final playbackRestartController = StreamController.broadcast(); + final fileLoadedController = StreamController.broadcast(); final backendSwitchedController = StreamController.broadcast(); PlayerStreams createStreams() { @@ -42,6 +43,7 @@ mixin PlayerStreamControllersMixin { audioDevices: audioDevicesController.stream, bufferRanges: bufferRangesController.stream, playbackRestart: playbackRestartController.stream, + fileLoaded: fileLoadedController.stream, backendSwitched: backendSwitchedController.stream, ); } @@ -64,6 +66,7 @@ mixin PlayerStreamControllersMixin { await audioDevicesController.close(); await bufferRangesController.close(); await playbackRestartController.close(); + await fileLoadedController.close(); await backendSwitchedController.close(); } } diff --git a/lib/mpv/player/player_streams.dart b/lib/mpv/player/player_streams.dart index 2f1bb6a5..bc91ed52 100644 --- a/lib/mpv/player/player_streams.dart +++ b/lib/mpv/player/player_streams.dart @@ -53,6 +53,9 @@ class PlayerStreams { /// Stream that emits when playback restarts (first frame ready after load/seek). final Stream playbackRestart; + /// Stream that emits when the player has loaded the current media file. + final Stream fileLoaded; + /// Stream of seekable buffer ranges from the demuxer cache. final Stream> bufferRanges; @@ -78,6 +81,7 @@ class PlayerStreams { required this.audioDevices, required this.bufferRanges, required this.playbackRestart, + this.fileLoaded = const Stream.empty(), required this.backendSwitched, }); } diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 1c95aa9f..0e2abc06 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -341,8 +341,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { ); if (!isCurrentReload()) return true; - final hasExternalSubs = result.externalSubtitles.isNotEmpty; - final attachesSubsAtOpen = currentPlayer.attachesExternalSubtitlesAtOpen; final displayCriteria = result.mediaInfo?.displayCriteria; final settingsService = await SettingsService.getInstance(); if (!isCurrentReload()) return true; @@ -384,6 +382,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { if (!isCurrentReload()) return true; frameRatePlan.armStartupRefreshGate(currentPlayer); + final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( + player: currentPlayer, + externalSubtitles: result.externalSubtitles, + ); final didOpen = await _openMediaOnPlayer( player: currentPlayer, settingsService: settingsService, @@ -395,8 +397,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { selectedVersion: result.selectedVersion, timing: openTiming, headers: result.usesLocalMedia ? null : streamHeaders, - play: !frameRatePlan.holdPlaybackStart && !wtOwnsStart && (attachesSubsAtOpen || !hasExternalSubs), - externalSubtitlesAtOpen: attachesSubsAtOpen && hasExternalSubs ? result.externalSubtitles : null, + play: !frameRatePlan.holdPlaybackStart && !wtOwnsStart && externalSubtitlePlan.canStartBeforeTrackSetup, + externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, shouldContinue: isCurrentReload, onOpened: () { // The player now owns the new file — publish the session at the @@ -438,9 +440,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { trackManager.cacheExternalSubtitles(result.externalSubtitles); await _applyTracksAfterOpen( - forPlayer: currentPlayer, trackManager: trackManager, - externalSubtitles: result.externalSubtitles, + externalSubtitlePlan: externalSubtitlePlan, // Same guard as the start path: don't resume a player a newer flow // owns, and let a pending startup gate (or Watch Together's group // start) own the resume instead. @@ -456,8 +457,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { plan: frameRatePlan, resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether( currentPlayer: currentPlayer, - attachesSubsAtOpen: attachesSubsAtOpen, - hasExternalSubs: hasExternalSubs, + externalSubtitlePlan: externalSubtitlePlan, reason: reason, wtOwnsStart: wtOwnsStart, ), diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 7c71d70f..1b37c31d 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -41,6 +41,19 @@ class _FrameRateStartupPlan { } } +class _ExternalSubtitleOpenPlan { + const _ExternalSubtitleOpenPlan({required this.externalSubtitles, required this.attachesAtOpen, this.readyAfterOpen}); + + final List externalSubtitles; + final bool attachesAtOpen; + final Future? readyAfterOpen; + + bool get hasExternalSubtitles => externalSubtitles.isNotEmpty; + bool get requiresPostOpenAdd => !attachesAtOpen && hasExternalSubtitles; + bool get canStartBeforeTrackSetup => attachesAtOpen || !hasExternalSubtitles; + List? get subtitlesAtOpen => attachesAtOpen && hasExternalSubtitles ? externalSubtitles : null; +} + /// Shared building blocks for opening media on the live player. /// /// The initial start flow ([_startPlayback]), the in-place reload flow @@ -267,15 +280,14 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { /// the start and reload flows. Future _resumeAfterFrameRateStartupGate({ required Player currentPlayer, - required bool attachesSubsAtOpen, - required bool hasExternalSubs, + required _ExternalSubtitleOpenPlan externalSubtitlePlan, required String reason, }) async { if (!mounted || player != currentPlayer) return; final trackManager = _trackManager; if (trackManager == null) return; appLogger.d('Frame rate matching: resuming playback after $reason'); - if (!attachesSubsAtOpen && hasExternalSubs) { + if (externalSubtitlePlan.requiresPostOpenAdd) { await trackManager.resumeAfterSubtitleLoad(); } else { await currentPlayer.play(); @@ -289,8 +301,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { /// flows. Future _resumeAfterStartupGateOrYieldToWatchTogether({ required Player currentPlayer, - required bool attachesSubsAtOpen, - required bool hasExternalSubs, + required _ExternalSubtitleOpenPlan externalSubtitlePlan, required String reason, required bool wtOwnsStart, Completer? wtStartupHold, @@ -298,14 +309,13 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { if (!wtOwnsStart) { return _resumeAfterFrameRateStartupGate( currentPlayer: currentPlayer, - attachesSubsAtOpen: attachesSubsAtOpen, - hasExternalSubs: hasExternalSubs, + externalSubtitlePlan: externalSubtitlePlan, reason: reason, ); } appLogger.d('Frame rate matching: yielding post-gate resume to Watch Together ($reason)'); final trackManager = _trackManager; - if (trackManager != null && !attachesSubsAtOpen && hasExternalSubs) { + if (trackManager != null && externalSubtitlePlan.requiresPostOpenAdd) { trackManager.waitingForExternalSubsTrackSelection = false; trackManager.applyTrackSelectionWhenReady(); } @@ -331,6 +341,28 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { ); } + _ExternalSubtitleOpenPlan _prepareExternalSubtitleOpenPlan({ + required Player player, + required List externalSubtitles, + bool waitForFileLoaded = true, + }) { + final attachesAtOpen = player.attachesExternalSubtitlesAtOpen; + final hasExternalSubtitles = externalSubtitles.isNotEmpty; + + return _ExternalSubtitleOpenPlan( + externalSubtitles: externalSubtitles, + attachesAtOpen: attachesAtOpen, + readyAfterOpen: waitForFileLoaded && !attachesAtOpen && hasExternalSubtitles + ? player.streams.fileLoaded.first.timeout( + const Duration(seconds: 15), + onTimeout: () { + appLogger.w('Timed out waiting for file-loaded before adding external subtitles'); + }, + ) + : null, + ); + } + /// Build the per-item [TrackManager] for a freshly opened source. The /// start and reload flows construct it identically apart from where the /// preferred tracks and profile settings come from. @@ -370,16 +402,18 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { /// paused (e.g. a transcode restart while paused): selection is still /// armed and the waiting flag cleared instead of leaving both dangling. Future _applyTracksAfterOpen({ - required Player forPlayer, required TrackManager trackManager, - required List externalSubtitles, + required _ExternalSubtitleOpenPlan externalSubtitlePlan, required bool Function() shouldResumeAfterSubtitleLoad, bool applySelectionWhenResumeSkipped = false, }) async { - if (!forPlayer.attachesExternalSubtitlesAtOpen && externalSubtitles.isNotEmpty) { + if (externalSubtitlePlan.requiresPostOpenAdd) { trackManager.waitingForExternalSubsTrackSelection = true; try { - await trackManager.addExternalSubtitles(externalSubtitles); + await trackManager.addExternalSubtitles( + externalSubtitlePlan.externalSubtitles, + waitUntilReady: externalSubtitlePlan.readyAfterOpen, + ); } finally { if (shouldResumeAfterSubtitleLoad()) { await trackManager.resumeAfterSubtitleLoad(); diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index a26f9059..7d959277 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -153,8 +153,6 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { final settingsService = await SettingsService.getInstance(); if (!attempt.isCurrent) return; final displayCriteria = result.mediaInfo?.displayCriteria; - final attachesSubsAtOpen = currentPlayer.attachesExternalSubtitlesAtOpen; - final hasExternalSubs = result.externalSubtitles.isNotEmpty; var audioFocusReady = false; Future ensureAudioFocus() async { @@ -184,6 +182,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // simultaneous group start. final wtOwnsStart = _watchTogetherOwnsPlaybackStart(); Completer? wtStartupHold; + late _ExternalSubtitleOpenPlan externalSubtitlePlan; // Open video through Player if (result.videoUrl != null) { @@ -215,8 +214,13 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { isTranscoding: result.isTranscoding, ); - final shouldAutoPlay = !shouldHoldPlaybackStart && !wtOwnsStart && (attachesSubsAtOpen || !hasExternalSubs); frameRatePlan.armStartupRefreshGate(currentPlayer); + externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( + player: currentPlayer, + externalSubtitles: result.externalSubtitles, + ); + final shouldAutoPlay = + !shouldHoldPlaybackStart && !wtOwnsStart && externalSubtitlePlan.canStartBeforeTrackSetup; // ExoPlayer: attach external subs at open time so it discovers // them in a single prepare() — no media reload needed for selection. @@ -237,7 +241,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { timing: openTiming, headers: streamHeaders, play: shouldAutoPlay, - externalSubtitlesAtOpen: attachesSubsAtOpen && hasExternalSubs ? result.externalSubtitles : null, + externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, shouldContinue: () => attempt.isCurrent, ); if (!didOpen || !attempt.isCurrent) return; @@ -252,6 +256,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _attachToWatchTogetherSession(startupHold: wtStartupHold?.future); _notifyWatchTogetherMediaChange(); } + } else { + externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( + player: currentPlayer, + externalSubtitles: result.externalSubtitles, + waitForFileLoaded: false, + ); } // Versions/mediaInfo come from the committed session; rebuild so the @@ -311,9 +321,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _trackManager!.cacheExternalSubtitles(result.externalSubtitles); await _applyTracksAfterOpen( - forPlayer: currentPlayer, trackManager: _trackManager!, - externalSubtitles: result.externalSubtitles, + externalSubtitlePlan: externalSubtitlePlan, // When a startup gate below owns the resume, skip this one to // avoid a double-play. Watch Together stays paused for the group // start, so selection is armed through the resume-skipped branch. @@ -328,8 +337,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { plan: frameRatePlan, resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether( currentPlayer: currentPlayer, - attachesSubsAtOpen: attachesSubsAtOpen, - hasExternalSubs: hasExternalSubs, + externalSubtitlePlan: externalSubtitlePlan, reason: reason, wtOwnsStart: wtOwnsStart, wtStartupHold: wtStartupHold, diff --git a/lib/screens/video_player/parts/seeking.dart b/lib/screens/video_player/parts/seeking.dart index 24827820..2a22d743 100644 --- a/lib/screens/video_player/parts/seeking.dart +++ b/lib/screens/video_player/parts/seeking.dart @@ -87,9 +87,11 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { requestedMediaSourceId: _requestedMediaSourceId, ); - final attachesSubsAtOpen = currentPlayer.attachesExternalSubtitlesAtOpen; - final hasExternalSubs = result.externalSubtitles.isNotEmpty; - final shouldAutoPlay = wasPlaying && (attachesSubsAtOpen || !hasExternalSubs); + final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( + player: currentPlayer, + externalSubtitles: result.externalSubtitles, + ); + final shouldAutoPlay = wasPlaying && externalSubtitlePlan.canStartBeforeTrackSetup; final didOpen = await _openMediaOnPlayer( player: currentPlayer, @@ -106,7 +108,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { ), headers: playbackContext.streamHeaders, play: shouldAutoPlay, - externalSubtitlesAtOpen: attachesSubsAtOpen && hasExternalSubs ? result.externalSubtitles : null, + externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, shouldContinue: () => mounted && player == currentPlayer, onOpened: () { // A pre-open failure leaves the previous session (and ids) @@ -142,9 +144,8 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { trackManager.mediaInfo = _currentMediaInfo; trackManager.cacheExternalSubtitles(result.externalSubtitles); await _applyTracksAfterOpen( - forPlayer: currentPlayer, trackManager: trackManager, - externalSubtitles: result.externalSubtitles, + externalSubtitlePlan: externalSubtitlePlan, // A restart while paused must stay paused — selection is still // applied through the resume-skipped branch. shouldResumeAfterSubtitleLoad: () => wasPlaying && mounted && player == currentPlayer, diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index 533db3af..0733c126 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -62,6 +62,7 @@ class TrackManager { // ── Internal state ───────────────────────────────────────────────── bool waitingForExternalSubsTrackSelection = false; + bool _externalSubtitleAddsInFlight = false; bool _isApplyingTrackSelection = false; List _lastExternalSubtitles = const []; StreamSubscription? _trackLoadingSubscription; @@ -98,26 +99,40 @@ class TrackManager { /// 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 addExternalSubtitles(List externalSubtitles) async { + Future addExternalSubtitles(List externalSubtitles, {Future? waitUntilReady}) 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 { + _externalSubtitleAddsInFlight = true; + try { + if (waitUntilReady != 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}'); + await waitUntilReady; } catch (e) { - appLogger.w('Failed to add external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}', error: 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'); + + 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); + } + }), + ); + } finally { + _externalSubtitleAddsInFlight = false; + } } /// Resume playback after external subtitles have been loaded (or failed). @@ -230,6 +245,7 @@ class TrackManager { /// Called when playbackRestart fires — checks the flag and applies selection. void onPlaybackRestart() { if (waitingForExternalSubsTrackSelection) { + if (_externalSubtitleAddsInFlight) return; waitingForExternalSubsTrackSelection = false; applyTrackSelection(); } @@ -478,6 +494,7 @@ class TrackManager { /// Clean up subscriptions. void dispose() { + _externalSubtitleAddsInFlight = false; _trackLoadingSubscription?.cancel(); _trackLoadingSubscription = null; _subtitleFallbackTimer?.cancel(); diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index 2da8f8db..06142fe3 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -313,6 +313,25 @@ void main() { ); }); + test('MPV exposes file-loaded events through PlayerStreams', () async { + await _withMockChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_player/events', + testBody: () async { + final player = PlayerNative(); + try { + final fileLoaded = expectLater(player.streams.fileLoaded, emits(isNull)); + + player.handlePlayerEvent('file-loaded', null); + + await fileLoaded; + } finally { + await player.dispose(); + } + }, + ); + }); + test('MPV maps server-offset streams to absolute timeline positions', () async { final calls = []; diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index 1abeaff9..15b26e64 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -47,6 +49,9 @@ class _FakePlayer implements Player { @override PlayerState get state => _state; + @override + bool get disposed => false; + set tracks(Tracks t) { _state = _state.copyWith(tracks: t); } @@ -253,6 +258,26 @@ void main() { // One add failed, one succeeded. expect(player.addSubtitleCalls, hasLength(1)); }); + + test('waits for player readiness before adding subtitles', () async { + final player = _FakePlayer(); + final mgr = _make(player: player); + addTearDown(mgr.dispose); + final ready = Completer(); + + final addFuture = mgr.addExternalSubtitles([ + SubtitleTrack.uri('https://example/ready.srt', title: 'EN'), + ], waitUntilReady: ready.future); + await Future.delayed(Duration.zero); + + expect(player.addSubtitleCalls, isEmpty); + + ready.complete(); + await addFuture; + + expect(player.addSubtitleCalls, hasLength(1)); + expect(player.addSubtitleCalls.single.uri, 'https://example/ready.srt'); + }); }); // ============================================================ @@ -331,6 +356,32 @@ void main() { // No exception is the contract. expect(mgr.waitingForExternalSubsTrackSelection, isFalse); }); + + test('keeps selection pending while external subtitle add is in flight', () async { + final player = _FakePlayer(); + final mgr = _make(player: player); + addTearDown(mgr.dispose); + final ready = Completer(); + + mgr.waitingForExternalSubsTrackSelection = true; + final addFuture = mgr.addExternalSubtitles([ + SubtitleTrack.uri('https://example/pending.srt', title: 'EN'), + ], waitUntilReady: ready.future); + await Future.delayed(Duration.zero); + + mgr.onPlaybackRestart(); + + expect(mgr.waitingForExternalSubsTrackSelection, isTrue); + expect(player.addSubtitleCalls, isEmpty); + + ready.complete(); + await addFuture; + + mgr.onPlaybackRestart(); + + expect(mgr.waitingForExternalSubsTrackSelection, isFalse); + expect(player.addSubtitleCalls, hasLength(1)); + }); }); group('onSecondarySubtitleTrackChanged', () {