Add Prettier to the frontend and gate formatting in CI
Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах. Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки, ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера. Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check` для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и `prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет вывод и красит CI на файлах, которых никто не трогал. `.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с настройками csharpier и Prettier намеренно — оба его читают. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0442056367
commit
0606ea3e6e
@@ -1,339 +1,332 @@
|
||||
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 }: Readonly<{ 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 }
|
||||
}
|
||||
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 }: Readonly<{ 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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user