146 lines
5.0 KiB
TypeScript
146 lines
5.0 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { Radio } from 'lucide-react'
|
||
import type { ScheduleEntryDto } from '@/shared/api/types'
|
||
import { cn } from '@/shared/lib/cn'
|
||
import { Badge } from '@/shared/ui/badge'
|
||
import { ChannelPlayer } from './ChannelPlayer'
|
||
import { getEpg, 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<string | null>(null)
|
||
const [watchReady, setWatchReady] = useState(false)
|
||
|
||
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)
|
||
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 } = useMemo(() => splitEpg(epg ?? []), [epg])
|
||
|
||
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">
|
||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
||
|
||
<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}
|
||
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',
|
||
)}
|
||
>
|
||
<Radio className="h-4 w-4" />
|
||
{channel.name}
|
||
</button>
|
||
))}
|
||
</aside>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
{selected && watchReady ? (
|
||
<ChannelPlayer slug={selected} />
|
||
) : (
|
||
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
|
||
)}
|
||
|
||
<div className="flex flex-col gap-3">
|
||
{current && (
|
||
<div className="flex flex-col gap-1">
|
||
<div className="flex items-center gap-2">
|
||
<Badge>{t('air.now')}</Badge>
|
||
<span className="font-medium">{programLabel(current, t)}</span>
|
||
</div>
|
||
<span className="text-xs text-muted-foreground">
|
||
{formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)}
|
||
</span>
|
||
</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((entry) => (
|
||
<li key={entry.id} className="flex items-center gap-3 px-4 py-2 text-sm">
|
||
<span className="w-12 shrink-0 text-muted-foreground">
|
||
{formatTime(entry.startsAtUtc)}
|
||
</span>
|
||
<span>{programLabel(entry, t)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function splitEpg(entries: ScheduleEntryDto[]) {
|
||
const now = Date.now()
|
||
const current = entries.find(
|
||
(e) => new Date(e.startsAtUtc).getTime() <= now && new Date(e.endsAtUtc).getTime() > now,
|
||
)
|
||
const upcoming = entries.filter((e) => new Date(e.startsAtUtc).getTime() > now)
|
||
return { current, upcoming }
|
||
}
|
||
|
||
function programLabel(entry: ScheduleEntryDto, t: (key: string) => string) {
|
||
if (entry.kind === 'Ad') return t('air.ad')
|
||
const name = entry.showName ?? '—'
|
||
return entry.episodeIndex != null ? `${name} · ${t('air.episode')} ${entry.episodeIndex + 1}` : name
|
||
}
|