From 4488914b2da020967f77ca62e456b38915cc46dd Mon Sep 17 00:00:00 2001 From: blomios Date: Wed, 30 Sep 2026 12:01:08 +0200 Subject: [PATCH] feat: add quality/transcoding options to live player (#6) Manual quality profiles (source/1080p/720p/480p) in the player overflow menu plus an honest/preparatory Auto contract: adaptive when the HLS manifest exposes multiple levels, single-rendition automatic selection otherwise. - server: transcodeQualityProfiles service and qualityMode session option - player: quality menu, persisted preference, live switch with rollback - tests: npm test script and unit tests for quality profiles --- package.json | 5 +- public/js/components/VideoPlayer.js | 266 +++++++++++++++++++- server/routes/transcode.js | 22 +- server/services/transcodeQualityProfiles.js | 69 +++++ test/transcodeQualityProfiles.test.js | 82 ++++++ 5 files changed, 427 insertions(+), 17 deletions(-) create mode 100644 server/services/transcodeQualityProfiles.js create mode 100644 test/transcodeQualityProfiles.test.js diff --git a/package.json b/package.json index 6bef281..504f49c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "main": "server/index.js", "scripts": { "start": "node server/index.js", - "dev": "node --watch server/index.js" + "dev": "node --watch server/index.js", + "test": "node --test" }, "dependencies": { "@ffprobe-installer/ffprobe": "^2.1.2", @@ -29,4 +30,4 @@ "engines": { "node": ">=18.0.0" } -} \ No newline at end of file +} diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index c62d9f0..51d4f9a 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -28,6 +28,13 @@ class VideoPlayer { this.isUsingProxy = false; this.currentUrl = null; this.settingsLoaded = false; + this.liveQualityPreferenceKey = 'nodecast_tv_live_quality'; + this.requestedQuality = this.loadLiveQualityPreference(); + this.activeQuality = null; + this.availableQualityProfiles = ['auto', 'source', '1080p', '720p', '480p']; + this.qualitySwitchInProgress = false; + this.originalStreamUrl = null; + this.sourceStreamInfo = null; // Settings - start with defaults, load from server async this.settings = this.getDefaultSettings(); @@ -61,6 +68,23 @@ class VideoPlayer { }; } + loadLiveQualityPreference() { + try { + const saved = localStorage.getItem('nodecast_tv_live_quality'); + return ['auto', 'source', '1080p', '720p', '480p'].includes(saved) ? saved : 'auto'; + } catch { + return 'auto'; + } + } + + saveLiveQualityPreference(qualityMode) { + try { + localStorage.setItem(this.liveQualityPreferenceKey, qualityMode); + } catch (err) { + console.warn('[Player] Unable to persist live quality preference:', err.message); + } + } + /** * Load settings from server API */ @@ -340,6 +364,8 @@ class VideoPlayer { const btnOverflow = document.getElementById('btn-overflow'); const overflowMenu = document.getElementById('player-overflow-menu'); + this.initQualityControl(overflowMenu); + btnOverflow?.addEventListener('click', (e) => { e.stopPropagation(); overflowMenu?.classList.toggle('hidden'); @@ -415,6 +441,77 @@ class VideoPlayer { updateVolumeUI(); } + /** + * Add the live quality control to the existing player overflow menu. + */ + initQualityControl(overflowMenu) { + if (!overflowMenu || document.getElementById('player-quality-control')) return; + + const control = document.createElement('div'); + control.id = 'player-quality-control'; + control.setAttribute('role', 'group'); + control.setAttribute('aria-label', 'Qualité vidéo'); + control.style.borderTop = '1px solid var(--glass-border)'; + overflowMenu.style.minWidth = 'min(280px, calc(100vw - 24px))'; + overflowMenu.style.maxHeight = 'min(70vh, 420px)'; + overflowMenu.style.overflowY = 'auto'; + control.innerHTML = ` +
+ Qualité vidéo +
+
+ `; + overflowMenu.prepend(control); + this.renderQualityControl(); + } + + qualityLabel(mode) { + return ({ auto: 'Auto', source: 'Originale', '1080p': '1080p', '720p': '720p', '480p': '480p' })[mode] || mode; + } + + renderQualityControl() { + const list = document.getElementById('player-quality-options'); + const summary = document.getElementById('player-quality-summary'); + if (!list || !summary) return; + + const profiles = this.availableQualityProfiles; + list.innerHTML = ''; + profiles.forEach(mode => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'overflow-menu-item'; + button.dataset.qualityMode = mode; + button.setAttribute('role', 'menuitemradio'); + button.setAttribute('aria-checked', String(this.requestedQuality === mode)); + button.disabled = this.qualitySwitchInProgress; + button.style.justifyContent = 'space-between'; + button.innerHTML = `${this.qualityLabel(mode)}`; + button.addEventListener('click', (event) => { + event.stopPropagation(); + this.changeLiveQuality(mode); + }); + list.appendChild(button); + }); + + if (this.qualitySwitchInProgress) { + summary.textContent = `Passage en ${this.qualityLabel(this.pendingQuality)}…`; + } else if (this.requestedQuality === 'auto') { + const autoKind = this.autoIsAdaptive ? 'adaptatif' : 'choix automatique au démarrage'; + summary.textContent = `Demandée : Auto (${autoKind})${this.activeQuality ? ` · Active : ${this.qualityLabel(this.activeQuality)}` : ''}`; + } else { + summary.textContent = `Demandée : ${this.qualityLabel(this.requestedQuality)}${this.activeQuality ? ` · Active : ${this.qualityLabel(this.activeQuality)}` : ''}`; + } + } + + updateAvailableQualityProfiles(profiles, sourceHeight) { + const ordered = ['auto', 'source', '1080p', '720p', '480p']; + const backendProfiles = Array.isArray(profiles) ? profiles : ordered; + const maxHeights = { '1080p': 1080, '720p': 720, '480p': 480 }; + this.availableQualityProfiles = ordered.filter(mode => backendProfiles.includes(mode) && + (!maxHeights[mode] || !sourceHeight || sourceHeight >= maxHeights[mode])); + this.renderQualityControl(); + } + /** * Toggle fullscreen mode (cross-browser including Safari) */ @@ -623,7 +720,9 @@ class VideoPlayer { width: this.video.videoWidth, height: this.video.videoHeight }; + this.activeQuality = this.getQualityLabel(this.video.videoHeight); this.updateQualityBadge(); + this.renderQualityControl(); } }); @@ -815,7 +914,7 @@ class VideoPlayer { /** * Start a HLS transcode session */ - async startTranscodeSession(url, options = {}) { + async startTranscodeSession(url, options = {}, allowLegacyFallback = true) { this.currentTranscodeSession = null; try { console.log('[Player] Starting HLS transcode session...', options); @@ -824,15 +923,24 @@ class VideoPlayer { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, ...options }) }); - if (!res.ok) throw new Error('Failed to start session'); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Failed to start session'); + } const session = await res.json(); this.currentSessionId = session.sessionId; this.currentTranscodeSession = session; + this.activeQuality = session.selectedProfile || options.qualityMode || null; + this.autoIsAdaptive = session.mode === 'adaptive'; + this.updateAvailableQualityProfiles(session.availableProfiles, this.sourceStreamInfo?.height); + this.renderQualityControl(); return session.playlistUrl; } catch (err) { console.error('[Player] Session start failed:', err); - // Fallback to direct transcode if session fails - return `/api/transcode?url=${encodeURIComponent(url)}`; + if (allowLegacyFallback) { + return `/api/transcode?url=${encodeURIComponent(url)}`; + } + throw err; } } @@ -840,15 +948,18 @@ class VideoPlayer { * Stop and cleanup current transcode session */ async stopTranscodeSession() { - if (this.currentSessionId) { - console.log('[Player] Stopping transcode session:', this.currentSessionId); + const sessionId = this.currentSessionId; + if (sessionId) { + console.log('[Player] Stopping transcode session:', sessionId); try { - // Fire and forget cleanup - fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' }); + await fetch(`/api/transcode/${sessionId}`, { method: 'DELETE' }); } catch (err) { console.error('Failed to stop session:', err); } - this.currentSessionId = null; + // A new session may have started while the DELETE was in flight. + if (this.currentSessionId === sessionId) { + this.currentSessionId = null; + } } } @@ -857,6 +968,7 @@ class VideoPlayer { */ async play(channel, streamUrl) { this.currentChannel = channel; + this.originalStreamUrl = streamUrl; try { // Stop any WatchPage playback (movies/series) before starting Live TV @@ -875,6 +987,13 @@ class VideoPlayer { // Determine if HLS or direct stream this.currentUrl = streamUrl; + this.sourceStreamInfo = null; + + // A persisted manual preference always creates a dedicated transcode session. + if (this.requestedQuality !== 'auto') { + await this.playManualQuality(channel, streamUrl, this.requestedQuality); + return; + } // Forced playback modes take priority over automatic selection below. const hasForcedPlaybackMode = this.settings.forceVideoTranscode || @@ -891,6 +1010,8 @@ class VideoPlayer { // Store probe result for quality badge display this.currentStreamInfo = info; + this.sourceStreamInfo = info; + this.updateAvailableQualityProfiles(null, info.height); this.updateQualityBadge(); // Handle subtitles from probe result @@ -1135,12 +1256,24 @@ class VideoPlayer { this.hls.loadSource(finalUrl); this.hls.attachMedia(this.video); - this.hls.on(Hls.Events.MANIFEST_PARSED, () => { + this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => { + const levels = data?.levels || this.hls.levels || []; + this.autoIsAdaptive = this.requestedQuality === 'auto' && levels.length > 1; + this.renderQualityControl(); this.video.play().catch(e => { if (e.name !== 'AbortError') console.log('Autoplay prevented:', e); }); }); + this.hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => { + const level = this.hls?.levels?.[data.level]; + if (level?.height) { + this.activeQuality = this.getQualityLabel(level.height); + this.updateQualityBadge(); + this.renderQualityControl(); + } + }); + // Re-attach error handler for the new Hls instance this.hls.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) { @@ -1240,12 +1373,27 @@ class VideoPlayer { this.hls.loadSource(url); this.hls.attachMedia(this.video); - this.hls.on(Hls.Events.MANIFEST_PARSED, () => { + this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => { + const levels = data?.levels || this.hls.levels || []; + this.autoIsAdaptive = this.requestedQuality === 'auto' && levels.length > 1; + if (this.autoIsAdaptive && this.hls.currentLevel >= 0) { + this.activeQuality = this.getQualityLabel(levels[this.hls.currentLevel]?.height); + } + this.renderQualityControl(); this.video.play().catch(e => { if (e.name !== 'AbortError') console.log('Autoplay prevented:', e); }); }); + this.hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => { + const level = this.hls?.levels?.[data.level]; + if (level?.height) { + this.activeQuality = this.getQualityLabel(level.height); + this.updateQualityBadge(); + this.renderQualityControl(); + } + }); + this.hls.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) { // Simple error handling for forced HLS/transcode modes @@ -1255,6 +1403,92 @@ class VideoPlayer { }); } + async playManualQuality(channel, streamUrl, qualityMode, restorePaused = false) { + this.updateTranscodeStatus('transcoding', `Passage en ${this.qualityLabel(qualityMode)}…`); + let effectiveQuality = qualityMode; + if (!this.sourceStreamInfo?.height) { + try { + const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); + if (probeRes.ok) this.sourceStreamInfo = await probeRes.json(); + } catch (err) { + console.warn('[Player] Could not probe source quality:', err.message); + } + } + if (this.sourceStreamInfo?.height) { + this.updateAvailableQualityProfiles(null, this.sourceStreamInfo.height); + const requestedHeight = Number.parseInt(qualityMode, 10); + if (requestedHeight > this.sourceStreamInfo.height) { + effectiveQuality = ['1080p', '720p', '480p'] + .find(mode => Number.parseInt(mode, 10) <= this.sourceStreamInfo.height) || 'source'; + } + } + const playlistUrl = await this.startTranscodeSession(streamUrl, { + qualityMode: effectiveQuality, + videoMode: effectiveQuality === 'source' ? undefined : 'encode', + allowHardwareFallback: this.settings.allowHardwareFallback !== false + }, false); + this.currentUrl = playlistUrl; + this.playHls(playlistUrl); + if (restorePaused) { + this.video.addEventListener('playing', () => this.video.pause(), { once: true }); + } + this.video.addEventListener('canplay', () => { + this.updateTranscodeStatus('transcoding', this.currentTranscodeSession?.hardwareFallbackOccurred ? 'CPU Fallback' : 'Transcodage'); + }, { once: true }); + this.updateQualityBadge(); + this.updateNowPlaying(channel); + this.showNowPlayingOverlay(); + this.fetchEpgData(channel); + window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel })); + } + + async changeLiveQuality(qualityMode) { + if (!this.currentChannel || !this.originalStreamUrl || this.qualitySwitchInProgress || qualityMode === this.requestedQuality) return; + + const previousQuality = this.requestedQuality; + const wasPaused = this.video.paused; + this.pendingQuality = qualityMode; + this.qualitySwitchInProgress = true; + this.renderQualityControl(); + this.loadingSpinner?.classList.add('show'); + + try { + await this.stopTranscodeSession(); + if (this.hls) { + this.hls.destroy(); + this.hls = null; + } + this.requestedQuality = qualityMode; + this.saveLiveQualityPreference(qualityMode); + + if (qualityMode === 'auto') { + await this.play(this.currentChannel, this.originalStreamUrl); + if (wasPaused) this.video.addEventListener('playing', () => this.video.pause(), { once: true }); + } else { + await this.playManualQuality(this.currentChannel, this.originalStreamUrl, qualityMode, wasPaused); + } + } catch (err) { + console.error('[Player] Quality switch failed, restoring previous quality:', err); + this.requestedQuality = previousQuality; + this.saveLiveQualityPreference(previousQuality); + try { + if (previousQuality === 'auto') { + await this.play(this.currentChannel, this.originalStreamUrl); + } else { + await this.playManualQuality(this.currentChannel, this.originalStreamUrl, previousQuality, wasPaused); + } + } catch (restoreError) { + console.error('[Player] Failed to restore previous quality:', restoreError); + this.showError('Impossible de changer la qualité vidéo. Réessayez.'); + } + } finally { + this.pendingQuality = null; + this.qualitySwitchInProgress = false; + this.loadingSpinner?.classList.remove('show'); + this.renderQualityControl(); + } + } + async updateTranscodeStatus(mode, text) { const el = document.getElementById('player-transcode-status'); if (!el) return; @@ -1293,7 +1527,12 @@ class VideoPlayer { const badge = document.getElementById('player-quality-badge'); if (!badge) return; - if (targetBitrateKbps) { + if (this.requestedQuality && this.activeQuality) { + badge.textContent = this.requestedQuality === 'auto' + ? `Auto · ${this.qualityLabel(this.activeQuality)}` + : `${this.qualityLabel(this.requestedQuality)} · actif ${this.qualityLabel(this.activeQuality)}`; + badge.classList.remove('hidden'); + } else if (targetBitrateKbps) { const targetMbps = targetBitrateKbps / 1000; badge.textContent = `${Number.isInteger(targetMbps) ? targetMbps : targetMbps.toFixed(1)} Mbps`; badge.classList.remove('hidden'); @@ -1453,6 +1692,9 @@ class VideoPlayer { // Hide quality badge this.currentStreamInfo = null; + this.activeQuality = null; + this.autoIsAdaptive = false; + this.renderQualityControl(); const badge = document.getElementById('player-quality-badge'); if (badge) badge.classList.add('hidden'); } diff --git a/server/routes/transcode.js b/server/routes/transcode.js index b11ef57..8f7e5a7 100644 --- a/server/routes/transcode.js +++ b/server/routes/transcode.js @@ -5,6 +5,7 @@ const path = require('path'); const fs = require('fs').promises; const db = require('../db'); const transcodeSession = require('../services/transcodeSession'); +const { QUALITY_MODES, resolveQualityMode } = require('../services/transcodeQualityProfiles'); /** * Transcode Routes @@ -31,7 +32,8 @@ transcodeSession.startCleanupInterval(); router.post('/session', async (req, res) => { const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels, - targetVideoBitrateKbps, targetAudioBitrateKbps, allowHardwareFallback + targetVideoBitrateKbps, targetAudioBitrateKbps, allowHardwareFallback, + qualityMode } = req.body; if (!url) { @@ -43,6 +45,7 @@ router.post('/session', async (req, res) => { const userAgent = db.getUserAgent(settings); try { + const qualitySelection = resolveQualityMode(qualityMode, settings); const session = await transcodeSession.createSession(url, { ffmpegPath, userAgent, @@ -61,7 +64,8 @@ router.post('/session', async (req, res) => { videoMode: videoMode, // 'copy' or 'encode' videoCodec: videoCodec, // 'h264', 'hevc', etc. audioCodec: audioCodec, // 'aac', 'ac3', etc. - audioChannels: audioChannels // number of channels (2=stereo) + audioChannels: audioChannels, // number of channels (2=stereo) + ...(qualitySelection?.sessionOptions || {}) }); await session.start(); @@ -80,10 +84,22 @@ router.post('/session', async (req, res) => { status: session.status, requestedEncoder: session.requestedEncoder, effectiveEncoder: session.effectiveEncoder, - hardwareFallbackOccurred: session.hardwareFallbackOccurred + hardwareFallbackOccurred: session.hardwareFallbackOccurred, + ...(qualitySelection && { + qualityMode: qualitySelection.qualityMode, + mode: qualitySelection.mode, + selectedProfile: qualitySelection.selectedProfile, + availableProfiles: qualitySelection.availableProfiles + }) }); } catch (err) { + if (err.code === 'INVALID_QUALITY_MODE') { + return res.status(400).json({ + error: err.message, + availableProfiles: QUALITY_MODES + }); + } console.error('[Transcode] Session creation failed:', err); res.status(500).json({ error: 'Failed to create session', details: err.message }); } diff --git a/server/services/transcodeQualityProfiles.js b/server/services/transcodeQualityProfiles.js new file mode 100644 index 0000000..13ac145 --- /dev/null +++ b/server/services/transcodeQualityProfiles.js @@ -0,0 +1,69 @@ +const QUALITY_PROFILES = Object.freeze({ + source: Object.freeze({ + id: 'source', + videoMode: 'copy' + }), + '1080p': Object.freeze({ + id: '1080p', + videoMode: 'encode', + maxResolution: '1080p', + targetVideoBitrateKbps: 6000 + }), + '720p': Object.freeze({ + id: '720p', + videoMode: 'encode', + maxResolution: '720p', + targetVideoBitrateKbps: 3500 + }), + '480p': Object.freeze({ + id: '480p', + videoMode: 'encode', + maxResolution: '480p', + targetVideoBitrateKbps: 1500 + }) +}); + +const QUALITY_MODES = Object.freeze(['auto', ...Object.keys(QUALITY_PROFILES)]); + +function getAutomaticProfile(settings = {}) { + const configuredResolution = settings.maxResolution; + return QUALITY_PROFILES[configuredResolution] || QUALITY_PROFILES['1080p']; +} + +function resolveQualityMode(qualityMode, settings = {}) { + if (qualityMode === undefined || qualityMode === null || qualityMode === '') { + return null; + } + + if (!QUALITY_MODES.includes(qualityMode)) { + const error = new Error(`Unsupported qualityMode: ${qualityMode}`); + error.code = 'INVALID_QUALITY_MODE'; + throw error; + } + + const profile = qualityMode === 'auto' + ? getAutomaticProfile(settings) + : QUALITY_PROFILES[qualityMode]; + + return { + qualityMode, + mode: 'single', + selectedProfile: profile.id, + availableProfiles: QUALITY_MODES, + sessionOptions: { + videoMode: profile.videoMode, + ...(profile.maxResolution && { maxResolution: profile.maxResolution }), + ...(profile.targetVideoBitrateKbps && { + targetVideoBitrateKbps: profile.targetVideoBitrateKbps + }), + // A manual downscale profile must not inherit a global upscaling setting. + ...(profile.videoMode === 'encode' && { upscaleEnabled: false }) + } + }; +} + +module.exports = { + QUALITY_MODES, + QUALITY_PROFILES, + resolveQualityMode +}; diff --git a/test/transcodeQualityProfiles.test.js b/test/transcodeQualityProfiles.test.js new file mode 100644 index 0000000..0fb9fb8 --- /dev/null +++ b/test/transcodeQualityProfiles.test.js @@ -0,0 +1,82 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + QUALITY_MODES, + resolveQualityMode +} = require('../server/services/transcodeQualityProfiles'); +const { TranscodeSession } = require('../server/services/transcodeSession'); + +test('legacy payloads do not receive profile overrides', () => { + assert.equal(resolveQualityMode(undefined, { maxResolution: '720p' }), null); +}); + +test('manual quality profiles map to encoding height and bitrate', () => { + const expected = { + '1080p': { maxResolution: '1080p', targetVideoBitrateKbps: 6000 }, + '720p': { maxResolution: '720p', targetVideoBitrateKbps: 3500 }, + '480p': { maxResolution: '480p', targetVideoBitrateKbps: 1500 } + }; + + for (const [qualityMode, profile] of Object.entries(expected)) { + const selection = resolveQualityMode(qualityMode); + assert.equal(selection.mode, 'single'); + assert.equal(selection.selectedProfile, qualityMode); + assert.deepEqual(selection.availableProfiles, QUALITY_MODES); + assert.deepEqual(selection.sessionOptions, { + videoMode: 'encode', + ...profile, + upscaleEnabled: false + }); + } +}); + +test('profile options produce the expected FFmpeg scale and bitrate arguments', () => { + for (const qualityMode of ['1080p', '720p', '480p']) { + const selection = resolveQualityMode(qualityMode); + const session = new TranscodeSession('https://example.test/live.ts', { + hwEncoder: 'software', + audioMixPreset: 'passthrough', + ...selection.sessionOptions + }); + const args = session.buildFFmpegArgs('software'); + const bitrate = selection.sessionOptions.targetVideoBitrateKbps; + + assert.equal(args[args.indexOf('-vf') + 1], `scale=-2:${qualityMode.replace('p', '')}`); + assert.equal(args[args.indexOf('-b:v') + 1], `${bitrate}k`); + assert.equal(args[args.indexOf('-maxrate') + 1], `${bitrate}k`); + assert.equal(args[args.indexOf('-bufsize') + 1], `${bitrate * 2}k`); + } +}); + +test('source profile preserves the original video stream', () => { + const selection = resolveQualityMode('source'); + + assert.equal(selection.selectedProfile, 'source'); + assert.deepEqual(selection.sessionOptions, { videoMode: 'copy' }); +}); + +test('auto selects one configured rendition and reports single-rendition mode', () => { + const selection = resolveQualityMode('auto', { maxResolution: '720p' }); + + assert.equal(selection.qualityMode, 'auto'); + assert.equal(selection.mode, 'single'); + assert.equal(selection.selectedProfile, '720p'); + assert.deepEqual(selection.sessionOptions, { + videoMode: 'encode', + maxResolution: '720p', + targetVideoBitrateKbps: 3500, + upscaleEnabled: false + }); +}); + +test('auto falls back to 1080p for an unsupported global resolution', () => { + assert.equal(resolveQualityMode('auto', { maxResolution: '4k' }).selectedProfile, '1080p'); +}); + +test('unsupported quality modes are rejected', () => { + assert.throws( + () => resolveQualityMode('adaptive'), + error => error.code === 'INVALID_QUALITY_MODE' + ); +});