fix: stop Jellyfin live TV sessions

close #1533
This commit is contained in:
edde746
2026-07-11 22:00:05 +02:00
parent 4443b76145
commit 2679745941
21 changed files with 277 additions and 20 deletions
+15 -1
View File
@@ -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<LiveTvPlaybackSession?> 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
+5 -1
View File
@@ -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<void> 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(),
});
@@ -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.
@@ -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');
}
@@ -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;
}
+6 -1
View File
@@ -717,6 +717,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
break;
case AppLifecycleState.detached:
_recordLifecycleState('detached');
if (widget.isLive) unawaited(_sendStoppedProgressOnce());
break;
}
}
@@ -1221,7 +1222,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
unawaited(_sendStoppedProgressOnce());
_progressTracker?.stopTracking();
_progressTracker?.dispose();
_sendLiveTimeline('stopped');
_stopLiveTimelineUpdates();
_detachPipStateListener();
@@ -1449,6 +1449,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
void _onSecondarySubtitleTrackChanged(SubtitleTrack track) => _trackManager?.onSecondarySubtitleTrackChanged(track);
Future<void> _sendStoppedProgressOnce({Duration? positionOverride}) {
if (widget.isLive) {
_stopLiveTimelineUpdates();
return _sendLiveTimeline('stopped');
}
final tracker = _progressTracker;
if (tracker == null) return Future<void>.value();
@@ -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;
@@ -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);
+18 -3
View File
@@ -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);
}
+11 -1
View File
@@ -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,
);
+3
View File
@@ -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(
@@ -1429,6 +1429,9 @@ class _PlexLiveTvPlaybackSession implements LiveTvPlaybackSession {
@override
final LiveProgramInfo program;
@override
LiveTvBackgroundPolicy get backgroundPolicy => LiveTvBackgroundPolicy.retainSession;
@override
final CaptureBuffer? captureBuffer;
@@ -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);
});
}
@@ -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 {
@@ -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<String, dynamic> body})>[];
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
requests.add((path: request.url.path, body: jsonDecode(request.body) as Map<String, dynamic>));
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 = <Uri>[];
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);
+34 -5
View File
@@ -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']);
});
}
@@ -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);
@@ -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 {
@@ -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 {
@@ -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 {}
@@ -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 {