@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user