From 6a5e9b1ae358918f524467b98b5ebf38e76cf526 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Thu, 26 Feb 2026 09:52:13 +0400 Subject: [PATCH] feat: add global streaming video quality setting (#365) Add a "Streaming" section to the GENERAL settings tab with a quality dropdown (Auto / Low 360p / Medium 480p / High / HD 720p). The setting persists to localStorage and applies to all live streams: - LiveWebcamsPanel: appends vq= to direct embeds, passes through proxy - LiveNewsPanel: sets quality via YT.Player API onReady + desktop proxy - YouTube embed proxy: accepts vq param, calls setPlaybackQuality() Closes #365 --- api/youtube/embed.js | 3 +++ src/components/LiveNewsPanel.ts | 6 +++++ src/components/LiveWebcamsPanel.ts | 7 +++++- src/components/UnifiedSettings.ts | 25 +++++++++++++++++++- src/locales/en.json | 3 +++ src/services/ai-flow-settings.ts | 38 ++++++++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 2 deletions(-) diff --git a/api/youtube/embed.js b/api/youtube/embed.js index eec2611c8..913eb828c 100644 --- a/api/youtube/embed.js +++ b/api/youtube/embed.js @@ -59,6 +59,7 @@ export default async function handler(request) { const autoplay = parseFlag(url.searchParams.get('autoplay'), '1'); const mute = parseFlag(url.searchParams.get('mute'), '1'); + const vq = ['small', 'medium', 'large', 'hd720', 'hd1080'].includes(url.searchParams.get('vq') || '') ? url.searchParams.get('vq') : ''; const origin = sanitizeOrigin(url.searchParams.get('origin')); const parentOrigin = sanitizeParentOrigin(url.searchParams.get('parentOrigin'), origin); @@ -120,6 +121,7 @@ export default async function handler(request) { events:{ onReady:function(){ window.parent.postMessage({type:'yt-ready'},parentOrigin); + ${vq ? `if(player.setPlaybackQuality)player.setPlaybackQuality('${vq}');` : ''} if(${autoplay}===1){player.playVideo()} startMuteSync(); }, @@ -145,6 +147,7 @@ export default async function handler(request) { case'mute':player.mute();break; case'unmute':player.unMute();break; case'loadVideo':if(m.videoId)player.loadVideoById(m.videoId);break; + case'setQuality':if(m.quality&&player.setPlaybackQuality)player.setPlaybackQuality(m.quality);break; } }); diff --git a/src/components/LiveNewsPanel.ts b/src/components/LiveNewsPanel.ts index e1936ec4e..a31f37fc7 100644 --- a/src/components/LiveNewsPanel.ts +++ b/src/components/LiveNewsPanel.ts @@ -4,6 +4,7 @@ import { isDesktopRuntime, getRemoteApiBaseUrl, getApiBaseUrl } from '@/services import { t } from '../services/i18n'; import { loadFromStorage, saveToStorage } from '@/utils'; import { STORAGE_KEYS, SITE_VARIANT } from '@/config'; +import { getStreamQuality } from '@/services/ai-flow-settings'; // YouTube IFrame Player API types type YouTubePlayer = { @@ -13,6 +14,7 @@ type YouTubePlayer = { pauseVideo(): void; loadVideoById(videoId: string): void; cueVideoById(videoId: string): void; + setPlaybackQuality?(quality: string): void; getIframe?(): HTMLIFrameElement; getVolume?(): number; destroy(): void; @@ -799,6 +801,8 @@ export class LiveNewsPanel extends Panel { if (this.youtubeOrigin) params.set('origin', this.youtubeOrigin); const parentOrigin = this.parentPostMessageOrigin; if (parentOrigin) params.set('parentOrigin', parentOrigin); + const quality = getStreamQuality(); + if (quality !== 'auto') params.set('vq', quality); return `/api/youtube/embed?${params.toString()}`; } @@ -965,6 +969,8 @@ export class LiveNewsPanel extends Panel { this.currentVideoId = this.activeChannel.videoId || null; const iframe = this.player?.getIframe?.(); if (iframe) iframe.referrerPolicy = 'strict-origin-when-cross-origin'; + const quality = getStreamQuality(); + if (quality !== 'auto') this.player?.setPlaybackQuality?.(quality); this.syncPlayerState(); this.startMuteSyncPolling(); }, diff --git a/src/components/LiveWebcamsPanel.ts b/src/components/LiveWebcamsPanel.ts index 5a38fed06..3df1264d3 100644 --- a/src/components/LiveWebcamsPanel.ts +++ b/src/components/LiveWebcamsPanel.ts @@ -3,6 +3,7 @@ import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime'; import { escapeHtml } from '@/utils/sanitize'; import { t } from '../services/i18n'; import { trackWebcamSelected, trackWebcamRegionFiltered } from '@/services/analytics'; +import { getStreamQuality, subscribeStreamQualityChange } from '@/services/ai-flow-settings'; type WebcamRegion = 'middle-east' | 'europe' | 'asia' | 'americas'; @@ -67,6 +68,7 @@ export class LiveWebcamsPanel extends Panel { this.createToolbar(); this.setupIntersectionObserver(); this.setupIdleDetection(); + subscribeStreamQualityChange(() => this.render()); this.render(); } @@ -159,6 +161,7 @@ export class LiveWebcamsPanel extends Panel { } private buildEmbedUrl(videoId: string): string { + const quality = getStreamQuality(); if (isDesktopRuntime()) { const remoteBase = getRemoteApiBaseUrl(); const params = new URLSearchParams({ @@ -166,9 +169,11 @@ export class LiveWebcamsPanel extends Panel { autoplay: '1', mute: '1', }); + if (quality !== 'auto') params.set('vq', quality); return `${remoteBase}/api/youtube/embed?${params.toString()}`; } - return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&controls=0&modestbranding=1&playsinline=1&rel=0`; + const vq = quality !== 'auto' ? `&vq=${quality}` : ''; + return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&controls=0&modestbranding=1&playsinline=1&rel=0${vq}`; } private createIframe(feed: WebcamFeed): HTMLIFrameElement { diff --git a/src/components/UnifiedSettings.ts b/src/components/UnifiedSettings.ts index f3dbc1f38..8f12d0f52 100644 --- a/src/components/UnifiedSettings.ts +++ b/src/components/UnifiedSettings.ts @@ -2,7 +2,8 @@ import { FEEDS, INTEL_SOURCES, SOURCE_REGION_MAP } from '@/config/feeds'; import { PANEL_CATEGORY_MAP } from '@/config/panels'; import { SITE_VARIANT } from '@/config/variant'; import { LANGUAGES, changeLanguage, getCurrentLanguage, t } from '@/services/i18n'; -import { getAiFlowSettings, setAiFlowSetting } from '@/services/ai-flow-settings'; +import { getAiFlowSettings, setAiFlowSetting, getStreamQuality, setStreamQuality, STREAM_QUALITY_OPTIONS } from '@/services/ai-flow-settings'; +import type { StreamQuality } from '@/services/ai-flow-settings'; import { escapeHtml } from '@/utils/sanitize'; import { trackLanguageChange } from '@/services/analytics'; import type { PanelConfig } from '@/types'; @@ -148,6 +149,12 @@ export class UnifiedSettings { this.overlay.addEventListener('change', (e) => { const target = e.target as HTMLInputElement; + // Stream quality select + if (target.id === 'us-stream-quality') { + setStreamQuality(target.value as StreamQuality); + return; + } + // Language select if (target.closest('.unified-settings-lang-select')) { trackLanguageChange(target.value); @@ -299,6 +306,22 @@ export class UnifiedSettings { `; } + // Streaming quality section + const currentQuality = getStreamQuality(); + html += `
${t('components.insights.sectionStreaming')}
`; + html += `
+
+
${t('components.insights.streamQualityLabel')}
+
${t('components.insights.streamQualityDesc')}
+
+
`; + html += ``; + // Language section html += `
${t('header.languageLabel')}
`; html += `