@@ -57,6 +57,9 @@ class LiveProgramInfo {
|
|||||||
abstract class LiveTvPlaybackSession {
|
abstract class LiveTvPlaybackSession {
|
||||||
LiveProgramInfo get program;
|
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
|
/// Seekable-history snapshot from session start. Heartbeats may return
|
||||||
/// fresher ones ([reportTimeline]); the caller owns tracking the current
|
/// fresher ones ([reportTimeline]); the caller owns tracking the current
|
||||||
/// value.
|
/// value.
|
||||||
@@ -85,6 +88,15 @@ abstract class LiveTvPlaybackSession {
|
|||||||
Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio});
|
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 {
|
enum FavoriteChannelPersistenceMode {
|
||||||
/// A single write replaces the full backend account's favorite list.
|
/// A single write replaces the full backend account's favorite list.
|
||||||
sharedFullList,
|
sharedFullList,
|
||||||
@@ -96,8 +108,10 @@ enum FavoriteChannelPersistenceMode {
|
|||||||
class LiveTvStreamResolution {
|
class LiveTvStreamResolution {
|
||||||
final String url;
|
final String url;
|
||||||
final String? playSessionId;
|
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
|
/// Backend-neutral live-TV operations. Implementations are obtained via
|
||||||
|
|||||||
@@ -561,13 +561,15 @@ abstract class MediaServerClient {
|
|||||||
///
|
///
|
||||||
/// [duration] is the media's total length — passed through to Plex's
|
/// [duration] is the media's total length — passed through to Plex's
|
||||||
/// timeline param so the server can use it. Jellyfin ignores [duration] but
|
/// 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({
|
Future<void> reportPlaybackStarted({
|
||||||
required String itemId,
|
required String itemId,
|
||||||
required Duration position,
|
required Duration position,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -583,6 +585,7 @@ abstract class MediaServerClient {
|
|||||||
bool isPaused = false,
|
bool isPaused = false,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -596,6 +599,7 @@ abstract class MediaServerClient {
|
|||||||
required Duration position,
|
required Duration position,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ class LiveTvSessionState {
|
|||||||
/// from the background (it is suspended on hide).
|
/// from the background (it is suspended on hide).
|
||||||
bool resumeTimelineOnResume = false;
|
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
|
/// Make [newSession] current and seed the seekable window from its tune
|
||||||
/// snapshot. Every flow that produces a session (start, retry, channel
|
/// snapshot. Every flow that produces a session (start, retry, channel
|
||||||
/// zap) adopts it here, so a field can't be forgotten in one copy.
|
/// 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();
|
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;
|
final shouldPauseForBackground = PlatformDetector.isHandheld(context) || isTv;
|
||||||
|
|
||||||
// Pause first so Android MPV does not keep decoding against a transient
|
// Pause first so Android MPV does not keep decoding against a transient
|
||||||
@@ -127,6 +147,13 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
|||||||
_recordLifecycleState('resumed', action: 'begin');
|
_recordLifecycleState('resumed', action: 'begin');
|
||||||
_watchTogetherProvider?.setBackgrounded(false);
|
_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) {
|
if (Platform.isAndroid && _androidAutoPipTransitionInFlight && !PipService().isPipActive.value) {
|
||||||
_setAndroidAutoPipTransitionInFlight(false, reason: 'resume_without_pip');
|
_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
|
/// Whether backgrounding may release the native video pipeline after its
|
||||||
/// grace period.
|
/// grace period.
|
||||||
///
|
///
|
||||||
/// Live TV deliberately stays paused with its player open: the tuned session
|
/// Retained live sessions stay paused with their player open because stopping
|
||||||
/// owns the capture buffer, so stopping it would lose both the time-shift
|
/// one would discard its capture buffer and time-shift position.
|
||||||
/// position and the user's paused/playing intent when playback is rebuilt.
|
|
||||||
bool shouldSuspendPlayerForTvBackground({
|
bool shouldSuspendPlayerForTvBackground({
|
||||||
required bool isAndroid,
|
required bool isAndroid,
|
||||||
required bool isTv,
|
required bool isTv,
|
||||||
@@ -12,3 +13,8 @@ bool shouldSuspendPlayerForTvBackground({
|
|||||||
}) {
|
}) {
|
||||||
return isAndroid && isTv && !isLive && !alreadySuspended;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -717,6 +717,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
break;
|
break;
|
||||||
case AppLifecycleState.detached:
|
case AppLifecycleState.detached:
|
||||||
_recordLifecycleState('detached');
|
_recordLifecycleState('detached');
|
||||||
|
if (widget.isLive) unawaited(_sendStoppedProgressOnce());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1221,7 +1222,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
unawaited(_sendStoppedProgressOnce());
|
unawaited(_sendStoppedProgressOnce());
|
||||||
_progressTracker?.stopTracking();
|
_progressTracker?.stopTracking();
|
||||||
_progressTracker?.dispose();
|
_progressTracker?.dispose();
|
||||||
_sendLiveTimeline('stopped');
|
|
||||||
_stopLiveTimelineUpdates();
|
_stopLiveTimelineUpdates();
|
||||||
|
|
||||||
_detachPipStateListener();
|
_detachPipStateListener();
|
||||||
@@ -1449,6 +1449,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
void _onSecondarySubtitleTrackChanged(SubtitleTrack track) => _trackManager?.onSecondarySubtitleTrackChanged(track);
|
void _onSecondarySubtitleTrackChanged(SubtitleTrack track) => _trackManager?.onSecondarySubtitleTrackChanged(track);
|
||||||
|
|
||||||
Future<void> _sendStoppedProgressOnce({Duration? positionOverride}) {
|
Future<void> _sendStoppedProgressOnce({Duration? positionOverride}) {
|
||||||
|
if (widget.isLive) {
|
||||||
|
_stopLiveTimelineUpdates();
|
||||||
|
return _sendLiveTimeline('stopped');
|
||||||
|
}
|
||||||
|
|
||||||
final tracker = _progressTracker;
|
final tracker = _progressTracker;
|
||||||
if (tracker == null) return Future<void>.value();
|
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;
|
String? nonEmptyString(dynamic raw) => raw is String && raw.isNotEmpty ? raw : null;
|
||||||
|
|
||||||
var playSessionId = nonEmptyString(info?['PlaySessionId']);
|
var playSessionId = nonEmptyString(info?['PlaySessionId']);
|
||||||
|
var mediaSourceId = nonEmptyString(source['Id']);
|
||||||
|
var liveStreamId = nonEmptyString(source['LiveStreamId']);
|
||||||
final rawUrl = nonEmptyString(source['DirectStreamUrl']);
|
final rawUrl = nonEmptyString(source['DirectStreamUrl']);
|
||||||
final url = rawUrl != null
|
final url = rawUrl != null
|
||||||
? _client._withApiKey(rawUrl)
|
? _client._withApiKey(rawUrl)
|
||||||
: _client.buildDirectStreamUrl(
|
: _client.buildDirectStreamUrl(
|
||||||
channelKey,
|
channelKey,
|
||||||
container: nonEmptyString(source['Container']),
|
container: nonEmptyString(source['Container']),
|
||||||
mediaSourceId: nonEmptyString(source['Id']),
|
mediaSourceId: mediaSourceId,
|
||||||
playSessionId: playSessionId,
|
playSessionId: playSessionId,
|
||||||
liveStreamId: nonEmptyString(source['LiveStreamId']),
|
liveStreamId: liveStreamId,
|
||||||
);
|
);
|
||||||
playSessionId ??= Uri.tryParse(url)?.queryParameters['PlaySessionId'];
|
final query = Uri.tryParse(url)?.queryParameters;
|
||||||
return LiveTvStreamResolution(url: url, playSessionId: playSessionId);
|
playSessionId ??= query?['PlaySessionId'];
|
||||||
|
mediaSourceId ??= query?['MediaSourceId'];
|
||||||
|
liveStreamId ??= query?['LiveStreamId'];
|
||||||
|
return LiveTvStreamResolution(
|
||||||
|
url: url,
|
||||||
|
playSessionId: playSessionId,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
liveStreamId: liveStreamId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -460,11 +470,18 @@ class _JellyfinLiveTvPlaybackSession implements LiveTvPlaybackSession {
|
|||||||
|
|
||||||
_JellyfinLiveTvPlaybackSession(this._client, this._channelKey, LiveTvStreamResolution resolution)
|
_JellyfinLiveTvPlaybackSession(this._client, this._channelKey, LiveTvStreamResolution resolution)
|
||||||
: _url = resolution.url,
|
: _url = resolution.url,
|
||||||
_tracker = JellyfinLiveSessionTracker(playSessionId: resolution.playSessionId);
|
_tracker = JellyfinLiveSessionTracker(
|
||||||
|
playSessionId: resolution.playSessionId,
|
||||||
|
mediaSourceId: resolution.mediaSourceId,
|
||||||
|
liveStreamId: resolution.liveStreamId,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
LiveProgramInfo get program => LiveProgramInfo.none;
|
LiveProgramInfo get program => LiveProgramInfo.none;
|
||||||
|
|
||||||
|
@override
|
||||||
|
LiveTvBackgroundPolicy get backgroundPolicy => LiveTvBackgroundPolicy.stopAndExit;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
CaptureBuffer? get captureBuffer => null;
|
CaptureBuffer? get captureBuffer => null;
|
||||||
|
|
||||||
|
|||||||
@@ -659,6 +659,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -678,6 +679,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
|||||||
'RepeatMode': 'RepeatNone',
|
'RepeatMode': 'RepeatNone',
|
||||||
'PlaybackOrder': 'Default',
|
'PlaybackOrder': 'Default',
|
||||||
'PlaySessionId': ?playSessionId,
|
'PlaySessionId': ?playSessionId,
|
||||||
|
'LiveStreamId': ?liveStreamId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
@@ -694,6 +696,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
|||||||
bool isPaused = false,
|
bool isPaused = false,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -713,6 +716,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
|||||||
'RepeatMode': 'RepeatNone',
|
'RepeatMode': 'RepeatNone',
|
||||||
'PlaybackOrder': 'Default',
|
'PlaybackOrder': 'Default',
|
||||||
'PlaySessionId': ?playSessionId,
|
'PlaySessionId': ?playSessionId,
|
||||||
|
'LiveStreamId': ?liveStreamId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
@@ -726,6 +730,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
|||||||
required Duration position,
|
required Duration position,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
||||||
}) async {
|
}) async {
|
||||||
@@ -737,6 +742,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
|||||||
'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
|
'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
|
||||||
'Failed': false,
|
'Failed': false,
|
||||||
'PlaySessionId': ?playSessionId,
|
'PlaySessionId': ?playSessionId,
|
||||||
|
'LiveStreamId': ?liveStreamId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import 'playback_report_session.dart';
|
|||||||
/// Plex live path keeps its bespoke capture-buffer flow inline at the call
|
/// Plex live path keeps its bespoke capture-buffer flow inline at the call
|
||||||
/// site; this tracker only covers Jellyfin's `/Sessions/Playing*` flow.
|
/// site; this tracker only covers Jellyfin's `/Sessions/Playing*` flow.
|
||||||
class JellyfinLiveSessionTracker {
|
class JellyfinLiveSessionTracker {
|
||||||
JellyfinLiveSessionTracker({String? playSessionId}) : _playSessionId = playSessionId ?? generateSessionIdentifier();
|
JellyfinLiveSessionTracker({String? playSessionId, this.mediaSourceId, this.liveStreamId})
|
||||||
|
: _playSessionId = playSessionId ?? generateSessionIdentifier();
|
||||||
|
|
||||||
final String _playSessionId;
|
final String _playSessionId;
|
||||||
|
final String? mediaSourceId;
|
||||||
|
final String? liveStreamId;
|
||||||
PlaybackReportSession? _session;
|
PlaybackReportSession? _session;
|
||||||
|
|
||||||
/// Session id reused across all heartbeats for this playback. Exposed
|
/// Session id reused across all heartbeats for this playback. Exposed
|
||||||
@@ -29,8 +32,20 @@ class JellyfinLiveSessionTracker {
|
|||||||
required Duration duration,
|
required Duration duration,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final session = _session ??= PlaybackReportSession(client: client, itemId: itemId, playSessionId: _playSessionId);
|
final session = _session ??= PlaybackReportSession(
|
||||||
await session.report(PlaybackReportSnapshot(state: state, position: position, duration: duration));
|
client: client,
|
||||||
|
itemId: itemId,
|
||||||
|
playSessionId: _playSessionId,
|
||||||
|
liveStreamId: liveStreamId,
|
||||||
|
);
|
||||||
|
await session.report(
|
||||||
|
PlaybackReportSnapshot(
|
||||||
|
state: state,
|
||||||
|
position: position,
|
||||||
|
duration: duration,
|
||||||
|
resolveStreamSelection: () => PlaybackStreamSelection(mediaSourceId: mediaSourceId),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.d('Jellyfin live progress report failed', error: e);
|
appLogger.d('Jellyfin live progress report failed', error: e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,12 +58,19 @@ class PlaybackReportSnapshot {
|
|||||||
/// fire reports concurrently, but state changes are recorded synchronously
|
/// fire reports concurrently, but state changes are recorded synchronously
|
||||||
/// before any async work such as settings lookup, track mapping, or HTTP calls.
|
/// before any async work such as settings lookup, track mapping, or HTTP calls.
|
||||||
class PlaybackReportSession {
|
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 MediaServerClient client;
|
||||||
final String itemId;
|
final String itemId;
|
||||||
final String? playSessionId;
|
final String? playSessionId;
|
||||||
final String? playMethod;
|
final String? playMethod;
|
||||||
|
final String? liveStreamId;
|
||||||
|
|
||||||
_PlaybackReportState _state = _PlaybackReportState.idle;
|
_PlaybackReportState _state = _PlaybackReportState.idle;
|
||||||
PlaybackReportSnapshot? _startSnapshot;
|
PlaybackReportSnapshot? _startSnapshot;
|
||||||
@@ -244,6 +251,7 @@ class PlaybackReportSession {
|
|||||||
duration: snapshot.duration,
|
duration: snapshot.duration,
|
||||||
playSessionId: playSessionId,
|
playSessionId: playSessionId,
|
||||||
playMethod: playMethod,
|
playMethod: playMethod,
|
||||||
|
liveStreamId: liveStreamId,
|
||||||
mediaSourceId: selection.mediaSourceId,
|
mediaSourceId: selection.mediaSourceId,
|
||||||
audioStreamIndex: selection.audioStreamIndex,
|
audioStreamIndex: selection.audioStreamIndex,
|
||||||
subtitleStreamIndex: selection.subtitleStreamIndex,
|
subtitleStreamIndex: selection.subtitleStreamIndex,
|
||||||
@@ -260,6 +268,7 @@ class PlaybackReportSession {
|
|||||||
isPaused: snapshot.state == 'paused',
|
isPaused: snapshot.state == 'paused',
|
||||||
playSessionId: playSessionId,
|
playSessionId: playSessionId,
|
||||||
playMethod: playMethod,
|
playMethod: playMethod,
|
||||||
|
liveStreamId: liveStreamId,
|
||||||
mediaSourceId: selection.mediaSourceId,
|
mediaSourceId: selection.mediaSourceId,
|
||||||
audioStreamIndex: selection.audioStreamIndex,
|
audioStreamIndex: selection.audioStreamIndex,
|
||||||
subtitleStreamIndex: selection.subtitleStreamIndex,
|
subtitleStreamIndex: selection.subtitleStreamIndex,
|
||||||
@@ -274,6 +283,7 @@ class PlaybackReportSession {
|
|||||||
position: snapshot.position,
|
position: snapshot.position,
|
||||||
duration: snapshot.duration,
|
duration: snapshot.duration,
|
||||||
playSessionId: playSessionId,
|
playSessionId: playSessionId,
|
||||||
|
liveStreamId: liveStreamId,
|
||||||
mediaSourceId: selection.mediaSourceId,
|
mediaSourceId: selection.mediaSourceId,
|
||||||
report: snapshot.report,
|
report: snapshot.report,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4472,6 +4472,7 @@ class PlexClient
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -4491,6 +4492,7 @@ class PlexClient
|
|||||||
bool isPaused = false,
|
bool isPaused = false,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -4508,6 +4510,7 @@ class PlexClient
|
|||||||
required Duration position,
|
required Duration position,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
||||||
}) => updateProgress(
|
}) => updateProgress(
|
||||||
|
|||||||
@@ -1429,6 +1429,9 @@ class _PlexLiveTvPlaybackSession implements LiveTvPlaybackSession {
|
|||||||
@override
|
@override
|
||||||
final LiveProgramInfo program;
|
final LiveProgramInfo program;
|
||||||
|
|
||||||
|
@override
|
||||||
|
LiveTvBackgroundPolicy get backgroundPolicy => LiveTvBackgroundPolicy.retainSession;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final CaptureBuffer? captureBuffer;
|
final CaptureBuffer? captureBuffer;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
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';
|
import 'package:plezy/screens/video_player/tv_background_suspend_policy.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -33,4 +34,16 @@ void main() {
|
|||||||
isFalse,
|
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,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -58,6 +59,7 @@ class _RecordingClient implements MediaServerClient {
|
|||||||
required Duration position,
|
required Duration position,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
||||||
}) async {
|
}) async {
|
||||||
|
|||||||
@@ -311,6 +311,53 @@ void main() {
|
|||||||
expect(body['IsPaused'], isTrue);
|
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 {
|
test('resolveDownload pins direct stream URL and subtitles to selected media source', () async {
|
||||||
final requests = <Uri>[];
|
final requests = <Uri>[];
|
||||||
String? playbackInfoBody;
|
String? playbackInfoBody;
|
||||||
@@ -1396,6 +1443,8 @@ void main() {
|
|||||||
expect(body['EnableTranscoding'], isFalse);
|
expect(body['EnableTranscoding'], isFalse);
|
||||||
expect(resolution, isNotNull);
|
expect(resolution, isNotNull);
|
||||||
expect(resolution!.playSessionId, 'live-session-1');
|
expect(resolution!.playSessionId, 'live-session-1');
|
||||||
|
expect(resolution.mediaSourceId, 'source-1');
|
||||||
|
expect(resolution.liveStreamId, 'open-stream-1');
|
||||||
final uri = Uri.parse(resolution.url);
|
final uri = Uri.parse(resolution.url);
|
||||||
expect(uri.path, '/Videos/channel-1/stream');
|
expect(uri.path, '/Videos/channel-1/stream');
|
||||||
expect(uri.queryParameters['Static'], 'true');
|
expect(uri.queryParameters['Static'], 'true');
|
||||||
@@ -1407,6 +1456,38 @@ void main() {
|
|||||||
expect(uri.queryParameters['api_key'], 'tok-abc');
|
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', () {
|
test('buildTrickplayTileUrl wires width, sheet index, api_key, and DeviceId', () {
|
||||||
final url = client.buildTrickplayTileUrl('item-99', 320, 4);
|
final url = client.buildTrickplayTileUrl('item-99', 320, 4);
|
||||||
final uri = Uri.parse(url);
|
final uri = Uri.parse(url);
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ class _FakeJellyfinClient implements JellyfinClient {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
}) async {
|
}) async {
|
||||||
await startGate.future;
|
await startGate.future;
|
||||||
calls.add('started:$itemId:$playSessionId');
|
calls.add('started:$itemId:$playSessionId:$mediaSourceId:$liveStreamId');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -32,11 +33,12 @@ class _FakeJellyfinClient implements JellyfinClient {
|
|||||||
bool isPaused = false,
|
bool isPaused = false,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
}) async {
|
}) async {
|
||||||
calls.add('${isPaused ? 'paused' : 'playing'}:$itemId:$playSessionId');
|
calls.add('${isPaused ? 'paused' : 'playing'}:$itemId:$playSessionId:$mediaSourceId:$liveStreamId');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -45,10 +47,11 @@ class _FakeJellyfinClient implements JellyfinClient {
|
|||||||
required Duration position,
|
required Duration position,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
||||||
}) async {
|
}) async {
|
||||||
calls.add('stopped:$itemId:$playSessionId');
|
calls.add('stopped:$itemId:$playSessionId:$mediaSourceId:$liveStreamId');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -58,7 +61,11 @@ class _FakeJellyfinClient implements JellyfinClient {
|
|||||||
void main() {
|
void main() {
|
||||||
test('coalesces duplicate live starts and orders stop after in-flight start', () async {
|
test('coalesces duplicate live starts and orders stop after in-flight start', () async {
|
||||||
final client = _FakeJellyfinClient();
|
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(
|
final first = tracker.report(
|
||||||
client: client,
|
client: client,
|
||||||
@@ -89,6 +96,28 @@ void main() {
|
|||||||
client.startGate.complete();
|
client.startGate.complete();
|
||||||
await Future.wait([first, second, stopped]);
|
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/database/app_database.dart';
|
||||||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||||
import 'package:plezy/media/ids.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/models/plex/plex_config.dart';
|
||||||
import 'package:plezy/services/jellyfin_client.dart';
|
import 'package:plezy/services/jellyfin_client.dart';
|
||||||
import 'package:plezy/services/plex_api_cache.dart';
|
import 'package:plezy/services/plex_api_cache.dart';
|
||||||
@@ -245,6 +246,7 @@ void main() {
|
|||||||
expect(session!.program.id, isNull);
|
expect(session!.program.id, isNull);
|
||||||
expect(session.captureBuffer, isNull);
|
expect(session.captureBuffer, isNull);
|
||||||
expect(session.canTimeShift, isFalse);
|
expect(session.canTimeShift, isFalse);
|
||||||
|
expect(session.backgroundPolicy, LiveTvBackgroundPolicy.stopAndExit);
|
||||||
|
|
||||||
final url = await session.streamUrlAt();
|
final url = await session.streamUrlAt();
|
||||||
expect(url, isNotNull);
|
expect(url, isNotNull);
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ class FakeMediaServerClient extends Fake implements MediaServerClient {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -388,6 +389,7 @@ class FakeMediaServerClient extends Fake implements MediaServerClient {
|
|||||||
bool isPaused = false,
|
bool isPaused = false,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -401,6 +403,7 @@ class FakeMediaServerClient extends Fake implements MediaServerClient {
|
|||||||
required Duration position,
|
required Duration position,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
||||||
}) async {
|
}) async {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class _RecordingMediaClient implements MediaServerClient {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -114,6 +115,7 @@ class _RecordingMediaClient implements MediaServerClient {
|
|||||||
required Duration position,
|
required Duration position,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
||||||
}) async {
|
}) async {
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ class _FakePlexClient implements PlexClient {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -187,6 +188,7 @@ class _FakePlexClient implements PlexClient {
|
|||||||
bool isPaused = false,
|
bool isPaused = false,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -211,6 +213,7 @@ class _FakePlexClient implements PlexClient {
|
|||||||
required Duration position,
|
required Duration position,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
||||||
}) {
|
}) {
|
||||||
@@ -255,6 +258,7 @@ class _DelayedStartClient extends _FakePlexClient {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -266,6 +270,7 @@ class _DelayedStartClient extends _FakePlexClient {
|
|||||||
duration: duration,
|
duration: duration,
|
||||||
playSessionId: playSessionId,
|
playSessionId: playSessionId,
|
||||||
playMethod: playMethod,
|
playMethod: playMethod,
|
||||||
|
liveStreamId: liveStreamId,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
audioStreamIndex: audioStreamIndex,
|
audioStreamIndex: audioStreamIndex,
|
||||||
subtitleStreamIndex: subtitleStreamIndex,
|
subtitleStreamIndex: subtitleStreamIndex,
|
||||||
@@ -1133,6 +1138,7 @@ class _ScrobblePreciseClient implements PlexClient {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -1146,6 +1152,7 @@ class _ScrobblePreciseClient implements PlexClient {
|
|||||||
bool isPaused = false,
|
bool isPaused = false,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -1157,6 +1164,7 @@ class _ScrobblePreciseClient implements PlexClient {
|
|||||||
required Duration position,
|
required Duration position,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
||||||
}) async {}
|
}) async {}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class _RecordingClient implements MediaServerClient {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -35,6 +36,7 @@ class _RecordingClient implements MediaServerClient {
|
|||||||
bool isPaused = false,
|
bool isPaused = false,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
String? playMethod,
|
String? playMethod,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
int? audioStreamIndex,
|
int? audioStreamIndex,
|
||||||
int? subtitleStreamIndex,
|
int? subtitleStreamIndex,
|
||||||
@@ -48,6 +50,7 @@ class _RecordingClient implements MediaServerClient {
|
|||||||
required Duration position,
|
required Duration position,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
String? playSessionId,
|
String? playSessionId,
|
||||||
|
String? liveStreamId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
||||||
}) async {
|
}) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user