fix(live-tv): stabilize HLS playback
This commit is contained in:
@@ -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) {
|
||||
|
||||
+3
-50
@@ -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,
|
||||
|
||||
+7
-6
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,7 +77,6 @@ abstract class Player {
|
||||
bool play = true,
|
||||
bool isLive = false,
|
||||
List<SubtitleTrack>? externalSubtitles,
|
||||
Duration timelineOffset = Duration.zero,
|
||||
Duration? timelineDuration,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user