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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}` : ''}`)
|
||||
}
|
||||
Reference in New Issue
Block a user