fix(tv): live playback and timeline keepalive

Disable MKV Cues seeking for live streams, fix timeline params.
This commit is contained in:
edde746
2026-02-14 03:33:58 +01:00
parent fa06f23b42
commit b3ff48feca
7 changed files with 73 additions and 13 deletions
@@ -39,8 +39,10 @@ import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
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
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.SubtitleView
import io.github.peerless2012.ass.media.AssHandler
@@ -662,13 +664,42 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// Public API
fun open(uri: String, headers: Map<String, String>?, startPositionMs: Long, autoPlay: Boolean) {
fun open(uri: String, headers: Map<String, String>?, startPositionMs: Long, autoPlay: Boolean, isLive: Boolean = false) {
if (!isInitialized) return
currentMediaUri = uri
currentHeaders = headers
externalSubtitles.clear()
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.
val dataSourceFactory = if (!headers.isNullOrEmpty()) {
DefaultDataSource.Factory(activity,
androidx.media3.datasource.DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(headers))
} else {
DefaultDataSource.Factory(activity)
}
val extractorsFactory = androidx.media3.extractor.ExtractorsFactory {
arrayOf(MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES))
}
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
.createMediaSource(MediaItem.fromUri(uri))
exoPlayer?.apply {
setMediaSource(mediaSource, startPositionMs)
prepare()
playWhenReady = autoPlay
}
Log.d(TAG, "Opened live: $uri, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay")
return
}
val mediaItemBuilder = MediaItem.Builder()
.setUri(uri)
@@ -175,6 +175,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
val headers = call.argument<Map<String, String>>("headers")
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
val autoPlay = call.argument<Boolean>("autoPlay") ?: true
val isLive = call.argument<Boolean>("isLive") ?: false
if (uri == null) {
result.error("INVALID_ARGS", "Missing 'uri'", null)
@@ -196,7 +197,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri
mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
} else {
playerCore?.open(uri, headers, startPositionMs, autoPlay)
playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive)
}
result.success(null)
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
+1 -1
View File
@@ -61,7 +61,7 @@ abstract class Player {
///
/// [media] - The media source to open.
/// [play] - Whether to start playback immediately (default: true).
Future<void> open(Media media, {bool play = true});
Future<void> open(Media media, {bool play = true, bool isLive = false});
/// Start or resume playback.
Future<void> play();
+2 -1
View File
@@ -62,7 +62,7 @@ class PlayerAndroid extends PlayerBase {
// ============================================
@override
Future<void> open(Media media, {bool play = true}) async {
Future<void> open(Media media, {bool play = true, bool isLive = false}) async {
checkDisposed();
await _ensureInitialized();
@@ -74,6 +74,7 @@ class PlayerAndroid extends PlayerBase {
'headers': media.headers,
'startPositionMs': media.start?.inMilliseconds ?? 0,
'autoPlay': play,
'isLive': isLive,
});
}
+1 -1
View File
@@ -96,7 +96,7 @@ class PlayerNative extends PlayerBase {
}
@override
Future<void> open(Media media, {bool play = true}) async {
Future<void> open(Media media, {bool play = true, bool isLive = false}) async {
checkDisposed();
await _ensureInitialized();
+29 -7
View File
@@ -139,6 +139,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
String? _liveSessionIdentifier;
String? _liveSessionPath;
Timer? _liveTimelineTimer;
DateTime? _livePlaybackStartTime;
String? _liveRatingKey;
int? _liveDurationMs;
// Auto-play next episode
Timer? _autoPlayTimer;
@@ -877,9 +880,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_liveSessionIdentifier = result.sessionIdentifier;
_liveSessionPath = result.sessionPath;
_liveRatingKey = result.metadata.ratingKey;
_liveDurationMs = result.metadata.duration;
}
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true);
_livePlaybackStartTime = DateTime.now();
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
if (mounted) {
setState(() {
@@ -1676,15 +1682,27 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (client == null) return;
try {
final position = player?.state.position ?? Duration.zero;
final duration = player?.state.duration ?? Duration.zero;
// Use the program ratingKey from tune metadata, not the channel key
final ratingKey = _liveRatingKey ?? widget.metadata.ratingKey;
// playbackTime: wall-clock ms since playback started
final playbackTime = _livePlaybackStartTime != null
? DateTime.now().difference(_livePlaybackStartTime!).inMilliseconds
: 0;
// For live TV, player position/duration are unreliable (often 0).
// Use playbackTime as time, and program duration from tune metadata.
final time = playbackTime;
final duration = _liveDurationMs ?? 0;
await client.updateLiveTimeline(
ratingKey: widget.metadata.ratingKey,
ratingKey: ratingKey,
sessionPath: sessionPath,
sessionIdentifier: sessionId,
state: state,
time: position.inMilliseconds,
duration: duration.inMilliseconds,
time: time,
duration: duration,
playbackTime: playbackTime,
);
} catch (e) {
appLogger.d('Live timeline update failed', error: e);
@@ -1743,7 +1761,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token);
await _setLiveStreamOptions();
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true);
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
_livePlaybackStartTime = DateTime.now();
_liveRatingKey = result.metadata.ratingKey;
_liveDurationMs = result.metadata.duration;
setState(() {
_liveChannelIndex = newIndex;
+6 -1
View File
@@ -1139,8 +1139,9 @@ class PlexClient {
required String state,
required int time,
required int duration,
required int playbackTime,
}) async {
await _dio.post(
final response = await _dio.get(
'/:/timeline',
queryParameters: {
'ratingKey': ratingKey,
@@ -1149,9 +1150,13 @@ class PlexClient {
'hasMDE': '1',
'time': time,
'duration': duration,
'playbackTime': playbackTime,
'X-Plex-Session-Identifier': sessionIdentifier,
},
);
if (response.statusCode != null && response.statusCode != 200) {
appLogger.e('Live timeline returned ${response.statusCode}: ${response.data}');
}
}
/// Remove item from Continue Watching (On Deck) without affecting watch status or progress