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:
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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?,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user