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 ?? '—'}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
AdInsertion,
|
||||
BlockMode,
|
||||
BumperSettings,
|
||||
ChannelDto,
|
||||
ChannelSummaryDto,
|
||||
CreatedIdResponse,
|
||||
@@ -26,6 +27,8 @@ export type ChannelSettingsBody = {
|
||||
isEnabled: boolean
|
||||
adInsertion: AdInsertion
|
||||
adsPerBreak: number
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
fillerAssetId: string | null
|
||||
}
|
||||
|
||||
@@ -67,6 +70,60 @@ 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`, {
|
||||
method: 'POST',
|
||||
body: { mediaAssetId },
|
||||
})
|
||||
}
|
||||
|
||||
export function removeChannelJingle(id: string, channelJingleId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/jingles/${channelJingleId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Загрузка сырого файла заставки (фон/музыка): тело — файл, имя — в query (как в uploadMedia). */
|
||||
function uploadBumperFile(id: string, kind: 'background' | 'music', 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()}`)
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadBumperBackground(id: string, file: File) {
|
||||
return uploadBumperFile(id, 'background', file)
|
||||
}
|
||||
|
||||
export function uploadBumperMusic(id: string, file: File) {
|
||||
return uploadBumperFile(id, 'music', file)
|
||||
}
|
||||
|
||||
export function clearBumperBackground(id: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/background`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function clearBumperMusic(id: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/music`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export type OverrideBody = {
|
||||
mode: OverrideMode
|
||||
startsAtUtc: string
|
||||
|
||||
Reference in New Issue
Block a user