| |
| |
| |
| |
| import type { LiveChannel } from '@/components/LiveNewsPanel'; |
| import { |
| loadChannelsFromStorage, |
| saveChannelsToStorage, |
| BUILTIN_IDS, |
| getDefaultLiveChannels, |
| getFilteredOptionalChannels, |
| getFilteredChannelRegions, |
| } from '@/components/LiveNewsPanel'; |
| import { t } from '@/services/i18n'; |
| import { escapeHtml } from '@/utils/sanitize'; |
| import { toApiUrl } from '@/services/runtime'; |
| import { resolveUserCountryCode } from '@/utils/user-location'; |
| import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; |
|
|
|
|
| |
| function customChannelIdFromHandle(handle: string): string { |
| const normalized = handle |
| .replace(/^@/, '') |
| .toLowerCase() |
| .replace(/[^a-z0-9]/g, '-') |
| .replace(/-+/g, '-') |
| .replace(/^-|-$/g, ''); |
| return 'custom-' + normalized; |
| } |
|
|
| |
| function parseYouTubeInput(raw: string): { handle: string } | { videoId: string } | null { |
| let url: URL; |
| try { |
| url = new URL(raw); |
| } catch { |
| return null; |
| } |
| if (!url.hostname.match(/^(www\.)?(youtube\.com|youtu\.be)$/)) return null; |
|
|
| |
| if (url.hostname.includes('youtu.be')) { |
| const vid = url.pathname.slice(1); |
| if (/^[A-Za-z0-9_-]{11}$/.test(vid)) return { videoId: vid }; |
| return null; |
| } |
| |
| const v = url.searchParams.get('v'); |
| if (v && /^[A-Za-z0-9_-]{11}$/.test(v)) return { videoId: v }; |
| |
| const handleMatch = url.pathname.match(/^\/@([\w.-]{3,30})$/); |
| if (handleMatch) return { handle: `@${handleMatch[1]}` }; |
| |
| const channelMatch = url.pathname.match(/^\/(c|channel)\/([\w.-]+)$/); |
| if (channelMatch) return { handle: `@${channelMatch[2]}` }; |
| |
| const bareMatch = url.pathname.match(/^\/([\w.-]{3,30})$/); |
| if (bareMatch) return { handle: `@${bareMatch[1]}` }; |
|
|
| return null; |
| } |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| let activeRegionTab = 'na'; |
|
|
| function channelInitials(name: string): string { |
| return name.split(/[\s-]+/).map((w) => w[0] ?? '').join('').slice(0, 2).toUpperCase(); |
| } |
|
|
| export async function initLiveChannelsWindow(containerEl?: HTMLElement): Promise<void> { |
| const appEl = containerEl ?? document.getElementById('app'); |
| if (!appEl) return; |
|
|
| const userCountry = await resolveUserCountryCode(); |
| const filteredChannels = getFilteredOptionalChannels(userCountry); |
| const filteredRegions = getFilteredChannelRegions(userCountry); |
| const optionalChannelMap = new Map<string, LiveChannel>(); |
| for (const c of filteredChannels) optionalChannelMap.set(c.id, c); |
|
|
| let channels: LiveChannel[] = []; |
|
|
| if (document.getElementById('liveChannelsList')) { |
| |
| channels = loadChannelsFromStorage(); |
| const listEl = document.getElementById('liveChannelsList') as HTMLElement; |
| renderList(listEl); |
| return; |
| } |
|
|
| if (!containerEl) { |
| document.title = `${t('components.liveNews.manage') ?? 'Channel management'} - World Monitor`; |
| } |
|
|
| channels = loadChannelsFromStorage(); |
| let suppressRowClick = false; |
| let searchQuery = ''; |
|
|
| |
| function applyOrderFromDom(listEl: HTMLElement): void { |
| const rows = listEl.querySelectorAll<HTMLElement>('.live-news-manage-row'); |
| const ids = Array.from(rows).map((el) => el.dataset.channelId).filter((id): id is string => !!id); |
| const map = new Map(channels.map((c) => [c.id, c])); |
| channels = ids.map((id) => map.get(id)).filter((c): c is LiveChannel => !!c); |
| saveChannelsToStorage(channels); |
| } |
|
|
| function setupListDnD(listEl: HTMLElement): void { |
| let dragging: HTMLElement | null = null; |
| let dragStarted = false; |
| let startY = 0; |
| const THRESHOLD = 6; |
|
|
| listEl.addEventListener('mousedown', (e) => { |
| if (e.button !== 0) return; |
| const target = e.target as HTMLElement; |
| if (target.closest('input, button, textarea, select')) return; |
| const row = target.closest('.live-news-manage-row') as HTMLElement | null; |
| if (!row || row.classList.contains('live-news-manage-row-editing')) return; |
| dragging = row; |
| dragStarted = false; |
| startY = e.clientY; |
| e.preventDefault(); |
| }); |
|
|
| document.addEventListener('mousemove', (e) => { |
| if (!dragging) return; |
| if (!dragStarted) { |
| if (Math.abs(e.clientY - startY) < THRESHOLD) return; |
| dragStarted = true; |
| dragging.classList.add('live-news-manage-row-dragging'); |
| } |
| const target = document.elementFromPoint(e.clientX, e.clientY)?.closest('.live-news-manage-row') as HTMLElement | null; |
| if (!target || target === dragging) return; |
| const all = Array.from(listEl.querySelectorAll('.live-news-manage-row')); |
| const idx = all.indexOf(dragging); |
| 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); |
| } |
| }); |
|
|
| document.addEventListener('mouseup', () => { |
| if (!dragging) return; |
| if (dragStarted) { |
| dragging.classList.remove('live-news-manage-row-dragging'); |
| applyOrderFromDom(listEl); |
| suppressRowClick = true; |
| setTimeout(() => { |
| suppressRowClick = false; |
| }, 0); |
| } |
| dragging = null; |
| dragStarted = false; |
| }); |
| } |
|
|
| function renderList(listEl: HTMLElement): void { |
| setTrustedHtml(listEl, trustedHtml('', "legacy direct innerHTML migration")); |
| for (const ch of channels) { |
| const isCustom = !BUILTIN_IDS.has(ch.id); |
| const row = document.createElement('div'); |
| row.className = 'live-news-manage-row'; |
| row.dataset.channelId = ch.id; |
|
|
| const nameSpan = document.createElement('span'); |
| nameSpan.className = 'live-news-manage-row-name'; |
| nameSpan.textContent = ch.name ?? ''; |
| row.appendChild(nameSpan); |
|
|
| const removeX = document.createElement('span'); |
| removeX.className = 'live-news-manage-row-remove-x'; |
| removeX.textContent = '✕'; |
| removeX.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| channels = channels.filter((c) => c.id !== ch.id); |
| saveChannelsToStorage(channels); |
| renderList(listEl); |
| }); |
| row.appendChild(removeX); |
|
|
| if (isCustom) { |
| row.addEventListener('click', (e) => { |
| if (suppressRowClick || row.classList.contains('live-news-manage-row-dragging')) return; |
| if ((e.target as HTMLElement).closest('input, button, textarea, select, .live-news-manage-row-remove-x')) return; |
| e.preventDefault(); |
| showEditForm(row, ch, listEl); |
| }); |
| } |
|
|
| listEl.appendChild(row); |
| } |
| updateRestoreButton(); |
| } |
|
|
| |
| function getMissingDefaultChannels(): LiveChannel[] { |
| const currentIds = new Set(channels.map((c) => c.id)); |
| return getDefaultLiveChannels().filter((c) => !currentIds.has(c.id)); |
| } |
|
|
| function updateRestoreButton(): void { |
| const btn = document.getElementById('liveChannelsRestoreBtn'); |
| if (!btn) return; |
| const missing = getMissingDefaultChannels(); |
| (btn as HTMLButtonElement).style.display = missing.length > 0 ? '' : 'none'; |
| } |
|
|
| |
| |
| |
| |
| function applyEditFormToChannels( |
| currentCh: LiveChannel, |
| formRow: HTMLElement, |
| isCustom: boolean, |
| displayName: string, |
| ): LiveChannel[] | null { |
| const idx = channels.findIndex((c) => c.id === currentCh.id); |
| if (idx === -1) return null; |
|
|
| if (isCustom) { |
| const handleRaw = (formRow.querySelector('.live-news-manage-edit-handle') as HTMLInputElement | null)?.value?.trim(); |
| if (handleRaw) { |
| const handle = handleRaw.startsWith('@') ? handleRaw : `@${handleRaw}`; |
| const newId = customChannelIdFromHandle(handle); |
| const existing = channels.find((c) => c.id === newId && c.id !== currentCh.id); |
| if (existing) return null; |
| const next = channels.slice(); |
| next[idx] = { ...currentCh, id: newId, handle, name: displayName }; |
| return next; |
| } |
| } |
| const next = channels.slice(); |
| next[idx] = { ...currentCh, name: displayName }; |
| return next; |
| } |
|
|
| function showEditForm(row: HTMLElement, ch: LiveChannel, listEl: HTMLElement): void { |
| const isCustom = !BUILTIN_IDS.has(ch.id); |
| setTrustedHtml(row, trustedHtml('', "legacy direct innerHTML migration")); |
| row.className = 'live-news-manage-row live-news-manage-row-editing'; |
|
|
| if (isCustom) { |
| const handleInput = document.createElement('input'); |
| handleInput.type = 'text'; |
| handleInput.className = 'live-news-manage-edit-handle'; |
| handleInput.value = ch.handle ?? ''; |
| handleInput.placeholder = t('components.liveNews.youtubeHandle') ?? 'YouTube handle'; |
| row.appendChild(handleInput); |
| } |
|
|
| const nameInput = document.createElement('input'); |
| nameInput.type = 'text'; |
| nameInput.className = 'live-news-manage-edit-name'; |
| nameInput.value = ch.name ?? ''; |
| nameInput.placeholder = t('components.liveNews.displayName') ?? 'Display name'; |
| row.appendChild(nameInput); |
|
|
| const removeBtn = document.createElement('button'); |
| removeBtn.type = 'button'; |
| removeBtn.className = 'live-news-manage-remove live-news-manage-remove-in-form'; |
| removeBtn.textContent = t('components.liveNews.remove') ?? 'Remove'; |
| removeBtn.addEventListener('click', () => { |
| channels = channels.filter((c) => c.id !== ch.id); |
| saveChannelsToStorage(channels); |
| renderList(listEl); |
| }); |
| row.appendChild(removeBtn); |
|
|
| const saveBtn = document.createElement('button'); |
| saveBtn.type = 'button'; |
| 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 next = applyEditFormToChannels(ch, row, isCustom, displayName); |
| if (next) { |
| channels = next; |
| saveChannelsToStorage(channels); |
| } |
| renderList(listEl); |
| }); |
| row.appendChild(saveBtn); |
|
|
| const cancelBtn = document.createElement('button'); |
| cancelBtn.type = 'button'; |
| cancelBtn.className = 'live-news-manage-cancel'; |
| cancelBtn.textContent = t('components.liveNews.cancel') ?? 'Cancel'; |
| cancelBtn.addEventListener('click', () => { |
| renderList(listEl); |
| }); |
| row.appendChild(cancelBtn); |
| } |
|
|
| |
|
|
| function renderAvailableChannels(listEl: HTMLElement): void { |
| const tabBar = document.getElementById('liveChannelsTabBar'); |
| const tabContents = document.getElementById('liveChannelsTabContents'); |
| if (!tabBar || !tabContents) return; |
|
|
| const currentIds = new Set(channels.map((c) => c.id)); |
| const term = searchQuery.toLowerCase().trim(); |
|
|
| |
| if (term) { |
| const activeHasMatch = filteredRegions.some(r => { |
| if (r.key !== activeRegionTab) return false; |
| return r.channelIds.some(id => { |
| const ch = optionalChannelMap.get(id); |
| return ch && (ch.name.toLowerCase().includes(term) || ch.handle?.toLowerCase().includes(term)); |
| }); |
| }); |
| if (!activeHasMatch) { |
| const firstMatch = filteredRegions.find(r => |
| r.channelIds.some(id => { |
| const ch = optionalChannelMap.get(id); |
| return ch && (ch.name.toLowerCase().includes(term) || ch.handle?.toLowerCase().includes(term)); |
| }), |
| ); |
| if (firstMatch) activeRegionTab = firstMatch.key; |
| } |
| } |
|
|
| |
| setTrustedHtml(tabBar, trustedHtml('', "legacy direct innerHTML migration")); |
| for (const region of filteredRegions) { |
| const regionChannels = region.channelIds |
| .map(id => optionalChannelMap.get(id)) |
| .filter((ch): ch is LiveChannel => !!ch); |
|
|
| const matchingChannels = 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; |
|
|
| const btn = document.createElement('button'); |
| btn.type = 'button'; |
| btn.className = 'panel-tab' + (region.key === activeRegionTab ? ' active' : ''); |
| const label = t(region.labelKey) ?? region.key.toUpperCase(); |
| btn.textContent = term |
| ? `${label} (${matchingChannels.length})` |
| : addedCount > 0 ? `${label} (${addedCount})` : label; |
| btn.addEventListener('click', () => { |
| activeRegionTab = region.key; |
| renderAvailableChannels(listEl); |
| }); |
| tabBar.appendChild(btn); |
| } |
|
|
| |
| setTrustedHtml(tabContents, trustedHtml('', "legacy direct innerHTML migration")); |
| for (const region of filteredRegions) { |
| const panel = document.createElement('div'); |
| panel.className = 'live-news-manage-tab-content' + (region.key === activeRegionTab ? ' active' : ''); |
|
|
| const grid = document.createElement('div'); |
| grid.className = 'live-news-manage-card-grid'; |
|
|
| let matchCount = 0; |
| 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; |
| const isAdded = currentIds.has(chId); |
| grid.appendChild(createCard(ch, isAdded, listEl)); |
| matchCount++; |
| } |
|
|
| if (matchCount === 0 && term) { |
| const empty = document.createElement('div'); |
| empty.className = 'live-news-manage-empty'; |
| empty.textContent = (t('components.liveNews.noResults') ?? 'No channels found matching "{{term}}"').replace('{{term}}', term); |
| panel.appendChild(empty); |
| } else { |
| panel.appendChild(grid); |
| } |
| tabContents.appendChild(panel); |
| } |
| } |
|
|
| function createCard(ch: LiveChannel, isAdded: boolean, listEl: HTMLElement): HTMLElement { |
| const card = document.createElement('div'); |
| card.className = 'live-news-manage-card' + (isAdded ? ' added' : ''); |
|
|
| const icon = document.createElement('div'); |
| icon.className = 'live-news-manage-card-icon'; |
| icon.textContent = channelInitials(ch.name); |
|
|
| const info = document.createElement('div'); |
| info.className = 'live-news-manage-card-info'; |
| const nameEl = document.createElement('span'); |
| nameEl.className = 'live-news-manage-card-name'; |
| nameEl.textContent = ch.name; |
| const handleEl = document.createElement('span'); |
| handleEl.className = 'live-news-manage-card-handle'; |
| handleEl.textContent = ch.handle ?? ''; |
| info.appendChild(nameEl); |
| info.appendChild(handleEl); |
|
|
| const action = document.createElement('span'); |
| action.className = 'live-news-manage-card-action'; |
| action.textContent = isAdded ? '✓' : '+'; |
|
|
| card.appendChild(icon); |
| card.appendChild(info); |
| card.appendChild(action); |
|
|
| card.addEventListener('mouseenter', () => { |
| if (card.classList.contains('added')) action.textContent = '✕'; |
| }); |
| card.addEventListener('mouseleave', () => { |
| if (card.classList.contains('added')) action.textContent = '✓'; |
| }); |
|
|
| card.addEventListener('click', () => { |
| if (isAdded) { |
| channels = channels.filter((c) => c.id !== ch.id); |
| } else { |
| if (channels.some((c) => c.id === ch.id)) return; |
| channels.push({ ...ch }); |
| } |
| saveChannelsToStorage(channels); |
| renderList(listEl); |
| renderAvailableChannels(listEl); |
| }); |
| return card; |
| } |
|
|
| |
|
|
| setTrustedHtml(appEl, trustedHtml(` |
| <div class="live-channels-window-shell"> |
| <div class="live-channels-window-header"> |
| <span class="live-channels-window-title">${escapeHtml(t('components.liveNews.manage') ?? 'Channel management')}</span> |
| </div> |
| <div class="live-channels-window-content"> |
| <div class="live-channels-window-toolbar"> |
| <button type="button" class="live-news-manage-restore-defaults" id="liveChannelsRestoreBtn" style="display: none;">${escapeHtml(t('components.liveNews.restoreDefaults') ?? 'Restore default channels')}</button> |
| </div> |
| <div class="live-news-manage-list" id="liveChannelsList"></div> |
| <div class="live-news-manage-available-section"> |
| <div class="live-news-manage-available-header"> |
| <span class="live-news-manage-add-title">${escapeHtml(t('components.liveNews.availableChannels') ?? 'Available channels')}</span> |
| <div class="live-news-manage-search-wrap"> |
| <span class="live-news-manage-search-icon"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> |
| </span> |
| <input type="text" id="liveChannelsSearch" class="live-news-manage-search-input" placeholder="${escapeHtml(t('header.search') ?? 'Search')}..." autocomplete="off" /> |
| </div> |
| </div> |
| <div class="panel-tabs" id="liveChannelsTabBar"></div> |
| <div class="live-news-manage-tab-contents" id="liveChannelsTabContents"></div> |
| </div> |
| <div class="live-news-manage-add-section"> |
| <span class="live-news-manage-add-title">${escapeHtml(t('components.liveNews.customChannel') ?? 'Custom channel')}</span> |
| <div class="live-news-manage-add"> |
| <div class="live-news-manage-add-field"> |
| <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="" /> |
| </div> |
| <button type="button" class="live-news-manage-add-btn" id="liveChannelsAddBtn">${escapeHtml(t('components.liveNews.addChannel') ?? 'Add channel')}</button> |
| </div> |
| </div> |
| </div> |
| </div> |
| `, "legacy direct innerHTML migration")); |
|
|
| const listEl = document.getElementById('liveChannelsList'); |
| if (!listEl) return; |
| setupListDnD(listEl); |
| renderList(listEl); |
| renderAvailableChannels(listEl); |
|
|
| |
| 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(); |
| if (missing.length === 0) return; |
| channels = [...channels, ...missing]; |
| saveChannelsToStorage(channels); |
| renderList(listEl); |
| }); |
|
|
| 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(); |
| const hlsUrl = hlsInput?.value?.trim(); |
| if (!raw && !hlsUrl) return; |
| if (handleInput) handleInput.classList.remove('invalid'); |
| if (hlsInput) hlsInput.classList.remove('invalid'); |
|
|
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| if (!raw) return; |
|
|
| |
| const parsed = parseYouTubeInput(raw); |
|
|
| |
| if (parsed && 'videoId' in parsed) { |
| const videoId = parsed.videoId; |
| const id = `custom-vid-${videoId}`; |
| if (channels.some((c) => c.id === id)) return; |
|
|
| if (addBtn) { |
| addBtn.disabled = true; |
| addBtn.textContent = t('components.liveNews.verifying') ?? 'Verifying…'; |
| } |
|
|
| |
| let resolvedName = nameInput?.value?.trim() || ''; |
| if (!resolvedName) { |
| try { |
| const res = await fetch(toApiUrl(`/api/youtube/live?videoId=${encodeURIComponent(videoId)}`)); |
| if (res.ok) { |
| const data = await res.json(); |
| resolvedName = data.channelName || data.title || ''; |
| } |
| } catch { } |
| } |
| if (!resolvedName) resolvedName = `Video ${videoId}`; |
|
|
| if (addBtn) { |
| addBtn.disabled = false; |
| addBtn.textContent = t('components.liveNews.addChannel') ?? 'Add channel'; |
| } |
|
|
| channels.push({ id, name: resolvedName, handle: `@video`, fallbackVideoId: videoId, useFallbackOnly: true }); |
| saveChannelsToStorage(channels); |
| renderList(listEl); |
| if (handleInput) handleInput.value = ''; |
| if (hlsInput) hlsInput.value = ''; |
| if (nameInput) nameInput.value = ''; |
| return; |
| } |
|
|
| |
| const handle = parsed && 'handle' in parsed |
| ? parsed.handle |
| : raw.startsWith('@') ? raw : `@${raw}`; |
|
|
| |
| if (!/^@[\w.-]{3,30}$/i.test(handle)) { |
| if (handleInput) { |
| handleInput.classList.add('invalid'); |
| handleInput.setAttribute('title', t('components.liveNews.invalidHandle') ?? 'Enter a valid YouTube handle (e.g. @ChannelName)'); |
| } |
| return; |
| } |
|
|
| const id = customChannelIdFromHandle(handle); |
| if (channels.some((c) => c.id === id)) return; |
|
|
| |
| if (addBtn) { |
| addBtn.disabled = true; |
| addBtn.textContent = t('components.liveNews.verifying') ?? 'Verifying…'; |
| } |
|
|
| let resolvedName = ''; |
| try { |
| const res = await fetch(toApiUrl(`/api/youtube/live?channel=${encodeURIComponent(handle)}`)); |
| if (res.ok) { |
| const data = await res.json(); |
| resolvedName = data.channelName || ''; |
| } |
| |
| } catch (e) { |
| |
| console.warn('[LiveChannels] YouTube validation failed, allowing add:', e); |
| } finally { |
| if (addBtn) { |
| addBtn.disabled = false; |
| addBtn.textContent = t('components.liveNews.addChannel') ?? 'Add channel'; |
| } |
| } |
|
|
| const name = nameInput?.value?.trim() || resolvedName || handle; |
| channels.push({ id, name, handle }); |
| saveChannelsToStorage(channels); |
| renderList(listEl); |
| if (handleInput) handleInput.value = ''; |
| if (hlsInput) hlsInput.value = ''; |
| if (nameInput) nameInput.value = ''; |
| }); |
|
|
| let searchDebounce: ReturnType<typeof setTimeout> | null = null; |
| const searchInput = document.getElementById('liveChannelsSearch') as HTMLInputElement | null; |
| searchInput?.addEventListener('input', (e) => { |
| searchQuery = (e.target as HTMLInputElement).value; |
| if (searchDebounce) clearTimeout(searchDebounce); |
| searchDebounce = setTimeout(() => renderAvailableChannels(listEl), 150); |
| }); |
| } |
|
|