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 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 { episodeStillUrl, getEpg, listChannels, showPosterUrl, 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 handleUnavailable = useCallback(() => setPlayerError(true), []) const retry = () => { setPlayerError(false) setAttempt((a) => a + 1) } const { data: channels, isLoading } = useQuery({ queryKey: ['air', 'channels'], queryFn: listChannels, }) 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(() => undefined) return () => { cancelled = true } }, [selected]) const { data: epg } = useQuery({ queryKey: ['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]) if (isLoading) return

{t('common.loading')}

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

{t('nav.dashboard')}

{t('air.noChannels')}

) return (

{t('nav.dashboard')}

{selected && watchReady ? ( playerError ? (

{t('air.offline')}

{t('air.offlineHint')}

) : ( ) ) : (
)}
{current && (
{currentEntry?.episodeHasStill && currentEntry.episodeId ? ( ) : currentEntry?.showHasPoster && current.showId ? ( ) : 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 } }