fix(live-tv): stabilize HLS playback

This commit is contained in:
edde746
2026-07-18 20:04:09 +02:00
parent 46dfc7f992
commit 0707d4d9b1
27 changed files with 230 additions and 211 deletions
+14 -18
View File
@@ -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();
+36 -14
View File
@@ -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>();
+3 -2
View File
@@ -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);
+17
View File
@@ -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);