Add media and channel management routes: introduce new routes for admin channels, media, and shows in the routing structure. Update navigation in the admin layout to include links for these new sections. Enhance type definitions for media assets and shows in the API types. Integrate HLS.js for improved streaming support.

This commit is contained in:
Leonid Pershin
2026-07-24 16:18:54 +03:00
parent 1bbfd15907
commit a2685fb602
24 changed files with 2050 additions and 42 deletions
+145
View File
@@ -0,0 +1,145 @@
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
}
@@ -0,0 +1,55 @@
import Hls from 'hls.js'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
/**
* HLS-плеер канала. Cookie tw_stream (см. watchChannel) уже выдана к моменту монтирования, поэтому
* запросы плейлиста/сегментов авторизуются автоматически. hls.js для всех браузеров, нативный HLS —
* фолбэк для Safari/iOS.
*/
export function ChannelPlayer({ slug }: { slug: string }) {
const videoRef = useRef<HTMLVideoElement>(null)
const { t } = useTranslation()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const video = videoRef.current
if (!video) return
setError(null)
const src = `/api/channels/${slug}/live.m3u8`
let hls: Hls | null = null
if (Hls.isSupported()) {
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
hls.loadSource(src)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => void video.play().catch(() => undefined))
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) setError(t('air.playbackError'))
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src
video.addEventListener('loadedmetadata', () => void video.play().catch(() => undefined))
video.addEventListener('error', () => setError(t('air.playbackError')))
} else {
setError(t('air.unsupported'))
}
return () => {
hls?.destroy()
}
}, [slug, t])
return (
<div className="flex flex-col gap-2">
<video
ref={videoRef}
controls
playsInline
muted
className="aspect-video w-full rounded-md border border-border bg-black"
/>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { apiRequest } from '@/shared/api/client'
import type { PublicChannelDto, ScheduleEntryDto } from '@/shared/api/types'
export function listChannels() {
return apiRequest<PublicChannelDto[]>('/channels')
}
/** Выдаёт httpOnly-cookie tw_stream — после этого <video> сможет грузить плейлист и сегменты. */
export function watchChannel(slug: string) {
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
}
export function getEpg(slug: string, from?: Date, to?: Date) {
const query = new URLSearchParams()
if (from) query.set('from', from.toISOString())
if (to) query.set('to', to.toISOString())
const qs = query.toString()
return apiRequest<ScheduleEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
}