fix(tv): live playback and timeline keepalive
Disable MKV Cues seeking for live streams, fix timeline params.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user