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)}${this.requestedQuality === 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'
+ );
+});