Refactor Channel endpoints and data models: replace jingle functionality with bumper templates, update related commands and handlers, and enhance API routes for managing bumper templates. Remove obsolete jingle-related code and adjust channel data structures to support new bumper template features.
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronDown, ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
AdInsertion,
|
||||
BlockMode,
|
||||
BumperFont,
|
||||
BumperMode,
|
||||
BumperSelection,
|
||||
BumperSettings,
|
||||
BumperTemplateDto,
|
||||
ChannelShowDto,
|
||||
OverrideMode,
|
||||
ScheduleEntryDto,
|
||||
@@ -24,23 +26,26 @@ import { toast } from '@/shared/ui/toast-store'
|
||||
import { listMedia } from '@/features/admin/media/api'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import {
|
||||
addBumperTemplate,
|
||||
addChannelAd,
|
||||
addChannelJingle,
|
||||
addChannelShow,
|
||||
clearBumperBackground,
|
||||
clearBumperMusic,
|
||||
bumperPreviewPlaylistUrl,
|
||||
clearBumperTemplateAudio,
|
||||
clearBumperTemplateBackground,
|
||||
createOverride,
|
||||
deleteOverride,
|
||||
getChannel,
|
||||
getSchedule,
|
||||
regenerateSchedule,
|
||||
removeBumperTemplate,
|
||||
removeChannelAd,
|
||||
removeChannelJingle,
|
||||
removeChannelShow,
|
||||
renderBumperPreview,
|
||||
updateBumperTemplate,
|
||||
updateChannelSettings,
|
||||
updateChannelShow,
|
||||
uploadBumperBackground,
|
||||
uploadBumperMusic,
|
||||
uploadBumperTemplateAudio,
|
||||
uploadBumperTemplateBackground,
|
||||
} from './api'
|
||||
|
||||
function formatTime(iso: string) {
|
||||
@@ -117,6 +122,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
|
||||
<SettingsCard channel={channel} readyAssets={ready?.items ?? []} onSaved={invalidate} onError={onError} />
|
||||
|
||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
{/* Шоу канала */}
|
||||
<CollapsibleCard title={t('admin.channels.shows')} contentClassName="flex flex-col gap-3">
|
||||
<AddShowForm
|
||||
@@ -183,34 +190,6 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
</ul>
|
||||
</CollapsibleCard>
|
||||
|
||||
{/* Джинглы-отбивки (статичные заставки) */}
|
||||
<CollapsibleCard title={t('admin.channels.jingles')} contentClassName="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">{t('admin.channels.jinglesHint')}</p>
|
||||
<AddJingleForm
|
||||
channelId={channelId}
|
||||
options={(ready?.items ?? [])
|
||||
.filter((a) => !channel.jingles.some((j) => j.mediaAssetId === a.id))
|
||||
.map((a) => ({ id: a.id, name: a.originalFileName }))}
|
||||
onAdded={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{channel.jingles.map((j) => (
|
||||
<li key={j.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<span>{j.assetName ?? '—'}</span>
|
||||
<RemoveButton
|
||||
onClick={() =>
|
||||
removeChannelJingle(channelId, j.id).then(invalidate).catch(onError)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{channel.jingles.length === 0 && (
|
||||
<li className="py-2 text-muted-foreground">{t('admin.channels.noJingles')}</li>
|
||||
)}
|
||||
</ul>
|
||||
</CollapsibleCard>
|
||||
|
||||
{/* Override'ы / марафоны */}
|
||||
<CollapsibleCard title={t('admin.channels.overrides')} contentClassName="flex flex-col gap-3">
|
||||
<OverrideForm
|
||||
@@ -293,20 +272,13 @@ function SettingsCard({
|
||||
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||
const [adInsertion, setAdInsertion] = useState<AdInsertion>(channel.adInsertion)
|
||||
const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak)
|
||||
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||
|
||||
const setBumperField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
useEffect(() => {
|
||||
setName(channel.name)
|
||||
setIsEnabled(channel.isEnabled)
|
||||
setAdInsertion(channel.adInsertion)
|
||||
setAdsPerBreak(channel.adsPerBreak)
|
||||
setBumpersEnabled(channel.bumpersEnabled)
|
||||
setBumper(channel.bumper)
|
||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||
}, [channel])
|
||||
|
||||
@@ -317,8 +289,9 @@ function SettingsCard({
|
||||
isEnabled,
|
||||
adInsertion,
|
||||
adsPerBreak,
|
||||
bumpersEnabled,
|
||||
bumper,
|
||||
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
||||
bumpersEnabled: channel.bumpersEnabled,
|
||||
bumper: channel.bumper,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -384,31 +357,6 @@ function SettingsCard({
|
||||
/>
|
||||
{t('admin.channels.enabledLabel')}
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={bumpersEnabled}
|
||||
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.channels.bumpersLabel')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumpersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{bumpersEnabled && (
|
||||
<div className="sm:col-span-2">
|
||||
<BumperSettingsFields
|
||||
channelId={channel.id}
|
||||
bumper={bumper}
|
||||
setField={setBumperField}
|
||||
onSaved={onSaved}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end justify-end sm:col-span-2">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
@@ -425,68 +373,92 @@ function cssColor(value: string): string {
|
||||
return v
|
||||
}
|
||||
|
||||
function BumperSettingsFields({
|
||||
channelId,
|
||||
bumper,
|
||||
setField,
|
||||
function BumperCard({
|
||||
channel,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
bumper: BumperSettings
|
||||
setField: <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) => void
|
||||
channel: import('@/shared/api/types').ChannelDto
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||
|
||||
const colors: { key: keyof BumperSettings; label: string }[] = [
|
||||
{ key: 'backgroundColor', label: t('admin.channels.bumperBg') },
|
||||
{ key: 'backgroundColor2', label: t('admin.channels.bumperBg2') },
|
||||
{ key: 'accentColor', label: t('admin.channels.bumperAccent') },
|
||||
{ key: 'textColor', label: t('admin.channels.bumperText') },
|
||||
]
|
||||
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
// Оформление/подписи/шрифт нужны только динамическим заставкам.
|
||||
const showDynamicStyle = bumper.mode !== 'Static'
|
||||
useEffect(() => {
|
||||
setBumpersEnabled(channel.bumpersEnabled)
|
||||
setBumper(channel.bumper)
|
||||
}, [channel])
|
||||
|
||||
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
||||
// поля берём из канала без изменений (они правятся в своей карточке).
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: channel.name,
|
||||
isEnabled: channel.isEnabled,
|
||||
adInsertion: channel.adInsertion,
|
||||
adsPerBreak: channel.adsPerBreak,
|
||||
bumpersEnabled,
|
||||
bumper,
|
||||
fillerAssetId: channel.fillerAssetId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addTemplate = useMutation({
|
||||
mutationFn: () => addBumperTemplate(channel.id, ''),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
|
||||
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-md border border-border bg-muted/30 p-4">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperStyle')}</p>
|
||||
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={bumpersEnabled}
|
||||
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.channels.bumpersLabel')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumpersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Общие настройки */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperMode')}</Label>
|
||||
<Select value={bumper.mode} onValueChange={(v) => setField('mode', v as BumperMode)}>
|
||||
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||
<Select
|
||||
value={bumper.selection}
|
||||
onValueChange={(v) => setField('selection', v as BumperSelection)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Dynamic">{t('admin.channels.bumperModeDynamic')}</SelectItem>
|
||||
<SelectItem value="Static">{t('admin.channels.bumperModeStatic')}</SelectItem>
|
||||
<SelectItem value="Both">{t('admin.channels.bumperModeBoth')}</SelectItem>
|
||||
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
|
||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||
<SelectItem value="AlwaysFirst">
|
||||
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={2}
|
||||
max={30}
|
||||
value={bumper.durationSeconds}
|
||||
onChange={(e) => setField('durationSeconds', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperMinInterval')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
value={bumper.minIntervalMinutes}
|
||||
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperFont')}</Label>
|
||||
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
||||
@@ -499,6 +471,16 @@ function BumperSettingsFields({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperMinInterval')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
value={bumper.minIntervalMinutes}
|
||||
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperNowLabel')}</Label>
|
||||
<Input
|
||||
@@ -515,21 +497,6 @@ function BumperSettingsFields({
|
||||
onChange={(e) => setField('nextLabel', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{colors.map(({ key, label }) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-8 w-8 shrink-0 rounded border border-border"
|
||||
style={{ backgroundColor: cssColor(bumper[key] as string) }}
|
||||
/>
|
||||
<Input
|
||||
value={bumper[key] as string}
|
||||
onChange={(e) => setField(key, e.target.value as never)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
@@ -539,40 +506,259 @@ function BumperSettingsFields({
|
||||
/>
|
||||
{t('admin.channels.bumperOnlyDifferent')}
|
||||
</label>
|
||||
{showDynamicStyle && (
|
||||
<div className="grid gap-3 border-t border-border pt-3 sm:grid-cols-2">
|
||||
<BumperFileUpload
|
||||
channelId={channelId}
|
||||
kind="background"
|
||||
label={t('admin.channels.bumperBackground')}
|
||||
hint={t('admin.channels.bumperBackgroundHint')}
|
||||
has={bumper.hasBackground}
|
||||
accept="image/*,video/mp4,video/webm,video/quicktime,video/x-matroska"
|
||||
clear={clearBumperBackground}
|
||||
upload={uploadBumperBackground}
|
||||
onSaved={onSaved}
|
||||
onError={onError}
|
||||
/>
|
||||
<BumperFileUpload
|
||||
channelId={channelId}
|
||||
kind="music"
|
||||
label={t('admin.channels.bumperMusic')}
|
||||
hint={t('admin.channels.bumperMusicHint')}
|
||||
has={bumper.hasMusic}
|
||||
accept="audio/*"
|
||||
clear={clearBumperMusic}
|
||||
upload={uploadBumperMusic}
|
||||
onSaved={onSaved}
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Блоки заставок */}
|
||||
<div className="flex items-center justify-between border-t border-border pt-4">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={addTemplate.isPending}
|
||||
onClick={() => addTemplate.mutate()}
|
||||
>
|
||||
{t('admin.channels.bumperAddTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{templates.map((template) => (
|
||||
<BumperTemplateEditor
|
||||
key={template.id}
|
||||
channelId={channel.id}
|
||||
template={template}
|
||||
onChanged={onSaved}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
function BumperTemplateEditor({
|
||||
channelId,
|
||||
template,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
template: BumperTemplateDto
|
||||
onChanged: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState(template.name)
|
||||
const [colors, setColors] = useState({
|
||||
backgroundColor: template.backgroundColor,
|
||||
backgroundColor2: template.backgroundColor2,
|
||||
accentColor: template.accentColor,
|
||||
textColor: template.textColor,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setName(template.name)
|
||||
setColors({
|
||||
backgroundColor: template.backgroundColor,
|
||||
backgroundColor2: template.backgroundColor2,
|
||||
accentColor: template.accentColor,
|
||||
textColor: template.textColor,
|
||||
})
|
||||
}, [template])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onChanged()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: () => removeBumperTemplate(channelId, template.id),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const colorFields: { key: keyof typeof colors; label: string }[] = [
|
||||
{ key: 'backgroundColor', label: t('admin.channels.bumperBg') },
|
||||
{ key: 'backgroundColor2', label: t('admin.channels.bumperBg2') },
|
||||
{ key: 'accentColor', label: t('admin.channels.bumperAccent') },
|
||||
{ key: 'textColor', label: t('admin.channels.bumperText') },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-border bg-muted/30 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{template.name}</span>
|
||||
{template.isDefault && <Badge variant="muted">{t('admin.channels.bumperDefault')}</Badge>}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{template.hasAudio && template.audioDurationSeconds != null
|
||||
? `≈${Math.round(template.audioDurationSeconds)} ${t('admin.channels.bumperSeconds')}`
|
||||
: t('admin.channels.bumperDefaultDuration')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!template.isDefault && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate()}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperTemplateName')}</Label>
|
||||
<Input value={name} maxLength={64} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
{colorFields.map(({ key, label }) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-8 w-8 shrink-0 rounded border border-border"
|
||||
style={{ backgroundColor: cssColor(colors[key]) }}
|
||||
/>
|
||||
<Input
|
||||
value={colors[key]}
|
||||
onChange={(e) => setColors((c) => ({ ...c, [key]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 border-t border-border pt-3 sm:grid-cols-2">
|
||||
<BumperFileUpload
|
||||
channelId={channelId}
|
||||
templateId={template.id}
|
||||
kind="audio"
|
||||
label={t('admin.channels.bumperAudio')}
|
||||
hint={t('admin.channels.bumperAudioHint')}
|
||||
has={template.hasAudio}
|
||||
accept="audio/*"
|
||||
upload={uploadBumperTemplateAudio}
|
||||
clear={clearBumperTemplateAudio}
|
||||
onSaved={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
<BumperFileUpload
|
||||
channelId={channelId}
|
||||
templateId={template.id}
|
||||
kind="background"
|
||||
label={t('admin.channels.bumperBackground')}
|
||||
hint={t('admin.channels.bumperBackgroundHint')}
|
||||
has={template.hasBackground}
|
||||
accept="image/*"
|
||||
upload={uploadBumperTemplateBackground}
|
||||
clear={clearBumperTemplateBackground}
|
||||
onSaved={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<BumperPreviewPlayer channelId={channelId} templateId={template.id} onError={onError} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BumperPreviewPlayer({
|
||||
channelId,
|
||||
templateId,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
templateId: string
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [bust, setBust] = useState(0)
|
||||
|
||||
const render = useMutation({
|
||||
mutationFn: () => renderBumperPreview(channelId, templateId),
|
||||
onSuccess: () => {
|
||||
setBust(Date.now())
|
||||
setReady(true)
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
// Грузим отрендеренный превью-плейлист через hls.js, добавляя Bearer-токен (admin-роут под JWT).
|
||||
useEffect(() => {
|
||||
if (!ready) return
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const src = `${bumperPreviewPlaylistUrl(channelId, templateId)}?t=${bust}`
|
||||
let hls: Hls | null = null
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({
|
||||
xhrSetup: (xhr) => {
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
},
|
||||
})
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src
|
||||
}
|
||||
return () => {
|
||||
hls?.destroy()
|
||||
}
|
||||
}, [ready, bust, channelId, templateId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={render.isPending}
|
||||
onClick={() => render.mutate()}
|
||||
>
|
||||
{render.isPending
|
||||
? t('admin.channels.bumperPreviewRendering')
|
||||
: t('admin.channels.bumperPreview')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperPreviewHint')}
|
||||
</span>
|
||||
</div>
|
||||
{ready && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
playsInline
|
||||
className="aspect-video w-full max-w-sm rounded-md border border-border bg-black"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BumperFileUpload({
|
||||
channelId,
|
||||
templateId,
|
||||
kind,
|
||||
label,
|
||||
hint,
|
||||
@@ -584,26 +770,27 @@ function BumperFileUpload({
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
templateId: string
|
||||
kind: string
|
||||
label: string
|
||||
hint: string
|
||||
has: boolean
|
||||
accept: string
|
||||
upload: (id: string, file: File) => Promise<void>
|
||||
clear: (id: string) => Promise<void>
|
||||
upload: (id: string, templateId: string, file: File) => Promise<void>
|
||||
clear: (id: string, templateId: string) => Promise<void>
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const inputId = `bumper-${kind}-${channelId}`
|
||||
const inputId = `bumper-${kind}-${templateId}`
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => upload(channelId, file),
|
||||
mutationFn: (file: File) => upload(channelId, templateId, file),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => clear(channelId),
|
||||
mutationFn: () => clear(channelId, templateId),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
@@ -832,50 +1019,6 @@ function AddAdForm({
|
||||
)
|
||||
}
|
||||
|
||||
function AddJingleForm({
|
||||
channelId,
|
||||
options,
|
||||
onAdded,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onAdded: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [assetId, setAssetId] = useState('')
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => addChannelJingle(channelId, assetId),
|
||||
onSuccess: () => {
|
||||
setAssetId('')
|
||||
onAdded()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Select value={assetId} onValueChange={setAssetId}>
|
||||
<SelectTrigger className="max-w-md">
|
||||
<SelectValue placeholder={t('admin.channels.pickJingle')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" disabled={!assetId || add.isPending} onClick={() => add.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverrideForm({
|
||||
channelId,
|
||||
options,
|
||||
|
||||
@@ -70,23 +70,48 @@ export function removeChannelAd(id: string, channelAdId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/ads/${channelAdId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function addChannelJingle(id: string, mediaAssetId: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/jingles`, {
|
||||
export type BumperTemplateStyleBody = {
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
}
|
||||
|
||||
export function addBumperTemplate(id: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
|
||||
method: 'POST',
|
||||
body: { mediaAssetId },
|
||||
body: { name },
|
||||
})
|
||||
}
|
||||
|
||||
export function removeChannelJingle(id: string, channelJingleId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/jingles/${channelJingleId}`, { method: 'DELETE' })
|
||||
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/** Загрузка сырого файла заставки (фон/музыка): тело — файл, имя — в query (как в uploadMedia). */
|
||||
function uploadBumperFile(id: string, kind: 'background' | 'music', file: File): Promise<void> {
|
||||
export function removeBumperTemplate(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
|
||||
function uploadBumperTemplateFile(
|
||||
id: string,
|
||||
templateId: string,
|
||||
kind: 'audio' | 'background',
|
||||
file: File,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open('PUT', `/api/admin/channels/${id}/bumper/${kind}?${query.toString()}`)
|
||||
xhr.open(
|
||||
'PUT',
|
||||
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
|
||||
)
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.onload = () => {
|
||||
@@ -108,20 +133,35 @@ function uploadBumperFile(id: string, kind: 'background' | 'music', file: File):
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadBumperBackground(id: string, file: File) {
|
||||
return uploadBumperFile(id, 'background', file)
|
||||
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
|
||||
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
||||
}
|
||||
|
||||
export function uploadBumperMusic(id: string, file: File) {
|
||||
return uploadBumperFile(id, 'music', file)
|
||||
export function uploadBumperTemplateBackground(id: string, templateId: string, file: File) {
|
||||
return uploadBumperTemplateFile(id, templateId, 'background', file)
|
||||
}
|
||||
|
||||
export function clearBumperBackground(id: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/background`, { method: 'DELETE' })
|
||||
export function clearBumperTemplateAudio(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperMusic(id: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/music`, { method: 'DELETE' })
|
||||
export function clearBumperTemplateBackground(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Синхронно рендерит пример заставки блока (сервер собирает ffmpeg-клип). */
|
||||
export function renderBumperPreview(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function bumperPreviewPlaylistUrl(id: string, templateId: string) {
|
||||
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/index.m3u8`
|
||||
}
|
||||
|
||||
export type OverrideBody = {
|
||||
|
||||
@@ -117,22 +117,30 @@ export type AdInsertion = 'BetweenBlocks' | 'BetweenEpisodes'
|
||||
export type OverrideMode = 'Exclusive' | 'Boost'
|
||||
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper'
|
||||
export type BumperFont = 'Sans' | 'Serif'
|
||||
export type BumperMode = 'Dynamic' | 'Static' | 'Both'
|
||||
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst'
|
||||
|
||||
/** Общие для канала настройки заставок (стиль/звук — на каждом блоке, см. BumperTemplateDto). */
|
||||
export type BumperSettings = {
|
||||
mode: BumperMode
|
||||
durationSeconds: number
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
font: BumperFont
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
minIntervalMinutes: number
|
||||
onlyBetweenDifferentShows: boolean
|
||||
selection: BumperSelection
|
||||
}
|
||||
|
||||
export type BumperTemplateDto = {
|
||||
id: string
|
||||
position: number
|
||||
isDefault: boolean
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
hasBackground: boolean
|
||||
hasMusic: boolean
|
||||
hasAudio: boolean
|
||||
audioDurationSeconds: number | null
|
||||
}
|
||||
|
||||
export type ChannelSummaryDto = {
|
||||
@@ -160,13 +168,6 @@ export type ChannelAdDto = {
|
||||
position: number
|
||||
}
|
||||
|
||||
export type ChannelJingleDto = {
|
||||
id: string
|
||||
mediaAssetId: string
|
||||
assetName: string | null
|
||||
position: number
|
||||
}
|
||||
|
||||
export type OverrideShowDto = { showId: string; showName: string; weight: number }
|
||||
|
||||
export type ProgrammingOverrideDto = {
|
||||
@@ -186,10 +187,10 @@ export type ChannelDto = {
|
||||
adsPerBreak: number
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
bumperTemplates: BumperTemplateDto[]
|
||||
fillerAssetId: string | null
|
||||
shows: ChannelShowDto[]
|
||||
ads: ChannelAdDto[]
|
||||
jingles: ChannelJingleDto[]
|
||||
overrides: ProgrammingOverrideDto[]
|
||||
}
|
||||
|
||||
|
||||
@@ -183,10 +183,13 @@ const resources = {
|
||||
betweenBlocks: 'Между блоками',
|
||||
betweenEpisodes: 'Между сериями',
|
||||
adsPerBreak: 'Роликов подряд',
|
||||
bumpersLabel: 'ТВ-заставки на переходах',
|
||||
bumpers: 'ТВ-заставки',
|
||||
bumpersLabel: 'Заставки на переходах',
|
||||
bumpersHint: 'Короткая заставка «Сейчас / Далее» между разными шоу',
|
||||
bumperStyle: 'Оформление заставки',
|
||||
bumperDuration: 'Длительность, с',
|
||||
bumperSelection: 'Выбор блока',
|
||||
bumperSelectionRotation: 'По кругу',
|
||||
bumperSelectionRandom: 'Случайно',
|
||||
bumperSelectionAlwaysFirst: 'Всегда первый',
|
||||
bumperMinInterval: 'Мин. интервал, мин',
|
||||
bumperFont: 'Шрифт',
|
||||
bumperFontSans: 'Гротеск',
|
||||
@@ -198,22 +201,25 @@ const resources = {
|
||||
bumperAccent: 'Акцент',
|
||||
bumperText: 'Текст',
|
||||
bumperOnlyDifferent: 'Только на смене шоу (не внутри марафона)',
|
||||
bumperMode: 'Режим заставок',
|
||||
bumperModeDynamic: 'Динамические «Сейчас/Далее»',
|
||||
bumperModeStatic: 'Только джинглы',
|
||||
bumperModeBoth: 'Чередовать',
|
||||
bumperBackground: 'Фон',
|
||||
bumperBackgroundHint: 'Картинка или видео-петля; иначе — анимированный градиент',
|
||||
bumperMusic: 'Музыка',
|
||||
bumperMusicHint: 'Аудиофайл-подложка; иначе — синтезированный джингл',
|
||||
bumperTemplates: 'Блоки заставок',
|
||||
bumperTemplatesHint:
|
||||
'Каждый блок — свой звук и оформление. Первый блок дефолтный, его нельзя удалить. Длительность заставки — по длине звука.',
|
||||
bumperAddTemplate: 'Добавить блок',
|
||||
bumperTemplateName: 'Название',
|
||||
bumperDefault: 'по умолчанию',
|
||||
bumperSeconds: 'с',
|
||||
bumperDefaultDuration: '≈8 с (джингл)',
|
||||
bumperAudio: 'Звук',
|
||||
bumperAudioHint: 'Звук заставки; иначе — синтезированный джингл',
|
||||
bumperPreview: 'Отрендерить пример',
|
||||
bumperPreviewRendering: 'Рендерим…',
|
||||
bumperPreviewHint: 'Пример со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.',
|
||||
bumperBackground: 'Фон-картинка',
|
||||
bumperBackgroundHint: 'Картинка фона; иначе — постер шоу или градиент',
|
||||
bumperFileLoaded: 'загружено',
|
||||
bumperFileDefault: 'по умолчанию',
|
||||
bumperUpload: 'Загрузить',
|
||||
bumperReset: 'Сбросить',
|
||||
jingles: 'Джинглы-отбивки',
|
||||
jinglesHint: 'Готовые ролики для статичных заставок; крутятся по кругу на переходах (режимы «Только джинглы» и «Чередовать»).',
|
||||
pickJingle: 'Выберите ролик',
|
||||
noJingles: 'Пул джинглов пуст',
|
||||
filler: 'Заглушка',
|
||||
noFiller: 'Без заглушки',
|
||||
shows: 'Шоу канала',
|
||||
@@ -462,10 +468,13 @@ const resources = {
|
||||
betweenBlocks: 'Between blocks',
|
||||
betweenEpisodes: 'Between episodes',
|
||||
adsPerBreak: 'Ads per break',
|
||||
bumpers: 'TV bumpers',
|
||||
bumpersLabel: 'Transition bumpers',
|
||||
bumpersHint: 'Short “Now / Next” bumper between different shows',
|
||||
bumperStyle: 'Bumper style',
|
||||
bumperDuration: 'Duration, s',
|
||||
bumperSelection: 'Block selection',
|
||||
bumperSelectionRotation: 'Rotation',
|
||||
bumperSelectionRandom: 'Random',
|
||||
bumperSelectionAlwaysFirst: 'Always first',
|
||||
bumperMinInterval: 'Min interval, min',
|
||||
bumperFont: 'Font',
|
||||
bumperFontSans: 'Sans',
|
||||
@@ -477,22 +486,25 @@ const resources = {
|
||||
bumperAccent: 'Accent',
|
||||
bumperText: 'Text',
|
||||
bumperOnlyDifferent: 'Only on show change (not within a marathon)',
|
||||
bumperMode: 'Bumper mode',
|
||||
bumperModeDynamic: 'Dynamic “Now / Next”',
|
||||
bumperModeStatic: 'Jingles only',
|
||||
bumperModeBoth: 'Alternate',
|
||||
bumperBackground: 'Background',
|
||||
bumperBackgroundHint: 'Image or video loop; otherwise an animated gradient',
|
||||
bumperMusic: 'Music',
|
||||
bumperMusicHint: 'Audio bed file; otherwise a synthesized jingle',
|
||||
bumperTemplates: 'Bumper blocks',
|
||||
bumperTemplatesHint:
|
||||
'Each block has its own sound and style. The first block is the default and cannot be removed. Bumper length follows the sound length.',
|
||||
bumperAddTemplate: 'Add block',
|
||||
bumperTemplateName: 'Name',
|
||||
bumperDefault: 'default',
|
||||
bumperSeconds: 's',
|
||||
bumperDefaultDuration: '≈8 s (jingle)',
|
||||
bumperAudio: 'Sound',
|
||||
bumperAudioHint: 'Bumper sound; otherwise a synthesized jingle',
|
||||
bumperPreview: 'Render sample',
|
||||
bumperPreviewRendering: 'Rendering…',
|
||||
bumperPreviewHint: 'Sample with sound and animation (example show names). Uses saved settings.',
|
||||
bumperBackground: 'Background image',
|
||||
bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient',
|
||||
bumperFileLoaded: 'loaded',
|
||||
bumperFileDefault: 'default',
|
||||
bumperUpload: 'Upload',
|
||||
bumperReset: 'Reset',
|
||||
jingles: 'Jingles',
|
||||
jinglesHint: 'Pre-made clips for static bumpers; rotated on transitions (in “Jingles only” and “Alternate” modes).',
|
||||
pickJingle: 'Pick a clip',
|
||||
noJingles: 'Jingle pool is empty',
|
||||
filler: 'Filler',
|
||||
noFiller: 'No filler',
|
||||
shows: 'Channel shows',
|
||||
|
||||
Reference in New Issue
Block a user