diff --git a/lib/media/live_tv_support.dart b/lib/media/live_tv_support.dart index 466738ea..42012140 100644 --- a/lib/media/live_tv_support.dart +++ b/lib/media/live_tv_support.dart @@ -57,6 +57,9 @@ class LiveProgramInfo { abstract class LiveTvPlaybackSession { LiveProgramInfo get program; + /// Whether a TV app may retain this server session while backgrounded. + LiveTvBackgroundPolicy get backgroundPolicy; + /// Seekable-history snapshot from session start. Heartbeats may return /// fresher ones ([reportTimeline]); the caller owns tracking the current /// value. @@ -85,6 +88,15 @@ abstract class LiveTvPlaybackSession { Future recover({required bool directStream, required bool directStreamAudio}); } +enum LiveTvBackgroundPolicy { + /// Keep the tuned session alive so its capture buffer can be resumed. + retainSession, + + /// Stop the session when a TV app is backgrounded and leave playback when + /// the app resumes. + stopAndExit, +} + enum FavoriteChannelPersistenceMode { /// A single write replaces the full backend account's favorite list. sharedFullList, @@ -96,8 +108,10 @@ enum FavoriteChannelPersistenceMode { class LiveTvStreamResolution { final String url; final String? playSessionId; + final String? mediaSourceId; + final String? liveStreamId; - const LiveTvStreamResolution({required this.url, this.playSessionId}); + const LiveTvStreamResolution({required this.url, this.playSessionId, this.mediaSourceId, this.liveStreamId}); } /// Backend-neutral live-TV operations. Implementations are obtained via diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index a04efacd..d8a8d9bd 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -561,13 +561,15 @@ abstract class MediaServerClient { /// /// [duration] is the media's total length — passed through to Plex's /// timeline param so the server can use it. Jellyfin ignores [duration] but - /// uses [mediaSourceId] and stream indexes for active-session state. + /// uses [mediaSourceId], [liveStreamId], and stream indexes for active-session + /// state. Jellyfin needs [liveStreamId] to close an auto-opened live source. Future reportPlaybackStarted({ required String itemId, required Duration position, Duration? duration, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -583,6 +585,7 @@ abstract class MediaServerClient { bool isPaused = false, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -596,6 +599,7 @@ abstract class MediaServerClient { required Duration position, Duration? duration, String? playSessionId, + String? liveStreamId, String? mediaSourceId, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }); diff --git a/lib/screens/video_player/live_tv_session_state.dart b/lib/screens/video_player/live_tv_session_state.dart index da86de0b..cdeaf392 100644 --- a/lib/screens/video_player/live_tv_session_state.dart +++ b/lib/screens/video_player/live_tv_session_state.dart @@ -48,6 +48,10 @@ class LiveTvSessionState { /// from the background (it is suspended on hide). bool resumeTimelineOnResume = false; + /// A non-resumable live session was stopped while the TV app was hidden. + /// The player route is closed instead of attempting to reuse that session. + bool exitOnResume = false; + /// Make [newSession] current and seed the seekable window from its tune /// snapshot. Every flow that produces a session (start, retry, channel /// zap) adopts it here, so a field can't be forgotten in one copy. diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index 302e9db0..c7a5c607 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -88,6 +88,26 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { } final isTv = PlatformDetector.isTV(); + if (widget.isLive && shouldStopLiveSessionForTvBackground(isTv: isTv, policy: _live.session?.backgroundPolicy)) { + _live.exitOnResume = true; + _live.resumeTimelineOnResume = false; + _stopLiveTimelineUpdates(); + + // Start the server cleanup before releasing the native stream. tvOS may + // suspend the process shortly after this lifecycle callback returns. + final stoppedReport = _sendStoppedProgressOnce(); + try { + await currentPlayer.stop(); + } catch (e, stackTrace) { + appLogger.w('Failed to stop live player while backgrounding', error: e, stackTrace: stackTrace); + } + await stoppedReport; + if (!mounted || currentPlayer != player) return; + await _suspendMediaControlsForTvBackground('hidden_live_stopped'); + _recordLifecycleState('hidden', action: 'live_stopped_exit_on_resume'); + return; + } + final shouldPauseForBackground = PlatformDetector.isHandheld(context) || isTv; // Pause first so Android MPV does not keep decoding against a transient @@ -127,6 +147,13 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { _recordLifecycleState('resumed', action: 'begin'); _watchTogetherProvider?.setBackgrounded(false); + if (_live.exitOnResume) { + _live.exitOnResume = false; + _recordLifecycleState('resumed', action: 'exit_stopped_live_session'); + await _handleBackButton(); + return; + } + if (Platform.isAndroid && _androidAutoPipTransitionInFlight && !PipService().isPipActive.value) { _setAndroidAutoPipTransitionInFlight(false, reason: 'resume_without_pip'); } diff --git a/lib/screens/video_player/tv_background_suspend_policy.dart b/lib/screens/video_player/tv_background_suspend_policy.dart index bbbad314..b3d78977 100644 --- a/lib/screens/video_player/tv_background_suspend_policy.dart +++ b/lib/screens/video_player/tv_background_suspend_policy.dart @@ -1,9 +1,10 @@ +import '../../media/live_tv_support.dart'; + /// Whether backgrounding may release the native video pipeline after its /// grace period. /// -/// Live TV deliberately stays paused with its player open: the tuned session -/// owns the capture buffer, so stopping it would lose both the time-shift -/// position and the user's paused/playing intent when playback is rebuilt. +/// Retained live sessions stay paused with their player open because stopping +/// one would discard its capture buffer and time-shift position. bool shouldSuspendPlayerForTvBackground({ required bool isAndroid, required bool isTv, @@ -12,3 +13,8 @@ bool shouldSuspendPlayerForTvBackground({ }) { return isAndroid && isTv && !isLive && !alreadySuspended; } + +/// Whether the current TV live session must be closed before suspension. +bool shouldStopLiveSessionForTvBackground({required bool isTv, required LiveTvBackgroundPolicy? policy}) { + return isTv && policy == LiveTvBackgroundPolicy.stopAndExit; +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 63f69385..82bbd392 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -717,6 +717,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin break; case AppLifecycleState.detached: _recordLifecycleState('detached'); + if (widget.isLive) unawaited(_sendStoppedProgressOnce()); break; } } @@ -1221,7 +1222,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin unawaited(_sendStoppedProgressOnce()); _progressTracker?.stopTracking(); _progressTracker?.dispose(); - _sendLiveTimeline('stopped'); _stopLiveTimelineUpdates(); _detachPipStateListener(); @@ -1449,6 +1449,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin void _onSecondarySubtitleTrackChanged(SubtitleTrack track) => _trackManager?.onSecondarySubtitleTrackChanged(track); Future _sendStoppedProgressOnce({Duration? positionOverride}) { + if (widget.isLive) { + _stopLiveTimelineUpdates(); + return _sendLiveTimeline('stopped'); + } + final tracker = _progressTracker; if (tracker == null) return Future.value(); diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index 5e584e4e..bbac841c 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -189,18 +189,28 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { String? nonEmptyString(dynamic raw) => raw is String && raw.isNotEmpty ? raw : null; 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: nonEmptyString(source['Id']), + mediaSourceId: mediaSourceId, playSessionId: playSessionId, - liveStreamId: nonEmptyString(source['LiveStreamId']), + liveStreamId: liveStreamId, ); - playSessionId ??= Uri.tryParse(url)?.queryParameters['PlaySessionId']; - return LiveTvStreamResolution(url: url, playSessionId: playSessionId); + final query = Uri.tryParse(url)?.queryParameters; + playSessionId ??= query?['PlaySessionId']; + mediaSourceId ??= query?['MediaSourceId']; + liveStreamId ??= query?['LiveStreamId']; + return LiveTvStreamResolution( + url: url, + playSessionId: playSessionId, + mediaSourceId: mediaSourceId, + liveStreamId: liveStreamId, + ); } @override @@ -460,11 +470,18 @@ class _JellyfinLiveTvPlaybackSession implements LiveTvPlaybackSession { _JellyfinLiveTvPlaybackSession(this._client, this._channelKey, LiveTvStreamResolution resolution) : _url = resolution.url, - _tracker = JellyfinLiveSessionTracker(playSessionId: resolution.playSessionId); + _tracker = JellyfinLiveSessionTracker( + playSessionId: resolution.playSessionId, + mediaSourceId: resolution.mediaSourceId, + liveStreamId: resolution.liveStreamId, + ); @override LiveProgramInfo get program => LiveProgramInfo.none; + @override + LiveTvBackgroundPolicy get backgroundPolicy => LiveTvBackgroundPolicy.stopAndExit; + @override CaptureBuffer? get captureBuffer => null; diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 3a858d16..dd7d8cbd 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -659,6 +659,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { Duration? duration, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -678,6 +679,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { 'RepeatMode': 'RepeatNone', 'PlaybackOrder': 'Default', 'PlaySessionId': ?playSessionId, + 'LiveStreamId': ?liveStreamId, }, ); throwIfHttpError(response); @@ -694,6 +696,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { bool isPaused = false, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -713,6 +716,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { 'RepeatMode': 'RepeatNone', 'PlaybackOrder': 'Default', 'PlaySessionId': ?playSessionId, + 'LiveStreamId': ?liveStreamId, }, ); throwIfHttpError(response); @@ -726,6 +730,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { required Duration position, Duration? duration, String? playSessionId, + String? liveStreamId, String? mediaSourceId, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { @@ -737,6 +742,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), 'Failed': false, 'PlaySessionId': ?playSessionId, + 'LiveStreamId': ?liveStreamId, }, ); throwIfHttpError(response); diff --git a/lib/services/live_session_tracker.dart b/lib/services/live_session_tracker.dart index c7cf9148..54542b6a 100644 --- a/lib/services/live_session_tracker.dart +++ b/lib/services/live_session_tracker.dart @@ -10,9 +10,12 @@ 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}) : _playSessionId = playSessionId ?? generateSessionIdentifier(); + JellyfinLiveSessionTracker({String? playSessionId, this.mediaSourceId, this.liveStreamId}) + : _playSessionId = playSessionId ?? generateSessionIdentifier(); final String _playSessionId; + final String? mediaSourceId; + final String? liveStreamId; PlaybackReportSession? _session; /// Session id reused across all heartbeats for this playback. Exposed @@ -29,8 +32,20 @@ class JellyfinLiveSessionTracker { required Duration duration, }) async { try { - final session = _session ??= PlaybackReportSession(client: client, itemId: itemId, playSessionId: _playSessionId); - await session.report(PlaybackReportSnapshot(state: state, position: position, duration: duration)); + final session = _session ??= PlaybackReportSession( + client: client, + itemId: itemId, + playSessionId: _playSessionId, + liveStreamId: liveStreamId, + ); + await session.report( + PlaybackReportSnapshot( + state: state, + position: position, + duration: duration, + resolveStreamSelection: () => PlaybackStreamSelection(mediaSourceId: mediaSourceId), + ), + ); } catch (e) { appLogger.d('Jellyfin live progress report failed', error: e); } diff --git a/lib/services/playback_report_session.dart b/lib/services/playback_report_session.dart index 7dd81d23..a1ba94d0 100644 --- a/lib/services/playback_report_session.dart +++ b/lib/services/playback_report_session.dart @@ -58,12 +58,19 @@ class PlaybackReportSnapshot { /// fire reports concurrently, but state changes are recorded synchronously /// before any async work such as settings lookup, track mapping, or HTTP calls. class PlaybackReportSession { - PlaybackReportSession({required this.client, required this.itemId, this.playSessionId, this.playMethod}); + PlaybackReportSession({ + required this.client, + required this.itemId, + this.playSessionId, + this.playMethod, + this.liveStreamId, + }); final MediaServerClient client; final String itemId; final String? playSessionId; final String? playMethod; + final String? liveStreamId; _PlaybackReportState _state = _PlaybackReportState.idle; PlaybackReportSnapshot? _startSnapshot; @@ -244,6 +251,7 @@ class PlaybackReportSession { duration: snapshot.duration, playSessionId: playSessionId, playMethod: playMethod, + liveStreamId: liveStreamId, mediaSourceId: selection.mediaSourceId, audioStreamIndex: selection.audioStreamIndex, subtitleStreamIndex: selection.subtitleStreamIndex, @@ -260,6 +268,7 @@ class PlaybackReportSession { isPaused: snapshot.state == 'paused', playSessionId: playSessionId, playMethod: playMethod, + liveStreamId: liveStreamId, mediaSourceId: selection.mediaSourceId, audioStreamIndex: selection.audioStreamIndex, subtitleStreamIndex: selection.subtitleStreamIndex, @@ -274,6 +283,7 @@ class PlaybackReportSession { position: snapshot.position, duration: snapshot.duration, playSessionId: playSessionId, + liveStreamId: liveStreamId, mediaSourceId: selection.mediaSourceId, report: snapshot.report, ); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 3da0dc1f..7d0c6ebc 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -4472,6 +4472,7 @@ class PlexClient Duration? duration, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -4491,6 +4492,7 @@ class PlexClient bool isPaused = false, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -4508,6 +4510,7 @@ class PlexClient required Duration position, Duration? duration, String? playSessionId, + String? liveStreamId, String? mediaSourceId, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) => updateProgress( diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index 35bfded0..056580b5 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -1429,6 +1429,9 @@ class _PlexLiveTvPlaybackSession implements LiveTvPlaybackSession { @override final LiveProgramInfo program; + @override + LiveTvBackgroundPolicy get backgroundPolicy => LiveTvBackgroundPolicy.retainSession; + @override final CaptureBuffer? captureBuffer; diff --git a/test/screens/video_player/tv_background_suspend_policy_test.dart b/test/screens/video_player/tv_background_suspend_policy_test.dart index 33de6f3f..89b45120 100644 --- a/test/screens/video_player/tv_background_suspend_policy_test.dart +++ b/test/screens/video_player/tv_background_suspend_policy_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/live_tv_support.dart'; import 'package:plezy/screens/video_player/tv_background_suspend_policy.dart'; void main() { @@ -33,4 +34,16 @@ void main() { isFalse, ); }); + + test('TV backgrounding stops non-resumable live sessions', () { + expect(shouldStopLiveSessionForTvBackground(isTv: true, policy: LiveTvBackgroundPolicy.stopAndExit), isTrue); + }); + + test('TV backgrounding retains capture-buffer sessions', () { + expect(shouldStopLiveSessionForTvBackground(isTv: true, policy: LiveTvBackgroundPolicy.retainSession), isFalse); + }); + + test('non-TV backgrounding does not use the TV live-session policy', () { + expect(shouldStopLiveSessionForTvBackground(isTv: false, policy: LiveTvBackgroundPolicy.stopAndExit), isFalse); + }); } diff --git a/test/services/external_player_service_test.dart b/test/services/external_player_service_test.dart index 77003d23..f0c54e23 100644 --- a/test/services/external_player_service_test.dart +++ b/test/services/external_player_service_test.dart @@ -44,6 +44,7 @@ class _RecordingClient implements MediaServerClient { Duration? duration, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -58,6 +59,7 @@ class _RecordingClient implements MediaServerClient { required Duration position, Duration? duration, String? playSessionId, + String? liveStreamId, String? mediaSourceId, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 505430c5..9d4059c2 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -311,6 +311,53 @@ void main() { expect(body['IsPaused'], isTrue); }); + test('live playback reports preserve the same server session identity', () async { + final requests = <({String path, Map body})>[]; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + requests.add((path: request.url.path, body: jsonDecode(request.body) as Map)); + return http.Response('', 204); + }), + ); + addTearDown(scoped.close); + + await scoped.reportPlaybackStarted( + itemId: 'channel-1', + position: Duration.zero, + playSessionId: 'play-1', + liveStreamId: 'live-1', + mediaSourceId: 'source-1', + ); + await scoped.reportPlaybackProgress( + itemId: 'channel-1', + position: const Duration(seconds: 10), + duration: Duration.zero, + playSessionId: 'play-1', + liveStreamId: 'live-1', + mediaSourceId: 'source-1', + ); + await scoped.reportPlaybackStopped( + itemId: 'channel-1', + position: const Duration(seconds: 20), + playSessionId: 'play-1', + liveStreamId: 'live-1', + mediaSourceId: 'source-1', + ); + + expect(requests.map((request) => request.path), [ + '/Sessions/Playing', + '/Sessions/Playing/Progress', + '/Sessions/Playing/Stopped', + ]); + for (final request in requests) { + expect(request.body['ItemId'], 'channel-1'); + expect(request.body['PlaySessionId'], 'play-1'); + expect(request.body['LiveStreamId'], 'live-1'); + expect(request.body['MediaSourceId'], 'source-1'); + } + }); + test('resolveDownload pins direct stream URL and subtitles to selected media source', () async { final requests = []; String? playbackInfoBody; @@ -1396,6 +1443,8 @@ void main() { expect(body['EnableTranscoding'], isFalse); expect(resolution, isNotNull); expect(resolution!.playSessionId, 'live-session-1'); + expect(resolution.mediaSourceId, 'source-1'); + expect(resolution.liveStreamId, 'open-stream-1'); final uri = Uri.parse(resolution.url); expect(uri.path, '/Videos/channel-1/stream'); expect(uri.queryParameters['Static'], 'true'); @@ -1407,6 +1456,38 @@ void main() { expect(uri.queryParameters['api_key'], 'tok-abc'); }); + test('live TV stream resolution recovers identity from a negotiated direct 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': [ + { + 'Container': 'ts', + 'DirectStreamUrl': + '/Videos/channel-1/stream?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url', + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final resolution = await scoped.liveTv.resolveStreamUrl('channel-1'); + + expect(resolution, isNotNull); + expect(resolution!.playSessionId, 'play-url'); + expect(resolution.mediaSourceId, 'source-url'); + expect(resolution.liveStreamId, 'live-url'); + }); + test('buildTrickplayTileUrl wires width, sheet index, api_key, and DeviceId', () { final url = client.buildTrickplayTileUrl('item-99', 320, 4); final uri = Uri.parse(url); diff --git a/test/services/live_session_tracker_test.dart b/test/services/live_session_tracker_test.dart index e86fb95e..561f35dd 100644 --- a/test/services/live_session_tracker_test.dart +++ b/test/services/live_session_tracker_test.dart @@ -16,12 +16,13 @@ class _FakeJellyfinClient implements JellyfinClient { Duration? duration, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, }) async { await startGate.future; - calls.add('started:$itemId:$playSessionId'); + calls.add('started:$itemId:$playSessionId:$mediaSourceId:$liveStreamId'); } @override @@ -32,11 +33,12 @@ class _FakeJellyfinClient implements JellyfinClient { bool isPaused = false, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, }) async { - calls.add('${isPaused ? 'paused' : 'playing'}:$itemId:$playSessionId'); + calls.add('${isPaused ? 'paused' : 'playing'}:$itemId:$playSessionId:$mediaSourceId:$liveStreamId'); } @override @@ -45,10 +47,11 @@ class _FakeJellyfinClient implements JellyfinClient { required Duration position, Duration? duration, String? playSessionId, + String? liveStreamId, String? mediaSourceId, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { - calls.add('stopped:$itemId:$playSessionId'); + calls.add('stopped:$itemId:$playSessionId:$mediaSourceId:$liveStreamId'); } @override @@ -58,7 +61,11 @@ class _FakeJellyfinClient implements JellyfinClient { void main() { test('coalesces duplicate live starts and orders stop after in-flight start', () async { final client = _FakeJellyfinClient(); - final tracker = JellyfinLiveSessionTracker(playSessionId: 'live-session-1'); + final tracker = JellyfinLiveSessionTracker( + playSessionId: 'live-session-1', + mediaSourceId: 'source-1', + liveStreamId: 'live-stream-1', + ); final first = tracker.report( client: client, @@ -89,6 +96,28 @@ void main() { client.startGate.complete(); await Future.wait([first, second, stopped]); - expect(client.calls, ['started:channel-1:live-session-1', 'stopped:channel-1:live-session-1']); + expect(client.calls, [ + 'started:channel-1:live-session-1:source-1:live-stream-1', + 'stopped:channel-1:live-session-1:source-1:live-stream-1', + ]); + }); + + test('stopping before the first heartbeat still reports the negotiated live identity', () async { + final client = _FakeJellyfinClient(); + final tracker = JellyfinLiveSessionTracker( + playSessionId: 'live-session-1', + mediaSourceId: 'source-1', + liveStreamId: 'live-stream-1', + ); + + await tracker.report( + client: client, + itemId: 'channel-1', + state: 'stopped', + position: Duration.zero, + duration: Duration.zero, + ); + + expect(client.calls, ['stopped:channel-1:live-session-1:source-1:live-stream-1']); }); } diff --git a/test/services/live_tv_playback_session_test.dart b/test/services/live_tv_playback_session_test.dart index 57e20e83..73dbcd0a 100644 --- a/test/services/live_tv_playback_session_test.dart +++ b/test/services/live_tv_playback_session_test.dart @@ -8,6 +8,7 @@ import 'package:plezy/connection/connection.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/live_tv_support.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/plex_api_cache.dart'; @@ -245,6 +246,7 @@ void main() { expect(session!.program.id, isNull); expect(session.captureBuffer, isNull); expect(session.canTimeShift, isFalse); + expect(session.backgroundPolicy, LiveTvBackgroundPolicy.stopAndExit); final url = await session.streamUrlAt(); expect(url, isNotNull); diff --git a/test/services/music/music_playback_service_test.dart b/test/services/music/music_playback_service_test.dart index 11b0d090..a1f9d8d6 100644 --- a/test/services/music/music_playback_service_test.dart +++ b/test/services/music/music_playback_service_test.dart @@ -373,6 +373,7 @@ class FakeMediaServerClient extends Fake implements MediaServerClient { Duration? duration, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -388,6 +389,7 @@ class FakeMediaServerClient extends Fake implements MediaServerClient { bool isPaused = false, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -401,6 +403,7 @@ class FakeMediaServerClient extends Fake implements MediaServerClient { required Duration position, Duration? duration, String? playSessionId, + String? liveStreamId, String? mediaSourceId, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 3e4e7837..285d4a3e 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -101,6 +101,7 @@ class _RecordingMediaClient implements MediaServerClient { Duration? duration, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -114,6 +115,7 @@ class _RecordingMediaClient implements MediaServerClient { required Duration position, Duration? duration, String? playSessionId, + String? liveStreamId, String? mediaSourceId, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index e12bbdbe..a6a537b0 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -166,6 +166,7 @@ class _FakePlexClient implements PlexClient { Duration? duration, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -187,6 +188,7 @@ class _FakePlexClient implements PlexClient { bool isPaused = false, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -211,6 +213,7 @@ class _FakePlexClient implements PlexClient { required Duration position, Duration? duration, String? playSessionId, + String? liveStreamId, String? mediaSourceId, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) { @@ -255,6 +258,7 @@ class _DelayedStartClient extends _FakePlexClient { Duration? duration, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -266,6 +270,7 @@ class _DelayedStartClient extends _FakePlexClient { duration: duration, playSessionId: playSessionId, playMethod: playMethod, + liveStreamId: liveStreamId, mediaSourceId: mediaSourceId, audioStreamIndex: audioStreamIndex, subtitleStreamIndex: subtitleStreamIndex, @@ -1133,6 +1138,7 @@ class _ScrobblePreciseClient implements PlexClient { Duration? duration, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -1146,6 +1152,7 @@ class _ScrobblePreciseClient implements PlexClient { bool isPaused = false, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -1157,6 +1164,7 @@ class _ScrobblePreciseClient implements PlexClient { required Duration position, Duration? duration, String? playSessionId, + String? liveStreamId, String? mediaSourceId, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async {} diff --git a/test/services/playback_report_session_test.dart b/test/services/playback_report_session_test.dart index 58fce33a..848fa435 100644 --- a/test/services/playback_report_session_test.dart +++ b/test/services/playback_report_session_test.dart @@ -18,6 +18,7 @@ class _RecordingClient implements MediaServerClient { Duration? duration, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -35,6 +36,7 @@ class _RecordingClient implements MediaServerClient { bool isPaused = false, String? playSessionId, String? playMethod, + String? liveStreamId, String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, @@ -48,6 +50,7 @@ class _RecordingClient implements MediaServerClient { required Duration position, Duration? duration, String? playSessionId, + String? liveStreamId, String? mediaSourceId, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async {