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
|
||||
|
||||
@@ -73,6 +73,17 @@ export function ShowDetail({ showId }: { showId: string }) {
|
||||
|
||||
const selected = candidates.filter((c) => !deselected.has(c.asset.id))
|
||||
|
||||
// Сколько разных сезонов загружено — по распознанным SxxExx в именах серий.
|
||||
const seasonCount = useMemo(() => {
|
||||
if (!show) return 0
|
||||
const seasons = new Set<number>()
|
||||
for (const e of show.episodes) {
|
||||
const se = parseEpisode(e.assetName ?? '')
|
||||
if (se) seasons.add(se.season)
|
||||
}
|
||||
return seasons.size
|
||||
}, [show])
|
||||
|
||||
if (isLoading || !show) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
const isSingle = show.kind === 'Single'
|
||||
@@ -115,9 +126,17 @@ export function ShowDetail({ showId }: { showId: string }) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="crt-glow text-xl font-semibold">{show.name}</h2>
|
||||
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
|
||||
{show.kind === 'Series' && (
|
||||
<Badge variant="muted">
|
||||
{t('admin.shows.seasons')}: {seasonCount}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="muted">
|
||||
{t('admin.shows.episodes')}: {show.episodes.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{canAdd && (
|
||||
|
||||
@@ -91,7 +91,25 @@ export type ShowDto = {
|
||||
export type BlockMode = 'Count' | 'Duration'
|
||||
export type AdInsertion = 'BetweenBlocks' | 'BetweenEpisodes'
|
||||
export type OverrideMode = 'Exclusive' | 'Boost'
|
||||
export type ScheduleEntryKind = 'Program' | 'Ad'
|
||||
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper'
|
||||
export type BumperFont = 'Sans' | 'Serif'
|
||||
export type BumperMode = 'Dynamic' | 'Static' | 'Both'
|
||||
|
||||
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
|
||||
hasBackground: boolean
|
||||
hasMusic: boolean
|
||||
}
|
||||
|
||||
export type ChannelSummaryDto = {
|
||||
id: string
|
||||
@@ -118,6 +136,13 @@ 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 = {
|
||||
@@ -135,9 +160,12 @@ export type ChannelDto = {
|
||||
isEnabled: boolean
|
||||
adInsertion: AdInsertion
|
||||
adsPerBreak: number
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
fillerAssetId: string | null
|
||||
shows: ChannelShowDto[]
|
||||
ads: ChannelAdDto[]
|
||||
jingles: ChannelJingleDto[]
|
||||
overrides: ProgrammingOverrideDto[]
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ const resources = {
|
||||
now: 'Сейчас',
|
||||
next: 'Далее',
|
||||
ad: 'Реклама',
|
||||
bumper: 'Заставка',
|
||||
episode: 'Серия',
|
||||
live: 'В эфире',
|
||||
noChannels: 'Пока нет доступных каналов. Загляните позже.',
|
||||
@@ -156,6 +157,37 @@ const resources = {
|
||||
betweenBlocks: 'Между блоками',
|
||||
betweenEpisodes: 'Между сериями',
|
||||
adsPerBreak: 'Роликов подряд',
|
||||
bumpersLabel: 'ТВ-заставки на переходах',
|
||||
bumpersHint: 'Короткая заставка «Сейчас / Далее» между разными шоу',
|
||||
bumperStyle: 'Оформление заставки',
|
||||
bumperDuration: 'Длительность, с',
|
||||
bumperMinInterval: 'Мин. интервал, мин',
|
||||
bumperFont: 'Шрифт',
|
||||
bumperFontSans: 'Гротеск',
|
||||
bumperFontSerif: 'Антиква',
|
||||
bumperNowLabel: 'Подпись «Сейчас»',
|
||||
bumperNextLabel: 'Подпись «Далее»',
|
||||
bumperBg: 'Фон (цвет 1)',
|
||||
bumperBg2: 'Фон (цвет 2)',
|
||||
bumperAccent: 'Акцент',
|
||||
bumperText: 'Текст',
|
||||
bumperOnlyDifferent: 'Только на смене шоу (не внутри марафона)',
|
||||
bumperMode: 'Режим заставок',
|
||||
bumperModeDynamic: 'Динамические «Сейчас/Далее»',
|
||||
bumperModeStatic: 'Только джинглы',
|
||||
bumperModeBoth: 'Чередовать',
|
||||
bumperBackground: 'Фон',
|
||||
bumperBackgroundHint: 'Картинка или видео-петля; иначе — анимированный градиент',
|
||||
bumperMusic: 'Музыка',
|
||||
bumperMusicHint: 'Аудиофайл-подложка; иначе — синтезированный джингл',
|
||||
bumperFileLoaded: 'загружено',
|
||||
bumperFileDefault: 'по умолчанию',
|
||||
bumperUpload: 'Загрузить',
|
||||
bumperReset: 'Сбросить',
|
||||
jingles: 'Джинглы-отбивки',
|
||||
jinglesHint: 'Готовые ролики для статичных заставок; крутятся по кругу на переходах (режимы «Только джинглы» и «Чередовать»).',
|
||||
pickJingle: 'Выберите ролик',
|
||||
noJingles: 'Пул джинглов пуст',
|
||||
filler: 'Заглушка',
|
||||
noFiller: 'Без заглушки',
|
||||
shows: 'Шоу канала',
|
||||
@@ -257,6 +289,7 @@ const resources = {
|
||||
now: 'Now',
|
||||
next: 'Up next',
|
||||
ad: 'Ad',
|
||||
bumper: 'Bumper',
|
||||
episode: 'Episode',
|
||||
live: 'Live',
|
||||
noChannels: 'No channels available yet. Check back later.',
|
||||
@@ -352,6 +385,37 @@ const resources = {
|
||||
betweenBlocks: 'Between blocks',
|
||||
betweenEpisodes: 'Between episodes',
|
||||
adsPerBreak: 'Ads per break',
|
||||
bumpersLabel: 'Transition bumpers',
|
||||
bumpersHint: 'Short “Now / Next” bumper between different shows',
|
||||
bumperStyle: 'Bumper style',
|
||||
bumperDuration: 'Duration, s',
|
||||
bumperMinInterval: 'Min interval, min',
|
||||
bumperFont: 'Font',
|
||||
bumperFontSans: 'Sans',
|
||||
bumperFontSerif: 'Serif',
|
||||
bumperNowLabel: '“Now” label',
|
||||
bumperNextLabel: '“Next” label',
|
||||
bumperBg: 'Background (color 1)',
|
||||
bumperBg2: 'Background (color 2)',
|
||||
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',
|
||||
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