Files
TeleWave/frontend/src/features/streaming/AirPage.tsx
T

340 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}