diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 186d03e6..30404a80 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -505,7 +505,8 @@ abstract class MediaServerClient { /// Resolve the video URL, media info, and external subtitle list for /// playback. Backends own the per-backend particulars: Plex runs the /// transcode-decision flow when [PlaybackInitializationOptions.qualityPreset] - /// is non-original; Jellyfin always direct-streams. Throws + /// is non-original; Jellyfin asks PlaybackInfo for a matching stream when a + /// non-original preset is explicitly selected. Throws /// [PlaybackException] when the item can't be resolved (no MediaSources, /// no playable URL, transcode decision unavailable). /// diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 33409ab1..1c15b141 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -216,17 +216,19 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { await currentPlayer.setDisplayCriteria( !result.isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null, ); - final transcodeTimelineOffset = result.isTranscoding ? resumePosition ?? Duration.zero : Duration.zero; - final transcodeTimelineDuration = result.isTranscoding && episodeMetadata.durationMs != null - ? Duration(milliseconds: episodeMetadata.durationMs!) - : null; + final openTiming = _playbackOpenTiming( + backend: episodeMetadata.backend, + isTranscoding: result.isTranscoding, + resumePosition: resumePosition, + durationMs: episodeMetadata.durationMs, + ); await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no'); await currentPlayer.open( - Media(result.videoUrl!, start: result.isTranscoding ? null : resumePosition, headers: streamHeaders), + Media(result.videoUrl!, start: openTiming.mediaStart, headers: streamHeaders), play: isExoPlayer || !hasExternalSubs, externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, - timelineOffset: transcodeTimelineOffset, - timelineDuration: transcodeTimelineDuration, + timelineOffset: openTiming.timelineOffset, + timelineDuration: openTiming.timelineDuration, ); _completionTriggered = false; diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index b3e81a90..7fc72b48 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -307,21 +307,22 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // ExoPlayer: attach external subs at open time so it discovers // them in a single prepare() — no media reload needed for selection. // MPV (all platforms including Android): external subs added after open via sub-add. - final transcodeTimelineOffset = result.isTranscoding ? resumePosition ?? Duration.zero : Duration.zero; - final transcodeTimelineDuration = result.isTranscoding && _currentMetadata.durationMs != null - ? Duration(milliseconds: _currentMetadata.durationMs!) - : null; - // Plex's chunked HTTP/MKV transcode can be seekable even when MPV - // cannot prove it from response headers. Force only for that path and - // reset everywhere else so live/direct/offline streams keep native - // seekability detection. + final openTiming = _playbackOpenTiming( + backend: _currentMetadata.backend, + isTranscoding: result.isTranscoding, + resumePosition: resumePosition, + durationMs: _currentMetadata.durationMs, + ); + // Transcode streams can be seekable even when MPV cannot prove it + // from response headers. Reset non-transcodes so live/direct/offline + // streams keep native seekability detection. await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no'); await currentPlayer.open( - Media(result.videoUrl!, start: result.isTranscoding ? null : resumePosition, headers: streamHeaders), + Media(result.videoUrl!, start: openTiming.mediaStart, headers: streamHeaders), play: shouldAutoPlay, externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, - timelineOffset: transcodeTimelineOffset, - timelineDuration: transcodeTimelineDuration, + timelineOffset: openTiming.timelineOffset, + timelineDuration: openTiming.timelineDuration, ); if (!mounted || player != currentPlayer) return; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index c593da49..3d92f76c 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -114,6 +114,28 @@ Future _setWakelock(bool enabled) async { } } +class _PlaybackOpenTiming { + final Duration? mediaStart; + final Duration timelineOffset; + final Duration? timelineDuration; + + const _PlaybackOpenTiming({this.mediaStart, required this.timelineOffset, this.timelineDuration}); +} + +_PlaybackOpenTiming _playbackOpenTiming({ + required MediaBackend backend, + required bool isTranscoding, + required Duration? resumePosition, + required int? durationMs, +}) { + final usesSourceOffsetTranscode = isTranscoding && backend == MediaBackend.plex; + return _PlaybackOpenTiming( + mediaStart: usesSourceOffsetTranscode ? null : resumePosition, + timelineOffset: usesSourceOffsetTranscode ? resumePosition ?? Duration.zero : Duration.zero, + timelineDuration: isTranscoding && durationMs != null ? Duration(milliseconds: durationMs) : null, + ); +} + /// Builds a [TrackPreferencePersister] that fans the language-preference + /// stream-selection writes out to a [PlexClient] resolved lazily on each /// call. Returns a no-op-on-null persister so the [TrackManager] doesn't @@ -587,15 +609,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin throw StateError('No client registered for ${_currentMetadata.serverId}'); } _streamHeaders = genericClient.streamHeaders; - // Single source of truth — `capabilities.videoTranscoding` reflects - // the per-Plex-server probe (false on Plex installs without a working - // transcoder) and is hard-false on Jellyfin. The long-press context - // menu's quality picker reads the same flag. Alternate-version - // selection still works regardless because it's gated on - // `availableVersions.length`, not transcoding capability. + // Single source of truth for showing quality controls. Plex uses a + // per-server probe; Jellyfin supports explicit quality selection but + // should not inherit the global Plex-style default on ordinary play. _serverSupportsTranscoding = genericClient.capabilities.videoTranscoding; if (widget.selectedQualityPreset == null) { - _selectedQualityPreset = settingsService.read(SettingsService.defaultQualityPreset); + _selectedQualityPreset = genericClient.backend == MediaBackend.plex && _serverSupportsTranscoding + ? settingsService.read(SettingsService.defaultQualityPreset) + : TranscodeQualityPreset.original; } else { _selectedQualityPreset = widget.selectedQualityPreset!; } diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index c27a96ff..b21da7a2 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -16,6 +16,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { int? maxStreamingBitrate = 100000000, String? mediaSourceId, String? liveStreamId, + int? startTimeTicks, int? audioStreamIndex, int? subtitleStreamIndex, bool? autoOpenLiveStream, diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index ae11a57d..1ba4b4aa 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -161,10 +161,15 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { final preset = options.qualityPreset; final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo); final int? maxStreamingBitrate = preset.isOriginal ? null : (preset.videoBitrateKbps ?? 100000) * 1000; + final resumeOffsetMs = metadata.viewOffsetMs; + final int? transcodeStartTimeTicks = !preset.isOriginal && resumeOffsetMs != null && resumeOffsetMs > 0 + ? msToJellyfinTicks(resumeOffsetMs) + : null; final negotiation = await getPlaybackInfo( metadata.id, maxStreamingBitrate: maxStreamingBitrate, mediaSourceId: bundle.selectedSourceId, + startTimeTicks: transcodeStartTimeTicks, audioStreamIndex: requestedAudioStreamId, ); if (negotiation == null) { @@ -194,7 +199,8 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { } final transcodingUrl = chosenSource['TranscodingUrl']; - if (transcodingUrl is String && transcodingUrl.isNotEmpty) { + final directStreamUrl = chosenSource['DirectStreamUrl']; + if (!preset.isOriginal && transcodingUrl is String && transcodingUrl.isNotEmpty) { // TranscodingUrl is server-relative and already encodes container, // codecs, MediaSourceId, and PlaySessionId; we just append the // api_key for auth. @@ -202,13 +208,12 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { videoUrl = _withApiKey(transcodingUrl); playMethod = 'Transcode'; isTranscoding = true; + } else if (directStreamUrl is String && directStreamUrl.isNotEmpty) { + capturePlaySessionId(directStreamUrl); + videoUrl = _withApiKey(directStreamUrl); + playMethod = 'DirectStream'; } else { - final directStreamUrl = chosenSource['DirectStreamUrl']; - if (directStreamUrl is String && directStreamUrl.isNotEmpty) { - capturePlaySessionId(directStreamUrl); - videoUrl = _withApiKey(directStreamUrl); - playMethod = 'DirectStream'; - } else if (!preset.isOriginal) { + if (!preset.isOriginal) { fallbackReason = TranscodeFallbackReason.directPlayOnly; } } @@ -424,6 +429,8 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { /// transcode bitrate against the same ceiling. Original playback passes null /// to avoid capping high-bitrate files. [mediaSourceId] pins the negotiation /// to a specific version when the item has multiple sources. + /// [startTimeTicks] asks Jellyfin to start transcoding at the requested + /// source offset so server-generated streams line up with the app timeline. /// [audioStreamIndex] / [subtitleStreamIndex] tell the server which streams /// to pick for the transcode profile (Jellyfin's negotiation factors them in /// when picking codec compatibility). @@ -432,6 +439,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { int? maxStreamingBitrate = 100000000, String? mediaSourceId, String? liveStreamId, + int? startTimeTicks, int? audioStreamIndex, int? subtitleStreamIndex, bool? autoOpenLiveStream, @@ -447,6 +455,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { 'MaxStreamingBitrate': ?maxStreamingBitrate?.toString(), 'MediaSourceId': ?mediaSourceId, 'LiveStreamId': ?liveStreamId, + 'StartTimeTicks': ?startTimeTicks?.toString(), 'AudioStreamIndex': ?audioStreamIndex?.toString(), 'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(), 'AutoOpenLiveStream': ?autoOpenLiveStream?.toString(), @@ -464,6 +473,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { 'MaxStreamingBitrate': ?maxStreamingBitrate, 'MediaSourceId': ?mediaSourceId, 'LiveStreamId': ?liveStreamId, + 'StartTimeTicks': ?startTimeTicks, 'AudioStreamIndex': ?audioStreamIndex, 'SubtitleStreamIndex': ?subtitleStreamIndex, 'AutoOpenLiveStream': ?autoOpenLiveStream, diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index c4ed8a33..c03b62c5 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -60,7 +60,7 @@ class PlaybackInitializationResult { final List externalSubtitles; final bool isOffline; - /// `true` when [videoUrl] is a Plex transcode start URL. + /// `true` when [videoUrl] points at a backend transcoding stream. final bool isTranscoding; /// Non-null when a non-original preset was requested but fallback kicked in. diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 538ef32d..46ec51fe 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -251,7 +251,9 @@ void main() { expect(uri.queryParameters['Container'], 'mp4'); }); - test('getPlaybackInitialization preserves PlaySessionId from TranscodingUrl', () async { + test('getPlaybackInitialization sends resume ticks without rewriting TranscodingUrl', () async { + final playbackInfoUris = []; + final playbackInfoBodies = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { @@ -270,6 +272,8 @@ void main() { ); } if (request.url.path == '/Items/item-1/PlaybackInfo') { + playbackInfoUris.add(request.url); + playbackInfoBodies.add(request.body); return http.Response( jsonEncode({ 'MediaSources': [ @@ -302,7 +306,13 @@ void main() { final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( - metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + metadata: MediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + viewOffsetMs: 143894, + ), selectedMediaIndex: 0, qualityPreset: TranscodeQualityPreset.p720_2mbps, ), @@ -311,9 +321,16 @@ void main() { expect(result.isTranscoding, isTrue); expect(result.playMethod, 'Transcode'); expect(result.playSessionId, 'play-session-1'); + expect(playbackInfoUris, hasLength(1)); + expect(playbackInfoUris.single.queryParameters['StartTimeTicks'], '1438940000'); + final body = jsonDecode(playbackInfoBodies.single) as Map; + expect(body['StartTimeTicks'], 1438940000); final uri = Uri.parse(result.videoUrl!); + expect(uri.path, '/Videos/item-1/master.m3u8'); + expect(uri.queryParameters['MediaSourceId'], 'src-1'); expect(uri.queryParameters['PlaySessionId'], 'play-session-1'); expect(uri.queryParameters['api_key'], 'tok-abc'); + expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse); expect(result.mediaInfo!.subtitleTracks, hasLength(1)); expect(result.externalSubtitles, hasLength(1)); expect(result.externalSubtitles.single.title, 'English'); @@ -379,7 +396,7 @@ void main() { expect(uri.queryParameters['api_key'], 'tok-abc'); }); - test('getPlaybackInitialization negotiates original playback and uses returned source media streams', () async { + test('getPlaybackInitialization prefers DirectStreamUrl over TranscodingUrl for original playback', () async { final requests = []; String? playbackInfoBody; final scoped = JellyfinClient.forTesting( @@ -417,6 +434,8 @@ void main() { 'Container': 'mp4', 'DefaultAudioStreamIndex': 1, 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + 'TranscodingUrl': + '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', 'MediaStreams': [ {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, { @@ -450,9 +469,11 @@ void main() { final playbackInfoRequest = requests.firstWhere((uri) => uri.path == '/Items/item-1/PlaybackInfo'); expect(playbackInfoRequest.queryParameters.containsKey('MaxStreamingBitrate'), isFalse); + expect(playbackInfoRequest.queryParameters.containsKey('StartTimeTicks'), isFalse); expect(playbackInfoRequest.queryParameters['MediaSourceId'], 'src-1'); final body = jsonDecode(playbackInfoBody!) as Map; expect(body.containsKey('MaxStreamingBitrate'), isFalse); + expect(body.containsKey('StartTimeTicks'), isFalse); final profile = body['DeviceProfile'] as Map; expect(profile.containsKey('MaxStreamingBitrate'), isFalse); @@ -464,6 +485,7 @@ void main() { final uri = Uri.parse(result.videoUrl!); expect(uri.path, '/Videos/item-1/stream'); expect(uri.queryParameters['PlaySessionId'], 'play-session-direct'); + expect(uri.queryParameters['PlaySessionId'], isNot('play-session-transcode')); expect(uri.queryParameters['api_key'], 'tok-abc'); expect(result.mediaInfo!.subtitleTracks, hasLength(1)); expect(result.externalSubtitles, hasLength(1)); @@ -473,6 +495,78 @@ void main() { expect(subtitleUri.queryParameters['api_key'], 'tok-abc'); }); + test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async { + final requests = []; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + requests.add(request.url); + if (request.url.path == '/Users/user-1/Items/item-1') { + return http.Response( + jsonEncode({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + ], + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + return http.Response( + jsonEncode({ + 'MediaSources': [ + { + 'Id': 'src-1', + 'TranscodingUrl': + '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + ], + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + selectedMediaIndex: 0, + ), + ); + + final playbackInfoRequest = requests.firstWhere((uri) => uri.path == '/Items/item-1/PlaybackInfo'); + expect(playbackInfoRequest.queryParameters.containsKey('MaxStreamingBitrate'), isFalse); + expect(playbackInfoRequest.queryParameters.containsKey('StartTimeTicks'), isFalse); + expect(result.isTranscoding, isFalse); + expect(result.playMethod, 'DirectPlay'); + expect(result.playSessionId, isNull); + final uri = Uri.parse(result.videoUrl!); + expect(uri.path, '/Videos/item-1/stream'); + expect(uri.queryParameters['Static'], 'true'); + expect(uri.queryParameters['MediaSourceId'], 'src-1'); + expect(uri.queryParameters['Container'], 'mkv'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + expect(uri.queryParameters.containsKey('PlaySessionId'), isFalse); + expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse); + }); + test('selected external audio is sent to PlaybackInfo but omitted from static fallback URL', () async { Uri? playbackInfoUri; String? playbackInfoBody;