feat: add global streaming video quality setting (#365) (#396)

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
This commit is contained in:
Elie Habib
2026-02-26 09:57:43 +04:00
committed by GitHub
parent 5221486c88
commit 384cc78daa
6 changed files with 80 additions and 2 deletions

View File

@@ -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;
}
});
</script>

View File

@@ -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();
},

View File

@@ -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 {

View File

@@ -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 += `<div class="ai-flow-section-label">${t('components.insights.sectionStreaming')}</div>`;
html += `<div class="ai-flow-toggle-row">
<div class="ai-flow-toggle-label-wrap">
<div class="ai-flow-toggle-label">${t('components.insights.streamQualityLabel')}</div>
<div class="ai-flow-toggle-desc">${t('components.insights.streamQualityDesc')}</div>
</div>
</div>`;
html += `<select class="unified-settings-lang-select" id="us-stream-quality">`;
for (const opt of STREAM_QUALITY_OPTIONS) {
const selected = opt.value === currentQuality ? ' selected' : '';
html += `<option value="${opt.value}"${selected}>${opt.label}</option>`;
}
html += `</select>`;
// Language section
html += `<div class="ai-flow-section-label">${t('header.languageLabel')}</div>`;
html += `<select class="unified-settings-lang-select">`;

View File

@@ -954,6 +954,9 @@
"settingsTitle": "Settings",
"sectionMap": "Map",
"sectionAi": "AI Analysis",
"sectionStreaming": "Streaming",
"streamQualityLabel": "Video Quality",
"streamQualityDesc": "Set quality for all live streams (lower saves bandwidth)",
"mapFlashLabel": "Live Event Pulse",
"mapFlashDesc": "Flash locations on the map when breaking news arrives",
"aiFlowTitle": "Settings",

View File

@@ -9,7 +9,9 @@
const STORAGE_KEY_BROWSER_MODEL = 'wm-ai-flow-browser-model';
const STORAGE_KEY_CLOUD_LLM = 'wm-ai-flow-cloud-llm';
const STORAGE_KEY_MAP_NEWS_FLASH = 'wm-map-news-flash';
const STORAGE_KEY_STREAM_QUALITY = 'wm-stream-quality';
const EVENT_NAME = 'ai-flow-changed';
const STREAM_QUALITY_EVENT = 'stream-quality-changed';
export interface AiFlowSettings {
browserModel: boolean;
@@ -73,3 +75,39 @@ export function subscribeAiFlowChange(cb: (changedKey?: keyof AiFlowSettings) =>
window.addEventListener(EVENT_NAME, handler);
return () => window.removeEventListener(EVENT_NAME, handler);
}
// ── Stream Quality ──
export type StreamQuality = 'auto' | 'small' | 'medium' | 'large' | 'hd720';
export const STREAM_QUALITY_OPTIONS: { value: StreamQuality; label: string }[] = [
{ value: 'auto', label: 'Auto' },
{ value: 'small', label: 'Low (360p)' },
{ value: 'medium', label: 'Medium (480p)' },
{ value: 'large', label: 'High (480p+)' },
{ value: 'hd720', label: 'HD (720p)' },
];
export function getStreamQuality(): StreamQuality {
try {
const raw = localStorage.getItem(STORAGE_KEY_STREAM_QUALITY);
if (raw && ['auto', 'small', 'medium', 'large', 'hd720'].includes(raw)) return raw as StreamQuality;
} catch { /* ignore */ }
return 'auto';
}
export function setStreamQuality(quality: StreamQuality): void {
try {
localStorage.setItem(STORAGE_KEY_STREAM_QUALITY, quality);
} catch { /* ignore */ }
window.dispatchEvent(new CustomEvent(STREAM_QUALITY_EVENT, { detail: { quality } }));
}
export function subscribeStreamQualityChange(cb: (quality: StreamQuality) => void): () => void {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as { quality: StreamQuality };
cb(detail.quality);
};
window.addEventListener(STREAM_QUALITY_EVENT, handler);
return () => window.removeEventListener(STREAM_QUALITY_EVENT, handler);
}