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,
|
||||
|
||||
Reference in New Issue
Block a user