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
This commit is contained in:
@ -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 = `
|
||||
<div id="player-quality-label" style="padding: .55rem 1rem .25rem; font-size: .75rem; text-transform: uppercase; color: var(--color-text-muted);">
|
||||
Qualité vidéo
|
||||
</div>
|
||||
<div id="player-quality-summary" aria-live="polite" style="padding: 0 1rem .45rem; font-size: .75rem; color: var(--color-text-muted);"></div>
|
||||
<div id="player-quality-options" role="menu" aria-labelledby="player-quality-label"></div>`;
|
||||
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 = `<span>${this.qualityLabel(mode)}</span><span aria-hidden="true">${this.requestedQuality === mode ? '✓' : ''}</span>`;
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user