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.
build / backend (push) Successful in 1m54s
build / frontend (push) Successful in 43s
tests / backend-tests (push) Successful in 2m4s

This commit is contained in:
Leonid Pershin
2026-07-26 14:50:32 +03:00
parent 8494eb5e6a
commit f699576582
54 changed files with 5857 additions and 1631 deletions
@@ -12,25 +12,31 @@ import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import {
applyChannelTemplate,
copyTemplateTo,
createLayer,
createSlot,
deleteLayer,
getChannel,
getChannelTemplate,
getSchedule,
listChannels,
toSlotBody,
updateLayer,
updateSlot,
} from './api'
import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard'
import { CollapsibleCard } from './components/CollapsibleCard'
import { EntryTraceDialog } from './components/EntryTraceDialog'
import { JunctionsCard } from './components/JunctionsCard'
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
import { SchedulePreview } from './components/SchedulePreview'
import { RulesCard } from './components/RulesCard'
import { SettingsCard } from './components/SettingsCard'
import { TemplateIssues } from './components/TemplateIssues'
import { TemplatePreview } from './components/TemplatePreview'
import { ViewerCard } from './components/ViewerCard'
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
import { toTime } from './lib/format'
@@ -44,6 +50,9 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
// День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([])
const [applyOpen, setApplyOpen] = useState(false)
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
const [copyToChannel, setCopyToChannel] = useState('')
const { data: channel, isLoading } = useQuery({
queryKey: ['admin', 'channels', channelId],
@@ -68,9 +77,12 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const { data: channels } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
const applyMutation = useMutation({
mutationFn: () => applyChannelTemplate(channelId),
onSuccess: (result) => {
setApplyOpen(false)
toast.success(t('admin.channels.applied', { count: result.added }))
// Предупреждения показываем по одному: каждое указывает на конкретный слот.
for (const warning of result.warnings)
@@ -136,6 +148,21 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
onError,
})
const copyTemplateMutation = useMutation({
mutationFn: (targetChannelId: string) => copyTemplateTo(channelId, targetChannelId),
onSuccess: (result) => {
setCopyToChannel('')
toast.success(
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
)
if (result.droppedBumperRefs > 0)
toast.error(
t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }),
)
},
onError,
})
const moveSlotMutation = useMutation({
mutationFn: ({
slot,
@@ -215,7 +242,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
{template?.hasPendingChanges && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-500/50 bg-amber-500/10 px-4 py-2 text-sm">
<span>{t('admin.channels.pendingChanges')}</span>
<Button size="sm" disabled={applyMutation.isPending} onClick={() => applyMutation.mutate()}>
<Button size="sm" disabled={applyMutation.isPending} onClick={() => setApplyOpen(true)}>
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
</Button>
</div>
@@ -256,9 +283,48 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
onEditApplicability={setApplicabilityLayer}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
{/* Копия сетки на другой канал: группы общие, поэтому переносятся только правила. */}
<div className="flex flex-col gap-1.5 border-t border-border pt-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.copyTemplate')}
</span>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={copyToChannel}
onChange={(e) => setCopyToChannel(e.target.value)}
>
<option value="">{t('admin.channels.pickTargetChannel')}</option>
{(channels ?? [])
.filter((c) => c.id !== channelId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<Button
size="sm"
variant="outline"
disabled={!copyToChannel || copyTemplateMutation.isPending}
onClick={() => copyTemplateMutation.mutate(copyToChannel)}
>
{t('admin.channels.copy')}
</Button>
<p className="text-xs text-muted-foreground">
{t('admin.channels.copyTemplateHint')}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<TemplateIssues
channelId={channelId}
slotsById={
new Map(template.layers.flatMap((l) => l.slots).map((slot) => [slot.id, slot]))
}
onGoToSlot={openSlot}
/>
<TemplatePreview channelId={channelId} />
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
@@ -359,6 +425,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
<ViewerCard channel={channel} onSaved={invalidate} onError={onError} />
{applicabilityLayer && (
<LayerApplicabilityDialog
layer={applicabilityLayer}
@@ -368,7 +436,25 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
/>
)}
<SchedulePreview entries={schedule ?? []} />
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
{applyOpen && (
<ApplyDialog
channelId={channelId}
utcOffsetMinutes={channel.utcOffsetMinutes}
pending={applyMutation.isPending}
onApply={() => applyMutation.mutate()}
onClose={() => setApplyOpen(false)}
/>
)}
{traceEntryId && (
<EntryTraceDialog
entryId={traceEntryId}
utcOffsetMinutes={channel.utcOffsetMinutes}
onClose={() => setTraceEntryId(null)}
/>
)}
</div>
)
}
@@ -6,17 +6,22 @@ import type {
BumperTrigger,
ChannelDto,
ChannelSummaryDto,
CopyTemplateResultDto,
CreatedIdResponse,
EntryTraceDto,
JunctionAmountMode,
JunctionConditions,
JunctionElementKind,
JunctionTemplateDto,
LayerApplicability,
PlanningRules,
ScheduleDiffDto,
ScheduleEntryDto,
SchedulePreviewDto,
ScheduleTemplateDto,
SlotDto,
TemplateIssueDto,
ViewerSettings,
} from '@/shared/api/types'
export function listChannels() {
@@ -43,6 +48,11 @@ export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
}
/** Оверлеи и аналоговый фильтр канала — как он выглядит у зрителя. */
export function updateViewerSettings(id: string, body: ViewerSettings) {
return apiRequest<void>(`/admin/channels/${id}/viewer`, { method: 'PUT', body })
}
/** Номер канала и его время: смещение от UTC и начало вещательных суток. */
export function updateChannelTime(
id: string,
@@ -64,6 +74,29 @@ export function applyChannelTemplate(channelId: string) {
})
}
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
export function getTemplateIssues(channelId: string) {
return apiRequest<TemplateIssueDto[]>(`/admin/channels/${channelId}/template/issues`)
}
/** Что изменится в эфире, если применить сейчас. Прогон сухой — лента не трогается. */
export function getApplyDiff(channelId: string) {
return apiRequest<ScheduleDiffDto>(`/admin/channels/${channelId}/template/diff`)
}
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
export function copyTemplateTo(channelId: string, targetChannelId: string) {
return apiRequest<CopyTemplateResultDto>(
`/admin/channels/${channelId}/template/copy-to/${targetChannelId}`,
{ method: 'POST' },
)
}
/** Цепочка происхождения записи, записанная в момент генерации. */
export function getEntryTrace(entryId: string) {
return apiRequest<EntryTraceDto>(`/admin/channels/entries/${entryId}/trace`)
}
/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */
export function previewTemplate(channelId: string, days: number) {
const query = new URLSearchParams({ days: String(days) })
@@ -0,0 +1,102 @@
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { cn } from '@/shared/lib/cn'
import { getApplyDiff } from '../api'
import { formatChannelTime } from '../lib/format'
/**
* Диф перед применением (см. 6.6). Изменения в ближайшие сутки подсвечены отдельно — это самая
* частая причина случайного ущерба: у зрителя из-под носа уезжает то, что он уже видит в программе.
*/
export function ApplyDialog({
channelId,
utcOffsetMinutes,
pending,
onApply,
onClose,
}: {
channelId: string
utcOffsetMinutes: number
pending: boolean
onApply: () => void
onClose: () => void
}) {
const { t } = useTranslation()
const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'diff'],
queryFn: () => getApplyDiff(channelId),
staleTime: 0,
gcTime: 0,
})
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('admin.channels.apply')}</DialogTitle>
<DialogDescription>
{isFetching || !data
? t('common.loading')
: t('admin.channels.diffSummary', { total: data.total, changed: data.changed })}
</DialogDescription>
</DialogHeader>
{data && (
<div className="flex flex-col gap-2 text-sm">
{data.changedSoon > 0 && (
<p className="flex items-center gap-1.5 text-amber-500">
<AlertTriangle className="h-4 w-4 shrink-0" />
{t('admin.channels.diffSoon', { count: data.changedSoon })}
</p>
)}
{data.changes.length === 0 ? (
<p className="text-muted-foreground">{t('admin.channels.diffNoChanges')}</p>
) : (
<ul className="max-h-80 divide-y divide-border overflow-y-auto text-xs">
{data.changes.map((change, index) => (
<li
key={`${change.startsAtUtc}-${index}`}
className={cn('flex items-center gap-2 py-1', change.soon && 'text-amber-500')}
>
<span className="w-24 shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(change.startsAtUtc, utcOffsetMinutes)}
</span>
<span className="min-w-0 flex-1 truncate">{change.before ?? '—'}</span>
<span className="shrink-0 text-muted-foreground"></span>
<span className="min-w-0 flex-1 truncate">{change.after ?? '—'}</span>
</li>
))}
</ul>
)}
{data.changed > data.changes.length && (
<p className="text-xs text-muted-foreground">
{t('admin.channels.andMore', { count: data.changed - data.changes.length })}
</p>
)}
</div>
)}
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={pending} onClick={onApply}>
{t('admin.channels.apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,104 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { getEntryTrace } from '../api'
import { formatChannelTime } from '../lib/format'
/**
* «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации —
* восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой.
*/
export function EntryTraceDialog({
entryId,
utcOffsetMinutes,
onClose,
}: {
entryId: string
utcOffsetMinutes: number
onClose: () => void
}) {
const { t } = useTranslation()
const { data } = useQuery({
queryKey: ['admin', 'entries', entryId, 'trace'],
queryFn: () => getEntryTrace(entryId),
})
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{data
? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}`
: t('common.loading')}
</DialogTitle>
</DialogHeader>
{data && (
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
<Row label={t('admin.channels.traceLayer')}>
{data.layerName
? `${data.layerName}${data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''}`
: null}
</Row>
<Row label={t('admin.channels.traceSlot')}>
{data.slotTitle
? [
data.slotTitle,
data.slotWeekday === null
? t('admin.channels.everyDay')
: t(`admin.channels.weekdays.${data.slotWeekday}`),
data.slotTargetStart?.slice(0, 5),
data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null,
data.driftMinutes !== 0
? t('admin.channels.traceDrift', { minutes: data.driftMinutes })
: null,
data.snapped ? t('admin.channels.traceSnapped') : null,
]
.filter(Boolean)
.join(' · ')
: null}
</Row>
<Row label={t('admin.channels.traceGroup')}>
{data.groupName
? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}`
: null}
</Row>
<Row label={t('admin.channels.traceStrategy')}>
{data.strategy
? [
t(`admin.channels.strategies.${data.strategy}`),
data.cooldownDays
? t('admin.channels.traceCooldown', { days: data.cooldownDays })
: null,
data.candidatesAfterCooldown !== null
? t('admin.channels.traceCandidates', {
count: data.candidatesAfterCooldown,
})
: null,
]
.filter(Boolean)
.join(' · ')
: null}
</Row>
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
</dl>
)}
</DialogContent>
</Dialog>
)
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd>{children || '—'}</dd>
</>
)
}
@@ -1,189 +1,240 @@
import { useMutation } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
SHOW_AUDIENCES,
type AudienceWindow,
type PlanningRules,
type ScheduleTemplateDto,
type ShowAudience,
} from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateTemplate } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' }
/**
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
*/
export function RulesCard({
template,
onChanged,
onError,
}: {
template: ScheduleTemplateDto
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [windows, setWindows] = useState<AudienceWindow[]>(
() => template.rules?.maxAudienceByTime ?? [],
)
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
const [windowDays, setWindowDays] = useState(
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
)
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
useEffect(() => {
setWindows(template.rules?.maxAudienceByTime ?? [])
setLimitOn(template.rules?.maxRepeatsInWindow != null)
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
}, [template])
const save = useMutation({
mutationFn: () => {
const rules: PlanningRules = {
maxAudienceByTime: windows.length > 0 ? windows : null,
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
}
return updateTemplate(template.id, {
name: template.name,
fallbackGroupId: template.fallbackGroupId,
defaultJunctionId: template.defaultJunctionId,
rules,
})
},
onSuccess: onChanged,
onError,
})
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
return (
<CollapsibleCard title={t('admin.channels.rules')}>
<div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.audienceWindows')}
</h3>
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{windows.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
) : (
<ul className="flex flex-col gap-2">
{windows.map((window, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.from')}</Label>
<Input
type="time"
className="w-28"
value={window.from.slice(0, 5)}
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.to')}</Label>
<Input
type="time"
className="w-28"
value={window.to.slice(0, 5)}
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.maxAudience')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={window.maxAudience}
onChange={(e) =>
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
}
>
{SHOW_AUDIENCES.map((value) => (
<option key={value} value={value}>
{t(`admin.shows.audiences.${value}`)}
</option>
))}
</select>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={limitOn}
onChange={(e) => setLimitOn(e.target.checked)}
/>
{t('admin.channels.repeatLimit')}
</label>
{limitOn && (
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatWindowDays')}</Label>
<Input
type="number"
min={1}
max={365}
className="w-28"
value={windowDays}
onChange={(e) => setWindowDays(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatMax')}</Label>
<Input
type="number"
min={1}
className="w-28"
value={max}
onChange={(e) => setMax(Number(e.target.value))}
/>
</div>
</div>
)}
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
import { useMutation } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
SHOW_AUDIENCES,
type AudienceWindow,
type PlanningRules,
type ScheduleTemplateDto,
type ShowAudience,
} from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateTemplate } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' }
/**
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
*/
export function RulesCard({
template,
onChanged,
onError,
}: {
template: ScheduleTemplateDto
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [windows, setWindows] = useState<AudienceWindow[]>(
() => template.rules?.maxAudienceByTime ?? [],
)
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
const [windowDays, setWindowDays] = useState(
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
)
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
const [breakCap, setBreakCap] = useState(() => template.rules?.maxBreakMinutesPerHour ?? 0)
const [genreCap, setGenreCap] = useState(() => template.rules?.maxGenreSharePercent ?? 0)
const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0)
useEffect(() => {
setWindows(template.rules?.maxAudienceByTime ?? [])
setLimitOn(template.rules?.maxRepeatsInWindow != null)
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
setBreakCap(template.rules?.maxBreakMinutesPerHour ?? 0)
setGenreCap(template.rules?.maxGenreSharePercent ?? 0)
setFallbackCap(template.rules?.maxFallbackSharePercent ?? 0)
}, [template])
const save = useMutation({
mutationFn: () => {
const rules: PlanningRules = {
maxAudienceByTime: windows.length > 0 ? windows : null,
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
// Ноль означает «не проверять»: отдельного выключателя на каждый порог не нужно.
maxBreakMinutesPerHour: breakCap > 0 ? breakCap : null,
maxGenreSharePercent: genreCap > 0 ? genreCap : null,
maxFallbackSharePercent: fallbackCap > 0 ? fallbackCap : null,
}
return updateTemplate(template.id, {
name: template.name,
fallbackGroupId: template.fallbackGroupId,
defaultJunctionId: template.defaultJunctionId,
rules,
})
},
onSuccess: onChanged,
onError,
})
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
return (
<CollapsibleCard title={t('admin.channels.rules')}>
<div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.audienceWindows')}
</h3>
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{windows.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
) : (
<ul className="flex flex-col gap-2">
{windows.map((window, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.from')}</Label>
<Input
type="time"
className="w-28"
value={window.from.slice(0, 5)}
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.to')}</Label>
<Input
type="time"
className="w-28"
value={window.to.slice(0, 5)}
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.maxAudience')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={window.maxAudience}
onChange={(e) =>
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
}
>
{SHOW_AUDIENCES.map((value) => (
<option key={value} value={value}>
{t(`admin.shows.audiences.${value}`)}
</option>
))}
</select>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={limitOn}
onChange={(e) => setLimitOn(e.target.checked)}
/>
{t('admin.channels.repeatLimit')}
</label>
{limitOn && (
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatWindowDays')}</Label>
<Input
type="number"
min={1}
max={365}
className="w-28"
value={windowDays}
onChange={(e) => setWindowDays(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatMax')}</Label>
<Input
type="number"
min={1}
className="w-28"
value={max}
onChange={(e) => setMax(Number(e.target.value))}
/>
</div>
</div>
)}
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.postChecks')}
</h3>
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.breakLimit')}</Label>
<Input
type="number"
min={0}
className="w-28"
value={breakCap}
onChange={(e) => setBreakCap(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.genreShare')}</Label>
<Input
type="number"
min={0}
max={100}
className="w-28"
value={genreCap}
onChange={(e) => setGenreCap(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.fallbackShare')}</Label>
<Input
type="number"
min={0}
max={100}
className="w-28"
value={fallbackCap}
onChange={(e) => setFallbackCap(Number(e.target.value))}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.channels.postChecksHint')}</p>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
@@ -1,52 +1,67 @@
import { useTranslation } from 'react-i18next'
import type { ScheduleEntryDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { formatTime } from '../lib/format'
export function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
const { t } = useTranslation()
if (entries.length === 0)
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<ul className="flex flex-col divide-y divide-border text-sm">
{entries.slice(0, 40).map((e) => (
<li key={e.id} className="flex items-center gap-3 py-1.5">
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
{formatTime(e.startsAtUtc)}
</span>
{e.kind === 'Ad' ? (
<Badge variant="muted">{t('air.ad')}</Badge>
) : e.kind === 'Bumper' ? (
<span className="flex min-w-0 items-center gap-2">
<Badge variant="muted" className="shrink-0">
{t('air.bumper')}
</Badge>
{(e.bumperName || e.bumperText) && (
<span className="min-w-0 truncate text-muted-foreground">
{e.bumperName}
{e.bumperName && e.bumperText ? ' · ' : ''}
{e.bumperText}
</span>
)}
</span>
) : (
<span>
{e.showName ?? ''}
{e.seasonEpisode ? (
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
) : (
e.episodeIndex != null && (
<span className="text-muted-foreground">
{' '}
· {t('air.episode')} {e.episodeIndex + 1}
</span>
)
)}
</span>
)}
</li>
))}
</ul>
)
}
import { HelpCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ScheduleEntryDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { formatTime } from '../lib/format'
export function SchedulePreview({
entries,
onShowTrace,
}: {
entries: ScheduleEntryDto[]
onShowTrace: (entryId: string) => void
}) {
const { t } = useTranslation()
if (entries.length === 0)
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<ul className="flex flex-col divide-y divide-border text-sm">
{entries.slice(0, 40).map((e) => (
<li key={e.id} className="group flex items-center gap-3 py-1.5">
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
{formatTime(e.startsAtUtc)}
</span>
{e.kind === 'Ad' ? (
<Badge variant="muted">{t('air.ad')}</Badge>
) : e.kind === 'Bumper' ? (
<span className="flex min-w-0 items-center gap-2">
<Badge variant="muted" className="shrink-0">
{t('air.bumper')}
</Badge>
{(e.bumperName || e.bumperText) && (
<span className="min-w-0 truncate text-muted-foreground">
{e.bumperName}
{e.bumperName && e.bumperText ? ' · ' : ''}
{e.bumperText}
</span>
)}
</span>
) : (
<span>
{e.showName ?? '—'}
{e.seasonEpisode ? (
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
) : (
e.episodeIndex != null && (
<span className="text-muted-foreground">
{' '}
· {t('air.episode')} {e.episodeIndex + 1}
</span>
)
)}
</span>
)}
<button
type="button"
title={t('admin.channels.whyHere')}
className="ml-auto shrink-0 text-muted-foreground opacity-0 hover:text-foreground group-hover:opacity-100"
onClick={() => onShowTrace(e.id)}
>
<HelpCircle className="h-4 w-4" />
</button>
</li>
))}
</ul>
)
}
@@ -0,0 +1,88 @@
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CircleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { SlotDto, TemplateIssueDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { getTemplateIssues } from '../api'
/**
* Проверки по правилам (см. 5.1). Считаются на сервере по шаблону, без прогона генератора, поэтому
* показываются прямо в редакторе и обновляются вместе с сеткой.
*/
export function TemplateIssues({
channelId,
slotsById,
onGoToSlot,
}: {
channelId: string
slotsById: Map<string, SlotDto>
onGoToSlot: (slot: SlotDto) => void
}) {
const { t } = useTranslation()
const { data: issues } = useQuery({
queryKey: ['admin', 'channels', channelId, 'issues'],
queryFn: () => getTemplateIssues(channelId),
})
if (!issues || issues.length === 0) return null
const errors = issues.filter((i) => i.severity === 'Error')
const warnings = issues.filter((i) => i.severity === 'Warning')
return (
<div className="flex flex-col gap-1 rounded-md border border-border px-3 py-2 text-xs">
<span className="font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.issues', { errors: errors.length, warnings: warnings.length })}
</span>
<ul className="flex flex-col gap-0.5">
{[...errors, ...warnings].map((issue, index) => (
<IssueRow
key={`${issue.kind}-${index}`}
issue={issue}
slot={issue.slotId ? slotsById.get(issue.slotId) : undefined}
onGoToSlot={onGoToSlot}
/>
))}
</ul>
</div>
)
}
function IssueRow({
issue,
slot,
onGoToSlot,
}: {
issue: TemplateIssueDto
slot: SlotDto | undefined
onGoToSlot: (slot: SlotDto) => void
}) {
const { t } = useTranslation()
const Icon = issue.severity === 'Error' ? CircleAlert : AlertTriangle
return (
<li className="flex items-start gap-1.5">
<Icon
className={cn(
'mt-0.5 h-3 w-3 shrink-0',
issue.severity === 'Error' ? 'text-red-500' : 'text-amber-500',
)}
/>
<span className="min-w-0 flex-1">
<span className="text-muted-foreground">
{t(`admin.channels.issueKinds.${issue.kind}`)}:{' '}
</span>
{issue.details}
</span>
{slot && (
<button
type="button"
className="shrink-0 text-primary hover:underline"
onClick={() => onGoToSlot(slot)}
>
{t('admin.channels.goToSlot')}
</button>
)}
</li>
)
}
@@ -1,205 +1,327 @@
import { useQuery } from '@tanstack/react-query'
import { Eye } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/cn'
import { previewTemplate } from '../api'
import { channelTime, formatChannelTime } from '../lib/format'
const KIND_COLORS: Record<PlannedItemKind, string> = {
Program: 'bg-primary/70',
Fallback: 'bg-muted-foreground/40',
SignOff: 'bg-slate-500/60',
Ad: 'bg-amber-500/70',
Promo: 'bg-sky-500/70',
Bumper: 'bg-violet-500/70',
}
/** Что видит зритель как программу — врезки в программу передач не попадают. */
const PROGRAMME_KINDS: PlannedItemKind[] = ['Program', 'Fallback', 'SignOff']
/**
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
*/
export function TemplatePreview({ channelId }: { channelId: string }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [days, setDays] = useState(1)
const [tab, setTab] = useState<'programme' | 'tape'>('programme')
const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'preview', days],
queryFn: () => previewTemplate(channelId, days),
enabled: open,
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
staleTime: 0,
gcTime: 0,
})
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
<Eye className="h-4 w-4" />
{open ? t('admin.channels.previewHide') : t('admin.channels.preview')}
</Button>
{open && (
<>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={days}
onChange={(e) => setDays(Number(e.target.value))}
>
{[1, 3, 7].map((value) => (
<option key={value} value={value}>
{t('admin.channels.previewDays', { count: value })}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">
{isFetching ? t('common.loading') : t('admin.channels.previewHint')}
</span>
</>
)}
</div>
{open && data && (
<div className="flex flex-col gap-3">
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
{(['programme', 'tape'] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => setTab(value)}
className={cn(
'pb-1 text-muted-foreground hover:text-foreground',
tab === value && 'border-b-2 border-primary text-primary',
)}
>
{t(`admin.channels.previewTabs.${value}`)}
</button>
))}
</div>
{tab === 'programme' ? <Programme preview={data} /> : <Tape preview={data} />}
{data.warnings.length > 0 && (
<ul className="flex flex-col gap-1 text-xs text-amber-500">
{data.warnings.map((warning, index) => (
<li key={`${warning.kind}-${index}`}>
{t(`admin.channels.warnings.${warning.kind}`)}: {warning.details}
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
function Programme({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const items = preview.items.filter((i) => PROGRAMME_KINDS.includes(i.kind))
if (items.length === 0)
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<ul className="flex flex-col divide-y divide-border text-sm">
{items.map((item, index) => (
<li key={`${item.startsAtUtc}-${index}`} className="flex items-center gap-3 py-1.5">
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
</span>
<span className="min-w-0 flex-1 truncate">
{item.title ?? t(`admin.channels.previewKinds.${item.kind}`)}
</span>
{item.slotTitle && (
<span className="shrink-0 text-xs text-muted-foreground">{item.slotTitle}</span>
)}
</li>
))}
</ul>
)
}
/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */
function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] {
const buckets = new Map<number, number>()
for (const item of preview.items) {
if (item.kind !== 'Ad' && item.kind !== 'Promo' && item.kind !== 'Bumper') continue
const start = channelTime(item.startsAtUtc, preview.utcOffsetMinutes)
const hour = Date.UTC(
start.getUTCFullYear(),
start.getUTCMonth(),
start.getUTCDate(),
start.getUTCHours(),
)
const minutes = (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
buckets.set(hour, (buckets.get(hour) ?? 0) + minutes)
}
return [...buckets.entries()]
.sort((a, b) => a[0] - b[0])
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
}
function Tape({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const load = useMemo(() => loadByHour(preview), [preview])
const peak = Math.max(1, ...load.map((l) => l.minutes))
if (preview.items.length === 0)
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<div className="flex flex-col gap-4">
{load.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{t('admin.channels.previewLoad', { peak: Math.round(peak) })}
</span>
<div className="flex h-16 items-end gap-px">
{load.map((bucket) => (
<div
key={bucket.hour.toISOString()}
className="flex-1 bg-amber-500/70"
style={{ height: `${(bucket.minutes / peak) * 100}%` }}
title={`${String(bucket.hour.getUTCHours()).padStart(2, '0')}:00 · ${Math.round(bucket.minutes)} ${t('admin.groups.minutesShort')}`}
/>
))}
</div>
</div>
)}
<ul className="flex flex-col gap-0.5 text-xs">
{preview.items.map((item, index) => (
<TapeRow key={`${item.startsAtUtc}-${index}`} item={item} preview={preview} />
))}
</ul>
</div>
)
}
function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const minutes =
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
return (
<li className="flex items-center gap-2">
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
</span>
<span className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])} style={{ width: `${Math.max(4, minutes * 2)}px` }} />
<Badge variant="muted" className="shrink-0">
{t(`admin.channels.previewKinds.${item.kind}`)}
</Badge>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.title ?? ''}</span>
</li>
)
}
import { useQuery } from '@tanstack/react-query'
import { Eye } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/cn'
import { previewTemplate } from '../api'
import { channelTime, formatChannelTime } from '../lib/format'
import { toIsoDate } from '../lib/applicability'
const KIND_COLORS: Record<PlannedItemKind, string> = {
Program: 'bg-primary/70',
Fallback: 'bg-muted-foreground/40',
SignOff: 'bg-slate-500/60',
Ad: 'bg-amber-500/70',
Promo: 'bg-sky-500/70',
Bumper: 'bg-violet-500/70',
}
/** Что видит зритель как программу — врезки в программу передач не попадают. */
const PROGRAMME_KINDS: PlannedItemKind[] = ['Program', 'Fallback', 'SignOff']
/**
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
*/
export function TemplatePreview({ channelId }: { channelId: string }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [days, setDays] = useState(1)
const [tab, setTab] = useState<'programme' | 'tape' | 'problems'>('programme')
const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'preview', days],
queryFn: () => previewTemplate(channelId, days),
enabled: open,
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
staleTime: 0,
gcTime: 0,
})
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
<Eye className="h-4 w-4" />
{open ? t('admin.channels.previewHide') : t('admin.channels.preview')}
</Button>
{open && (
<>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={days}
onChange={(e) => setDays(Number(e.target.value))}
>
{[1, 3, 7].map((value) => (
<option key={value} value={value}>
{t('admin.channels.previewDays', { count: value })}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">
{isFetching ? t('common.loading') : t('admin.channels.previewHint')}
</span>
</>
)}
</div>
{open && data && (
<div className="flex flex-col gap-3">
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
{(['programme', 'tape', 'problems'] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => setTab(value)}
className={cn(
'pb-1 text-muted-foreground hover:text-foreground',
tab === value && 'border-b-2 border-primary text-primary',
)}
>
{t(`admin.channels.previewTabs.${value}`)}
{value === 'problems' && data.warnings.length > 0 && ` · ${data.warnings.length}`}
</button>
))}
</div>
{tab === 'programme' && <Programme preview={data} />}
{tab === 'tape' && <Tape preview={data} />}
{tab === 'problems' && <Problems preview={data} />}
</div>
)}
</div>
)
}
function Programme({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const items = preview.items.filter((i) => PROGRAMME_KINDS.includes(i.kind))
if (items.length === 0)
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<ul className="flex flex-col divide-y divide-border text-sm">
{items.map((item, index) => (
<li key={`${item.startsAtUtc}-${index}`} className="flex items-center gap-3 py-1.5">
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
</span>
<span className="min-w-0 flex-1 truncate">
{item.title ?? t(`admin.channels.previewKinds.${item.kind}`)}
</span>
{item.slotTitle && (
<span className="shrink-0 text-xs text-muted-foreground">{item.slotTitle}</span>
)}
</li>
))}
</ul>
)
}
/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */
function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] {
const buckets = new Map<number, number>()
for (const item of preview.items) {
if (item.kind !== 'Ad' && item.kind !== 'Promo' && item.kind !== 'Bumper') continue
const start = channelTime(item.startsAtUtc, preview.utcOffsetMinutes)
const hour = Date.UTC(
start.getUTCFullYear(),
start.getUTCMonth(),
start.getUTCDate(),
start.getUTCHours(),
)
const minutes = (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
buckets.set(hour, (buckets.get(hour) ?? 0) + minutes)
}
return [...buckets.entries()]
.sort((a, b) => a[0] - b[0])
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
}
function Tape({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const load = useMemo(() => loadByHour(preview), [preview])
const peak = Math.max(1, ...load.map((l) => l.minutes))
if (preview.items.length === 0)
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<div className="flex flex-col gap-4">
{load.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{t('admin.channels.previewLoad', { peak: Math.round(peak) })}
</span>
<div className="flex h-16 items-end gap-px">
{load.map((bucket) => (
<div
key={bucket.hour.toISOString()}
className="flex-1 bg-amber-500/70"
style={{ height: `${(bucket.minutes / peak) * 100}%` }}
title={`${String(bucket.hour.getUTCHours()).padStart(2, '0')}:00 · ${Math.round(bucket.minutes)} ${t('admin.groups.minutesShort')}`}
/>
))}
</div>
</div>
)}
<ul className="flex flex-col gap-0.5 text-xs">
{preview.items.map((item, index) => (
<TapeRow key={`${item.startsAtUtc}-${index}`} item={item} preview={preview} />
))}
</ul>
</div>
)
}
function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const minutes =
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
return (
<li className="flex items-center gap-2">
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
</span>
<span className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])} style={{ width: `${Math.max(4, minutes * 2)}px` }} />
<Badge variant="muted" className="shrink-0">
{t(`admin.channels.previewKinds.${item.kind}`)}
</Badge>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.title ?? ''}</span>
</li>
)
}
/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */
function Problems({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const grouped = useMemo(() => {
const map = new Map<string, string[]>()
for (const warning of preview.warnings) {
const list = map.get(warning.kind) ?? []
list.push(warning.details)
map.set(warning.kind, list)
}
return [...map.entries()]
}, [preview])
return (
<div className="flex flex-col gap-4">
{grouped.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.channels.noProblems')}</p>
) : (
<ul className="flex flex-col gap-2 text-xs">
{grouped.map(([kind, details]) => (
<li key={kind} className="flex flex-col gap-0.5">
<span className="font-medium text-amber-500">
{t(`admin.channels.warnings.${kind}`)} · {details.length}
</span>
{details.slice(0, 20).map((detail, index) => (
<span key={index} className="text-muted-foreground">
{detail}
</span>
))}
{details.length > 20 && (
<span className="text-muted-foreground">
{t('admin.channels.andMore', { count: details.length - 20 })}
</span>
)}
</li>
))}
</ul>
)}
<RepeatHeatmap preview={preview} />
</div>
)
}
/**
* Тепловая карта повторов: матрица «шоу × вещательные сутки», яркость — число показов. Сразу видно,
* что один фильм крутится четыре раза за неделю.
*/
function RepeatHeatmap({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const { days, rows } = useMemo(() => {
const counts = new Map<string, Map<string, number>>()
const dayKeys = new Set<string>()
for (const item of preview.items) {
if (item.kind !== 'Program' || !item.title) continue
const day = toIsoDate(channelTime(item.startsAtUtc, preview.utcOffsetMinutes))
dayKeys.add(day)
const row = counts.get(item.title) ?? new Map<string, number>()
row.set(day, (row.get(day) ?? 0) + 1)
counts.set(item.title, row)
}
const sortedDays = [...dayKeys].sort()
const sortedRows = [...counts.entries()]
.map(([title, byDay]) => ({
title,
byDay,
total: [...byDay.values()].reduce((sum, n) => sum + n, 0),
}))
.sort((a, b) => b.total - a.total)
.slice(0, 25)
return { days: sortedDays, rows: sortedRows }
}, [preview])
if (rows.length === 0) return null
const peak = Math.max(1, ...rows.flatMap((row) => [...row.byDay.values()]))
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.heatmap')}
</span>
<div className="overflow-x-auto">
<table className="text-[11px]">
<thead className="text-muted-foreground">
<tr>
<th className="px-1 text-left font-medium" />
{days.map((day) => (
<th key={day} className="px-1 font-medium">
{day.slice(8)}.{day.slice(5, 7)}
</th>
))}
<th className="px-1 font-medium">{t('admin.channels.heatmapTotal')}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.title}>
<td className="max-w-56 truncate px-1" title={row.title}>
{row.title}
</td>
{days.map((day) => {
const count = row.byDay.get(day) ?? 0
return (
<td key={day} className="px-0.5 py-0.5">
<span
className="block h-4 w-6 rounded-sm bg-primary text-center text-[10px] leading-4"
style={{ opacity: count === 0 ? 0.06 : 0.25 + (count / peak) * 0.75 }}
>
{count > 0 ? count : ''}
</span>
</td>
)
})}
<td className="px-1 tabular-nums text-muted-foreground">{row.total}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,146 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import type { ChannelDto, LogoCorner, ViewerSettings } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateViewerSettings } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
const CORNERS: LogoCorner[] = ['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight']
/**
* Как канал выглядит у зрителя (см. 6.8): логотип, часы, аналоговый фильтр. Всё рисуется на клиенте
* поверх видео и по умолчанию выключено — канал без логотипа и без шума остаётся нормальным каналом.
*/
export function ViewerCard({
channel,
onSaved,
onError,
}: {
channel: ChannelDto
onSaved: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [viewer, setViewer] = useState<ViewerSettings>(channel.viewer)
const [galleryOpen, setGalleryOpen] = useState(false)
useEffect(() => setViewer(channel.viewer), [channel])
const patch = (part: Partial<ViewerSettings>) => setViewer((prev) => ({ ...prev, ...part }))
const save = useMutation({
mutationFn: () => updateViewerSettings(channel.id, viewer),
onSuccess: onSaved,
onError,
})
return (
<CollapsibleCard title={t('admin.channels.viewer')}>
<div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.viewerHint')}</p>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logo')}</Label>
<div className="flex h-16 w-24 items-center justify-center rounded-md border border-border bg-muted/30">
{viewer.logoImageId ? (
<img
src={imageUrl(viewer.logoImageId)}
alt=""
className="h-full w-full object-contain"
/>
) : (
<span className="text-xs text-muted-foreground">{t('admin.channels.noLogo')}</span>
)}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.channels.pickLogo')}
</Button>
{viewer.logoImageId && (
<Button size="sm" variant="ghost" onClick={() => patch({ logoImageId: null })}>
{t('common.delete')}
</Button>
)}
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="Library"
onSelect={(image) => patch({ logoImageId: image.id })}
/>
</div>
{viewer.logoImageId && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logoCorner')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={viewer.logoCorner}
onChange={(e) => patch({ logoCorner: e.target.value as LogoCorner })}
>
{CORNERS.map((corner) => (
<option key={corner} value={corner}>
{t(`admin.channels.corners.${corner}`)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logoOpacity')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.05}
className="w-28"
value={viewer.logoOpacity}
onChange={(e) => patch({ logoOpacity: clamp01(e.target.value) })}
/>
</div>
</div>
)}
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={viewer.showClock}
onChange={(e) => patch({ showClock: e.target.checked })}
/>
{t('admin.channels.showClock')}
</label>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.analogFilter')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.1}
className="w-28"
value={viewer.analogFilterStrength}
onChange={(e) => patch({ analogFilterStrength: clamp01(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.analogFilterHint')}</p>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
/** Сила и прозрачность живут в 0..1: пустой ввод трактуем как ноль, а не как NaN. */
function clamp01(value: string): number {
const n = Number(value)
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n))
}
@@ -1,85 +1,108 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { getSiteSettings, updateSiteSettings } from './api'
export function SettingsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [registrationEnabled, setRegistrationEnabled] = useState(false)
const [preferredAudioLanguages, setPreferredAudioLanguages] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['admin', 'settings'],
queryFn: getSiteSettings,
})
useEffect(() => {
if (data) {
setRegistrationEnabled(data.registrationEnabled)
setPreferredAudioLanguages(data.preferredAudioLanguages)
}
}, [data])
const save = useMutation({
mutationFn: () => updateSiteSettings({ registrationEnabled, preferredAudioLanguages }),
onSuccess: () => {
toast.success(t('settings.saved'))
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
},
onError: (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
})
return (
<div className="flex flex-col gap-4">
<h2 className="crt-glow text-xl font-semibold">{t('admin.settings.title')}</h2>
<Card>
<CardHeader>
<CardTitle>{t('admin.settings.registration')}</CardTitle>
<CardDescription>{t('admin.settings.registrationHint')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={registrationEnabled}
disabled={isLoading}
onChange={(e) => setRegistrationEnabled(e.target.checked)}
/>
{t('admin.settings.registrationLabel')}
</label>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium" htmlFor="preferred-audio">
{t('admin.settings.preferredAudio')}
</label>
<Input
id="preferred-audio"
className="max-w-xs"
placeholder="rus, eng"
value={preferredAudioLanguages}
disabled={isLoading}
onChange={(e) => setPreferredAudioLanguages(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t('admin.settings.preferredAudioHint')}
</p>
</div>
<div>
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { getSiteSettings, updateSiteSettings } from './api'
export function SettingsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [registrationEnabled, setRegistrationEnabled] = useState(false)
const [preferredAudioLanguages, setPreferredAudioLanguages] = useState('')
const [channelNumbersEnabled, setChannelNumbersEnabled] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'settings'],
queryFn: getSiteSettings,
})
useEffect(() => {
if (data) {
setRegistrationEnabled(data.registrationEnabled)
setPreferredAudioLanguages(data.preferredAudioLanguages)
setChannelNumbersEnabled(data.channelNumbersEnabled)
}
}, [data])
const save = useMutation({
mutationFn: () =>
updateSiteSettings({
registrationEnabled,
preferredAudioLanguages,
channelNumbersEnabled,
}),
onSuccess: () => {
toast.success(t('settings.saved'))
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
},
onError: (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
})
return (
<div className="flex flex-col gap-4">
<h2 className="crt-glow text-xl font-semibold">{t('admin.settings.title')}</h2>
<Card>
<CardHeader>
<CardTitle>{t('admin.settings.registration')}</CardTitle>
<CardDescription>{t('admin.settings.registrationHint')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={registrationEnabled}
disabled={isLoading}
onChange={(e) => setRegistrationEnabled(e.target.checked)}
/>
{t('admin.settings.registrationLabel')}
</label>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium" htmlFor="preferred-audio">
{t('admin.settings.preferredAudio')}
</label>
<Input
id="preferred-audio"
className="max-w-xs"
placeholder="rus, eng"
value={preferredAudioLanguages}
disabled={isLoading}
onChange={(e) => setPreferredAudioLanguages(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t('admin.settings.preferredAudioHint')}
</p>
</div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-1"
checked={channelNumbersEnabled}
disabled={isLoading}
onChange={(e) => setChannelNumbersEnabled(e.target.checked)}
/>
<span>
{t('admin.settings.channelNumbers')}
<span className="block text-xs text-muted-foreground">
{t('admin.settings.channelNumbersHint')}
</span>
</span>
</label>
<div>
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+325 -261
View File
@@ -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 }
}
+279 -241
View File
@@ -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>
)
}
+29 -24
View File
@@ -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}` : ''}`)
}