Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/youtube/embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
},
Expand All @@ -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>
Expand Down
6 changes: 6 additions & 0 deletions src/components/LiveNewsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
Expand Down Expand Up @@ -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()}`;
}

Expand Down Expand Up @@ -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();
},
Expand Down
7 changes: 6 additions & 1 deletion src/components/LiveWebcamsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -67,6 +68,7 @@ export class LiveWebcamsPanel extends Panel {
this.createToolbar();
this.setupIntersectionObserver();
this.setupIdleDetection();
subscribeStreamQualityChange(() => this.render());
this.render();
}

Expand Down Expand Up @@ -159,16 +161,19 @@ export class LiveWebcamsPanel extends Panel {
}

private buildEmbedUrl(videoId: string): string {
const quality = getStreamQuality();
if (isDesktopRuntime()) {
const remoteBase = getRemoteApiBaseUrl();
const params = new URLSearchParams({
videoId,
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 {
Expand Down
25 changes: 24 additions & 1 deletion src/components/UnifiedSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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">`;
Expand Down
3 changes: 3 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions src/services/ai-flow-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}