Add TV bumpers functionality: introduce configuration options for bumpers in .env.example and appsettings.json, enhance ChannelEndpoints to manage jingles and bumper assets, and update Channel and ScheduleEntry models to support bumper logic. Implement validation for bumper settings and integrate bumper handling in scheduling logic.
This commit is contained in:
@@ -7,6 +7,9 @@ import { HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
AdInsertion,
|
||||
BlockMode,
|
||||
BumperFont,
|
||||
BumperMode,
|
||||
BumperSettings,
|
||||
ChannelShowDto,
|
||||
OverrideMode,
|
||||
ScheduleEntryDto,
|
||||
@@ -22,16 +25,22 @@ import { listMedia } from '@/features/admin/media/api'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import {
|
||||
addChannelAd,
|
||||
addChannelJingle,
|
||||
addChannelShow,
|
||||
clearBumperBackground,
|
||||
clearBumperMusic,
|
||||
createOverride,
|
||||
deleteOverride,
|
||||
getChannel,
|
||||
getSchedule,
|
||||
regenerateSchedule,
|
||||
removeChannelAd,
|
||||
removeChannelJingle,
|
||||
removeChannelShow,
|
||||
updateChannelSettings,
|
||||
updateChannelShow,
|
||||
uploadBumperBackground,
|
||||
uploadBumperMusic,
|
||||
} from './api'
|
||||
|
||||
function formatTime(iso: string) {
|
||||
@@ -184,6 +193,39 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Джинглы-отбивки (статичные заставки) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.channels.jingles')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Override'ы / марафоны */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -245,13 +287,20 @@ 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])
|
||||
|
||||
@@ -262,6 +311,8 @@ function SettingsCard({
|
||||
isEnabled,
|
||||
adInsertion,
|
||||
adsPerBreak,
|
||||
bumpersEnabled,
|
||||
bumper,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -327,6 +378,31 @@ 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')}
|
||||
@@ -337,6 +413,236 @@ function SettingsCard({
|
||||
)
|
||||
}
|
||||
|
||||
/** ffmpeg-цвет (0xRRGGBB / имя) → CSS для превью-плашки. */
|
||||
function cssColor(value: string): string {
|
||||
const v = value.trim()
|
||||
if (v.startsWith('0x')) return `#${v.slice(2)}`
|
||||
return v
|
||||
}
|
||||
|
||||
function BumperSettingsFields({
|
||||
channelId,
|
||||
bumper,
|
||||
setField,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
bumper: BumperSettings
|
||||
setField: <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) => void
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
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 showDynamicStyle = bumper.mode !== 'Static'
|
||||
|
||||
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>
|
||||
<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)}>
|
||||
<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>
|
||||
</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)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
||||
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperNowLabel')}</Label>
|
||||
<Input
|
||||
value={bumper.nowLabel}
|
||||
maxLength={64}
|
||||
onChange={(e) => setField('nowLabel', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperNextLabel')}</Label>
|
||||
<Input
|
||||
value={bumper.nextLabel}
|
||||
maxLength={64}
|
||||
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
|
||||
type="checkbox"
|
||||
checked={bumper.onlyBetweenDifferentShows}
|
||||
onChange={(e) => setField('onlyBetweenDifferentShows', e.target.checked)}
|
||||
/>
|
||||
{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}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BumperFileUpload({
|
||||
channelId,
|
||||
kind,
|
||||
label,
|
||||
hint,
|
||||
has,
|
||||
accept,
|
||||
upload,
|
||||
clear,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
kind: string
|
||||
label: string
|
||||
hint: string
|
||||
has: boolean
|
||||
accept: string
|
||||
upload: (id: string, file: File) => Promise<void>
|
||||
clear: (id: string) => Promise<void>
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const inputId = `bumper-${kind}-${channelId}`
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => upload(channelId, file),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => clear(channelId),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
const busy = uploadMutation.isPending || clearMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>
|
||||
{label}{' '}
|
||||
<span className={has ? 'text-emerald-500' : 'text-muted-foreground'}>
|
||||
{has ? `· ${t('admin.channels.bumperFileLoaded')}` : `· ${t('admin.channels.bumperFileDefault')}`}
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">{hint}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={inputId}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) uploadMutation.mutate(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => document.getElementById(inputId)?.click()}
|
||||
>
|
||||
{t('admin.channels.bumperUpload')}
|
||||
</Button>
|
||||
{has && (
|
||||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => clearMutation.mutate()}>
|
||||
{t('admin.channels.bumperReset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddShowForm({
|
||||
channelId,
|
||||
options,
|
||||
@@ -521,6 +827,50 @@ 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,
|
||||
@@ -611,6 +961,8 @@ function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
|
||||
<span className="w-28 shrink-0 text-muted-foreground">{formatTime(e.startsAtUtc)}</span>
|
||||
{e.kind === 'Ad' ? (
|
||||
<Badge variant="muted">{t('air.ad')}</Badge>
|
||||
) : e.kind === 'Bumper' ? (
|
||||
<Badge variant="muted">{t('air.bumper')}</Badge>
|
||||
) : (
|
||||
<span>
|
||||
{e.showName ?? '—'}
|
||||
|
||||
Reference in New Issue
Block a user