Keep Live TV playing while browsing other pages

This commit is contained in:
Technomancer702
2026-10-13 18:03:49 -07:00
parent 3af7392323
commit 1258a3d944
3 changed files with 61 additions and 21 deletions

View File

@ -215,7 +215,7 @@ All transcoding and stream processing settings are found in **Settings → Trans
Add the receiver's bouquet export URL (for example, `http://receiver/web/services.m3u?bRef=...`) as an M3U source. Program tags select the matching video/audio service during probing, remux and transcoding. Tagged raw streams are remuxed even when Force Remux is off. Add the receiver's bouquet export URL (for example, `http://receiver/web/services.m3u?bRef=...`) as an M3U source. Program tags select the matching video/audio service during probing, remux and transcoding. Tagged raw streams are remuxed even when Force Remux is off.
For a single-tuner receiver, enable **Zap before streaming (Enigma2/OpenWebif)** on that source. The player releases its previous stream, tunes the configured receiver, and waits for the source's zap delay before starting playback. The default delay is 1500 ms; values from 0 to 10000 ms are supported. Stop, changing channels, and leaving Live TV cancel pending playback startup. The receiver address comes from the administrator-managed source URL, and only imported channels can be tuned. For a single-tuner receiver, enable **Zap before streaming (Enigma2/OpenWebif)** on that source. The player releases its previous stream, tunes the configured receiver, and waits for the source's zap delay before starting playback. The default delay is 1500 ms; values from 0 to 10000 ms are supported. Stop and changing channels cancel pending playback startup. Live TV continues playing when you minimize the player and browse the guide or other pages. The receiver address comes from the administrator-managed source URL, and only imported channels can be tuned.
## Troubleshooting ## Troubleshooting

View File

@ -103,8 +103,6 @@ class LivePage {
hide() { hide() {
document.removeEventListener('keydown', this.handleKeydown); document.removeEventListener('keydown', this.handleKeydown);
// Stop the stream when leaving Live TV so the transcode session is released
this.app.player?.stop();
} }
} }

View File

@ -60,25 +60,67 @@ for (const name of ['VideoPlayer', 'WatchPage']) {
} }
} }
test('Stop and leaving Live TV cancel the actual zap delay without starting playback', async () => { test('Stop cancels the actual zap delay without starting playback', async () => {
for (const navigate of [false, true]) { let requested;
let requested; const request = new Promise(resolve => { requested = resolve; });
const request = new Promise(resolve => { requested = resolve; }); const f = playerFixture('VideoPlayer', { forceVideoTranscode: true }, async (url, options) => {
const f = playerFixture('VideoPlayer', { forceVideoTranscode: true }, async (url, options) => { requested(options.signal);
requested(options.signal); return { ok: true, json: async () => ({ success: true }) };
return { ok: true, json: async () => ({ success: true }) }; });
}); const playing = f.player.play({ streamId: 'service', sourceId: 1, zapBeforeStreaming: true, zapDelay: 10000 }, 'http://receiver/multi.ts');
const playing = f.player.play({ streamId: 'service', sourceId: 1, zapBeforeStreaming: true, zapDelay: 10000 }, 'http://receiver/multi.ts'); const signal = await request;
const signal = await request; f.player.stop();
if (navigate) { await playing;
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js/pages/LivePage.js'), 'utf8'), f.context); assert.equal(signal.aborted, true);
new f.context.window.LivePage({ player: f.player }).hide(); assert.equal(f.sessions.length, 0);
} else f.player.stop(); assert.equal(f.player.video.src, '');
await playing; });
assert.equal(signal.aborted, true);
assert.equal(f.sessions.length, 0); function navigationFixture(f) {
assert.equal(f.player.video.src, ''); Object.assign(f.context.document, { addEventListener() {}, querySelectorAll: () => [] });
f.context.history = { pushState() {}, replaceState() {} };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8') + ';window.TestApp = App;', f.context);
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js/pages/LivePage.js'), 'utf8'), f.context);
const app = Object.create(f.context.window.TestApp.prototype);
app.player = f.player;
app.channelList = { channels: [{}] };
app.pages = { live: new f.context.window.LivePage(app) };
app.currentPage = 'live';
return app;
}
test('browsing the guide and catalogs preserves Live TV playback and its session', async () => {
const f = playerFixture('VideoPlayer', { forceVideoTranscode: true });
await f.start();
let released = 0;
f.player.playbackSessions = { stop() { released++; } };
f.player.currentSessionId = 'live-session';
const source = f.player.video.src, generation = f.player.playbackGeneration;
const app = navigationFixture(f);
for (const page of ['guide', 'movies', 'series']) {
app.navigateTo('live');
app.navigateTo(page);
assert.equal(f.player.video.src, source);
assert.equal(f.player.currentSessionId, 'live-session');
assert.equal(f.player.playbackGeneration, generation);
} }
assert.equal(released, 0);
});
test('browsing during a zap delay lets the selected live channel start normally', async () => {
let requested;
const ready = new Promise(resolve => { requested = resolve; });
const f = playerFixture('VideoPlayer', { forceVideoTranscode: true }, async (url, options) => {
requested(options.signal);
return { ok: true, json: async () => ({ success: true }) };
});
const playing = f.player.play({ streamId: 'service', sourceId: 1, zapBeforeStreaming: true, zapDelay: 0 }, 'http://receiver/multi.ts');
const signal = await ready;
navigationFixture(f).navigateTo('guide');
assert.equal(signal.aborted, false);
await playing;
assert.equal(f.sessions.length, 1);
assert.equal(f.player.video.src, '/api/session/stream.m3u8');
}); });
test('Stop cancels an in-flight zap request and releases the old session before retuning', async () => { test('Stop cancels an in-flight zap request and releases the old session before retuning', async () => {