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' }) } /** Кадр серии, если он есть; иначе постер шоу (он вертикальный, отсюда другая ширина). */ function EntryThumb({ entry }: { 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 { 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')} )} {/* До выдачи cookie tw_stream плеер грузить нечем — держим место заглушкой. */} {(!selected || !watchReady) && ( )} {selected && watchReady && playerError && ( {t('air.offline')} {t('air.offlineHint')} {t('air.retry')} )} {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[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 } }
{t('common.loading')}
{t('air.noChannels')}
{t('air.offline')}
{t('air.offlineHint')}
{currentEntry.episodeOverview}