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
14 changes: 14 additions & 0 deletions live-channels.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data: https:;" />
<title>Channel management - World Monitor</title>
<script>(function(){try{var t=localStorage.getItem('worldmonitor-theme');if(t==='light')document.documentElement.dataset.theme='light';}catch(e){}document.documentElement.classList.add('no-transition');})()</script>
</head>
<body style="margin:0;background:var(--bg,#1a1c1e);color:var(--text,#e8eaed)">
<div id="app"></div>
<script type="module" src="/src/live-channels-main.ts"></script>
</body>
</html>
2 changes: 1 addition & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities for World Monitor main and settings windows",
"windows": ["main", "settings"],
"windows": ["main", "settings", "live-channels"],
"permissions": ["core:default"]
}
55 changes: 55 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,24 @@ fn close_settings_window(app: AppHandle) -> Result<(), String> {
Ok(())
}

#[tauri::command]
async fn open_live_channels_window_command(
app: AppHandle,
base_url: Option<String>,
) -> Result<(), String> {
open_live_channels_window(&app, base_url)
}

#[tauri::command]
fn close_live_channels_window(app: AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("live-channels") {
window
.close()
.map_err(|e| format!("Failed to close live channels window: {e}"))?;
}
Ok(())
}

/// Fetch JSON from Polymarket Gamma API using native TLS (bypasses Cloudflare JA3 blocking).
/// Called from frontend when browser CORS and sidecar Node.js TLS both fail.
#[tauri::command]
Expand Down Expand Up @@ -519,6 +537,41 @@ fn open_settings_window(app: &AppHandle) -> Result<(), String> {
Ok(())
}

fn open_live_channels_window(app: &AppHandle, base_url: Option<String>) -> Result<(), String> {
if let Some(window) = app.get_webview_window("live-channels") {
let _ = window.show();
window
.set_focus()
.map_err(|e| format!("Failed to focus live channels window: {e}"))?;
return Ok(());
}

// In dev, use the same origin as the main window (e.g. http://localhost:3001) so we don't
// get "connection refused" when Vite runs on a different port than devUrl.
let url = match base_url {
Some(ref origin) if !origin.is_empty() => {
let path = origin.trim_end_matches('/');
let full_url = format!("{}/live-channels.html", path);
WebviewUrl::External(Url::parse(&full_url).map_err(|_| "Invalid base URL".to_string())?)
}
_ => WebviewUrl::App("live-channels.html".into()),
};

let _live_channels_window = WebviewWindowBuilder::new(app, "live-channels", url)
.title("Channel management - World Monitor")
.inner_size(440.0, 560.0)
.min_inner_size(360.0, 480.0)
.resizable(true)
.background_color(tauri::webview::Color(26, 28, 30, 255))
.build()
.map_err(|e| format!("Failed to create live channels window: {e}"))?;

#[cfg(not(target_os = "macos"))]
let _ = _live_channels_window.remove_menu();

Ok(())
}

fn build_app_menu(handle: &AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
let settings_item = MenuItem::with_id(
handle,
Expand Down Expand Up @@ -904,6 +957,8 @@ fn main() {
open_sidecar_log_file,
open_settings_window_command,
close_settings_window,
open_live_channels_window_command,
close_live_channels_window,
open_url,
fetch_polymarket
])
Expand Down
20 changes: 20 additions & 0 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2642,6 +2642,26 @@ export class App {
document.getElementById('settingsModal')?.classList.add('active');
});

// Sync panel state when settings are changed in the separate settings window
window.addEventListener('storage', (e) => {
if (e.key === STORAGE_KEYS.panels && e.newValue) {
try {
this.panelSettings = JSON.parse(e.newValue) as Record<string, PanelConfig>;
this.applyPanelSettings();
this.renderPanelToggles();
} catch (_) {}
}
if (e.key === 'worldmonitor-intel-findings' && this.findingsBadge) {
this.findingsBadge.setEnabled(e.newValue !== 'hidden');
}
if (e.key === STORAGE_KEYS.liveChannels && e.newValue) {
const panel = this.panels['live-news'];
if (panel && typeof (panel as unknown as { refreshChannelsFromStorage?: () => void }).refreshChannelsFromStorage === 'function') {
(panel as unknown as { refreshChannelsFromStorage: () => void }).refreshChannelsFromStorage();
}
}
});

document.getElementById('modalClose')?.addEventListener('click', () => {
document.getElementById('settingsModal')?.classList.remove('active');
});
Expand Down
181 changes: 170 additions & 11 deletions src/components/LiveNewsPanel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Panel } from './Panel';
import { fetchLiveVideoId } from '@/services/live-news';
import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime';
import { invokeTauri } from '@/services/tauri-bridge';
import { t } from '../services/i18n';
import { loadFromStorage, saveToStorage } from '@/utils';
import { STORAGE_KEYS } from '@/config';

// YouTube IFrame Player API types
type YouTubePlayer = {
Expand Down Expand Up @@ -39,7 +42,7 @@ declare global {
}
}

