diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 71854514..b0c690f1 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -54,7 +54,6 @@ import androidx.media3.exoplayer.audio.AudioCapabilities import androidx.media3.exoplayer.audio.AudioSink import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.mkv.MatroskaExtractor @@ -94,6 +93,8 @@ interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate { ): Boolean = false } +internal fun playbackMimeType(isLive: Boolean): String? = if (isLive) MimeTypes.APPLICATION_M3U8 else null + @OptIn(UnstableApi::class) class ExoPlayerCore(private val activity: Activity) : Player.Listener { @@ -902,7 +903,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { lastDuration = 0L lastBufferedPosition = 0L // Dart already seeds the visible timeline before open. Emitting native - // zeroes here races server-offset Plex transcode restarts back to 0:00. + // zeroes here races HLS resume/restart state back to 0:00. delegate?.onPropertyChange("eof-reached", false) } @@ -1203,7 +1204,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { "lastOutput=${describeAudioTrackConfig(previousAudioTrackConfig)}, actions=$lastAudioRecoveryAction" ) - if (!setCurrentMediaForRetry(player, uri, savedPosition)) return false + player.setMediaItem(buildMediaItem(uri), savedPosition) player.prepare() player.playWhenReady = savedPlayWhenReady return true @@ -1219,21 +1220,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private fun isEncodedAudioMimeType(mimeType: String): Boolean = mimeType.startsWith("audio/") && mimeType != MimeTypes.AUDIO_RAW - private fun setCurrentMediaForRetry(player: ExoPlayer, uri: String, positionMs: Long): Boolean { - if (currentMediaIsLive) { - val factory = dataSourceFactory ?: return false - val extractorsFactory = androidx.media3.extractor.ExtractorsFactory { - arrayOf(LatmMatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES)) - } - val mediaSource = ProgressiveMediaSource.Factory(factory, extractorsFactory) - .createMediaSource(MediaItem.fromUri(uri)) - player.setMediaSource(mediaSource, positionMs) - } else { - player.setMediaItem(buildMediaItem(uri), positionMs) - } - return true - } - private fun activeDoviTrackOutput(): DoviConvertingTrackOutput? = activeDoviMkvWrapper?.doviTrackOutput ?: activeDoviMp4Wrapper?.doviTrackOutput private fun dolbyVisionProfile(format: Format?): Int? { @@ -2325,6 +2311,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val mediaItemBuilder = MediaItem.Builder() .setUri(uri) + // Every Live TV backend negotiates HLS before opening the native player. + // Pin the MIME type so tokenized manifests never fall back to progressive + // extension sniffing, and so initial opens and recovery use one source path. + playbackMimeType(currentMediaIsLive)?.let(mediaItemBuilder::setMimeType) + if (externalSubtitles.isNotEmpty()) { mediaItemBuilder.setSubtitleConfigurations(externalSubtitles.toList()) } @@ -2843,28 +2834,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { ) emitSeekable(false, force = true) - if (isLive) { - // Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells - // MatroskaExtractor to not seek for them, treating the stream as unseekable - // so data flows immediately without hanging. - // Headers already applied to httpDataSourceFactory above. - val extractorsFactory = androidx.media3.extractor.ExtractorsFactory { - arrayOf(LatmMatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES)) - } - - val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory!!, extractorsFactory) - .createMediaSource(MediaItem.fromUri(uri)) - - exoPlayer?.apply { - setMediaSource(mediaSource, startPositionMs) - prepare() - playWhenReady = autoPlay - } - - emitLog("info", "media", "Opened live: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback") - return - } - val mediaItem = buildMediaItem(uri) exoPlayer?.apply { @@ -2873,7 +2842,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { playWhenReady = autoPlay } - emitLog("info", "media", "Opened: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled") + val sourceLabel = if (isLive) "live HLS" else "media" + emitLog("info", "media", "Opened $sourceLabel: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled") } fun setAudioDelay(seconds: Double) { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupport.kt similarity index 54% rename from android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractor.kt rename to android/app/src/main/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupport.kt index 1de2a8a0..01976aad 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractor.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupport.kt @@ -1,6 +1,5 @@ package com.edde746.plezy.exoplayer -import android.util.Log import androidx.media3.extractor.ExtractorOutput import androidx.media3.extractor.SeekMap import androidx.media3.extractor.TrackOutput @@ -24,10 +23,9 @@ internal val matroskaExtractorOutputField by lazy { private const val WAVE_FORMAT_MPEG_LOAS = 0x1602 /** - * Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM — ffmpeg's (and - * therefore Plex's) fallback mapping for aac_latm, which Matroska has no native - * codec ID for. The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of - * CodecPrivate. + * Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM — ffmpeg's + * fallback mapping for aac_latm, which Matroska has no native codec ID for. + * The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of CodecPrivate. */ fun isLoasAcmTrack(codecId: String?, codecPrivate: ByteArray?): Boolean = codecId == CODEC_ID_ACM && codecPrivate != null && @@ -65,48 +63,3 @@ class LatmExtractorOutputWrapper( override fun endTracks() = delegate.endTracks() override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap) } - -/** - * MatroskaExtractor with LOAS/LATM AAC support, used for Plex Live TV MKV - * streams (which bypass the ASS/DV extractor chain). VOD playback gets the - * same LATM handling via ZlibMatroskaExtractor. - */ -class LatmMatroskaExtractor(flags: Int) : MatroskaExtractor(flags) { - - companion object { - private const val TAG = "LatmMkvExtractor" - private const val ID_SEGMENT = 0x18538067 - private const val ID_TRACK_ENTRY = 0xAE - } - - private var latmWrapper: LatmExtractorOutputWrapper? = null - - override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) { - super.startMasterElement(id, contentPosition, contentSize) - - // init() is final, so install the wrapping output when the Segment starts — - // before any TrackEntry can create a track through it. - if (id == ID_SEGMENT && latmWrapper == null) { - val currentOutput = matroskaExtractorOutputField.get(this) as ExtractorOutput - val wrapper = LatmExtractorOutputWrapper(currentOutput) - latmWrapper = wrapper - matroskaExtractorOutputField.set(this, wrapper) - } - } - - override fun endMasterElement(id: Int) { - if (id == ID_TRACK_ENTRY) { - val track = getCurrentTrack(id) - if (isLoasAcmTrack(track.codecId, track.codecPrivate)) { - Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC") - latmWrapper?.markNextTrackLatm() - } - } - super.endMasterElement(id) - } - - override fun seek(position: Long, timeUs: Long) { - latmWrapper?.resetTracks() - super.seek(position, timeUs) - } -} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt index 670202ef..0bbec6f3 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt @@ -21,8 +21,7 @@ import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor * * LOAS/LATM AAC as A_MS/ACM — media3 sets audio/x-unknown for non-PCM ACM * tracks (silent playback). Detected tracks are wrapped with LatmTrackOutput, - * which unwraps LOAS frames to raw AAC (see LatmMatroskaExtractor for the - * Live TV counterpart). + * which unwraps LOAS frames to raw AAC for direct-playing Matroska files. */ class ZlibMatroskaExtractor( subtitleParserFactory: SubtitleParser.Factory, diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractorTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupportTest.kt similarity index 95% rename from android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractorTest.kt rename to android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupportTest.kt index a2f55d6f..d918227e 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractorTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupportTest.kt @@ -11,7 +11,8 @@ import androidx.media3.extractor.ExtractorOutput import androidx.media3.extractor.PositionHolder import androidx.media3.extractor.SeekMap import androidx.media3.extractor.TrackOutput -import androidx.media3.extractor.mkv.MatroskaExtractor +import com.edde746.plezy.libass.media.AssHandler +import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory import java.io.EOFException import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -24,16 +25,15 @@ import org.robolectric.annotation.Config /** * Extracts the committed fixture (1s 440Hz sine, AAC-LC 48kHz stereo, LATM/LOAS - * muxed into MKV as A_MS/ACM tag 0x1602 — the layout Plex produces when - * Direct-Streaming HDHomeRun aac_latm audio) and verifies LOAS frames are - * unwrapped to raw AAC with a synthesized AudioSpecificConfig. + * muxed into MKV as A_MS/ACM tag 0x1602) and verifies direct Matroska playback + * unwraps LOAS frames to raw AAC with a synthesized AudioSpecificConfig. * * Robolectric provides real android.util.* implementations — MatroskaExtractor * stores tracks in a SparseArray, which is a no-op stub on plain JVM. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) -class LatmMatroskaExtractorTest { +class MatroskaLatmSupportTest { private class CapturedSample(val timeUs: Long, val flags: Int, val data: ByteArray) @@ -121,7 +121,8 @@ class LatmMatroskaExtractorTest { private fun extractFixture(): FakeExtractorOutput { val data = fixtureData() - val extractor = LatmMatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES) + val assHandler = AssHandler() + val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler) val output = FakeExtractorOutput() extractor.init(output) diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/PlaybackMimeTypeTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/PlaybackMimeTypeTest.kt new file mode 100644 index 00000000..0f6e5796 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/PlaybackMimeTypeTest.kt @@ -0,0 +1,19 @@ +package com.edde746.plezy.exoplayer + +import androidx.media3.common.MimeTypes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PlaybackMimeTypeTest { + + @Test + fun livePlaybackIsAlwaysHls() { + assertEquals(MimeTypes.APPLICATION_M3U8, playbackMimeType(isLive = true)) + } + + @Test + fun nonLivePlaybackUsesNormalSourceInference() { + assertNull(playbackMimeType(isLive = false)) + } +} diff --git a/lib/media/live_tv_support.dart b/lib/media/live_tv_support.dart index 20c0e176..c6cfec90 100644 --- a/lib/media/live_tv_support.dart +++ b/lib/media/live_tv_support.dart @@ -43,7 +43,7 @@ class LiveProgramInfo { /// the server has seekable history) and rebuilds its stream URL for /// time-shift; heartbeats go to `/:/timeline` and return capture-buffer /// updates. -/// - **Jellyfin** negotiates one direct stream URL up front; no time-shift, +/// - **Jellyfin** negotiates one HLS transcode URL up front; no time-shift, /// heartbeats go through `/Sessions/Playing*`, and [recover] re-uses the /// same URL. /// @@ -83,8 +83,8 @@ abstract class LiveTvPlaybackSession { /// Re-establish playback after stream death. Plex re-tunes (the previous /// capture session expires while the player exhausts its reconnect - /// attempts) applying the degradation flags; Jellyfin returns itself — - /// the session-less URL is simply re-opened. Returns `null` on failure. + /// attempts) applying the degradation flags; Jellyfin returns itself so + /// its negotiated HLS URL is re-opened. Returns `null` on failure. Future recover({required bool directStream, required bool directStreamAudio}); } @@ -110,8 +110,15 @@ class LiveTvStreamResolution { final String? playSessionId; final String? mediaSourceId; final String? liveStreamId; + final String? playMethod; - const LiveTvStreamResolution({required this.url, this.playSessionId, this.mediaSourceId, this.liveStreamId}); + const LiveTvStreamResolution({ + required this.url, + this.playSessionId, + this.mediaSourceId, + this.liveStreamId, + this.playMethod, + }); } /// Backend-neutral live-TV operations. Implementations are obtained via @@ -126,8 +133,8 @@ class LiveTvStreamResolution { /// the optional [lineup] (Plex provider identifier) to [fetchChannels]. /// /// Stream URL resolution differs sharply by backend: Plex's DVR allocates a -/// transcode session and returns a session-scoped path; Jellyfin negotiates -/// a direct-play URL. [startPlayback] owns that difference behind +/// transcode session and returns a session-scoped HLS path; Jellyfin negotiates +/// an HLS transcode URL. [startPlayback] owns that difference behind /// [LiveTvPlaybackSession] — it is the only entry playback callers use. abstract class LiveTvSupport { /// Recording and DVR administration, when implemented by this backend. @@ -151,15 +158,15 @@ abstract class LiveTvSupport { /// Resolve a playable stream URL for [channelKey]. /// - /// Jellyfin returns a negotiated stream URL plus the play session id. Plex + /// Jellyfin returns a negotiated HLS stream URL plus the play session id. Plex /// returns `null` because its stream URL is only valid after a tune; /// playback callers use [startPlayback], which owns that difference. Future resolveStreamUrl(String channelKey, {String? dvrKey}); /// Start a playback session for [channelKey] — the single entry the player /// uses for initial launch and channel switching. Plex requires [dvrKey] - /// (tune + transcode-session setup); Jellyfin ignores it and negotiates a - /// direct stream URL. Returns `null` when the channel can't be started. + /// (tune + transcode-session setup); Jellyfin ignores it and negotiates an + /// HLS transcode URL. Returns `null` when the channel can't be started. Future startPlayback(String channelKey, {String? dvrKey}); /// Source URI to stamp into [FavoriteChannel] entries. Plex uses diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index c7e9ef63..de9d71a2 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -138,17 +138,13 @@ class PlayerAndroid extends PlayerBase { bool play = true, bool isLive = false, List? externalSubtitles, - Duration timelineOffset = Duration.zero, Duration? timelineDuration, }) async { if (disposed) return; await _ensureInitialized(); final startPosition = media.start ?? Duration.zero; final hasStartPosition = media.start != null && startPosition > Duration.zero; - // ExoPlayer reports Plex copyts transcodes in source-time coordinates, - // unlike mpv which rebases them to zero. Do not add the timeline offset - // again on Android ExoPlayer or seeks/progress jump to roughly 2x (#1221). - configureTimeline(offset: Duration.zero, duration: timelineDuration); + configureTimeline(duration: timelineDuration); clearTracks(); setExternalSubtitleMetadata(externalSubtitles); setSeekable(false); @@ -178,7 +174,7 @@ class PlayerAndroid extends PlayerBase { ) .toList(), }); - resetPlaybackProgress(media.start ?? timelineOffset); + resetPlaybackProgress(media.start ?? Duration.zero); } @override @@ -200,8 +196,7 @@ class PlayerAndroid extends PlayerBase { @override Future seek(Duration position) async { - final sourcePosition = sourceSeekPosition(position); - await runSeek(position, () => invoke('seek', {'positionMs': sourcePosition.inMilliseconds})); + await runSeek(position, () => invoke('seek', {'positionMs': position.inMilliseconds})); } @override diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 23ad5cd5..877ecda7 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -77,7 +77,6 @@ abstract class Player { bool play = true, bool isLive = false, List? externalSubtitles, - Duration timelineOffset = Duration.zero, Duration? timelineDuration, }); diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index ffbd9878..40e859f1 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -58,7 +58,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { int _lastEmitMs = 0; int _lastCacheStateMs = 0; int _positionMs = 0; - Duration _timelineOffset = Duration.zero; Duration? _timelineDuration; int _nextPropId = 0; final Map _propIdToName = {}; @@ -198,7 +197,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { case 'time-pos': if (value is num) { - final pos = _toTimelinePosition(Duration(milliseconds: (value * 1000).round())); + final pos = Duration(milliseconds: (value * 1000).round()); _positionMs = pos.inMilliseconds; // Only allocate Duration + copyWith + emit at ~4Hz (250ms). // Raw int is stored every tick so synchronous reads via _positionMs stay current. @@ -213,7 +212,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { case 'duration': if (value is num) { - final duration = _timelineDuration ?? _toTimelinePosition(Duration(milliseconds: (value * 1000).toInt())); + final duration = _timelineDuration ?? Duration(milliseconds: (value * 1000).toInt()); _state = _state.copyWith(duration: duration); durationController.add(duration); } @@ -230,7 +229,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { final nowMs = _throttleSw.elapsedMilliseconds; if (nowMs - _lastCacheStateMs < 250) break; _lastCacheStateMs = nowMs; - final buffer = _toTimelinePosition(Duration(milliseconds: (value * 1000).toInt())); + final buffer = Duration(milliseconds: (value * 1000).toInt()); _state = _state.copyWith(buffer: buffer); bufferController.add(buffer); // Synthesize a single range for players without demuxer-cache-state (ExoPlayer). @@ -329,7 +328,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { // Extract cache-end for the single buffer duration (replaces demuxer-cache-time) final cacheEnd = cacheState['cache-end'] as num?; if (cacheEnd != null) { - final buffer = _toTimelinePosition(Duration(milliseconds: (cacheEnd * 1000).toInt())); + final buffer = Duration(milliseconds: (cacheEnd * 1000).toInt()); _state = _state.copyWith(buffer: buffer); bufferController.add(buffer); } @@ -345,8 +344,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { if (start != null && end != null) { ranges.add( BufferRange( - start: _toTimelinePosition(Duration(milliseconds: (start * 1000).toInt())), - end: _toTimelinePosition(Duration(milliseconds: (end * 1000).toInt())), + start: Duration(milliseconds: (start * 1000).toInt()), + end: Duration(milliseconds: (end * 1000).toInt()), ), ); } @@ -561,24 +560,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { } @protected - void configureTimeline({Duration offset = Duration.zero, Duration? duration}) { - _timelineOffset = offset; + void configureTimeline({Duration? duration}) { _timelineDuration = duration; } - @protected - Duration sourceSeekPosition(Duration timelinePosition) { - final sourcePosition = timelinePosition - _timelineOffset; - return sourcePosition.isNegative ? Duration.zero : sourcePosition; - } - - Duration _toTimelinePosition(Duration sourcePosition) { - return sourcePosition + _timelineOffset; - } - @protected void resetPlaybackProgress(Duration sourcePosition) { - final position = _toTimelinePosition(sourcePosition); + final position = sourcePosition; _positionMs = position.inMilliseconds; _state = _state.copyWith( completed: false, diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 74ae4a00..7963163d 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -267,7 +267,6 @@ class PlayerNative extends PlayerBase { bool play = true, bool isLive = false, List? externalSubtitles, - Duration timelineOffset = Duration.zero, Duration? timelineDuration, }) async { if (disposed) return; @@ -277,7 +276,7 @@ class PlayerNative extends PlayerBase { // No transition is surfaced: the caller is replacing playback anyway. await _clearArmedNext(adoptIfRolledIn: false); final startPosition = media.start ?? Duration.zero; - configureTimeline(offset: timelineOffset, duration: timelineDuration); + configureTimeline(duration: timelineDuration); clearTracks(); setExternalSubtitleMetadata(externalSubtitles); resetPlaybackProgress(startPosition); @@ -359,8 +358,7 @@ class PlayerNative extends PlayerBase { @override Future seek(Duration position) async { - final sourcePosition = sourceSeekPosition(position); - await runSeek(position, () => command(['seek', (sourcePosition.inMilliseconds / 1000.0).toString(), 'absolute'])); + await runSeek(position, () => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute'])); } @override diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index d96b766b..0b6cd2bf 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -607,7 +607,6 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { media, play: shouldPlay, externalSubtitles: externalSubtitles, - timelineOffset: timing.timelineOffset, timelineDuration: timing.timelineDuration, ); } diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index e252d23d..aa7a4505 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -2,8 +2,8 @@ part of '../../video_player_screen.dart'; extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { void _onVideoCompleted(bool completed, {bool skipAutoPlayCountdown = false}) async { - // Live TV streams are continuous — ignore spurious EOF events caused by - // inter-segment gaps in the chunked MKV transcode stream. + // Live TV streams are continuous — ignore transient EOF events while an + // HLS playlist refreshes or crosses a discontinuity. if (widget.isLive) return; if (!completed) return; // Ignore spurious EOF from the old file during an in-place media-source diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 4a59eac0..592ee1f4 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -192,10 +192,9 @@ class _PlaybackAttempt { class _PlaybackOpenTiming { final Duration? mediaStart; - final Duration timelineOffset; final Duration? timelineDuration; - const _PlaybackOpenTiming({this.mediaStart, required this.timelineOffset, this.timelineDuration}); + const _PlaybackOpenTiming({this.mediaStart, this.timelineDuration}); } _PlaybackOpenTiming _playbackOpenTiming({ @@ -205,7 +204,6 @@ _PlaybackOpenTiming _playbackOpenTiming({ }) { return _PlaybackOpenTiming( mediaStart: resumePosition, - timelineOffset: Duration.zero, timelineDuration: isTranscoding && durationMs != null ? Duration(milliseconds: durationMs) : null, ); } diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index 044129d9..3128e9fc 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -165,9 +165,9 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { final info = await _client.getPlaybackInfo( channelKey, autoOpenLiveStream: true, - enableDirectPlay: true, - enableDirectStream: true, - enableTranscoding: false, + enableDirectPlay: false, + enableDirectStream: false, + enableTranscoding: true, allowVideoStreamCopy: true, allowAudioStreamCopy: true, ); @@ -182,16 +182,17 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { var playSessionId = nonEmptyString(info?['PlaySessionId']); var mediaSourceId = nonEmptyString(source['Id']); var liveStreamId = nonEmptyString(source['LiveStreamId']); - final rawUrl = nonEmptyString(source['DirectStreamUrl']); - final url = rawUrl != null - ? _client._withApiKey(rawUrl) - : _client.buildDirectStreamUrl( - channelKey, - container: nonEmptyString(source['Container']), - mediaSourceId: mediaSourceId, - playSessionId: playSessionId, - liveStreamId: liveStreamId, - ); + final rawUrl = nonEmptyString(source['TranscodingUrl']); + if (rawUrl == null) { + appLogger.w('Jellyfin Live TV negotiation returned no HLS transcode URL'); + return null; + } + final rawUri = Uri.tryParse(rawUrl); + if (rawUri == null || !rawUri.path.toLowerCase().endsWith('.m3u8')) { + appLogger.w('Jellyfin Live TV negotiation returned no HLS transcode URL'); + return null; + } + final url = _client._withApiKey(rawUrl); final query = Uri.tryParse(url)?.queryParameters; playSessionId ??= query?['PlaySessionId']; mediaSourceId ??= query?['MediaSourceId']; @@ -201,6 +202,7 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { playSessionId: playSessionId, mediaSourceId: mediaSourceId, liveStreamId: liveStreamId, + playMethod: 'Transcode', ); } @@ -269,10 +271,10 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { } } -/// A Jellyfin live playback session: one negotiated direct-stream URL plus +/// A Jellyfin live playback session: one negotiated HLS transcode URL plus /// `/Sessions/Playing*` heartbeats via [JellyfinLiveSessionTracker]. No /// program-scoped session and no time-shift — [recover] re-opens the same -/// session-less URL. +/// negotiated URL. class _JellyfinLiveTvPlaybackSession implements LiveTvPlaybackSession { final JellyfinClient _client; final String _channelKey; @@ -285,6 +287,7 @@ class _JellyfinLiveTvPlaybackSession implements LiveTvPlaybackSession { playSessionId: resolution.playSessionId, mediaSourceId: resolution.mediaSourceId, liveStreamId: resolution.liveStreamId, + playMethod: resolution.playMethod, ); @override diff --git a/lib/services/live_seek_accumulator.dart b/lib/services/live_seek_accumulator.dart index c207ad68..75be49b1 100644 --- a/lib/services/live_seek_accumulator.dart +++ b/lib/services/live_seek_accumulator.dart @@ -82,7 +82,12 @@ class LiveSeekAccumulator { if (window == null) return; final base = _pendingEpoch ?? currentEpoch(); + final clampedBase = base.clamp(window.start, window.end); final target = (base + deltaSeconds).clamp(window.start, window.end); + // Do not rebuild the stream when a relative skip is clamped back to the + // position it already occupies (most commonly fast-forward at live edge). + // Once a burst has a pending target, keep its normal debounce semantics. + if (_pendingEpoch == null && target == clampedBase) return; if (target != _pendingEpoch) { _pendingEpoch = target; onChanged?.call(); diff --git a/lib/services/live_session_tracker.dart b/lib/services/live_session_tracker.dart index 54542b6a..9a1d682c 100644 --- a/lib/services/live_session_tracker.dart +++ b/lib/services/live_session_tracker.dart @@ -10,12 +10,13 @@ import 'playback_report_session.dart'; /// Plex live path keeps its bespoke capture-buffer flow inline at the call /// site; this tracker only covers Jellyfin's `/Sessions/Playing*` flow. class JellyfinLiveSessionTracker { - JellyfinLiveSessionTracker({String? playSessionId, this.mediaSourceId, this.liveStreamId}) + JellyfinLiveSessionTracker({String? playSessionId, this.mediaSourceId, this.liveStreamId, this.playMethod}) : _playSessionId = playSessionId ?? generateSessionIdentifier(); final String _playSessionId; final String? mediaSourceId; final String? liveStreamId; + final String? playMethod; PlaybackReportSession? _session; /// Session id reused across all heartbeats for this playback. Exposed @@ -36,6 +37,7 @@ class JellyfinLiveSessionTracker { client: client, itemId: itemId, playSessionId: _playSessionId, + playMethod: playMethod, liveStreamId: liveStreamId, ); await session.report( diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index aa7abca4..6acb8a69 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -82,6 +82,9 @@ part 'plex_client/parts/collections.dart'; part 'plex_client/parts/play_queues.dart'; part 'plex_client/parts/metadata_edit.dart'; +const _plexVideoTranscodeBaseEndpoint = '/video/:/transcode/universal'; +const _plexVideoHlsStartEndpoint = '$_plexVideoTranscodeBaseEndpoint/start.m3u8'; +const _plexVideoHlsProtocol = 'hls'; const _plexHlsVideoTranscodeTarget = 'add-transcode-target(type=videoProfile&context=streaming' '&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video' @@ -2380,7 +2383,7 @@ class PlexClient selectedSubtitleTrack: selectedSubtitleTrack, ); return await _runTranscodeDecision( - startEndpoint: _videoTranscodeStartEndpoint, + startEndpoint: _plexVideoHlsStartEndpoint, allParams: allParams, isOriginal: preset.isOriginal, ); @@ -2423,7 +2426,6 @@ class PlexClient } } - static const String _videoTranscodeStartEndpoint = '/video/:/transcode/universal/start.m3u8'; static const String _musicTranscodeStartEndpoint = '/music/:/transcode/universal/start.mp3'; /// Shared decision plumbing for the video and music transcode flows: GET @@ -2472,7 +2474,7 @@ class PlexClient String _buildTranscodeStartPathFromParams( Map params, { - String endpoint = _videoTranscodeStartEndpoint, + String endpoint = _plexVideoHlsStartEndpoint, }) { final startParams = Map.from(params)..remove('X-Plex-Token'); final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&'); @@ -2482,7 +2484,7 @@ class PlexClient @visibleForTesting String buildTranscodeStartPathFromParamsForTesting( Map params, { - String endpoint = _videoTranscodeStartEndpoint, + String endpoint = _plexVideoHlsStartEndpoint, }) { return _buildTranscodeStartPathFromParams(params, endpoint: endpoint); } @@ -2511,7 +2513,7 @@ class PlexClient 'path': '/library/metadata/$ratingKey', 'mediaIndex': mediaIndex.toString(), 'partIndex': partIndex.toString(), - 'protocol': 'hls', + 'protocol': _plexVideoHlsProtocol, 'fastSeek': '1', 'directPlay': isOriginal ? '1' : '0', 'directStream': isOriginal ? '1' : '0', diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index 1fd38a7a..e18d3360 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -1024,7 +1024,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport 'path': sessionPath, 'mediaIndex': '0', 'partIndex': '0', - 'protocol': 'hls', + 'protocol': _plexVideoHlsProtocol, 'fastSeek': '1', 'directPlay': '0', 'directStream': directStream ? '1' : '0', @@ -1066,7 +1066,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport receiveTimeout: MediaServerTimeouts.receive, defaultHeaders: {'Accept-Language': 'en'}, ); - final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; + final decisionUrl = '${config.baseUrl}$_plexVideoTranscodeBaseEndpoint/decision?$queryString'; final decisionResponse = await decisionClient.get(decisionUrl); if (decisionResponse.statusCode != 200) { @@ -1089,7 +1089,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') .join('&'); - return '/video/:/transcode/universal/start.m3u8?$startQuery'; + return '$_plexVideoHlsStartEndpoint?$startQuery'; } catch (e, st) { appLogger.e('Failed to build live stream path', error: e, stackTrace: st); return null; diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index ef2c2690..288bc200 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -237,7 +237,10 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { final isHorizontal = key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight; if (isHorizontal) { _showControlsWithTimelineFocus(); - if (widget.canControl) { + // A repeat may arrive before the post-frame focus handoff reaches + // the timeline. Consume it here without adding another seek step; + // once focused, the timeline owns intentional held-key repeats. + if (shouldStartHiddenDirectionalSeek(event) && widget.canControl) { final forward = key == LogicalKeyboardKey.arrowRight; unawaited(_seekByTime(forward: forward)); } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 80052c91..6cde7d0d 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -388,6 +388,9 @@ bool shouldSkipDuplicateTimelineSeek({required Duration? lastDispatchedSeek, req return lastDispatchedSeek == finalSeek; } +@visibleForTesting +bool shouldStartHiddenDirectionalSeek(KeyEvent event) => event is KeyDownEvent; + typedef PlaybackSourceChangeCallback = Future Function({ int? newMediaIndex, diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index 66a5f1f8..baf4ce0f 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -181,7 +181,7 @@ void main() { ); }); - test('ExoPlayer maps copyts transcode streams as absolute timeline positions', () async { + test('ExoPlayer opens HLS transcodes at native timeline positions', () async { final calls = []; await withMockPlayerChannels( @@ -202,8 +202,7 @@ void main() { const timelineStart = Duration(seconds: 2058); // 34:18 const timelineDuration = Duration(seconds: 2903); // 48:23 await player.open( - Media('https://example.test/transcode.mkv'), - timelineOffset: timelineStart, + Media('https://example.test/start.m3u8', start: timelineStart), timelineDuration: timelineDuration, ); @@ -212,8 +211,8 @@ void main() { final openCall = calls.singleWhere((call) => call.method == 'open'); final openArgs = Map.from(openCall.arguments as Map); - expect(openArgs['startPositionMs'], 0); - expect(openArgs['hasStartPosition'], isFalse); + expect(openArgs['startPositionMs'], timelineStart.inMilliseconds); + expect(openArgs['hasStartPosition'], isTrue); await Future.delayed(const Duration(milliseconds: 260)); player.handlePropertyChange('time-pos', 2058.0); @@ -232,7 +231,7 @@ void main() { ); }); - test('ExoPlayer source-offset open keeps timeline offset after stale native zero position', () async { + test('ExoPlayer HLS open keeps the requested position after stale native zero position', () async { final calls = []; late PlayerAndroid player; @@ -259,8 +258,7 @@ void main() { const timelineStart = Duration(seconds: 2058); const timelineDuration = Duration(seconds: 2903); await player.open( - Media('https://example.test/transcode.mkv'), - timelineOffset: timelineStart, + Media('https://example.test/start.m3u8', start: timelineStart), timelineDuration: timelineDuration, ); @@ -269,8 +267,8 @@ void main() { final openCall = calls.singleWhere((call) => call.method == 'open'); final openArgs = Map.from(openCall.arguments as Map); - expect(openArgs['startPositionMs'], 0); - expect(openArgs['hasStartPosition'], isFalse); + expect(openArgs['startPositionMs'], timelineStart.inMilliseconds); + expect(openArgs['hasStartPosition'], isTrue); } finally { await player.dispose(); } @@ -610,7 +608,7 @@ void main() { ); }); - test('MPV maps server-offset streams to absolute timeline positions', () async { + test('MPV opens HLS transcodes at native timeline positions', () async { final calls = []; await withMockPlayerChannels( @@ -629,8 +627,7 @@ void main() { final player = PlayerNative(); try { await player.open( - Media('https://example.test/transcode.mkv'), - timelineOffset: const Duration(seconds: 10), + Media('https://example.test/start.m3u8', start: const Duration(seconds: 10)), timelineDuration: const Duration(seconds: 100), ); @@ -644,7 +641,7 @@ void main() { final seekCall = calls.lastWhere((call) => call.method == 'command'); final args = Map.from(seekCall.arguments as Map)['args'] as List; - expect(args, ['seek', '15.0', 'absolute']); + expect(args, ['seek', '25.0', 'absolute']); expect(player.state.position, const Duration(seconds: 25)); } finally { await player.dispose(); @@ -653,7 +650,7 @@ void main() { ); }); - test('MPV refresh seek preserves timeline offset position', () async { + test('MPV HLS refresh seek preserves the requested position', () async { final calls = []; await withMockPlayerChannels( @@ -673,8 +670,7 @@ void main() { try { const timelineStart = Duration(milliseconds: 143894); await player.open( - Media('https://example.test/transcode.mkv'), - timelineOffset: timelineStart, + Media('https://example.test/start.m3u8', start: timelineStart), timelineDuration: const Duration(seconds: 1502), ); @@ -684,7 +680,7 @@ void main() { final seekCall = calls.lastWhere((call) => call.method == 'command'); final args = Map.from(seekCall.arguments as Map)['args'] as List; - expect(args, ['seek', '0.0', 'absolute']); + expect(args, ['seek', '143.894', 'absolute']); expect(player.state.position, timelineStart); } finally { await player.dispose(); diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 413a9376..dad313d3 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -1464,7 +1464,7 @@ void main() { expect(uri.queryParameters['api_key'], 'tok-abc'); }); - test('live TV stream resolution opens a direct stream instead of HLS transcode', () async { + test('live TV stream resolution requires an HLS transcode', () async { final requests = []; String? capturedBody; final scoped = JellyfinClient.forTesting( @@ -1498,30 +1498,28 @@ void main() { expect(requests.single.path, '/Items/channel-1/PlaybackInfo'); expect(requests.single.queryParameters['AutoOpenLiveStream'], 'true'); - expect(requests.single.queryParameters['EnableTranscoding'], 'false'); - expect(requests.single.queryParameters['EnableDirectPlay'], 'true'); - expect(requests.single.queryParameters['EnableDirectStream'], 'true'); + expect(requests.single.queryParameters['EnableTranscoding'], 'true'); + expect(requests.single.queryParameters['EnableDirectPlay'], 'false'); + expect(requests.single.queryParameters['EnableDirectStream'], 'false'); expect(requests.single.queryParameters['AllowVideoStreamCopy'], 'true'); expect(requests.single.queryParameters['AllowAudioStreamCopy'], 'true'); final body = jsonDecode(capturedBody!) as Map; expect(body['AutoOpenLiveStream'], isTrue); - expect(body['EnableTranscoding'], isFalse); + expect(body['EnableTranscoding'], isTrue); + expect(body['EnableDirectPlay'], isFalse); + expect(body['EnableDirectStream'], isFalse); expect(resolution, isNotNull); expect(resolution!.playSessionId, 'live-session-1'); expect(resolution.mediaSourceId, 'source-1'); expect(resolution.liveStreamId, 'open-stream-1'); + expect(resolution.playMethod, 'Transcode'); final uri = Uri.parse(resolution.url); - expect(uri.path, '/Videos/channel-1/stream'); - expect(uri.queryParameters['Static'], 'true'); - expect(uri.queryParameters['Container'], 'ts'); - expect(uri.queryParameters['MediaSourceId'], 'source-1'); - expect(uri.queryParameters['LiveStreamId'], 'open-stream-1'); + expect(uri.path, '/Videos/channel-1/live.m3u8'); expect(uri.queryParameters['PlaySessionId'], 'live-session-1'); - expect(uri.queryParameters['DeviceId'], 'dev-xyz'); expect(uri.queryParameters['api_key'], 'tok-abc'); }); - test('live TV stream resolution recovers identity from a negotiated direct URL', () async { + test('live TV stream resolution recovers identity from a negotiated HLS URL', () async { final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { @@ -1531,8 +1529,8 @@ void main() { 'MediaSources': [ { 'Container': 'ts', - 'DirectStreamUrl': - '/Videos/channel-1/stream?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url', + 'TranscodingUrl': + '/Videos/channel-1/live.m3u8?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url', }, ], }), @@ -1551,6 +1549,30 @@ void main() { expect(resolution!.playSessionId, 'play-url'); expect(resolution.mediaSourceId, 'source-url'); expect(resolution.liveStreamId, 'live-url'); + expect(resolution.playMethod, 'Transcode'); + }); + + test('live TV stream resolution rejects a non-HLS fallback URL', () async { + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Items/channel-1/PlaybackInfo') { + return http.Response( + jsonEncode({ + 'MediaSources': [ + {'DirectStreamUrl': '/Videos/channel-1/stream.ts'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + expect(await scoped.liveTv.resolveStreamUrl('channel-1'), isNull); }); test('buildTrickplayTileUrl wires width, sheet index, api_key, and DeviceId', () { diff --git a/test/services/live_seek_accumulator_test.dart b/test/services/live_seek_accumulator_test.dart index 012e9ad5..de656974 100644 --- a/test/services/live_seek_accumulator_test.dart +++ b/test/services/live_seek_accumulator_test.dart @@ -98,6 +98,41 @@ void main() { }); }); + test('does not reopen when a skip is clamped to the current boundary', () { + fakeAsync((async) { + window = (start: 950, end: 1050); + final acc = build(); + + currentEpoch = 1050; + acc.seekBy(15); + expect(acc.pendingEpoch, isNull); + + currentEpoch = 950; + acc.seekBy(-15); + expect(acc.pendingEpoch, isNull); + + async.elapse(const Duration(milliseconds: 300)); + expect(seeks, isEmpty); + expect(changes, 0); + acc.dispose(); + }); + }); + + test('still seeks away from a capture-buffer boundary', () { + fakeAsync((async) { + window = (start: 950, end: 1050); + currentEpoch = 1050; + final acc = build(); + + acc.seekBy(-15); + expect(acc.pendingEpoch, 1035); + + async.elapse(const Duration(milliseconds: 300)); + expect(seeks, [1035]); + acc.dispose(); + }); + }); + test('flushes the newer target when a press lands during the seek', () { fakeAsync((async) { gate = Completer(); diff --git a/test/services/live_session_tracker_test.dart b/test/services/live_session_tracker_test.dart index 561f35dd..8c8a18f5 100644 --- a/test/services/live_session_tracker_test.dart +++ b/test/services/live_session_tracker_test.dart @@ -22,7 +22,7 @@ class _FakeJellyfinClient implements JellyfinClient { int? subtitleStreamIndex, }) async { await startGate.future; - calls.add('started:$itemId:$playSessionId:$mediaSourceId:$liveStreamId'); + calls.add('started:$itemId:$playSessionId:$mediaSourceId:$liveStreamId:$playMethod'); } @override @@ -65,6 +65,7 @@ void main() { playSessionId: 'live-session-1', mediaSourceId: 'source-1', liveStreamId: 'live-stream-1', + playMethod: 'Transcode', ); final first = tracker.report( @@ -97,7 +98,7 @@ void main() { await Future.wait([first, second, stopped]); expect(client.calls, [ - 'started:channel-1:live-session-1:source-1:live-stream-1', + 'started:channel-1:live-session-1:source-1:live-stream-1:Transcode', 'stopped:channel-1:live-session-1:source-1:live-stream-1', ]); }); diff --git a/test/services/live_tv_playback_session_test.dart b/test/services/live_tv_playback_session_test.dart index 03cd3f4e..b2ada7eb 100644 --- a/test/services/live_tv_playback_session_test.dart +++ b/test/services/live_tv_playback_session_test.dart @@ -239,7 +239,7 @@ void main() { createdAt: DateTime.fromMillisecondsSinceEpoch(0), ); - test('startPlayback negotiates one direct URL; no time-shift; recover reuses it', () async { + test('startPlayback negotiates one HLS URL; no time-shift; recover reuses it', () async { final client = JellyfinClient.forTesting( connection: conn(), httpClient: MockClient((request) async { @@ -247,7 +247,12 @@ void main() { return jsonResponse({ 'PlaySessionId': 'play-1', 'MediaSources': [ - {'Id': 'source-1', 'Container': 'ts', 'LiveStreamId': 'live-1'}, + { + 'Id': 'source-1', + 'Container': 'ts', + 'LiveStreamId': 'live-1', + 'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=play-1', + }, ], }); } @@ -266,13 +271,13 @@ void main() { final url = await session.streamUrlAt(); expect(url, isNotNull); - expect(Uri.parse(url!).path, contains('/Videos/channel-1')); + expect(Uri.parse(url!).path, '/Videos/channel-1/live.m3u8'); expect(Uri.parse(url).queryParameters['PlaySessionId'], 'play-1'); // Time-shift unsupported — an offset request must not silently play live. expect(await session.streamUrlAt(offsetSeconds: 60), isNull); - // Session-less URL: recovery is just re-opening it. + // Recovery re-opens the negotiated HLS URL. expect(await session.recover(directStream: false, directStreamAudio: false), same(session)); }); }); diff --git a/test/services/music/music_playback_service_test.dart b/test/services/music/music_playback_service_test.dart index 86138df1..c48674c8 100644 --- a/test/services/music/music_playback_service_test.dart +++ b/test/services/music/music_playback_service_test.dart @@ -169,7 +169,6 @@ class FakePlayer implements Player { bool play = true, bool isLive = false, List? externalSubtitles, - Duration timelineOffset = Duration.zero, Duration? timelineDuration, }) async { openedUris.add(media.uri); diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index e58e3252..62789b3f 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -1342,6 +1342,23 @@ void main() { }); }); + group('shouldStartHiddenDirectionalSeek', () { + test('accepts only the initial key-down event', () { + expect(shouldStartHiddenDirectionalSeek(_keyDown(LogicalKeyboardKey.arrowRight)), isTrue); + expect( + shouldStartHiddenDirectionalSeek( + const KeyRepeatEvent( + physicalKey: PhysicalKeyboardKey.arrowRight, + logicalKey: LogicalKeyboardKey.arrowRight, + timeStamp: Duration.zero, + ), + ), + isFalse, + ); + expect(shouldStartHiddenDirectionalSeek(_keyUp(LogicalKeyboardKey.arrowRight)), isFalse); + }); + }); + group('SyncOffsetControl', () { testWidgets('uses 100ms slider steps without rendering tick marks', (tester) async { LocaleSettings.setLocaleSync(AppLocale.en);