340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
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 (
|
||
<img
|
||
src={imageUrl(entry.episodeStillImageId)}
|
||
alt=""
|
||
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
||
/>
|
||
)
|
||
|
||
if (entry?.showPosterImageId)
|
||
return (
|
||
<img
|
||
src={imageUrl(entry.showPosterImageId)}
|
||
alt=""
|
||
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||
/>
|
||
)
|
||
|
||
return null
|
||
}
|
||
|
||
export function AirPage() {
|
||
const { t } = useTranslation()
|
||
const [selected, setSelected] = useState<string | null>(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 <p className="text-muted-foreground">{t('common.loading')}</p>
|
||
|
||
if (!channels || channels.length === 0)
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
||
<p className="text-muted-foreground">{t('air.noChannels')}</p>
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
||
{numbersEnabled && (
|
||
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
||
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
|
||
{channels.map((channel) => (
|
||
<button
|
||
key={channel.id}
|
||
type="button"
|
||
onClick={() => setSelected(channel.slug)}
|
||
className={cn(
|
||
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
|
||
selected === channel.slug && 'border-primary text-primary',
|
||
)}
|
||
>
|
||
{numbersEnabled && channel.number !== null && (
|
||
<span className="w-6 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
||
{channel.number}
|
||
</span>
|
||
)}
|
||
{channel.currentShowPosterImageId ? (
|
||
<img
|
||
src={imageUrl(channel.currentShowPosterImageId)}
|
||
alt=""
|
||
className="h-10 w-7 shrink-0 rounded object-cover"
|
||
/>
|
||
) : (
|
||
<Radio className="h-4 w-4 shrink-0" />
|
||
)}
|
||
<span className="flex min-w-0 flex-col">
|
||
<span className="truncate">{channel.name}</span>
|
||
{channel.currentShowName && (
|
||
<span className="truncate text-xs text-muted-foreground">
|
||
{channel.currentShowName}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</aside>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
{/* До выдачи cookie tw_stream плеер грузить нечем — держим место заглушкой. */}
|
||
{(!selected || !watchReady) && (
|
||
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
|
||
)}
|
||
{selected && watchReady && playerError && (
|
||
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
|
||
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
|
||
<div className="flex flex-col gap-1">
|
||
<p className="font-medium">{t('air.offline')}</p>
|
||
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
|
||
</div>
|
||
<Button size="sm" variant="outline" onClick={retry}>
|
||
<RotateCw className="h-4 w-4" />
|
||
{t('air.retry')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
{selected && watchReady && !playerError && (
|
||
<ChannelPlayer
|
||
key={`${selected}-${attempt}`}
|
||
slug={selected}
|
||
channel={currentChannel}
|
||
nextUp={nextUp}
|
||
flash={flash}
|
||
onUnavailable={handleUnavailable}
|
||
/>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-3">
|
||
{current && (
|
||
<div className="crt-panel flex gap-3 rounded-md p-3">
|
||
<EntryThumb entry={currentEntry} />
|
||
<div className="flex min-w-0 flex-col gap-1">
|
||
<div className="flex items-center gap-2">
|
||
<Badge>{t('air.now')}</Badge>
|
||
<span className="font-medium">{current.showName}</span>
|
||
</div>
|
||
{currentEntry?.episodeTitle && (
|
||
<span className="text-sm">{currentEntry.episodeTitle}</span>
|
||
)}
|
||
<span className="text-xs text-muted-foreground">
|
||
{formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)}
|
||
</span>
|
||
{currentEntry?.episodeOverview && (
|
||
<p className="line-clamp-3 text-xs text-muted-foreground">
|
||
{currentEntry.episodeOverview}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{upcoming.length > 0 && (
|
||
<div className="crt-panel rounded-md">
|
||
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||
{t('air.next')}
|
||
</div>
|
||
<ul className="divide-y divide-border">
|
||
{upcoming.slice(0, 6).map((block) => (
|
||
<li key={block.key} className="flex items-center gap-3 px-4 py-2 text-sm">
|
||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||
{formatTime(block.startsAtUtc)} – {formatTime(block.endsAtUtc)}
|
||
</span>
|
||
<span>{block.showName}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 }
|
||
}
|