mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
20
.vercelignore
Normal file
20
.vercelignore
Normal 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
|
||||||
@@ -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}
|
[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>
|
</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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export default function middleware(request: Request) {
|
|||||||
if (path === '/' && SOCIAL_PREVIEW_UA.test(ua)) {
|
if (path === '/' && SOCIAL_PREVIEW_UA.test(ua)) {
|
||||||
const variant = VARIANT_HOST_MAP[host];
|
const variant = VARIANT_HOST_MAP[host];
|
||||||
if (variant && isAllowedHost(host)) {
|
if (variant && isAllowedHost(host)) {
|
||||||
const og = VARIANT_OG[variant] as { title: string; description: string; image: string; url: string } | undefined;
|
const og = VARIANT_OG[variant as keyof typeof VARIANT_OG];
|
||||||
if (!og) return;
|
if (og) {
|
||||||
const html = `<!DOCTYPE html><html><head>
|
const html = `<!DOCTYPE html><html><head>
|
||||||
<meta property="og:type" content="website"/>
|
<meta property="og:type" content="website"/>
|
||||||
<meta property="og:title" content="${og.title}"/>
|
<meta property="og:title" content="${og.title}"/>
|
||||||
@@ -87,6 +87,7 @@ export default function middleware(request: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only apply bot filtering to /api/* and /favico/* paths
|
// Only apply bot filtering to /api/* and /favico/* paths
|
||||||
if (!path.startsWith('/api/') && !path.startsWith('/favico/')) {
|
if (!path.startsWith('/api/') && !path.startsWith('/favico/')) {
|
||||||
|
|||||||
@@ -5,12 +5,16 @@
|
|||||||
*
|
*
|
||||||
* Run: node scripts/build-sidecar-sebuf.mjs
|
* Run: node scripts/build-sidecar-sebuf.mjs
|
||||||
* Or: npm run build:sidecar-sebuf
|
* 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 { build } from 'esbuild';
|
||||||
import { stat } from 'node:fs/promises';
|
import { stat } from 'node:fs/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const projectRoot = path.resolve(__dirname, '..');
|
const projectRoot = path.resolve(__dirname, '..');
|
||||||
@@ -18,6 +22,10 @@ const projectRoot = path.resolve(__dirname, '..');
|
|||||||
const entryPoint = path.join(projectRoot, 'api', '[domain]', 'v1', '[rpc].ts');
|
const entryPoint = path.join(projectRoot, 'api', '[domain]', 'v1', '[rpc].ts');
|
||||||
const outfile = path.join(projectRoot, 'api', '[domain]', 'v1', '[rpc].js');
|
const outfile = path.join(projectRoot, 'api', '[domain]', 'v1', '[rpc].js');
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
await build({
|
await build({
|
||||||
entryPoints: [entryPoint],
|
entryPoints: [entryPoint],
|
||||||
@@ -37,3 +45,4 @@ try {
|
|||||||
console.error('build:sidecar-sebuf failed:', err.message);
|
console.error('build:sidecar-sebuf failed:', err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ declare global {
|
|||||||
export interface LiveChannel {
|
export interface LiveChannel {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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
|
fallbackVideoId?: string; // Fallback if no live stream detected
|
||||||
videoId?: string; // Dynamically fetched live video ID
|
videoId?: string; // Dynamically fetched live video ID
|
||||||
isLive?: boolean;
|
isLive?: boolean;
|
||||||
@@ -389,7 +389,7 @@ export class LiveNewsPanel extends Panel {
|
|||||||
|
|
||||||
const label = document.createElement('div');
|
const label = document.createElement('div');
|
||||||
label.style.cssText = 'color:var(--text-secondary);font-size:13px;';
|
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');
|
const playBtn = document.createElement('button');
|
||||||
playBtn.className = 'offline-retry';
|
playBtn.className = 'offline-retry';
|
||||||
@@ -744,12 +744,18 @@ export class LiveNewsPanel extends Panel {
|
|||||||
this.syncPlayerState();
|
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. */
|
/** Creates a single channel tab button with click and drag handlers. */
|
||||||
private createChannelButton(channel: LiveChannel): HTMLButtonElement {
|
private createChannelButton(channel: LiveChannel): HTMLButtonElement {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = `live-channel-btn ${channel.id === this.activeChannel.id ? 'active' : ''}`;
|
btn.className = `live-channel-btn ${channel.id === this.activeChannel.id ? 'active' : ''}`;
|
||||||
btn.dataset.channelId = channel.id;
|
btn.dataset.channelId = channel.id;
|
||||||
btn.textContent = channel.name;
|
|
||||||
|
btn.textContent = this.getChannelDisplayName(channel);
|
||||||
|
|
||||||
btn.style.cursor = 'grab';
|
btn.style.cursor = 'grab';
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
if (this.suppressChannelClick) {
|
if (this.suppressChannelClick) {
|
||||||
@@ -920,6 +926,14 @@ export class LiveNewsPanel extends Panel {
|
|||||||
channel.hlsUrl = undefined;
|
channel.hlsUrl = undefined;
|
||||||
return;
|
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);
|
const info = await fetchLiveVideoInfo(channel.handle);
|
||||||
channel.videoId = info.videoId || channel.fallbackVideoId;
|
channel.videoId = info.videoId || channel.fallbackVideoId;
|
||||||
channel.isLive = !!info.videoId;
|
channel.isLive = !!info.videoId;
|
||||||
@@ -990,7 +1004,9 @@ export class LiveNewsPanel extends Panel {
|
|||||||
this.destroyPlayer();
|
this.destroyPlayer();
|
||||||
const watchUrl = channel.videoId
|
const watchUrl = channel.videoId
|
||||||
? `https://www.youtube.com/watch?v=${encodeURIComponent(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);
|
const safeName = escapeHtml(channel.name);
|
||||||
|
|
||||||
this.content.innerHTML = `
|
this.content.innerHTML = `
|
||||||
@@ -1346,7 +1362,9 @@ export class LiveNewsPanel extends Panel {
|
|||||||
const channel = this.activeChannel;
|
const channel = this.activeChannel;
|
||||||
const watchUrl = channel.videoId
|
const watchUrl = channel.videoId
|
||||||
? `https://www.youtube.com/watch?v=${encodeURIComponent(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.destroyPlayer();
|
||||||
this.content.innerHTML = '';
|
this.content.innerHTML = '';
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ function parseYouTubeInput(raw: string): { handle: string } | { videoId: string
|
|||||||
return null;
|
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
|
// Persist active region tab across re-renders
|
||||||
let activeRegionTab = 'all';
|
let activeRegionTab = 'all';
|
||||||
|
|
||||||
@@ -73,11 +84,21 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
|
|||||||
const optionalChannelMap = new Map<string, LiveChannel>();
|
const optionalChannelMap = new Map<string, LiveChannel>();
|
||||||
for (const c of filteredChannels) optionalChannelMap.set(c.id, c);
|
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) {
|
if (!containerEl) {
|
||||||
document.title = `${t('components.liveNews.manage') ?? 'Channel management'} - World Monitor`;
|
document.title = `${t('components.liveNews.manage') ?? 'Channel management'} - World Monitor`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let channels = loadChannelsFromStorage();
|
channels = loadChannelsFromStorage();
|
||||||
let suppressRowClick = false;
|
let suppressRowClick = false;
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
|
|
||||||
@@ -179,7 +200,6 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
|
|||||||
listEl.appendChild(row);
|
listEl.appendChild(row);
|
||||||
}
|
}
|
||||||
updateRestoreButton();
|
updateRestoreButton();
|
||||||
renderAvailableChannels(listEl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns default (built-in) channels that are not in the current list. */
|
/** 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');
|
const handleInput = document.createElement('input');
|
||||||
handleInput.type = 'text';
|
handleInput.type = 'text';
|
||||||
handleInput.className = 'live-news-manage-edit-handle';
|
handleInput.className = 'live-news-manage-edit-handle';
|
||||||
handleInput.value = ch.handle;
|
handleInput.value = ch.handle ?? '';
|
||||||
handleInput.placeholder = t('components.liveNews.youtubeHandle') ?? 'YouTube handle';
|
handleInput.placeholder = t('components.liveNews.youtubeHandle') ?? 'YouTube handle';
|
||||||
row.appendChild(handleInput);
|
row.appendChild(handleInput);
|
||||||
}
|
}
|
||||||
@@ -262,7 +282,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
|
|||||||
saveBtn.className = 'live-news-manage-save';
|
saveBtn.className = 'live-news-manage-save';
|
||||||
saveBtn.textContent = t('components.liveNews.save') ?? 'Save';
|
saveBtn.textContent = t('components.liveNews.save') ?? 'Save';
|
||||||
saveBtn.addEventListener('click', () => {
|
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);
|
const next = applyEditFormToChannels(ch, row, isCustom, displayName);
|
||||||
if (next) {
|
if (next) {
|
||||||
channels = next;
|
channels = next;
|
||||||
@@ -300,7 +320,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
|
|||||||
.filter((ch): ch is LiveChannel => !!ch);
|
.filter((ch): ch is LiveChannel => !!ch);
|
||||||
|
|
||||||
const matchingChannels = term
|
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;
|
: regionChannels;
|
||||||
|
|
||||||
const addedCount = matchingChannels.filter(ch => currentIds.has(ch.id)).length;
|
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) {
|
for (const chId of region.channelIds) {
|
||||||
const ch = optionalChannelMap.get(chId);
|
const ch = optionalChannelMap.get(chId);
|
||||||
if (!ch) continue;
|
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);
|
const isAdded = currentIds.has(chId);
|
||||||
grid.appendChild(createCard(ch, isAdded, listEl));
|
grid.appendChild(createCard(ch, isAdded, listEl));
|
||||||
matchCount++;
|
matchCount++;
|
||||||
@@ -365,7 +385,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
|
|||||||
nameEl.textContent = ch.name;
|
nameEl.textContent = ch.name;
|
||||||
const handleEl = document.createElement('span');
|
const handleEl = document.createElement('span');
|
||||||
handleEl.className = 'live-news-manage-card-handle';
|
handleEl.className = 'live-news-manage-card-handle';
|
||||||
handleEl.textContent = ch.handle;
|
handleEl.textContent = ch.handle ?? '';
|
||||||
info.appendChild(nameEl);
|
info.appendChild(nameEl);
|
||||||
info.appendChild(handleEl);
|
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>
|
<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=..." />
|
<input type="text" class="live-news-manage-handle" id="liveChannelsHandle" placeholder="@Channel or youtube.com/watch?v=..." />
|
||||||
</div>
|
</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">
|
<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>
|
<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="" />
|
<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;
|
if (!listEl) return;
|
||||||
setupListDnD(listEl);
|
setupListDnD(listEl);
|
||||||
renderList(listEl);
|
renderList(listEl);
|
||||||
|
renderAvailableChannels(listEl);
|
||||||
|
|
||||||
// Clear validation state on input
|
// Clear validation state on input
|
||||||
document.getElementById('liveChannelsHandle')?.addEventListener('input', (e) => {
|
document.getElementById('liveChannelsHandle')?.addEventListener('input', (e) => {
|
||||||
(e.target as HTMLInputElement).classList.remove('invalid');
|
(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', () => {
|
document.getElementById('liveChannelsRestoreBtn')?.addEventListener('click', () => {
|
||||||
const missing = getMissingDefaultChannels();
|
const missing = getMissingDefaultChannels();
|
||||||
@@ -461,10 +489,40 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
|
|||||||
const addBtn = document.getElementById('liveChannelsAddBtn') as HTMLButtonElement | null;
|
const addBtn = document.getElementById('liveChannelsAddBtn') as HTMLButtonElement | null;
|
||||||
addBtn?.addEventListener('click', async () => {
|
addBtn?.addEventListener('click', async () => {
|
||||||
const handleInput = document.getElementById('liveChannelsHandle') as HTMLInputElement | null;
|
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 nameInput = document.getElementById('liveChannelsName') as HTMLInputElement | null;
|
||||||
const raw = handleInput?.value?.trim();
|
const raw = handleInput?.value?.trim();
|
||||||
if (!raw) return;
|
const hlsUrl = hlsInput?.value?.trim();
|
||||||
|
if (!raw && !hlsUrl) return;
|
||||||
if (handleInput) handleInput.classList.remove('invalid');
|
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
|
// Try parsing as a YouTube URL first
|
||||||
const parsed = parseYouTubeInput(raw);
|
const parsed = parseYouTubeInput(raw);
|
||||||
@@ -503,6 +561,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
|
|||||||
saveChannelsToStorage(channels);
|
saveChannelsToStorage(channels);
|
||||||
renderList(listEl);
|
renderList(listEl);
|
||||||
if (handleInput) handleInput.value = '';
|
if (handleInput) handleInput.value = '';
|
||||||
|
if (hlsInput) hlsInput.value = '';
|
||||||
if (nameInput) nameInput.value = '';
|
if (nameInput) nameInput.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -561,6 +620,7 @@ export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise
|
|||||||
saveChannelsToStorage(channels);
|
saveChannelsToStorage(channels);
|
||||||
renderList(listEl);
|
renderList(listEl);
|
||||||
if (handleInput) handleInput.value = '';
|
if (handleInput) handleInput.value = '';
|
||||||
|
if (hlsInput) hlsInput.value = '';
|
||||||
if (nameInput) nameInput.value = '';
|
if (nameInput) nameInput.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1667,7 +1667,9 @@
|
|||||||
"regionOceania": "Oceania",
|
"regionOceania": "Oceania",
|
||||||
"invalidHandle": "Enter a valid YouTube handle (e.g. @ChannelName)",
|
"invalidHandle": "Enter a valid YouTube handle (e.g. @ChannelName)",
|
||||||
"channelNotFound": "YouTube channel not found",
|
"channelNotFound": "YouTube channel not found",
|
||||||
"verifying": "Verifying…"
|
"verifying": "Verifying…",
|
||||||
|
"hlsUrl": "HLS Stream URL (optional)",
|
||||||
|
"invalidHlsUrl": "Enter a valid HLS stream URL (.m3u8)"
|
||||||
},
|
},
|
||||||
"map": {
|
"map": {
|
||||||
"showMap": "Show Map",
|
"showMap": "Show Map",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[dir="rtl"] {
|
[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),
|
:lang(zh-CN),
|
||||||
|
|||||||
Reference in New Issue
Block a user