Enhance channel and settings functionalities: introduce viewer settings in ChannelEndpoints, update SiteSettings to include channel number toggling, and refactor related data structures. Implement new endpoints for validating templates and diffing scheduling changes, improving overall user experience and configuration management.
This commit is contained in:
@@ -1,261 +1,325 @@
|
||||
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 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, imageUrl, 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 [playerError, setPlayerError] = useState(false)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
const handleUnavailable = useCallback(() => setPlayerError(true), [])
|
||||
const retry = () => {
|
||||
setPlayerError(false)
|
||||
setAttempt((a) => a + 1)
|
||||
}
|
||||
|
||||
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)
|
||||
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: ['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])
|
||||
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{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">
|
||||
{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>
|
||||
) : (
|
||||
<ChannelPlayer
|
||||
key={`${selected}-${attempt}`}
|
||||
slug={selected}
|
||||
onUnavailable={handleUnavailable}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<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="crt-panel flex gap-3 rounded-md p-3">
|
||||
{currentEntry?.episodeStillImageId ? (
|
||||
<img
|
||||
src={imageUrl(currentEntry.episodeStillImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
) : currentEntry?.showPosterImageId ? (
|
||||
<img
|
||||
src={imageUrl(currentEntry.showPosterImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
) : null}
|
||||
<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 }
|
||||
}
|
||||
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 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' })
|
||||
}
|
||||
|
||||
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: ['air', 'channels'],
|
||||
queryFn: listChannels,
|
||||
})
|
||||
const { data: features } = useQuery({ queryKey: ['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: ['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}
|
||||
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">
|
||||
{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>
|
||||
) : (
|
||||
<ChannelPlayer
|
||||
key={`${selected}-${attempt}`}
|
||||
slug={selected}
|
||||
channel={currentChannel}
|
||||
nextUp={nextUp}
|
||||
flash={flash}
|
||||
onUnavailable={handleUnavailable}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<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="crt-panel flex gap-3 rounded-md p-3">
|
||||
{currentEntry?.episodeStillImageId ? (
|
||||
<img
|
||||
src={imageUrl(currentEntry.episodeStillImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
) : currentEntry?.showPosterImageId ? (
|
||||
<img
|
||||
src={imageUrl(currentEntry.showPosterImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
) : null}
|
||||
<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 }
|
||||
}
|
||||
|
||||
@@ -1,241 +1,279 @@
|
||||
import Hls from 'hls.js'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Maximize, Volume2, VolumeX } from 'lucide-react'
|
||||
|
||||
const STORAGE_KEY = 'tw:player'
|
||||
|
||||
/** Читает сохранённые громкость/mute из localStorage (с валидацией и дефолтами). */
|
||||
function readStoredAudio(): { volume: number; muted: boolean } {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { volume?: unknown; muted?: unknown }
|
||||
const volume =
|
||||
typeof parsed.volume === 'number' ? Math.min(1, Math.max(0, parsed.volume)) : 1
|
||||
const muted = typeof parsed.muted === 'boolean' ? parsed.muted : true
|
||||
return { volume, muted }
|
||||
}
|
||||
} catch {
|
||||
/* недоступен/битый localStorage — дефолты */
|
||||
}
|
||||
return { volume: 1, muted: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
||||
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
||||
*/
|
||||
export function ChannelPlayer({
|
||||
slug,
|
||||
onUnavailable,
|
||||
}: {
|
||||
slug: string
|
||||
onUnavailable?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const [muted, setMuted] = useState(() => readStoredAudio().muted)
|
||||
const [volume, setVolume] = useState(() => readStoredAudio().volume)
|
||||
|
||||
// Последние настройки звука без пересоздания HLS-эффекта + сохранение в localStorage.
|
||||
const audioRef = useRef({ volume, muted })
|
||||
useEffect(() => {
|
||||
audioRef.current = { volume, muted }
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ volume, muted }))
|
||||
} catch {
|
||||
/* localStorage недоступен — не критично */
|
||||
}
|
||||
}, [volume, muted])
|
||||
const [controlsVisible, setControlsVisible] = useState(true)
|
||||
const hideTimerRef = useRef<number | null>(null)
|
||||
const overControlsRef = useRef(false)
|
||||
|
||||
const clearHideTimer = useCallback(() => {
|
||||
if (hideTimerRef.current !== null) {
|
||||
window.clearTimeout(hideTimerRef.current)
|
||||
hideTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Прячем панель и курсор после бездействия — но не когда курсор на панели или видео на паузе.
|
||||
const scheduleHide = useCallback(() => {
|
||||
clearHideTimer()
|
||||
hideTimerRef.current = window.setTimeout(() => {
|
||||
if (!overControlsRef.current && videoRef.current && !videoRef.current.paused) {
|
||||
setControlsVisible(false)
|
||||
}
|
||||
}, 2500)
|
||||
}, [clearHideTimer])
|
||||
|
||||
const revealControls = useCallback(() => {
|
||||
setControlsVisible(true)
|
||||
scheduleHide()
|
||||
}, [scheduleHide])
|
||||
|
||||
useEffect(() => clearHideTimer, [clearHideTimer])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const src = `/api/channels/${slug}/live.m3u8`
|
||||
let hls: Hls | null = null
|
||||
|
||||
// Восстанавливаем сохранённую громкость/mute и запускаем; если браузер блокирует автоплей со
|
||||
// звуком — откатываемся на воспроизведение без звука.
|
||||
const startPlayback = () => {
|
||||
const audio = audioRef.current
|
||||
video.volume = audio.volume
|
||||
video.muted = audio.muted
|
||||
video.play().catch(() => {
|
||||
video.muted = true
|
||||
setMuted(true)
|
||||
void video.play().catch(() => undefined)
|
||||
})
|
||||
}
|
||||
|
||||
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
|
||||
const onNativeError = () => onUnavailable?.()
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
||||
hlsRef.current = hls
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, startPlayback)
|
||||
|
||||
// Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить
|
||||
// (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline.
|
||||
let recoverAttempts = 0
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (!data.fatal) return
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) {
|
||||
recoverAttempts += 1
|
||||
hls?.startLoad()
|
||||
return
|
||||
}
|
||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) {
|
||||
recoverAttempts += 1
|
||||
hls?.recoverMediaError()
|
||||
return
|
||||
}
|
||||
hls?.destroy()
|
||||
hls = null
|
||||
hlsRef.current = null
|
||||
onUnavailable?.()
|
||||
})
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src
|
||||
video.addEventListener('loadedmetadata', startPlayback)
|
||||
video.addEventListener('error', onNativeError)
|
||||
} else {
|
||||
onUnavailable?.()
|
||||
}
|
||||
|
||||
return () => {
|
||||
hls?.destroy()
|
||||
hlsRef.current = null
|
||||
video.removeEventListener('loadedmetadata', startPlayback)
|
||||
video.removeEventListener('error', onNativeError)
|
||||
}
|
||||
}, [slug, onUnavailable])
|
||||
|
||||
// Установить громкость (0..1); 0 = mute, >0 запоминаем как последний уровень для «размьютить».
|
||||
const applyVolume = (value: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const clamped = Math.min(1, Math.max(0, value))
|
||||
video.volume = clamped
|
||||
video.muted = clamped === 0
|
||||
setMuted(clamped === 0)
|
||||
if (clamped > 0) setVolume(clamped)
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.muted || video.volume === 0) {
|
||||
applyVolume(volume > 0 ? volume : 0.5)
|
||||
} else {
|
||||
video.muted = true
|
||||
setMuted(true)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
if (document.fullscreenElement) void document.exitFullscreen()
|
||||
else void container.requestFullscreen().catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseMove={revealControls}
|
||||
onMouseLeave={() => {
|
||||
clearHideTimer()
|
||||
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false)
|
||||
}}
|
||||
className={`relative aspect-video w-full overflow-hidden rounded-md border border-border bg-black ${
|
||||
controlsVisible ? '' : 'cursor-none'
|
||||
}`}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
playsInline
|
||||
muted
|
||||
className="h-full w-full"
|
||||
onDoubleClick={toggleFullscreen}
|
||||
onPlay={scheduleHide}
|
||||
onPause={() => {
|
||||
clearHideTimer()
|
||||
setControlsVisible(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
onMouseEnter={() => {
|
||||
overControlsRef.current = true
|
||||
clearHideTimer()
|
||||
setControlsVisible(true)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
overControlsRef.current = false
|
||||
scheduleHide()
|
||||
}}
|
||||
className={`absolute inset-x-0 bottom-0 flex items-center gap-3 bg-gradient-to-t from-black/70 to-transparent px-3 py-2 text-white transition-opacity ${
|
||||
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={toggleMute} aria-label="mute">
|
||||
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => applyVolume(Number(e.target.value))}
|
||||
aria-label={t('air.volume')}
|
||||
className="h-1 w-20 cursor-pointer accent-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
||||
{t('air.live')}
|
||||
</span>
|
||||
|
||||
<button type="button" onClick={toggleFullscreen} aria-label="fullscreen">
|
||||
<Maximize className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import Hls from 'hls.js'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Maximize, Volume2, VolumeX } from 'lucide-react'
|
||||
import type { PublicChannelDto } from '@/shared/api/types'
|
||||
import {
|
||||
AnalogFilter,
|
||||
ChannelFlash,
|
||||
ChannelLogo,
|
||||
NextUpBanner,
|
||||
ScreenClock,
|
||||
} from './PlayerOverlays'
|
||||
|
||||
const STORAGE_KEY = 'tw:player'
|
||||
|
||||
/** Читает сохранённые громкость/mute из localStorage (с валидацией и дефолтами). */
|
||||
function readStoredAudio(): { volume: number; muted: boolean } {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { volume?: unknown; muted?: unknown }
|
||||
const volume =
|
||||
typeof parsed.volume === 'number' ? Math.min(1, Math.max(0, parsed.volume)) : 1
|
||||
const muted = typeof parsed.muted === 'boolean' ? parsed.muted : true
|
||||
return { volume, muted }
|
||||
}
|
||||
} catch {
|
||||
/* недоступен/битый localStorage — дефолты */
|
||||
}
|
||||
return { volume: 1, muted: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
||||
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
||||
*/
|
||||
export function ChannelPlayer({
|
||||
slug,
|
||||
channel,
|
||||
nextUp,
|
||||
flash,
|
||||
onUnavailable,
|
||||
}: {
|
||||
slug: string
|
||||
/** Канал, чьи оверлеи рисуем. Всё опционально: канал без логотипа и без шума — норма. */
|
||||
channel?: PublicChannelDto
|
||||
/** Название следующей программы, когда до неё осталось меньше минуты. */
|
||||
nextUp?: string | null
|
||||
/** Показать чёрный кадр с номером — переключение по номерам, как на телевизоре. */
|
||||
flash?: boolean
|
||||
onUnavailable?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const [muted, setMuted] = useState(() => readStoredAudio().muted)
|
||||
const [volume, setVolume] = useState(() => readStoredAudio().volume)
|
||||
|
||||
// Последние настройки звука без пересоздания HLS-эффекта + сохранение в localStorage.
|
||||
const audioRef = useRef({ volume, muted })
|
||||
useEffect(() => {
|
||||
audioRef.current = { volume, muted }
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ volume, muted }))
|
||||
} catch {
|
||||
/* localStorage недоступен — не критично */
|
||||
}
|
||||
}, [volume, muted])
|
||||
const [controlsVisible, setControlsVisible] = useState(true)
|
||||
const hideTimerRef = useRef<number | null>(null)
|
||||
const overControlsRef = useRef(false)
|
||||
|
||||
const clearHideTimer = useCallback(() => {
|
||||
if (hideTimerRef.current !== null) {
|
||||
window.clearTimeout(hideTimerRef.current)
|
||||
hideTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Прячем панель и курсор после бездействия — но не когда курсор на панели или видео на паузе.
|
||||
const scheduleHide = useCallback(() => {
|
||||
clearHideTimer()
|
||||
hideTimerRef.current = window.setTimeout(() => {
|
||||
if (!overControlsRef.current && videoRef.current && !videoRef.current.paused) {
|
||||
setControlsVisible(false)
|
||||
}
|
||||
}, 2500)
|
||||
}, [clearHideTimer])
|
||||
|
||||
const revealControls = useCallback(() => {
|
||||
setControlsVisible(true)
|
||||
scheduleHide()
|
||||
}, [scheduleHide])
|
||||
|
||||
useEffect(() => clearHideTimer, [clearHideTimer])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const src = `/api/channels/${slug}/live.m3u8`
|
||||
let hls: Hls | null = null
|
||||
|
||||
// Восстанавливаем сохранённую громкость/mute и запускаем; если браузер блокирует автоплей со
|
||||
// звуком — откатываемся на воспроизведение без звука.
|
||||
const startPlayback = () => {
|
||||
const audio = audioRef.current
|
||||
video.volume = audio.volume
|
||||
video.muted = audio.muted
|
||||
video.play().catch(() => {
|
||||
video.muted = true
|
||||
setMuted(true)
|
||||
void video.play().catch(() => undefined)
|
||||
})
|
||||
}
|
||||
|
||||
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
|
||||
const onNativeError = () => onUnavailable?.()
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
||||
hlsRef.current = hls
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, startPlayback)
|
||||
|
||||
// Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить
|
||||
// (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline.
|
||||
let recoverAttempts = 0
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (!data.fatal) return
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) {
|
||||
recoverAttempts += 1
|
||||
hls?.startLoad()
|
||||
return
|
||||
}
|
||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) {
|
||||
recoverAttempts += 1
|
||||
hls?.recoverMediaError()
|
||||
return
|
||||
}
|
||||
hls?.destroy()
|
||||
hls = null
|
||||
hlsRef.current = null
|
||||
onUnavailable?.()
|
||||
})
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src
|
||||
video.addEventListener('loadedmetadata', startPlayback)
|
||||
video.addEventListener('error', onNativeError)
|
||||
} else {
|
||||
onUnavailable?.()
|
||||
}
|
||||
|
||||
return () => {
|
||||
hls?.destroy()
|
||||
hlsRef.current = null
|
||||
video.removeEventListener('loadedmetadata', startPlayback)
|
||||
video.removeEventListener('error', onNativeError)
|
||||
}
|
||||
}, [slug, onUnavailable])
|
||||
|
||||
// Установить громкость (0..1); 0 = mute, >0 запоминаем как последний уровень для «размьютить».
|
||||
const applyVolume = (value: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const clamped = Math.min(1, Math.max(0, value))
|
||||
video.volume = clamped
|
||||
video.muted = clamped === 0
|
||||
setMuted(clamped === 0)
|
||||
if (clamped > 0) setVolume(clamped)
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.muted || video.volume === 0) {
|
||||
applyVolume(volume > 0 ? volume : 0.5)
|
||||
} else {
|
||||
video.muted = true
|
||||
setMuted(true)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
if (document.fullscreenElement) void document.exitFullscreen()
|
||||
else void container.requestFullscreen().catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseMove={revealControls}
|
||||
onMouseLeave={() => {
|
||||
clearHideTimer()
|
||||
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false)
|
||||
}}
|
||||
className={`relative aspect-video w-full overflow-hidden rounded-md border border-border bg-black ${
|
||||
controlsVisible ? '' : 'cursor-none'
|
||||
}`}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
playsInline
|
||||
muted
|
||||
className="h-full w-full"
|
||||
style={
|
||||
channel && channel.analogFilterStrength > 0
|
||||
? {
|
||||
filter: `saturate(${1 + channel.analogFilterStrength * 0.4}) contrast(${1 + channel.analogFilterStrength * 0.15}) blur(${channel.analogFilterStrength * 0.6}px)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
onPlay={scheduleHide}
|
||||
onPause={() => {
|
||||
clearHideTimer()
|
||||
setControlsVisible(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
{channel && channel.analogFilterStrength > 0 && (
|
||||
<AnalogFilter strength={channel.analogFilterStrength} />
|
||||
)}
|
||||
{channel?.logoImageId && (
|
||||
<ChannelLogo
|
||||
imageId={channel.logoImageId}
|
||||
corner={channel.logoCorner}
|
||||
opacity={channel.logoOpacity}
|
||||
/>
|
||||
)}
|
||||
{channel?.showClock && <ScreenClock />}
|
||||
{nextUp && <NextUpBanner title={nextUp} />}
|
||||
{flash && channel && <ChannelFlash number={channel.number} name={channel.name} />}
|
||||
|
||||
<div
|
||||
onMouseEnter={() => {
|
||||
overControlsRef.current = true
|
||||
clearHideTimer()
|
||||
setControlsVisible(true)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
overControlsRef.current = false
|
||||
scheduleHide()
|
||||
}}
|
||||
className={`absolute inset-x-0 bottom-0 flex items-center gap-3 bg-gradient-to-t from-black/70 to-transparent px-3 py-2 text-white transition-opacity ${
|
||||
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={toggleMute} aria-label="mute">
|
||||
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => applyVolume(Number(e.target.value))}
|
||||
aria-label={t('air.volume')}
|
||||
className="h-1 w-20 cursor-pointer accent-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
||||
{t('air.live')}
|
||||
</span>
|
||||
|
||||
<button type="button" onClick={toggleFullscreen} aria-label="fullscreen">
|
||||
<Maximize className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { LogoCorner } from '@/shared/api/types'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { imageUrl } from './api'
|
||||
|
||||
const CORNER_CLASS: Record<LogoCorner, string> = {
|
||||
TopLeft: 'left-3 top-3',
|
||||
TopRight: 'right-3 top-3',
|
||||
BottomLeft: 'left-3 bottom-14',
|
||||
BottomRight: 'right-3 bottom-14',
|
||||
}
|
||||
|
||||
/**
|
||||
* Логотип канала поверх картинки. Настоящий вещательный логотип вжигается при кодировании; для нас
|
||||
* это означало бы перекодирование всей библиотеки при смене логотипа, поэтому только оверлей.
|
||||
*/
|
||||
export function ChannelLogo({
|
||||
imageId,
|
||||
corner,
|
||||
opacity,
|
||||
}: {
|
||||
imageId: string
|
||||
corner: LogoCorner
|
||||
opacity: number
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
src={imageUrl(imageId)}
|
||||
alt=""
|
||||
style={{ opacity }}
|
||||
className={cn('pointer-events-none absolute h-10 w-auto max-w-24 object-contain', CORNER_CLASS[corner])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Часы поверх картинки — опция канала, а не общее украшение плеера. */
|
||||
export function ScreenClock() {
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
|
||||
useEffect(() => {
|
||||
// Тик раз в 10 секунд: минуты меняются реже, а секунды на часах в углу никому не нужны.
|
||||
const id = window.setInterval(() => setNow(new Date()), 10_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<span className="pointer-events-none absolute right-3 top-3 rounded bg-black/40 px-2 py-0.5 text-sm tabular-nums text-white/90">
|
||||
{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Плашка «Далее: …» — данные уже есть в EPG, отдельного запроса не нужно. */
|
||||
export function NextUpBanner({ title }: { title: string }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<span className="pointer-events-none absolute bottom-14 left-3 rounded bg-black/60 px-2 py-1 text-sm text-white">
|
||||
{t('air.next')}: {title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Аналоговый фильтр: лёгкий VHS-шум, дрожание и размытие краёв. Переборщить очень легко, поэтому
|
||||
* сила регулируется, а вклад каждого слоя от неё убывает нелинейно.
|
||||
*/
|
||||
export function AnalogFilter({ strength }: { strength: number }) {
|
||||
const s = Math.min(1, Math.max(0, strength))
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 mix-blend-overlay"
|
||||
style={{
|
||||
opacity: s * 0.35,
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(0deg, rgba(255,255,255,.12) 0 1px, transparent 1px 3px)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
opacity: s * 0.5,
|
||||
boxShadow: `inset 0 0 ${40 + s * 60}px rgba(0,0,0,.75)`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Короткий чёрный кадр с номером канала — как при переключении на телевизоре. */
|
||||
export function ChannelFlash({ number, name }: { number: number | null; name: string }) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-start justify-end bg-black">
|
||||
<span className="m-6 flex items-baseline gap-2 text-white">
|
||||
{number !== null && <span className="text-5xl font-bold tabular-nums">{number}</span>}
|
||||
<span className="text-lg">{name}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,29 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
|
||||
|
||||
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
|
||||
export function imageUrl(imageId: string) {
|
||||
return `/api/images/${imageId}`
|
||||
}
|
||||
|
||||
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<PublicEpgEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
|
||||
|
||||
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
|
||||
export function imageUrl(imageId: string) {
|
||||
return `/api/images/${imageId}`
|
||||
}
|
||||
|
||||
export function listChannels() {
|
||||
return apiRequest<PublicChannelDto[]>('/channels')
|
||||
}
|
||||
|
||||
/** Что включено глобально на стороне зрителя (сейчас — переключение по номерам). */
|
||||
export function getViewerFeatures() {
|
||||
return apiRequest<{ channelNumbersEnabled: boolean }>('/channels/features')
|
||||
}
|
||||
|
||||
/** Выдаёт 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<PublicEpgEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user