diff --git a/lib/data/viewmodels/item_detail_view_model.dart b/lib/data/viewmodels/item_detail_view_model.dart index ba5535ec..f4d0456d 100644 --- a/lib/data/viewmodels/item_detail_view_model.dart +++ b/lib/data/viewmodels/item_detail_view_model.dart @@ -26,6 +26,25 @@ class ItemDetailViewModel extends ChangeNotifier { AggregatedItem? _item; AggregatedItem? get item => _item; + int? _selectedAudioIndex; + int? get selectedAudioIndex => _selectedAudioIndex; + set selectedAudioIndex(int? value) { + if (_selectedAudioIndex != value) { + _selectedAudioIndex = value; + notifyListeners(); + } + } + + int? _selectedSubtitleIndex; + int? get selectedSubtitleIndex => _selectedSubtitleIndex; + set selectedSubtitleIndex(int? value) { + if (_selectedSubtitleIndex != value) { + _selectedSubtitleIndex = value; + notifyListeners(); + } + } + + List _similar = const []; List get similar => _similar; diff --git a/lib/ui/screens/detail/item_detail_screen.dart b/lib/ui/screens/detail/item_detail_screen.dart index a7962866..088c2ac3 100644 --- a/lib/ui/screens/detail/item_detail_screen.dart +++ b/lib/ui/screens/detail/item_detail_screen.dart @@ -4348,8 +4348,12 @@ class _DolbyVisionPlayDecision { } class _ActionButtonsState extends State<_ActionButtons> { - int? _selectedAudioIndex; - int? _selectedSubtitleIndex; + int? get _selectedAudioIndex => viewModel.selectedAudioIndex; + set _selectedAudioIndex(int? value) => viewModel.selectedAudioIndex = value; + + int? get _selectedSubtitleIndex => viewModel.selectedSubtitleIndex; + set _selectedSubtitleIndex(int? value) => viewModel.selectedSubtitleIndex = value; + bool _expanded = false; bool _playLaunchInFlight = false; bool _autoPlayTriggered = false; @@ -5199,7 +5203,11 @@ class _ActionButtonsState extends State<_ActionButtons> { final prefs = GetIt.instance(); final preferred = prefs.get(UserPreferences.defaultAudioLanguage).trim(); if (preferred.isEmpty) { - return null; + final defaultStream = audioStreams.firstWhere( + (s) => s['IsDefault'] == true, + orElse: () => {}, + ); + return defaultStream['Index'] as int?; } final preferredNormalized = normalizeLanguage(preferred); @@ -5236,7 +5244,11 @@ class _ActionButtonsState extends State<_ActionButtons> { final preferred = prefs.get(UserPreferences.defaultSubtitleLanguage).trim(); if (preferred.isEmpty) { - return null; + final defaultStream = subtitleStreams.firstWhere( + (s) => s['IsDefault'] == true, + orElse: () => {}, + ); + return defaultStream['Index'] as int?; } final preferredNormalized = normalizeLanguage(preferred); diff --git a/packages/moonfin_native_video/android/src/main/kotlin/org/moonfin/nativevideo/Media3VideoView.kt b/packages/moonfin_native_video/android/src/main/kotlin/org/moonfin/nativevideo/Media3VideoView.kt index 8595659e..e8ac099a 100644 --- a/packages/moonfin_native_video/android/src/main/kotlin/org/moonfin/nativevideo/Media3VideoView.kt +++ b/packages/moonfin_native_video/android/src/main/kotlin/org/moonfin/nativevideo/Media3VideoView.kt @@ -479,6 +479,13 @@ class Media3VideoView( private var selectedSubtitleIsBitmap = false private var selectedExternalSubtitleUrl: String? = null private var subtitleTrackEnabled = false + + private var pendingSubtitleIndex: Int? = null + private var pendingSubtitleCodec: String? = null + private var pendingSubtitleIsExternal: Boolean? = null + private var pendingSubtitleIsBitmap: Boolean? = null + private var pendingExternalSubtitleUrl: String? = null + private var pendingAudioIndex: Int? = null private var zoomMode = ZoomMode.FIT private var videoWidthPx = 0 private var videoHeightPx = 0 @@ -665,6 +672,28 @@ class Media3VideoView( } override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { + pendingSubtitleIndex?.let { index -> + if (selectTrack(C.TRACK_TYPE_TEXT, index)) { + selectedSubtitleCodec = pendingSubtitleCodec?.trim()?.lowercase() + selectedSubtitleIsExternal = pendingSubtitleIsExternal ?: false + selectedSubtitleIsBitmap = pendingSubtitleIsBitmap ?: false + selectedExternalSubtitleUrl = pendingExternalSubtitleUrl?.takeIf { it.isNotBlank() } + subtitleTrackEnabled = true + applyTrackSelectorForCurrentSource() + refreshSubtitleRendererMode() + + pendingSubtitleIndex = null + pendingSubtitleCodec = null + pendingSubtitleIsExternal = null + pendingSubtitleIsBitmap = null + pendingExternalSubtitleUrl = null + } + } + pendingAudioIndex?.let { index -> + if (selectTrack(C.TRACK_TYPE_AUDIO, index)) { + pendingAudioIndex = null + } + } emitTracksChanged() emitState() } @@ -1028,7 +1057,11 @@ class Media3VideoView( "setAudioTrack" -> { val index = ((call.arguments as? Map<*, *>)?.get("index") as? Number)?.toInt() ?: 0 - selectTrack(C.TRACK_TYPE_AUDIO, index) + pendingAudioIndex = index + val selected = selectTrack(C.TRACK_TYPE_AUDIO, index) + if (selected) { + pendingAudioIndex = null + } result.success(null) } @@ -1039,6 +1072,13 @@ class Media3VideoView( val isExternal = args?.get("isExternalSubtitle") as? Boolean ?: false val isBitmap = args?.get("isBitmapSubtitle") as? Boolean ?: false val externalUrl = args?.get("externalSubtitleUrl")?.toString() + + pendingSubtitleIndex = index + pendingSubtitleCodec = codec + pendingSubtitleIsExternal = isExternal + pendingSubtitleIsBitmap = isBitmap + pendingExternalSubtitleUrl = externalUrl + val selected = selectTrack(C.TRACK_TYPE_TEXT, index) if (selected) { selectedSubtitleCodec = codec?.trim()?.lowercase() @@ -1048,6 +1088,12 @@ class Media3VideoView( subtitleTrackEnabled = true applyTrackSelectorForCurrentSource() refreshSubtitleRendererMode() + + pendingSubtitleIndex = null + pendingSubtitleCodec = null + pendingSubtitleIsExternal = null + pendingSubtitleIsBitmap = null + pendingExternalSubtitleUrl = null } result.success(null) } @@ -1063,6 +1109,13 @@ class Media3VideoView( selectedSubtitleIsBitmap = false selectedExternalSubtitleUrl = null subtitleTrackEnabled = false + + pendingSubtitleIndex = null + pendingSubtitleCodec = null + pendingSubtitleIsExternal = null + pendingSubtitleIsBitmap = null + pendingExternalSubtitleUrl = null + applyTrackSelectorForCurrentSource() clearAssSubtitleScript() refreshSubtitleRendererMode() @@ -1303,6 +1356,12 @@ class Media3VideoView( selectedSubtitleIsBitmap = false selectedExternalSubtitleUrl = null subtitleTrackEnabled = false + pendingSubtitleIndex = null + pendingSubtitleCodec = null + pendingSubtitleIsExternal = null + pendingSubtitleIsBitmap = null + pendingExternalSubtitleUrl = null + pendingAudioIndex = null firstFrameRendered = false firstFrameCover.visibility = View.VISIBLE cancelPendingSubtitleCue(clearView = true) diff --git a/packages/playback_core/lib/src/media_stream_resolver.dart b/packages/playback_core/lib/src/media_stream_resolver.dart index 1a0d57ae..664e6653 100644 --- a/packages/playback_core/lib/src/media_stream_resolver.dart +++ b/packages/playback_core/lib/src/media_stream_resolver.dart @@ -7,29 +7,29 @@ abstract class MediaStreamResolver { } static String applyStreamIndices(String url, int? audioStreamIndex, int? subtitleStreamIndex) { - var result = url; + try { + final uri = Uri.parse(url); + final params = Map.from(uri.queryParameters); - if (audioStreamIndex != null) { - final audioRegex = RegExp(r'AudioStreamIndex=\d+'); - if (audioRegex.hasMatch(result)) { - result = result.replaceFirst(audioRegex, 'AudioStreamIndex=$audioStreamIndex'); - } else { - result = '$result&AudioStreamIndex=$audioStreamIndex'; + if (audioStreamIndex != null) { + params['AudioStreamIndex'] = '$audioStreamIndex'; + params['audioStreamIndex'] = '$audioStreamIndex'; } - } - if (subtitleStreamIndex != null && subtitleStreamIndex >= 0) { - final subRegex = RegExp(r'SubtitleStreamIndex=\d+'); - if (subRegex.hasMatch(result)) { - result = result.replaceFirst(subRegex, 'SubtitleStreamIndex=$subtitleStreamIndex'); - } else { - result = '$result&SubtitleStreamIndex=$subtitleStreamIndex'; + if (subtitleStreamIndex != null) { + if (subtitleStreamIndex >= 0) { + params['SubtitleStreamIndex'] = '$subtitleStreamIndex'; + params['subtitleStreamIndex'] = '$subtitleStreamIndex'; + } else if (subtitleStreamIndex == -1) { + params.remove('SubtitleStreamIndex'); + params.remove('subtitleStreamIndex'); + } } - } else if (subtitleStreamIndex == -1) { - result = result.replaceAll(RegExp(r'[&?]SubtitleStreamIndex=\d+'), ''); - } - return result; + return uri.replace(queryParameters: params).toString(); + } catch (_) { + return url; + } } static List extractExternalSubtitles( diff --git a/packages/playback_core/lib/src/playback_manager.dart b/packages/playback_core/lib/src/playback_manager.dart index ee804093..94b418fe 100644 --- a/packages/playback_core/lib/src/playback_manager.dart +++ b/packages/playback_core/lib/src/playback_manager.dart @@ -539,10 +539,10 @@ class PlaybackManager implements AudioOwnable { _sessionEndedController.add(null); } - void _applyPendingItemOverridesIfNeeded(String itemId) { + bool _applyPendingItemOverridesIfNeeded(String itemId) { final pendingItemId = _pendingItemOverrideId; if (pendingItemId == null || pendingItemId != itemId) { - return; + return false; } _audioStreamIndex = _pendingItemAudioStreamIndex; @@ -550,6 +550,7 @@ class PlaybackManager implements AudioOwnable { _subtitleSelectionExplicit = false; _mediaSourceId = _pendingItemMediaSourceId; _clearPendingItemOverrides(); + return true; } void _onBackendErrorEvent(Map event) { @@ -845,8 +846,8 @@ class PlaybackManager implements AudioOwnable { _lastKnownPosition = Duration.zero; final sessionToken = ++_playbackSessionToken; final itemId = _traceItemId(item); - _applyPendingItemOverridesIfNeeded(itemId); - if (_lastItemId != null && _lastItemId != itemId) { + final appliedOverrides = _applyPendingItemOverridesIfNeeded(itemId); + if (!appliedOverrides && _lastItemId != null && _lastItemId != itemId) { _translateTrackSelectionsForNewItem(item); } _lastItemId = itemId; @@ -895,6 +896,7 @@ class PlaybackManager implements AudioOwnable { enableDirectStream: enableDirectStream, enableTranscoding: enableTranscoding, ); + _setBringupState( PlaybackBringupState( phase: PlaybackBringupPhase.opening, @@ -1153,7 +1155,8 @@ class PlaybackManager implements AudioOwnable { if (_subtitleStreamIndex == -1) { _waitAndDisableSubtitles(sessionToken); } - } else if (resolution.playMethod == StreamPlayMethod.transcode) { + } else if (resolution.playMethod == StreamPlayMethod.transcode || + resolution.playMethod == StreamPlayMethod.directStream) { if (_subtitleStreamIndex != null && _subtitleStreamIndex != -1) { final isBurnedIn = (_isSubtitleBitmap(_subtitleStreamIndex!) && @@ -1576,7 +1579,8 @@ class PlaybackManager implements AudioOwnable { } else { _waitAndApplyTrackSelections(_playbackSessionToken); } - } else if (_currentResolution?.playMethod == StreamPlayMethod.transcode) { + } else if (_currentResolution?.playMethod == StreamPlayMethod.transcode || + _currentResolution?.playMethod == StreamPlayMethod.directStream) { final previousWasBurned = (previousSubtitleStreamIndex != null && previousSubtitleStreamIndex >= 0 && diff --git a/packages/playback_emby/lib/src/emby_media_stream_resolver.dart b/packages/playback_emby/lib/src/emby_media_stream_resolver.dart index 2981643b..4154637e 100644 --- a/packages/playback_emby/lib/src/emby_media_stream_resolver.dart +++ b/packages/playback_emby/lib/src/emby_media_stream_resolver.dart @@ -53,11 +53,11 @@ class EmbyMediaStreamResolver implements MediaStreamResolver { final source = _selectBestSource(info.mediaSources, preferredId: mediaSourceId); var (url, playMethod) = _resolveStreamUrl(itemId, source); - if (playMethod == StreamPlayMethod.transcode) { + if (playMethod == StreamPlayMethod.transcode || playMethod == StreamPlayMethod.directStream) { url = MediaStreamResolver.applyStreamIndices(url, audioStreamIndex, subtitleStreamIndex); url = url - .replaceFirst(RegExp(r'\?StartTimeTicks=\d+&'), '?') - .replaceFirst(RegExp(r'[&?]StartTimeTicks=\d+'), ''); + .replaceFirst(RegExp(r'\?StartTimeTicks=\d+&', caseSensitive: false), '?') + .replaceFirst(RegExp(r'[&?]StartTimeTicks=\d+', caseSensitive: false), ''); } url = _appendAuth(url); diff --git a/packages/playback_jellyfin/lib/src/jellyfin_media_stream_resolver.dart b/packages/playback_jellyfin/lib/src/jellyfin_media_stream_resolver.dart index e3922e2b..bf4c24b3 100644 --- a/packages/playback_jellyfin/lib/src/jellyfin_media_stream_resolver.dart +++ b/packages/playback_jellyfin/lib/src/jellyfin_media_stream_resolver.dart @@ -92,11 +92,11 @@ class JellyfinMediaStreamResolver implements MediaStreamResolver { final isAudio = isAudioByStreams || _isAudioMediaItem(mediaItem); var (url, playMethod) = _resolveStreamUrl(itemId, source, isAudio: isAudio); - if (playMethod == StreamPlayMethod.transcode) { + if (playMethod == StreamPlayMethod.transcode || playMethod == StreamPlayMethod.directStream) { url = MediaStreamResolver.applyStreamIndices(url, audioStreamIndex, subtitleStreamIndex); url = url - .replaceFirst(RegExp(r'\?StartTimeTicks=\d+&'), '?') - .replaceFirst(RegExp(r'[&?]StartTimeTicks=\d+'), ''); + .replaceFirst(RegExp(r'\?StartTimeTicks=\d+&', caseSensitive: false), '?') + .replaceFirst(RegExp(r'[&?]StartTimeTicks=\d+', caseSensitive: false), ''); } // Append auth token for mpv (which doesn't use our Dio interceptors). diff --git a/packages/server_emby/lib/src/api/emby_playback_api.dart b/packages/server_emby/lib/src/api/emby_playback_api.dart index ff2f1f0e..314478ca 100644 --- a/packages/server_emby/lib/src/api/emby_playback_api.dart +++ b/packages/server_emby/lib/src/api/emby_playback_api.dart @@ -41,12 +41,22 @@ class EmbyPlaybackApi implements PlaybackApi { 'UserId': ?userId, ...?requestBody, }; + + final audioStreamIndex = requestBody?['AudioStreamIndex'] ?? requestBody?['audioStreamIndex']; + final subtitleStreamIndex = requestBody?['SubtitleStreamIndex'] ?? requestBody?['subtitleStreamIndex']; + final mediaSourceId = requestBody?['MediaSourceId'] ?? requestBody?['mediaSourceId']; + final maxStreamingBitrate = requestBody?['MaxStreamingBitrate'] ?? requestBody?['maxStreamingBitrate']; + final response = await _dio.post( '/Items/$itemId/PlaybackInfo', data: body, queryParameters: { 'userId': ?userId, 'startTimeTicks': ?startTimeTicks, + 'audioStreamIndex': ?audioStreamIndex?.toString(), + 'subtitleStreamIndex': ?subtitleStreamIndex?.toString(), + 'mediaSourceId': ?mediaSourceId, + 'maxStreamingBitrate': ?maxStreamingBitrate?.toString(), }, ); return response.data as Map; diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_playback_api.dart b/packages/server_jellyfin/lib/src/api/jellyfin_playback_api.dart index 851115a7..a88b9277 100644 --- a/packages/server_jellyfin/lib/src/api/jellyfin_playback_api.dart +++ b/packages/server_jellyfin/lib/src/api/jellyfin_playback_api.dart @@ -41,12 +41,22 @@ class JellyfinPlaybackApi implements PlaybackApi { 'UserId': ?userId, ...?requestBody, }; + + final audioStreamIndex = requestBody?['AudioStreamIndex'] ?? requestBody?['audioStreamIndex']; + final subtitleStreamIndex = requestBody?['SubtitleStreamIndex'] ?? requestBody?['subtitleStreamIndex']; + final mediaSourceId = requestBody?['MediaSourceId'] ?? requestBody?['mediaSourceId']; + final maxStreamingBitrate = requestBody?['MaxStreamingBitrate'] ?? requestBody?['maxStreamingBitrate']; + final response = await _dio.post( '/Items/$itemId/PlaybackInfo', data: body, queryParameters: { 'userId': ?userId, 'startTimeTicks': ?startTimeTicks, + 'audioStreamIndex': ?audioStreamIndex?.toString(), + 'subtitleStreamIndex': ?subtitleStreamIndex?.toString(), + 'mediaSourceId': ?mediaSourceId, + 'maxStreamingBitrate': ?maxStreamingBitrate?.toString(), }, ); return response.data as Map;