diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 456c359c..fac7b8c1 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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?, startPositionMs: Long, autoPlay: Boolean) { + fun open(uri: String, headers: Map?, 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) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 947f7d0f..44726539 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -175,6 +175,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, val headers = call.argument>("headers") val startPositionMs = call.argument("startPositionMs")?.toLong() ?: 0L val autoPlay = call.argument("autoPlay") ?: true + val isLive = call.argument("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) diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 9786a1a8..64f51521 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -61,7 +61,7 @@ abstract class Player { /// /// [media] - The media source to open. /// [play] - Whether to start playback immediately (default: true). - Future open(Media media, {bool play = true}); + Future open(Media media, {bool play = true, bool isLive = false}); /// Start or resume playback. Future play(); diff --git a/lib/mpv/player/player_android.dart b/lib/mpv/player/player_android.dart index f25fc605..07f5d7c4 100644 --- a/lib/mpv/player/player_android.dart +++ b/lib/mpv/player/player_android.dart @@ -62,7 +62,7 @@ class PlayerAndroid extends PlayerBase { // ============================================ @override - Future open(Media media, {bool play = true}) async { + Future 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, }); } diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 3e63444e..7f3acd7b 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -96,7 +96,7 @@ class PlayerNative extends PlayerBase { } @override - Future open(Media media, {bool play = true}) async { + Future open(Media media, {bool play = true, bool isLive = false}) async { checkDisposed(); await _ensureInitialized(); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index d6071653..de7e3fce 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -139,6 +139,9 @@ class VideoPlayerScreenState extends State 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 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 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 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; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 8d9c3de1..b43006bb 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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