refactor: extract TrackManager, fix subtitle bugs

- 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
This commit is contained in:
edde746
2026-03-20 03:32:32 +01:00
parent d64c72f40e
commit 2d37d37596
9 changed files with 562 additions and 447 deletions
@@ -153,13 +153,13 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// External subtitles added dynamically
private val externalSubtitles = mutableListOf<MediaItem.SubtitleConfiguration>()
private val externalSubtitleUris = mutableListOf<String>()
private var currentMediaUri: String? = null
private var currentHeaders: Map<String, String>? = 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<String, TrackGroup>()
private val subtitleTrackGroupMap = mutableMapOf<String, TrackGroup>()
@@ -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<String, Any?>(
"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<String, String>?, startPositionMs: Long, autoPlay: Boolean, isLive: Boolean = false) {
fun open(uri: String, headers: Map<String, String>?, startPositionMs: Long, autoPlay: Boolean, isLive: Boolean = false,
externalSubtitleList: List<Map<String, String?>>? = 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()
@@ -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<String>("uri")
val headers = call.argument<Map<String, String>>("headers")
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
val autoPlay = call.argument<Boolean>("autoPlay") ?: true
val isLive = call.argument<Boolean>("isLive") ?: false
val externalSubtitles = call.argument<List<Map<String, String?>>>("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)
+6 -1
View File
@@ -89,7 +89,7 @@ class PlayerAndroid extends PlayerBase {
// ============================================
@override
Future<void> open(Media media, {bool play = true, bool isLive = false}) async {
Future<void> open(Media media, {bool play = true, bool isLive = false, List<SubtitleTrack>? 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(),
});
}
+1 -1
View File
@@ -61,7 +61,7 @@ abstract class Player {
///
/// [media] - The media source to open.
/// [play] - Whether to start playback immediately (default: true).
Future<void> open(Media media, {bool play = true, bool isLive = false});
Future<void> open(Media media, {bool play = true, bool isLive = false, List<SubtitleTrack>? externalSubtitles});
/// Start or resume playback.
Future<void> play();
+2
View File
@@ -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?,
),
+1 -1
View File
@@ -91,7 +91,7 @@ class PlayerNative extends PlayerBase {
}
@override
Future<void> open(Media media, {bool play = true, bool isLive = false}) async {
Future<void> open(Media media, {bool play = true, bool isLive = false, List<SubtitleTrack>? externalSubtitles}) async {
if (disposed) return;
await _ensureInitialized();
setSeekable(false);
+47 -365
View File
@@ -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<VideoPlayerScreen> with WidgetsBindin
StreamSubscription<bool>? _completedSubscription;
StreamSubscription<dynamic>? _mediaControlSubscription;
StreamSubscription<bool>? _bufferingSubscription;
StreamSubscription<Tracks>? _trackLoadingSubscription;
StreamSubscription<Duration>? _positionSubscription;
StreamSubscription<void>? _playbackRestartSubscription;
StreamSubscription<void>? _backendSwitchedSubscription;
TrackManager? _trackManager;
StreamSubscription<PlayerLog>? _logSubscription;
StreamSubscription<void>? _sleepTimerSubscription;
StreamSubscription<bool>? _mediaControlsPlayingSubscription;
@@ -163,10 +161,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
StreamSubscription<Map<String, bool>>? _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<SubtitleTrack> _lastExternalSubtitles = const [];
BifThumbnailService? _bifService;
// Live TV channel navigation
@@ -379,29 +374,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<void> _initializePlayer() async {
try {
// Load buffer size from settings
@@ -633,10 +605,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
}
}
/// Add external subtitle tracks to the player
Future<void> _addExternalSubtitles(List<SubtitleTrack> 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<void> _initializeServices() async {
if (!mounted || player == null) return;
@@ -1015,7 +961,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
_currentMediaInfo = null;
_isPlayerInitialized = true;
});
_trackManager?.mediaInfo = null;
}
_startLiveTimelineUpdates();
@@ -1091,13 +1038,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<UserProfileProvider>().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<VideoPlayerScreen> with WidgetsBindin
}
}
/// Resume playback after external subtitles have been loaded (or failed to load).
Future<void> _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<PlaybackInitializationResult> _startOfflinePlayback() async {
final downloadProvider = context.read<DownloadProvider>();
@@ -1658,45 +1591,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<void> _toggleFullscreen() async {
if (PlatformDetector.isMobile(context)) return;
@@ -1812,7 +1709,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
/// Handle notification when native player switched from ExoPlayer to MPV
Future<void> _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<VideoPlayerScreen> with WidgetsBindin
}
}
/// Apply track selection using the TrackSelectionService
Future<void> _applyTrackSelection() async {
if (!mounted || player == null || _isApplyingTrackSelection) return;
Future<void> _onAudioTrackChanged(AudioTrack track) async => _trackManager?.onAudioTrackChanged(track);
_isApplyingTrackSelection = true;
try {
await _waitForProfileSettingsIfNeeded();
if (!mounted || player == null) return;
Future<void> _onSubtitleTrackChanged(SubtitleTrack track) async => _trackManager?.onSubtitleTrackChanged(track);
final profileSettings = context.read<UserProfileProvider>().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<int?> _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<void> _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 = <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;
}
/// Handle audio track changes from the user - save both stream selection and language preference
Future<void> _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<void> _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() {
+443
View File
@@ -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<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;
/// 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 one by one.
Future<void> addExternalSubtitles(List<SubtitleTrack> 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<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;
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<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.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<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 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<void> 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<int?> _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<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;
}
}
+11 -4
View File
@@ -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;