fix(live-tv): stabilize HLS playback

This commit is contained in:
edde746
2026-07-18 20:04:09 +02:00
parent 46dfc7f992
commit 0707d4d9b1
27 changed files with 230 additions and 211 deletions
@@ -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) {
@@ -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)
}
}
@@ -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,
@@ -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)
@@ -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))
}
}
+16 -9
View File
@@ -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<LiveTvPlaybackSession?> 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<LiveTvStreamResolution?> 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<LiveTvPlaybackSession?> startPlayback(String channelKey, {String? dvrKey});
/// Source URI to stamp into [FavoriteChannel] entries. Plex uses
+3 -8
View File
@@ -138,17 +138,13 @@ class PlayerAndroid extends PlayerBase {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? 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<void> 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
-1
View File
@@ -77,7 +77,6 @@ abstract class Player {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? externalSubtitles,
Duration timelineOffset = Duration.zero,
Duration? timelineDuration,
});
+8 -20
View File
@@ -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<int, String> _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,
+2 -4
View File
@@ -267,7 +267,6 @@ class PlayerNative extends PlayerBase {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? 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<void> 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
@@ -607,7 +607,6 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
media,
play: shouldPlay,
externalSubtitles: externalSubtitles,
timelineOffset: timing.timelineOffset,
timelineDuration: timing.timelineDuration,
);
}
@@ -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
+1 -3
View File
@@ -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,
);
}
+18 -15
View File
@@ -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
+5
View File
@@ -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();
+3 -1
View File
@@ -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(
+7 -5
View File
@@ -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<String, String> params, {
String endpoint = _videoTranscodeStartEndpoint,
String endpoint = _plexVideoHlsStartEndpoint,
}) {
final startParams = Map<String, String>.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<String, String> 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',
+3 -3
View File
@@ -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;
@@ -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));
}
@@ -388,6 +388,9 @@ bool shouldSkipDuplicateTimelineSeek({required Duration? lastDispatchedSeek, req
return lastDispatchedSeek == finalSeek;
}
@visibleForTesting
bool shouldStartHiddenDirectionalSeek(KeyEvent event) => event is KeyDownEvent;
typedef PlaybackSourceChangeCallback =
Future<PlaybackSourceChangeOutcome> Function({
int? newMediaIndex,
+14 -18
View File
@@ -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 = <MethodCall>[];
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<Object?, Object?>.from(openCall.arguments as Map);
expect(openArgs['startPositionMs'], 0);
expect(openArgs['hasStartPosition'], isFalse);
expect(openArgs['startPositionMs'], timelineStart.inMilliseconds);
expect(openArgs['hasStartPosition'], isTrue);
await Future<void>.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 = <MethodCall>[];
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<Object?, Object?>.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 = <MethodCall>[];
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<Object?, Object?>.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 = <MethodCall>[];
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<Object?, Object?>.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();
+36 -14
View File
@@ -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 = <Uri>[];
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<String, dynamic>;
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', () {
@@ -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<void>();
+3 -2
View File
@@ -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',
]);
});
@@ -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));
});
});
@@ -169,7 +169,6 @@ class FakePlayer implements Player {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? externalSubtitles,
Duration timelineOffset = Duration.zero,
Duration? timelineDuration,
}) async {
openedUris.add(media.uri);
+17
View File
@@ -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);