import { useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { 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 { ChannelPlayer } from './ChannelPlayer' import { getEpg, getViewerFeatures, imageUrl, listChannels, watchChannel } from './api' function formatTime(iso: string) { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } 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 { data: channels, isLoading } = useQuery({ queryKey: qk.air.channels, queryFn: listChannels, }) const { data: features } = useQuery({ queryKey: qk.air.features, queryFn: getViewerFeatures }) 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')} )}
{selected && watchReady ? ( playerError ? (

{t('air.offline')}

{t('air.offlineHint')}

) : ( ) ) : (
)}
{current && (
{currentEntry?.episodeStillImageId ? ( ) : currentEntry?.showPosterImageId ? ( ) : null}
{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[blocks.length - 1] if (last && 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 } }