fix(live-tv): stabilize HLS playback
This commit is contained in:
@@ -181,7 +181,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('ExoPlayer maps copyts transcode streams as absolute timeline positions', () async {
|
||||
test('ExoPlayer opens HLS transcodes at native timeline positions', () async {
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
await withMockPlayerChannels(
|
||||
@@ -202,8 +202,7 @@ void main() {
|
||||
const timelineStart = Duration(seconds: 2058); // 34:18
|
||||
const timelineDuration = Duration(seconds: 2903); // 48:23
|
||||
await player.open(
|
||||
Media('https://example.test/transcode.mkv'),
|
||||
timelineOffset: timelineStart,
|
||||
Media('https://example.test/start.m3u8', start: timelineStart),
|
||||
timelineDuration: timelineDuration,
|
||||
);
|
||||
|
||||
@@ -212,8 +211,8 @@ void main() {
|
||||
|
||||
final openCall = calls.singleWhere((call) => call.method == 'open');
|
||||
final openArgs = Map<Object?, Object?>.from(openCall.arguments as Map);
|
||||
expect(openArgs['startPositionMs'], 0);
|
||||
expect(openArgs['hasStartPosition'], isFalse);
|
||||
expect(openArgs['startPositionMs'], timelineStart.inMilliseconds);
|
||||
expect(openArgs['hasStartPosition'], isTrue);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 260));
|
||||
player.handlePropertyChange('time-pos', 2058.0);
|
||||
@@ -232,7 +231,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('ExoPlayer source-offset open keeps timeline offset after stale native zero position', () async {
|
||||
test('ExoPlayer HLS open keeps the requested position after stale native zero position', () async {
|
||||
final calls = <MethodCall>[];
|
||||
late PlayerAndroid player;
|
||||
|
||||
@@ -259,8 +258,7 @@ void main() {
|
||||
const timelineStart = Duration(seconds: 2058);
|
||||
const timelineDuration = Duration(seconds: 2903);
|
||||
await player.open(
|
||||
Media('https://example.test/transcode.mkv'),
|
||||
timelineOffset: timelineStart,
|
||||
Media('https://example.test/start.m3u8', start: timelineStart),
|
||||
timelineDuration: timelineDuration,
|
||||
);
|
||||
|
||||
@@ -269,8 +267,8 @@ void main() {
|
||||
|
||||
final openCall = calls.singleWhere((call) => call.method == 'open');
|
||||
final openArgs = Map<Object?, Object?>.from(openCall.arguments as Map);
|
||||
expect(openArgs['startPositionMs'], 0);
|
||||
expect(openArgs['hasStartPosition'], isFalse);
|
||||
expect(openArgs['startPositionMs'], timelineStart.inMilliseconds);
|
||||
expect(openArgs['hasStartPosition'], isTrue);
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
@@ -610,7 +608,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV maps server-offset streams to absolute timeline positions', () async {
|
||||
test('MPV opens HLS transcodes at native timeline positions', () async {
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
await withMockPlayerChannels(
|
||||
@@ -629,8 +627,7 @@ void main() {
|
||||
final player = PlayerNative();
|
||||
try {
|
||||
await player.open(
|
||||
Media('https://example.test/transcode.mkv'),
|
||||
timelineOffset: const Duration(seconds: 10),
|
||||
Media('https://example.test/start.m3u8', start: const Duration(seconds: 10)),
|
||||
timelineDuration: const Duration(seconds: 100),
|
||||
);
|
||||
|
||||
@@ -644,7 +641,7 @@ void main() {
|
||||
|
||||
final seekCall = calls.lastWhere((call) => call.method == 'command');
|
||||
final args = Map<Object?, Object?>.from(seekCall.arguments as Map)['args'] as List;
|
||||
expect(args, ['seek', '15.0', 'absolute']);
|
||||
expect(args, ['seek', '25.0', 'absolute']);
|
||||
expect(player.state.position, const Duration(seconds: 25));
|
||||
} finally {
|
||||
await player.dispose();
|
||||
@@ -653,7 +650,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV refresh seek preserves timeline offset position', () async {
|
||||
test('MPV HLS refresh seek preserves the requested position', () async {
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
await withMockPlayerChannels(
|
||||
@@ -673,8 +670,7 @@ void main() {
|
||||
try {
|
||||
const timelineStart = Duration(milliseconds: 143894);
|
||||
await player.open(
|
||||
Media('https://example.test/transcode.mkv'),
|
||||
timelineOffset: timelineStart,
|
||||
Media('https://example.test/start.m3u8', start: timelineStart),
|
||||
timelineDuration: const Duration(seconds: 1502),
|
||||
);
|
||||
|
||||
@@ -684,7 +680,7 @@ void main() {
|
||||
|
||||
final seekCall = calls.lastWhere((call) => call.method == 'command');
|
||||
final args = Map<Object?, Object?>.from(seekCall.arguments as Map)['args'] as List;
|
||||
expect(args, ['seek', '0.0', 'absolute']);
|
||||
expect(args, ['seek', '143.894', 'absolute']);
|
||||
expect(player.state.position, timelineStart);
|
||||
} finally {
|
||||
await player.dispose();
|
||||
|
||||
@@ -1464,7 +1464,7 @@ void main() {
|
||||
expect(uri.queryParameters['api_key'], 'tok-abc');
|
||||
});
|
||||
|
||||
test('live TV stream resolution opens a direct stream instead of HLS transcode', () async {
|
||||
test('live TV stream resolution requires an HLS transcode', () async {
|
||||
final requests = <Uri>[];
|
||||
String? capturedBody;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
@@ -1498,30 +1498,28 @@ void main() {
|
||||
|
||||
expect(requests.single.path, '/Items/channel-1/PlaybackInfo');
|
||||
expect(requests.single.queryParameters['AutoOpenLiveStream'], 'true');
|
||||
expect(requests.single.queryParameters['EnableTranscoding'], 'false');
|
||||
expect(requests.single.queryParameters['EnableDirectPlay'], 'true');
|
||||
expect(requests.single.queryParameters['EnableDirectStream'], 'true');
|
||||
expect(requests.single.queryParameters['EnableTranscoding'], 'true');
|
||||
expect(requests.single.queryParameters['EnableDirectPlay'], 'false');
|
||||
expect(requests.single.queryParameters['EnableDirectStream'], 'false');
|
||||
expect(requests.single.queryParameters['AllowVideoStreamCopy'], 'true');
|
||||
expect(requests.single.queryParameters['AllowAudioStreamCopy'], 'true');
|
||||
final body = jsonDecode(capturedBody!) as Map<String, dynamic>;
|
||||
expect(body['AutoOpenLiveStream'], isTrue);
|
||||
expect(body['EnableTranscoding'], isFalse);
|
||||
expect(body['EnableTranscoding'], isTrue);
|
||||
expect(body['EnableDirectPlay'], isFalse);
|
||||
expect(body['EnableDirectStream'], isFalse);
|
||||
expect(resolution, isNotNull);
|
||||
expect(resolution!.playSessionId, 'live-session-1');
|
||||
expect(resolution.mediaSourceId, 'source-1');
|
||||
expect(resolution.liveStreamId, 'open-stream-1');
|
||||
expect(resolution.playMethod, 'Transcode');
|
||||
final uri = Uri.parse(resolution.url);
|
||||
expect(uri.path, '/Videos/channel-1/stream');
|
||||
expect(uri.queryParameters['Static'], 'true');
|
||||
expect(uri.queryParameters['Container'], 'ts');
|
||||
expect(uri.queryParameters['MediaSourceId'], 'source-1');
|
||||
expect(uri.queryParameters['LiveStreamId'], 'open-stream-1');
|
||||
expect(uri.path, '/Videos/channel-1/live.m3u8');
|
||||
expect(uri.queryParameters['PlaySessionId'], 'live-session-1');
|
||||
expect(uri.queryParameters['DeviceId'], 'dev-xyz');
|
||||
expect(uri.queryParameters['api_key'], 'tok-abc');
|
||||
});
|
||||
|
||||
test('live TV stream resolution recovers identity from a negotiated direct URL', () async {
|
||||
test('live TV stream resolution recovers identity from a negotiated HLS URL', () async {
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((request) async {
|
||||
@@ -1531,8 +1529,8 @@ void main() {
|
||||
'MediaSources': [
|
||||
{
|
||||
'Container': 'ts',
|
||||
'DirectStreamUrl':
|
||||
'/Videos/channel-1/stream?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url',
|
||||
'TranscodingUrl':
|
||||
'/Videos/channel-1/live.m3u8?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -1551,6 +1549,30 @@ void main() {
|
||||
expect(resolution!.playSessionId, 'play-url');
|
||||
expect(resolution.mediaSourceId, 'source-url');
|
||||
expect(resolution.liveStreamId, 'live-url');
|
||||
expect(resolution.playMethod, 'Transcode');
|
||||
});
|
||||
|
||||
test('live TV stream resolution rejects a non-HLS fallback 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': [
|
||||
{'DirectStreamUrl': '/Videos/channel-1/stream.ts'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 404);
|
||||
}),
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
expect(await scoped.liveTv.resolveStreamUrl('channel-1'), isNull);
|
||||
});
|
||||
|
||||
test('buildTrickplayTileUrl wires width, sheet index, api_key, and DeviceId', () {
|
||||
|
||||
@@ -98,6 +98,41 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('does not reopen when a skip is clamped to the current boundary', () {
|
||||
fakeAsync((async) {
|
||||
window = (start: 950, end: 1050);
|
||||
final acc = build();
|
||||
|
||||
currentEpoch = 1050;
|
||||
acc.seekBy(15);
|
||||
expect(acc.pendingEpoch, isNull);
|
||||
|
||||
currentEpoch = 950;
|
||||
acc.seekBy(-15);
|
||||
expect(acc.pendingEpoch, isNull);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 300));
|
||||
expect(seeks, isEmpty);
|
||||
expect(changes, 0);
|
||||
acc.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('still seeks away from a capture-buffer boundary', () {
|
||||
fakeAsync((async) {
|
||||
window = (start: 950, end: 1050);
|
||||
currentEpoch = 1050;
|
||||
final acc = build();
|
||||
|
||||
acc.seekBy(-15);
|
||||
expect(acc.pendingEpoch, 1035);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 300));
|
||||
expect(seeks, [1035]);
|
||||
acc.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('flushes the newer target when a press lands during the seek', () {
|
||||
fakeAsync((async) {
|
||||
gate = Completer<void>();
|
||||
|
||||
@@ -22,7 +22,7 @@ class _FakeJellyfinClient implements JellyfinClient {
|
||||
int? subtitleStreamIndex,
|
||||
}) async {
|
||||
await startGate.future;
|
||||
calls.add('started:$itemId:$playSessionId:$mediaSourceId:$liveStreamId');
|
||||
calls.add('started:$itemId:$playSessionId:$mediaSourceId:$liveStreamId:$playMethod');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -65,6 +65,7 @@ void main() {
|
||||
playSessionId: 'live-session-1',
|
||||
mediaSourceId: 'source-1',
|
||||
liveStreamId: 'live-stream-1',
|
||||
playMethod: 'Transcode',
|
||||
);
|
||||
|
||||
final first = tracker.report(
|
||||
@@ -97,7 +98,7 @@ void main() {
|
||||
await Future.wait([first, second, stopped]);
|
||||
|
||||
expect(client.calls, [
|
||||
'started:channel-1:live-session-1:source-1:live-stream-1',
|
||||
'started:channel-1:live-session-1:source-1:live-stream-1:Transcode',
|
||||
'stopped:channel-1:live-session-1:source-1:live-stream-1',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -239,7 +239,7 @@ void main() {
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
|
||||
test('startPlayback negotiates one direct URL; no time-shift; recover reuses it', () async {
|
||||
test('startPlayback negotiates one HLS URL; no time-shift; recover reuses it', () async {
|
||||
final client = JellyfinClient.forTesting(
|
||||
connection: conn(),
|
||||
httpClient: MockClient((request) async {
|
||||
@@ -247,7 +247,12 @@ void main() {
|
||||
return jsonResponse({
|
||||
'PlaySessionId': 'play-1',
|
||||
'MediaSources': [
|
||||
{'Id': 'source-1', 'Container': 'ts', 'LiveStreamId': 'live-1'},
|
||||
{
|
||||
'Id': 'source-1',
|
||||
'Container': 'ts',
|
||||
'LiveStreamId': 'live-1',
|
||||
'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=play-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -266,13 +271,13 @@ void main() {
|
||||
|
||||
final url = await session.streamUrlAt();
|
||||
expect(url, isNotNull);
|
||||
expect(Uri.parse(url!).path, contains('/Videos/channel-1'));
|
||||
expect(Uri.parse(url!).path, '/Videos/channel-1/live.m3u8');
|
||||
expect(Uri.parse(url).queryParameters['PlaySessionId'], 'play-1');
|
||||
|
||||
// Time-shift unsupported — an offset request must not silently play live.
|
||||
expect(await session.streamUrlAt(offsetSeconds: 60), isNull);
|
||||
|
||||
// Session-less URL: recovery is just re-opening it.
|
||||
// Recovery re-opens the negotiated HLS URL.
|
||||
expect(await session.recover(directStream: false, directStreamAudio: false), same(session));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,7 +169,6 @@ class FakePlayer implements Player {
|
||||
bool play = true,
|
||||
bool isLive = false,
|
||||
List<SubtitleTrack>? externalSubtitles,
|
||||
Duration timelineOffset = Duration.zero,
|
||||
Duration? timelineDuration,
|
||||
}) async {
|
||||
openedUris.add(media.uri);
|
||||
|
||||
@@ -1342,6 +1342,23 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldStartHiddenDirectionalSeek', () {
|
||||
test('accepts only the initial key-down event', () {
|
||||
expect(shouldStartHiddenDirectionalSeek(_keyDown(LogicalKeyboardKey.arrowRight)), isTrue);
|
||||
expect(
|
||||
shouldStartHiddenDirectionalSeek(
|
||||
const KeyRepeatEvent(
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
timeStamp: Duration.zero,
|
||||
),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(shouldStartHiddenDirectionalSeek(_keyUp(LogicalKeyboardKey.arrowRight)), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncOffsetControl', () {
|
||||
testWidgets('uses 100ms slider steps without rendering tick marks', (tester) async {
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
|
||||
Reference in New Issue
Block a user