interface LiveChannel {
export interface LiveChannel {
id: string;
name: string;
handle: string; // YouTube channel handle (e.g., @bloomberg)
Expand Down Expand Up @@ -71,11 +74,69 @@ const TECH_LIVE_CHANNELS: LiveChannel[] = [
{ id: 'nasa', name: 'NASA TV', handle: '@NASA', fallbackVideoId: 'fO9e9jnhYK8', useFallbackOnly: true },
];

const LIVE_CHANNELS = SITE_VARIANT === 'tech' ? TECH_LIVE_CHANNELS : FULL_LIVE_CHANNELS;
const DEFAULT_LIVE_CHANNELS = SITE_VARIANT === 'tech' ? TECH_LIVE_CHANNELS : FULL_LIVE_CHANNELS;

/** Default channel list for the current variant (for restore in channel management). */
export function getDefaultLiveChannels(): LiveChannel[] {
return [...DEFAULT_LIVE_CHANNELS];
}

export interface StoredLiveChannels {
order: string[];
custom?: LiveChannel[];
/** Display name overrides for built-in channels (and custom). */
displayNameOverrides?: Record<string, string>;
}

const DEFAULT_STORED: StoredLiveChannels = {
order: DEFAULT_LIVE_CHANNELS.map((c) => c.id),
};

export const BUILTIN_IDS = new Set([
...FULL_LIVE_CHANNELS.map((c) => c.id),
...TECH_LIVE_CHANNELS.map((c) => c.id),
]);

export function loadChannelsFromStorage(): LiveChannel[] {
const stored = loadFromStorage<StoredLiveChannels>(STORAGE_KEYS.liveChannels, DEFAULT_STORED);
const order = stored.order?.length ? stored.order : DEFAULT_STORED.order;
const channelMap = new Map<string, LiveChannel>();
for (const c of FULL_LIVE_CHANNELS) channelMap.set(c.id, { ...c });
for (const c of TECH_LIVE_CHANNELS) channelMap.set(c.id, { ...c });
for (const c of stored.custom ?? []) {
if (c.id && c.handle) channelMap.set(c.id, { ...c });
}
const overrides = stored.displayNameOverrides ?? {};
for (const [id, name] of Object.entries(overrides)) {
const ch = channelMap.get(id);
if (ch) ch.name = name;
}
const result: LiveChannel[] = [];
for (const id of order) {
const ch = channelMap.get(id);
if (ch) result.push(ch);
}
return result;
}

export function saveChannelsToStorage(channels: LiveChannel[]): void {
const order = channels.map((c) => c.id);
const custom = channels.filter((c) => !BUILTIN_IDS.has(c.id));
const builtinNames = new Map<string, string>();
for (const c of [...FULL_LIVE_CHANNELS, ...TECH_LIVE_CHANNELS]) builtinNames.set(c.id, c.name);
const displayNameOverrides: Record<string, string> = {};
for (const c of channels) {
if (builtinNames.has(c.id) && c.name !== builtinNames.get(c.id)) {
displayNameOverrides[c.id] = c.name;
}
}
saveToStorage(STORAGE_KEYS.liveChannels, { order, custom, displayNameOverrides });
}

export class LiveNewsPanel extends Panel {
private static apiPromise: Promise<void> | null = null;
private activeChannel: LiveChannel = LIVE_CHANNELS[0]!;
private channels: LiveChannel[] = [];
private activeChannel!: LiveChannel;
private channelSwitcher: HTMLElement | null = null;
private isMuted = true;
private isPlaying = true;
Expand Down Expand Up @@ -109,6 +170,9 @@ export class LiveNewsPanel extends Panel {
this.youtubeOrigin = LiveNewsPanel.resolveYouTubeOrigin();
this.playerElementId = `live-news-player-${Date.now()}`;
this.element.classList.add('panel-wide');
this.channels = loadChannelsFromStorage();
if (this.channels.length === 0) this.channels = getDefaultLiveChannels();
this.activeChannel = this.channels[0]!;
this.createLiveButton();
this.createMuteButton();
this.createChannelSwitcher();
Expand All @@ -117,6 +181,10 @@ export class LiveNewsPanel extends Panel {
this.setupIdleDetection();
}

private saveChannels(): void {
saveChannelsToStorage(this.channels);
}

private get embedOrigin(): string {
try { return new URL(getRemoteApiBaseUrl()).origin; } catch { return 'https://worldmonitor.app'; }
}
Expand Down Expand Up @@ -302,20 +370,100 @@ export class LiveNewsPanel extends Panel {
this.syncPlayerState();
}

/** Creates a single channel tab button with click and drag handlers. */
private createChannelButton(channel: LiveChannel): HTMLButtonElement {
const btn = document.createElement('button');
btn.className = `live-channel-btn ${channel.id === this.activeChannel.id ? 'active' : ''}`;
btn.dataset.channelId = channel.id;
btn.draggable = true;
btn.textContent = channel.name;
btn.addEventListener('click', (e) => {
e.preventDefault();
this.switchChannel(channel);
});
btn.addEventListener('dragstart', (e) => {
btn.classList.add('live-channel-dragging');
if (e.dataTransfer) {
e.dataTransfer.setData('text/plain', channel.id);
e.dataTransfer.effectAllowed = 'move';
}
});
btn.addEventListener('dragend', () => {
btn.classList.remove('live-channel-dragging');
this.applyChannelOrderFromDom();
});
return btn;
}

private createChannelSwitcher(): void {
this.channelSwitcher = document.createElement('div');
this.channelSwitcher.className = 'live-news-switcher';

LIVE_CHANNELS.forEach(channel => {
const btn = document.createElement('button');
btn.className = `live-channel-btn ${channel.id === this.activeChannel.id ? 'active' : ''}`;
btn.dataset.channelId = channel.id;
btn.textContent = channel.name;
btn.addEventListener('click', () => this.switchChannel(channel));
this.channelSwitcher!.appendChild(btn);
for (const channel of this.channels) {
this.channelSwitcher.appendChild(this.createChannelButton(channel));
}

this.channelSwitcher.addEventListener('dragover', (e) => {
e.preventDefault();
const dragging = this.channelSwitcher?.querySelector('.live-channel-dragging');
if (!dragging || !this.channelSwitcher) return;
const target = (e.target as HTMLElement).closest?.('.live-channel-btn');
if (!target || target === dragging) return;
const all = Array.from(this.channelSwitcher.querySelectorAll('.live-channel-btn'));
const idx = all.indexOf(dragging as Element);
const targetIdx = all.indexOf(target);
if (idx === -1 || targetIdx === -1) return;
if (idx < targetIdx) {
target.parentElement?.insertBefore(dragging, target.nextSibling);
} else {
target.parentElement?.insertBefore(dragging, target);
}
});

this.element.insertBefore(this.channelSwitcher, this.content);
const toolbar = document.createElement('div');
toolbar.className = 'live-news-toolbar';
toolbar.appendChild(this.channelSwitcher);
this.createManageButton(toolbar);
this.element.insertBefore(toolbar, this.content);
}

private createManageButton(toolbar: HTMLElement): void {
const openBtn = document.createElement('button');
openBtn.type = 'button';
openBtn.className = 'live-news-settings-btn';
openBtn.title = t('components.liveNews.channelSettings') ?? 'Channel Settings';
openBtn.innerHTML =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
openBtn.addEventListener('click', () => {
if (isDesktopRuntime()) {
void invokeTauri<void>('open_live_channels_window_command', {
base_url: window.location.origin,
}).catch(() => {});
return;
}
const url = new URL(window.location.href);
url.searchParams.set('live-channels', '1');
window.open(url.toString(), 'worldmonitor-live-channels', 'width=440,height=560,scrollbars=yes');
});
toolbar.appendChild(openBtn);
}

private refreshChannelSwitcher(): void {
if (!this.channelSwitcher) return;
this.channelSwitcher.innerHTML = '';
for (const channel of this.channels) {
this.channelSwitcher.appendChild(this.createChannelButton(channel));
}
}

private applyChannelOrderFromDom(): void {
if (!this.channelSwitcher) return;
const ids = Array.from(this.channelSwitcher.querySelectorAll<HTMLElement>('.live-channel-btn'))
.map((el) => el.dataset.channelId)
.filter((id): id is string => !!id);
const orderMap = new Map(this.channels.map((c) => [c.id, c]));
this.channels = ids.map((id) => orderMap.get(id)).filter((c): c is LiveChannel => !!c);
this.saveChannels();
}

private async resolveChannelVideo(channel: LiveChannel, forceFallback = false): Promise<void> {
Expand Down Expand Up @@ -682,6 +830,17 @@ export class LiveNewsPanel extends Panel {
this.syncPlayerState();
}

/** Reload channel list from storage (e.g. after edit in separate channel management window). */
public refreshChannelsFromStorage(): void {
this.channels = loadChannelsFromStorage();
if (this.channels.length === 0) this.channels = getDefaultLiveChannels();
if (!this.channels.some((c) => c.id === this.activeChannel.id)) {
this.activeChannel = this.channels[0]!;
void this.switchChannel(this.activeChannel);
}
this.refreshChannelSwitcher();
}

public destroy(): void {
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
Expand Down
1 change: 1 addition & 0 deletions src/config/variants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const STORAGE_KEYS = {
monitors: 'worldmonitor-monitors',
mapLayers: 'worldmonitor-layers',
disabledFeeds: 'worldmonitor-disabled-feeds',
liveChannels: 'worldmonitor-live-channels',
} as const;

// Type definitions for variant configs
Expand Down
14 changes: 14 additions & 0 deletions src/live-channels-main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Entry point for the standalone channel management window (Tauri desktop).
* Web version uses index.html?live-channels=1 and main.ts instead.
*/
import './styles/main.css';
import { initI18n } from '@/services/i18n';
import { initLiveChannelsWindow } from '@/live-channels-window';

async function main(): Promise<void> {
await initI18n();
initLiveChannelsWindow();
}

void main().catch(console.error);
Loading