import { useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Download, Send, Radio, RotateCw } from 'lucide-react' import { qk } from '@/shared/api/query-keys' import type { PublicEpgEntryDto } from '@/shared/api/types' import { cn } from '@/shared/lib/cn' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { toast } from '@/shared/ui/toast-store' import { ChannelPlayer } from './ChannelPlayer' import { downloadIptvPlaylist, getTelegramStatus, issueTelegramLink, getEpg, getViewerFeatures, imageUrl, listChannels, watchChannel, } from './api' function formatTime(iso: string) { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } /** Кадр серии, если он есть; иначе постер шоу (он вертикальный, отсюда другая ширина). */ function EntryThumb({ entry }: Readonly<{ entry: PublicEpgEntryDto | undefined }>) { if (entry?.episodeStillImageId) return ( ) if (entry?.showPosterImageId) return ( ) return null } export function AirPage() { const { t } = useTranslation() const [selected, setSelected] = useState(null) const [watchReady, setWatchReady] = useState(false) const [playerError, setPlayerError] = useState(false) const [attempt, setAttempt] = useState(0) const [flash, setFlash] = useState(false) const handleUnavailable = useCallback(() => setPlayerError(true), []) const retry = () => { setPlayerError(false) setAttempt((a) => a + 1) } const [downloading, setDownloading] = useState(false) const downloadPlaylist = async () => { setDownloading(true) try { await downloadIptvPlaylist() // Предупреждение показываем в момент скачивания, а не подписью под кнопкой: висеть на экране // ему незачем, а прочитать его надо ровно тогда, когда файл уже на руках. toast.success(t('air.iptvDone')) } catch { toast.error(t('air.iptvFailed')) } finally { setDownloading(false) } } const { data: channels, isLoading } = useQuery({ queryKey: qk.air.channels, queryFn: listChannels, }) const { data: features } = useQuery({ queryKey: qk.air.features, queryFn: getViewerFeatures }) // Настройки бота читаем публичной частью: кнопка нужна только когда бот действительно настроен. const { data: telegram } = useQuery({ queryKey: qk.air.telegram, queryFn: getTelegramStatus, }) /** * Ссылка на чат выдаётся по нажатию, а не заранее: в ней одноразовый код привязки, и держать * его в разметке страницы всё время незачем. */ const openBot = async () => { try { const { url } = await issueTelegramLink() window.open(url, '_blank', 'noopener') } catch { toast.error(t('admin.telegram.linkFailed')) } } const numbersEnabled = features?.channelNumbersEnabled ?? false const currentChannel = channels?.find((c) => c.slug === selected) /** * Переключение по номерам: список уже отсортирован сервером, поэтому «вверх-вниз» — это шаг * по нему. Короткий чёрный кадр с номером ставится сразу, до готовности потока. */ const step = useCallback( (delta: number) => { if (!channels || channels.length === 0) return const index = channels.findIndex((c) => c.slug === selected) const next = channels[(index + delta + channels.length) % channels.length] if (!next || next.slug === selected) return setFlash(true) setSelected(next.slug) }, [channels, selected], ) useEffect(() => { if (!flash) return const id = window.setTimeout(() => setFlash(false), 900) return () => window.clearTimeout(id) }, [flash, selected]) useEffect(() => { if (!numbersEnabled) return const onKey = (event: KeyboardEvent) => { // Не перехватываем стрелки, пока фокус в поле ввода: там они двигают каретку. const target = event.target as HTMLElement | null if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return if (event.key === 'ArrowUp' || event.key === 'PageUp') { event.preventDefault() step(-1) } else if (event.key === 'ArrowDown' || event.key === 'PageDown') { event.preventDefault() step(1) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [numbersEnabled, step]) useEffect(() => { if (!selected && channels && channels.length > 0) setSelected(channels[0].slug) }, [channels, selected]) useEffect(() => { if (!selected) return setWatchReady(false) setPlayerError(false) let cancelled = false void watchChannel(selected) .then(() => { if (!cancelled) setWatchReady(true) }) .catch(() => { // Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая, // а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel. if (!cancelled) { setWatchReady(true) setPlayerError(true) } }) return () => { cancelled = true } }, [selected, attempt]) // Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим, // иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста. useEffect(() => { if (!selected || playerError) return const id = window.setInterval(() => { void watchChannel(selected).catch(() => undefined) }, 20 * 60_000) return () => window.clearInterval(id) }, [selected, playerError]) const { data: epg } = useQuery({ queryKey: qk.air.epg(selected), queryFn: () => getEpg(selected!, new Date(Date.now() - 30 * 60_000), new Date(Date.now() + 3 * 60 * 60_000)), enabled: !!selected, refetchInterval: 60_000, }) const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg]) // Плашка «Далее» — только на исходе программы: висеть весь эфир ей незачем. const nextUp = current && upcoming.length > 0 && new Date(current.endsAtUtc).getTime() - Date.now() < 60_000 ? upcoming[0].showName : null if (isLoading) return

{t('common.loading')}

if (!channels || channels.length === 0) return (

{t('nav.dashboard')}

{t('air.noChannels')}

) return (

{t('nav.dashboard')}

{numbersEnabled && ( {t('air.numbersHint')} )} {telegram?.isConfigured && ( )}
{/* До выдачи cookie tw_stream плеер грузить нечем — держим место заглушкой. */} {(!selected || !watchReady) && (
)} {selected && watchReady && playerError && (

{t('air.offline')}

{t('air.offlineHint')}

)} {selected && watchReady && !playerError && ( )}
{current && (
{t('air.now')} {current.showName}
{currentEntry?.episodeTitle && ( {currentEntry.episodeTitle} )} {formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)} {currentEntry?.episodeOverview && (

{currentEntry.episodeOverview}

)}
)} {upcoming.length > 0 && (
{t('air.next')}
    {upcoming.slice(0, 6).map((block) => (
  • {formatTime(block.startsAtUtc)} – {formatTime(block.endsAtUtc)} {block.showName}
  • ))}
)}
) } type GuideBlock = { key: string showId: string | null showName: string startsAtUtc: string endsAtUtc: string } /** * Строит телегид: рекламу/заставки не показываем, а подряд идущие серии одного шоу склеиваем в один * блок с диапазоном «с – по». Отдельно возвращаем текущую серию (для метаданных карточки «сейчас»). */ function buildGuide(entries: PublicEpgEntryDto[]): { current?: GuideBlock upcoming: GuideBlock[] currentEntry?: PublicEpgEntryDto } { const blocks: GuideBlock[] = [] for (const entry of entries) { if (entry.kind !== 'Program') continue const last = blocks.at(-1) if (last?.showId === entry.showId) { last.endsAtUtc = entry.endsAtUtc } else { blocks.push({ key: entry.startsAtUtc, showId: entry.showId, showName: entry.showName ?? '—', startsAtUtc: entry.startsAtUtc, endsAtUtc: entry.endsAtUtc, }) } } const now = Date.now() const active = (start: string, end: string) => new Date(start).getTime() <= now && new Date(end).getTime() > now const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc)) const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now) const currentEntry = entries.find( (e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc), ) return { current, upcoming, currentEntry } }