feat: Arabic font support and HLS live streaming UI (#1020)

* feat: enhance support for HLS streams and update font styles

* chore: add .vercelignore to exclude large local build artifacts from Vercel deploys

* chore: include node types in tsconfig to fix server type errors on Vercel build

* fix(middleware): guard optional variant OG lookup to satisfy strict TS

* fix: desktop build and live channels handle null safety

- scripts/build-sidecar-sebuf.mjs: Skip building removed [domain]/v1/[rpc].ts (removed in #785)
- src/live-channels-window.ts: Add optional chaining for handle property to prevent null errors
- src-tauri/Cargo.lock: Bump version to 2.5.24

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: address review issues on PR #1020

- Remove AGENTS.md (project guidelines belong to repo owner)
- Restore tracking script in index.html (accidentally removed)
- Revert tsconfig.json "node" types (leaks Node globals to frontend)
- Add protocol validation to isHlsUrl() (security: block non-http URIs)
- Revert Cargo.lock version bump (release management concern)

* fix: address P2/P3 review findings

- Preserve hlsUrl for HLS-only channels in refreshChannelInfo (was
  incorrectly clearing the stream URL on every refresh cycle)
- Replace deprecated .substr() with .substring()
- Extract duplicated HLS display name logic into getChannelDisplayName()

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Elie Habib <elie.habib@gmail.com>
This commit is contained in:
Hasan AlDoy
2026-03-05 09:16:43 +03:00
committed by GitHub
parent f06db59720
commit 02f3fe77a9
8 changed files with 155 additions and 45 deletions

20
.vercelignore Normal file
View File

@@ -0,0 +1,20 @@
# Exclude local desktop build artifacts and sidecar binaries from deployments
# These files are large and not needed by the Vercel-hosted frontend/API
# Tauri build outputs
src-tauri/target/
src-tauri/bundle/
# Sidecar and bundled node binaries
src-tauri/sidecar/
src-tauri/**/node
# macOS disk images and app bundles
**/*.dmg
**/*.app
# Rust/Cargo build artifacts (safety)
target/
# Common local artifacts
.DS_Store

View File

@@ -157,10 +157,10 @@
[data-variant="happy"][data-theme="dark"] .skeleton-line{background:linear-gradient(90deg,rgba(139,175,122,.05) 25%,rgba(139,175,122,.10) 50%,rgba(139,175,122,.05) 75%);background-size:200% 100%;animation:skel-shimmer 1.5s infinite}
</style>
<!-- Google Fonts (Nunito for happy variant) -->
<!-- Google Fonts (Nunito for happy variant, Tajawal for Arabic) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,300;0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,300;0,400;0,600;0,700;1,400&family=Tajawal:wght@200;300;400;500;700;800;900&display=swap" rel="stylesheet">
<script>window.addEventListener('load',function(){var s=document.createElement('script');s.async=1;s.src='https://emrldco.com/NTA0NDAw.js?t=504400';document.head.appendChild(s);});</script>
</head>
<body>

View File

@@ -63,9 +63,9 @@ export default function middleware(request: Request) {
if (path === '/' && SOCIAL_PREVIEW_UA.test(ua)) {
const variant = VARIANT_HOST_MAP[host];
if (variant && isAllowedHost(host)) {
const og = VARIANT_OG[variant] as { title: string; description: string; image: string; url: string } | undefined;
if (!og) return;
const html = `<!DOCTYPE html><html><head>
const og = VARIANT_OG[variant as keyof typeof VARIANT_OG];
if (og) {
const html = `<!DOCTYPE html><html><head>
<meta property="og:type" content="website"/>
<meta property="og:title" content="${og.title}"/>
<meta property="og:description" content="${og.description}"/>
@@ -77,14 +77,15 @@ export default function middleware(request: Request) {
<meta name="twitter:image" content="${og.image}"/>
<title>${og.title}</title>
</head><body></body></html>`;
return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'Vary': 'User-Agent, Host',
},
});
return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'Vary': 'User-Agent, Host',
},
});
}
}
}

View File

@@ -5,12 +5,16 @@
*
* Run: node scripts/build-sidecar-sebuf.mjs
* Or: npm run build:sidecar-sebuf
*
* Note: api/[domain]/v1/[rpc].ts was removed in #785 as it was a catch-all
* that intercepted all RPC routes. This script now skips the [domain] folder.
*/
import { build } from 'esbuild';
import { stat } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { existsSync } from 'node:fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
@@ -18,22 +22,27 @@ const projectRoot = path.resolve(__dirname, '..');
const entryPoint = path.join(projectRoot, 'api', '[domain]', 'v1', '[rpc].ts');
const outfile = path.join(projectRoot, 'api', '[domain]', 'v1', '[rpc].js');
try {
await build({
entryPoints: [entryPoint],
outfile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node18',
// Tree-shake unused exports for smaller bundle
treeShaking: true,
});
// Skip if the source file doesn't exist (removed in #785)
if (!existsSync(entryPoint)) {
console.log('build:sidecar-sebuf skipped (api/[domain]/v1/[rpc].ts removed in #785)');
} else {
try {
await build({
entryPoints: [entryPoint],
outfile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node18',
// Tree-shake unused exports for smaller bundle
treeShaking: true,
});
const { size } = await stat(outfile);
const sizeKB = (size / 1024).toFixed(1);
console.log(`build:sidecar-sebuf api/[domain]/v1/[rpc].js ${sizeKB} KB`);
} catch (err) {
console.error('build:sidecar-sebuf failed:', err.message);
process.exit(1);
const { size } = await stat(outfile);
const sizeKB = (size / 1024).toFixed(1);
console.log(`build:sidecar-sebuf api/[domain]/v1/[rpc].js ${sizeKB} KB`);
} catch (err) {
console.error('build:sidecar-sebuf failed:', err.message);
process.exit(1);
}
}

View File

@@ -50,7 +50,7 @@ declare global {
export interface LiveChannel {
id: string;
name: string;
handle: string; // YouTube channel handle (e.g., @bloomberg)
handle?: string; // YouTube channel handle (e.g., @bloomberg) - optional for HLS streams
fallbackVideoId?: string; // Fallback if no live stream detected
videoId?: string; // Dynamically fetched live video ID
isLive?: boolean;
@@ -389,7 +389,7 @@ export class LiveNewsPanel extends Panel {
const label = document.createElement('div');
label.style.cssText = 'color:var(--text-secondary);font-size:13px;';
label.textContent = this.activeChannel.name;
label.textContent = this.getChannelDisplayName(this.activeChannel);
const playBtn = document.createElement('button');
playBtn.className = 'offline-retry';
@@ -744,12 +744,18 @@ export class LiveNewsPanel extends Panel {
this.syncPlayerState();
}
private getChannelDisplayName(channel: LiveChannel): string {
return channel.hlsUrl && !channel.handle ? `${channel.name} 🔗` : channel.name;
}
/** 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.textContent = channel.name;
btn.textContent = this.getChannelDisplayName(channel);
btn.style.cursor = 'grab';
btn.addEventListener('click', (e) => {
if (this.suppressChannelClick) {
@@ -920,6 +926,14 @@ export class LiveNewsPanel extends Panel {
channel.hlsUrl = undefined;
return;
}
// Skip fetchLiveVideoInfo for channels without handle (HLS-only)
if (!channel.handle) {
channel.videoId = channel.fallbackVideoId;
channel.isLive = false;
return;
}
const info = await fetchLiveVideoInfo(channel.handle);
channel.videoId = info.videoId || channel.fallbackVideoId;
channel.isLive = !!info.videoId;
@@ -990,7 +1004,9 @@ export class LiveNewsPanel extends Panel {
this.destroyPlayer();
const watchUrl = channel.videoId
? `https://www.youtube.com/watch?v=${encodeURIComponent(channel.videoId)}`
: `https://www.youtube.com/${encodeURIComponent(channel.handle)}`;
: channel.handle
? `https://www.youtube.com/${encodeURIComponent(channel.handle)}`
: 'https://www.youtube.com';
const safeName = escapeHtml(channel.name);
this.content.innerHTML = `
@@ -1346,7 +1362,9 @@ export class LiveNewsPanel extends Panel {
const channel = this.activeChannel;
const watchUrl = channel.videoId
? `https://www.youtube.com/watch?v=${encodeURIComponent(channel.videoId)}`
: `https://www.youtube.com/${encodeURIComponent(channel.handle)}`;
: channel.handle
? `https://www.youtube.com/${encodeURIComponent(channel.handle)}`
: 'https://www.youtube.com';
this.destroyPlayer();
this.content.innerHTML = '';

View File

@@ -56,6 +56,17 @@ function parseYouTubeInput(raw: string): { handle: string } | { videoId: string
return null;
}
/** Check if input is an HLS stream URL (.m3u8) */
function isHlsUrl(raw: string): boolean {
try {
const url = new URL(raw);
if (url.protocol !== 'https:' && url.protocol !== 'http:') return false;
return url.pathname.endsWith('.m3u8') || raw.includes('.m3u8');
} catch {
return false;
}
}
// Persist active region tab across re-renders
let activeRegionTab = 'all';
@@ -73,11 +84,21 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
const optionalChannelMap = new Map<string, LiveChannel>();
for (const c of filteredChannels) optionalChannelMap.set(c.id, c);
let channels: LiveChannel[] = [];
if (document.getElementById('liveChannelsList')) {
// Already initialized, just update the list
channels = loadChannelsFromStorage();
const listEl = document.getElementById('liveChannelsList') as HTMLElement;
renderList(listEl);
return;
}
if (!containerEl) {
document.title = `${t('components.liveNews.manage') ?? 'Channel management'} - World Monitor`;
}
let channels = loadChannelsFromStorage();
channels = loadChannelsFromStorage();
let suppressRowClick = false;
let searchQuery = '';
@@ -179,7 +200,6 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
listEl.appendChild(row);
}
updateRestoreButton();
renderAvailableChannels(listEl);
}
/** Returns default (built-in) channels that are not in the current list. */
@@ -234,7 +254,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
const handleInput = document.createElement('input');
handleInput.type = 'text';
handleInput.className = 'live-news-manage-edit-handle';
handleInput.value = ch.handle;
handleInput.value = ch.handle ?? '';
handleInput.placeholder = t('components.liveNews.youtubeHandle') ?? 'YouTube handle';
row.appendChild(handleInput);
}
@@ -262,7 +282,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
saveBtn.className = 'live-news-manage-save';
saveBtn.textContent = t('components.liveNews.save') ?? 'Save';
saveBtn.addEventListener('click', () => {
const displayName = nameInput.value.trim() || ch.name || ch.handle;
const displayName = nameInput.value.trim() || ch.name || ch.handle || '';
const next = applyEditFormToChannels(ch, row, isCustom, displayName);
if (next) {
channels = next;
@@ -300,7 +320,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
.filter((ch): ch is LiveChannel => !!ch);
const matchingChannels = term
? regionChannels.filter(ch => ch.name.toLowerCase().includes(term) || ch.handle.toLowerCase().includes(term))
? regionChannels.filter(ch => ch.name.toLowerCase().includes(term) || ch.handle?.toLowerCase().includes(term))
: regionChannels;
const addedCount = matchingChannels.filter(ch => currentIds.has(ch.id)).length;
@@ -332,7 +352,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
for (const chId of region.channelIds) {
const ch = optionalChannelMap.get(chId);
if (!ch) continue;
if (term && !ch.name.toLowerCase().includes(term) && !ch.handle.toLowerCase().includes(term)) continue;
if (term && !ch.name.toLowerCase().includes(term) && !ch.handle?.toLowerCase().includes(term)) continue;
const isAdded = currentIds.has(chId);
grid.appendChild(createCard(ch, isAdded, listEl));
matchCount++;
@@ -365,7 +385,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
nameEl.textContent = ch.name;
const handleEl = document.createElement('span');
handleEl.className = 'live-news-manage-card-handle';
handleEl.textContent = ch.handle;
handleEl.textContent = ch.handle ?? '';
info.appendChild(nameEl);
info.appendChild(handleEl);
@@ -429,6 +449,10 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
<label class="live-news-manage-add-label" for="liveChannelsHandle">${escapeHtml(t('components.liveNews.youtubeHandleOrUrl') ?? 'YouTube handle or URL')}</label>
<input type="text" class="live-news-manage-handle" id="liveChannelsHandle" placeholder="@Channel or youtube.com/watch?v=..." />
</div>
<div class="live-news-manage-add-field">
<label class="live-news-manage-add-label" for="liveChannelsHlsUrl">${escapeHtml(t('components.liveNews.hlsUrl') ?? 'HLS Stream URL (optional)')}</label>
<input type="text" class="live-news-manage-handle" id="liveChannelsHlsUrl" placeholder="https://example.com/stream.m3u8" />
</div>
<div class="live-news-manage-add-field">
<label class="live-news-manage-add-label" for="liveChannelsName">${escapeHtml(t('components.liveNews.displayName') ?? 'Display name (optional)')}</label>
<input type="text" class="live-news-manage-name" id="liveChannelsName" placeholder="" />
@@ -444,11 +468,15 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
if (!listEl) return;
setupListDnD(listEl);
renderList(listEl);
renderAvailableChannels(listEl);
// Clear validation state on input
document.getElementById('liveChannelsHandle')?.addEventListener('input', (e) => {
(e.target as HTMLInputElement).classList.remove('invalid');
});
document.getElementById('liveChannelsHlsUrl')?.addEventListener('input', (e) => {
(e.target as HTMLInputElement).classList.remove('invalid');
});
document.getElementById('liveChannelsRestoreBtn')?.addEventListener('click', () => {
const missing = getMissingDefaultChannels();
@@ -461,10 +489,40 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
const addBtn = document.getElementById('liveChannelsAddBtn') as HTMLButtonElement | null;
addBtn?.addEventListener('click', async () => {
const handleInput = document.getElementById('liveChannelsHandle') as HTMLInputElement | null;
const hlsInput = document.getElementById('liveChannelsHlsUrl') as HTMLInputElement | null;
const nameInput = document.getElementById('liveChannelsName') as HTMLInputElement | null;
const raw = handleInput?.value?.trim();
if (!raw) return;
const hlsUrl = hlsInput?.value?.trim();
if (!raw && !hlsUrl) return;
if (handleInput) handleInput.classList.remove('invalid');
if (hlsInput) hlsInput.classList.remove('invalid');
// Check if HLS URL is provided
if (hlsUrl) {
if (!isHlsUrl(hlsUrl)) {
if (hlsInput) {
hlsInput.classList.add('invalid');
hlsInput.setAttribute('title', t('components.liveNews.invalidHlsUrl') ?? 'Enter a valid HLS stream URL (.m3u8)');
}
return;
}
// Create custom HLS channel
const id = `custom-hls-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
if (channels.some((c) => c.id === id)) return;
const name = nameInput?.value?.trim() || 'HLS Stream';
channels.push({ id, name, hlsUrl, useFallbackOnly: true });
saveChannelsToStorage(channels);
renderList(listEl);
if (handleInput) handleInput.value = '';
if (hlsInput) hlsInput.value = '';
if (nameInput) nameInput.value = '';
return;
}
// Handle YouTube input (existing logic)
if (!raw) return;
// Try parsing as a YouTube URL first
const parsed = parseYouTubeInput(raw);
@@ -503,6 +561,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
saveChannelsToStorage(channels);
renderList(listEl);
if (handleInput) handleInput.value = '';
if (hlsInput) hlsInput.value = '';
if (nameInput) nameInput.value = '';
return;
}
@@ -561,6 +620,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
saveChannelsToStorage(channels);
renderList(listEl);
if (handleInput) handleInput.value = '';
if (hlsInput) hlsInput.value = '';
if (nameInput) nameInput.value = '';
});

View File

@@ -1667,7 +1667,9 @@
"regionOceania": "Oceania",
"invalidHandle": "Enter a valid YouTube handle (e.g. @ChannelName)",
"channelNotFound": "YouTube channel not found",
"verifying": "Verifying…"
"verifying": "Verifying…",
"hlsUrl": "HLS Stream URL (optional)",
"invalidHlsUrl": "Enter a valid HLS stream URL (.m3u8)"
},
"map": {
"showMap": "Show Map",

View File

@@ -59,7 +59,7 @@
}
[dir="rtl"] {
--font-body: 'Geeza Pro', 'SF Arabic', 'Tahoma', system-ui, sans-serif;
--font-body: 'Tajawal', 'Geeza Pro', 'SF Arabic', 'Tahoma', system-ui, sans-serif;
}
:lang(zh-CN),