feat(web): dedicated channel edit screen (#146) #154

Merged
timothy merged 2 commits from feat/146-channel-edit into main 2026-07-07 12:40:18 +02:00
5 changed files with 1191 additions and 1 deletions
+22 -1
View File
@@ -54,6 +54,7 @@ import {
} from 'lucide-react';
import chicoryMarkUrl from '../../design-system/assets/chicory-mark.svg';
import { ChannelBuilderScreen } from './builder/ChannelBuilder';
import { ChannelEditScreen } from './screens/ChannelEditScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { navigateToPath } from './routing';
import {
@@ -124,6 +125,7 @@ type ScreenId =
| 'dashboard'
| 'channels'
| 'builder'
| 'editChannel'
| 'guide'
| 'schedules'
| 'playouts'
@@ -181,6 +183,21 @@ const routes: ScreenRoute[] = [
primaryAction: 'Create Channel',
placeholder: 'Channel builder workspace'
},
{
// Not in the sidebar nav; reached via the edit pencil on the channel table. The
// screen owns parsing the {id} suffix (see ChannelEditScreen), so it opts into
// sub-paths like /app/edit-channel/5.
id: 'editChannel',
path: '/app/edit-channel',
label: 'Edit Channel',
title: 'Edit Channel',
kicker: 'Channel',
description: 'Full channel editor: identity, playout, streaming, selection and branding.',
icon: <Tv aria-hidden="true" size={16} />,
primaryAction: 'Save Changes',
placeholder: 'Channel editor workspace',
allowSubPaths: true
},
{
id: 'guide',
path: '/app/guide',
@@ -1213,7 +1230,7 @@ function ChannelTableRow({
<IconButton disabled size="sm" title={`Preview unavailable for ${channel.name}`}>
<Play aria-hidden="true" size={15} />
</IconButton>
<IconButton onClick={() => navigateToPath(`/app/new-channel?edit=${channel.id}`)} size="sm" title={`Edit ${channel.name}`}>
<IconButton onClick={() => navigateToPath(`/app/edit-channel/${channel.id}`)} size="sm" title={`Edit ${channel.name}`}>
<Pencil aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled size="sm" title={`Troubleshoot ${channel.name}`}>
@@ -2841,6 +2858,10 @@ function ScreenContent({
return <ChannelBuilderScreen />;
}
if (route.id === 'editChannel') {
return <ChannelEditScreen key={window.location.pathname} />;
}
if (route.id === 'guide') {
return <GuideScreen />;
}
+92
View File
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getChannelById, updateChannel, type UpdateChannelRequest } from './channels';
const sampleChannel = {
id: 5,
number: '5',
name: 'Cartoons',
group: 'ChicoryTV',
categories: '',
fFmpegProfileId: 1,
slugSeconds: null,
logo: { path: '', contentType: '' },
streamSelectorMode: 'Default',
streamSelector: null,
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
playoutSource: 'Generated',
playoutMode: 'Continuous',
mirrorSourceChannelId: null,
playoutOffset: null,
streamingMode: 'TransportStreamHybrid',
watermarkId: null,
fallbackFillerId: null,
playoutCount: 0,
preferredSubtitleLanguageCode: null,
subtitleMode: 'None',
musicVideoCreditsMode: 'None',
musicVideoCreditsTemplate: null,
songVideoMode: 'Default',
transcodeMode: 'OnDemand',
idleBehavior: 'StopOnDisconnect',
isEnabled: true,
showInEpg: true
};
describe('getChannelById', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs the channel by id and returns the view model', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleChannel), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(getChannelById(5)).resolves.toMatchObject({ id: 5, name: 'Cartoons' });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/channels/5');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
it('rejects with the ApiError status on 404', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 404, title: 'Not Found' }), {
headers: { 'Content-Type': 'application/json' },
status: 404
})
);
await expect(getChannelById(999)).rejects.toMatchObject({ status: 404 });
});
});
describe('updateChannel', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('PUTs the request body to the channel id and returns the updated view model', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ...sampleChannel, name: 'Renamed' }), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
const body = { ...sampleChannel, name: 'Renamed' } as unknown as UpdateChannelRequest;
await expect(updateChannel(5, body)).resolves.toMatchObject({ name: 'Renamed' });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/channels/5');
expect((init?.method ?? '').toUpperCase()).toBe('PUT');
expect(JSON.parse(init?.body as string)).toMatchObject({ name: 'Renamed', number: '5' });
});
});
+13
View File
@@ -4,6 +4,8 @@ import type { components } from './generated/v1';
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
export type ChannelState = components['schemas']['ChannelStateResponseModel'];
export type Channel = components['schemas']['ChannelViewModel'];
export type UpdateChannelRequest = components['schemas']['UpdateChannelRequest'];
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
@@ -33,6 +35,17 @@ export function getChannelStates(): Promise<ChannelState[]> {
return request<ChannelState[]>('/api/channels/state');
}
export function getChannelById(channelId: number): Promise<Channel> {
return request<Channel>(`/api/channels/${channelId}`);
}
export function updateChannel(channelId: number, body: UpdateChannelRequest): Promise<Channel> {
return request<Channel>(`/api/channels/${channelId}`, {
body,
method: 'PUT'
});
}
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
const [channels, channelStates] = await Promise.all([
getChannels(),
+161
View File
@@ -0,0 +1,161 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChannelEditScreen } from './ChannelEditScreen';
const channel = {
id: 5,
number: '5',
name: 'Cartoons',
group: 'ChicoryTV',
categories: 'Kids',
fFmpegProfileId: 1,
slugSeconds: null,
logo: { path: '', contentType: '' },
streamSelectorMode: 'Default',
streamSelector: null,
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
playoutSource: 'Generated',
playoutMode: 'Continuous',
mirrorSourceChannelId: null,
playoutOffset: null,
streamingMode: 'TransportStreamHybrid',
watermarkId: null,
fallbackFillerId: null,
playoutCount: 0,
preferredSubtitleLanguageCode: null,
subtitleMode: 'None',
musicVideoCreditsMode: 'None',
musicVideoCreditsTemplate: null,
songVideoMode: 'Default',
transcodeMode: 'OnDemand',
idleBehavior: 'StopOnDisconnect',
isEnabled: true,
showInEpg: true
};
// The PUT body is the GET response minus the read-only id / playoutCount fields.
function expectedPutBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const { id, playoutCount, ...rest } = channel;
void id;
void playoutCount;
return { ...rest, ...overrides };
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
interface FetchOptions {
channelStatus?: number;
onPut?: (body: unknown) => void;
}
function mockApi({ channelStatus = 200, onPut }: FetchOptions = {}) {
return vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
const method = (init?.method ?? 'GET').toUpperCase();
if (url === '/api/channels/5' && method === 'PUT') {
onPut?.(JSON.parse(init?.body as string));
return Promise.resolve(json({ ...channel, name: 'Renamed' }));
}
if (url === '/api/channels/5') {
return channelStatus === 200
? Promise.resolve(json(channel))
: Promise.resolve(json({ status: channelStatus, title: 'Not Found' }, channelStatus));
}
if (url === '/api/ffmpeg/profiles') {
return Promise.resolve(json([{ id: 1, name: 'Default profile' }]));
}
if (url === '/api/watermarks') {
return Promise.resolve(json([{ id: 2, name: 'Corner bug' }]));
}
if (url === '/api/filler-presets') {
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
}
if (url === '/api/channels') {
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
}
return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
});
}
describe('ChannelEditScreen', () => {
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState(null, '', '/app/edit-channel/5');
vi.restoreAllMocks();
});
afterEach(() => {
cleanup();
window.history.replaceState(null, '', '/');
});
it('hydrates the form from the loaded channel', async () => {
mockApi();
render(<ChannelEditScreen />);
expect(await screen.findByDisplayValue('Cartoons')).toBeInTheDocument();
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
expect(screen.getByDisplayValue('Kids')).toBeInTheDocument();
});
it('saves an edited field with a PUT carrying the updated body', async () => {
const puts: unknown[] = [];
mockApi({ onPut: (body) => puts.push(body) });
render(<ChannelEditScreen />);
const nameInput = await screen.findByDisplayValue('Cartoons');
fireEvent.change(nameInput, { target: { value: 'Renamed' } });
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect(puts[0]).toEqual(expectedPutBody({ name: 'Renamed' }));
expect(await screen.findByText('Channel saved')).toBeInTheDocument();
});
it('forces showInEpg off in the PUT body when Enabled is turned off', async () => {
const puts: unknown[] = [];
mockApi({ onPut: (body) => puts.push(body) });
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
// The General pane renders exactly two switches, in document order: Enabled, then Show in EPG.
const [enabledSwitch, showInEpgSwitch] = screen.getAllByRole('switch');
fireEvent.click(enabledSwitch);
expect(showInEpgSwitch).toHaveAttribute('aria-checked', 'false');
expect(showInEpgSwitch).toHaveAttribute('aria-disabled', 'true');
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect(puts[0]).toEqual(expectedPutBody({ isEnabled: false, showInEpg: false }));
});
it('shows an error state when the channel is not found', async () => {
mockApi({ channelStatus: 404 });
render(<ChannelEditScreen />);
expect(await screen.findByText('Channel not found.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Back to channels' })).toBeInTheDocument();
});
});
+903
View File
@@ -0,0 +1,903 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
AudioLines,
Check,
CircleCheck,
Image as ImageIcon,
ListVideo,
Music,
SlidersHorizontal,
Tv,
TriangleAlert,
Upload
} from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, ChannelLogo, Input, Select, Switch } from '../components';
import {
ApiError,
getChannelById,
getChannels,
getFFmpegProfiles,
getFillerPresets,
getWatermarks,
messageFromError,
updateChannel,
uploadArtwork,
type Channel,
type ChannelSummary,
type FFmpegProfile,
type FillerPreset,
type UpdateChannelRequest,
type Watermark
} from '../api';
const EDIT_BASE_PATH = '/app/edit-channel';
type SectionId = 'general' | 'playout' | 'streaming' | 'selection' | 'music' | 'branding';
interface SectionDef {
hint: string;
icon: ReactNode;
id: SectionId;
label: string;
}
const SECTIONS: SectionDef[] = [
{ hint: 'Number, name, group', icon: <Tv aria-hidden="true" size={16} />, id: 'general', label: 'General' },
{ hint: 'Source & scheduling', icon: <ListVideo aria-hidden="true" size={16} />, id: 'playout', label: 'Playout' },
{ hint: 'FFmpeg & output', icon: <SlidersHorizontal aria-hidden="true" size={16} />, id: 'streaming', label: 'Streaming' },
{ hint: 'Audio & subtitles', icon: <AudioLines aria-hidden="true" size={16} />, id: 'selection', label: 'Stream selection' },
{ hint: 'Credits & songs', icon: <Music aria-hidden="true" size={16} />, id: 'music', label: 'Music video' },
{ hint: 'Logo & watermark', icon: <ImageIcon aria-hidden="true" size={16} />, id: 'branding', label: 'Branding' }
];
const SLUG_SECONDS_OPTIONS = [
{ label: '(none)', value: '' },
{ label: '0.5 seconds', value: '0.5' },
{ label: '1 second', value: '1' },
{ label: '2 seconds', value: '2' },
{ label: '3 seconds', value: '3' },
{ label: '5 seconds', value: '5' }
];
// Server serializes TimeSpan with the constant ("c") format: [-][d.]hh:mm:ss. The Blazor
// editor exposes the offset as whole hours, so we translate to/from that here. Hours >= 24
// must use the days component, or the value fails to round-trip on the wire.
function timeSpanToHours(value: null | string | undefined): number {
if (!value) {
return 0;
}
const negative = value.startsWith('-');
const body = negative ? value.slice(1) : value;
const dotIndex = body.indexOf('.');
const colonIndex = body.indexOf(':');
let days = 0;
let rest = body;
if (dotIndex !== -1 && (colonIndex === -1 || dotIndex < colonIndex)) {
days = Number(body.slice(0, dotIndex));
rest = body.slice(dotIndex + 1);
}
const hours = Number(rest.split(':')[0] ?? '0');
const total = days * 24 + hours;
return negative ? -total : total;
}
function hoursToTimeSpan(hours: number): null | string {
if (!Number.isFinite(hours) || hours === 0) {
return null;
}
const negative = hours < 0;
const abs = Math.abs(Math.trunc(hours));
const days = Math.floor(abs / 24);
const rem = abs % 24;
const core = days > 0
? `${days}.${String(rem).padStart(2, '0')}:00:00`
: `${rem}:00:00`;
return `${negative ? '-' : ''}${core}`;
}
function channelIdFromPathname(pathname: string): number | null {
const normalized = pathname.replace(/\/+$/, '');
if (!normalized.startsWith(`${EDIT_BASE_PATH}/`)) {
return null;
}
const id = Number(normalized.slice(EDIT_BASE_PATH.length + 1).split('/')[0]);
return Number.isInteger(id) && id > 0 ? id : null;
}
// The PUT body is the GET response minus the read-only id / playoutCount / webEncodedName
// fields, so hydration is a straight field copy.
function draftFromChannel(channel: Channel): UpdateChannelRequest {
return {
name: channel.name,
number: channel.number,
group: channel.group,
categories: channel.categories,
fFmpegProfileId: channel.fFmpegProfileId,
slugSeconds: channel.slugSeconds,
logo: channel.logo,
streamSelectorMode: channel.streamSelectorMode,
streamSelector: channel.streamSelector,
preferredAudioLanguageCode: channel.preferredAudioLanguageCode,
preferredAudioTitle: channel.preferredAudioTitle,
playoutSource: channel.playoutSource,
playoutMode: channel.playoutMode,
mirrorSourceChannelId: channel.mirrorSourceChannelId,
playoutOffset: channel.playoutOffset,
streamingMode: channel.streamingMode,
watermarkId: channel.watermarkId,
fallbackFillerId: channel.fallbackFillerId,
preferredSubtitleLanguageCode: channel.preferredSubtitleLanguageCode,
subtitleMode: channel.subtitleMode,
musicVideoCreditsMode: channel.musicVideoCreditsMode,
musicVideoCreditsTemplate: channel.musicVideoCreditsTemplate,
songVideoMode: channel.songVideoMode,
transcodeMode: channel.transcodeMode,
idleBehavior: channel.idleBehavior,
isEnabled: channel.isEnabled,
showInEpg: channel.showInEpg
};
}
function numberOrEmpty(value: null | number | undefined): string {
return value == null ? '' : String(value);
}
function sortByChannelNumber(channels: ChannelSummary[]): ChannelSummary[] {
return [...channels].sort((left, right) => {
const leftNumber = Number.parseFloat(left.number);
const rightNumber = Number.parseFloat(right.number);
if (Number.isNaN(leftNumber) || Number.isNaN(rightNumber)) {
return left.number.localeCompare(right.number, undefined, { numeric: true });
}
return leftNumber - rightNumber;
});
}
/* ---------- shared row primitives (mirror SettingsScreen) ---------- */
function Row({
children,
control = 300,
first = false,
help,
label
}: {
children: ReactNode;
control?: number;
first?: boolean;
help?: string;
label: string;
}) {
return (
<div className="ctv-settings-row" style={first ? { borderTop: 'none' } : undefined}>
<div className="ctv-settings-row-main">
<div className="ctv-settings-row-label">{label}</div>
{help && <div className="ctv-settings-row-help">{help}</div>}
</div>
<div className="ctv-settings-row-control" style={{ flex: `0 0 ${control}px` }}>
{children}
</div>
</div>
);
}
function Pane({ children, subtitle, title }: { children: ReactNode; subtitle: string; title: string }) {
return (
<div className="ctv-settings-pane">
<div className="ctv-settings-pane-header">
<div className="ctv-settings-pane-title">{title}</div>
<div className="ctv-settings-pane-subtitle">{subtitle}</div>
</div>
{children}
</div>
);
}
interface ReferenceData {
channels: ChannelSummary[];
fillerPresets: FillerPreset[];
watermarks: Watermark[];
ffmpegProfiles: FFmpegProfile[];
}
/* ---------- panes ---------- */
function GeneralPane({
draft,
set
}: {
draft: UpdateChannelRequest;
set: (patch: Partial<UpdateChannelRequest>) => void;
}) {
return (
<Pane subtitle="Identity shown in the lineup, guide and IPTV playlist." title="General">
<Card padded={false}>
<Row control={200} first help="Displayed channel number, e.g. 5 or 5.1." label="Number">
<Input
error={draft.number?.trim() ? null : 'Number is required'}
fullWidth
onChange={(event) => set({ number: event.target.value })}
size="sm"
value={draft.number ?? ''}
/>
</Row>
<Row help="Channel display name." label="Name">
<Input
error={draft.name?.trim() ? null : 'Name is required'}
fullWidth
onChange={(event) => set({ name: event.target.value })}
size="sm"
value={draft.name ?? ''}
/>
</Row>
<Row help="Groups channels in clients that support it." label="Group">
<Input fullWidth onChange={(event) => set({ group: event.target.value })} size="sm" value={draft.group ?? ''} />
</Row>
<Row help="Comma-separated list of categories." label="Categories">
<Input
fullWidth
onChange={(event) => set({ categories: event.target.value })}
placeholder="News, Sports"
size="sm"
value={draft.categories ?? ''}
/>
</Row>
<Row help="Whether this channel is served to clients." label="Enabled">
<Switch
checked={draft.isEnabled}
onChange={(next) => set(next ? { isEnabled: next } : { isEnabled: next, showInEpg: false })}
size="sm"
/>
</Row>
<Row help="Include this channel in the XMLTV guide." label="Show in EPG">
<Switch
checked={draft.showInEpg}
disabled={!draft.isEnabled}
onChange={(next) => set({ showInEpg: next })}
size="sm"
/>
</Row>
</Card>
</Pane>
);
}
function PlayoutPane({
channelId,
data,
draft,
playoutLocked,
set
}: {
channelId: number;
data: ReferenceData;
draft: UpdateChannelRequest;
playoutLocked: boolean;
set: (patch: Partial<UpdateChannelRequest>) => void;
}) {
const mirrorOptions = [
{ label: '(none)', value: '' },
...sortByChannelNumber(data.channels.filter((channel) => channel.id !== channelId)).map((channel) => ({
label: `(${channel.number}) - ${channel.name}`,
value: String(channel.id)
}))
];
return (
<Pane subtitle="Where the channel's content comes from and how it progresses." title="Playout">
<Card padded={false}>
<Row
first
help={
playoutLocked
? 'Cannot be changed once a generated channel has a playout.'
: 'Generated builds its own schedule; Mirror follows another channel.'
}
label="Playout source"
>
<Select
disabled={playoutLocked}
fullWidth
onChange={(event) => set({ playoutSource: event.target.value as UpdateChannelRequest['playoutSource'] })}
options={[
{ label: 'Generated', value: 'Generated' },
{ label: 'Mirror', value: 'Mirror' }
]}
size="sm"
value={draft.playoutSource}
/>
</Row>
{draft.playoutSource === 'Mirror' ? (
<>
<Row help="The generated channel this channel mirrors." label="Mirror source channel">
<Select
fullWidth
onChange={(event) =>
set({ mirrorSourceChannelId: event.target.value === '' ? null : Number(event.target.value) })
}
options={mirrorOptions}
size="sm"
value={draft.mirrorSourceChannelId == null ? '' : String(draft.mirrorSourceChannelId)}
/>
</Row>
<Row control={160} help="Shift the mirrored playout by this many hours." label="Playout offset">
<Input
fullWidth
onChange={(event) => set({ playoutOffset: hoursToTimeSpan(Number(event.target.value)) })}
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
trailing={<span className="ctv-settings-unit">hours</span>}
type="number"
value={String(timeSpanToHours(draft.playoutOffset))}
/>
</Row>
</>
) : (
<Row help="Controls how the generated playout progresses." label="Playout mode">
<Select
fullWidth
onChange={(event) => set({ playoutMode: event.target.value as UpdateChannelRequest['playoutMode'] })}
options={[
{ label: 'Continuous', value: 'Continuous' },
{ label: 'On Demand', value: 'OnDemand' }
]}
size="sm"
value={draft.playoutMode}
/>
</Row>
)}
<Row help="How the transcoder behaves once all clients disconnect." label="Idle behavior">
<Select
fullWidth
onChange={(event) => set({ idleBehavior: event.target.value as UpdateChannelRequest['idleBehavior'] })}
options={[
{ label: 'Stop On Disconnect', value: 'StopOnDisconnect' },
{ label: 'Keep Running', value: 'KeepRunning' }
]}
size="sm"
value={draft.idleBehavior}
/>
</Row>
<Row help="When the transcoding process is active. Only On Demand is supported." label="Transcode mode">
<Select disabled fullWidth options={[{ label: 'On Demand', value: 'OnDemand' }]} size="sm" value={draft.transcodeMode} />
</Row>
</Card>
</Pane>
);
}
function StreamingPane({
data,
draft,
set
}: {
data: ReferenceData;
draft: UpdateChannelRequest;
set: (patch: Partial<UpdateChannelRequest>) => void;
}) {
const hlsDirect = draft.streamingMode === 'HttpLiveStreamingDirect';
return (
<Pane subtitle="Output container and the transcoding profile applied to this channel." title="Streaming">
<Card padded={false}>
<Row first help="Delivery container / protocol served to clients." label="Streaming mode">
<Select
fullWidth
onChange={(event) => set({ streamingMode: event.target.value as UpdateChannelRequest['streamingMode'] })}
options={[
{ label: 'MPEG-TS', value: 'TransportStreamHybrid' },
{ label: 'MPEG-TS (Legacy)', value: 'TransportStream' },
{ label: 'HLS Direct', value: 'HttpLiveStreamingDirect' },
{ label: 'HLS Segmenter', value: 'HttpLiveStreamingSegmenter' }
]}
size="sm"
value={draft.streamingMode}
/>
</Row>
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Transcoding preset for this channel.'} label="FFmpeg profile">
<Select
disabled={hlsDirect}
fullWidth
onChange={(event) => set({ fFmpegProfileId: Number(event.target.value) })}
options={data.ffmpegProfiles.map((profile) => ({
label: profile.name ?? `Profile ${profile.id}`,
value: String(profile.id)
}))}
size="sm"
value={String(draft.fFmpegProfileId)}
/>
</Row>
<Row help="Black video / silent audio inserted between every playout item." label="Slug seconds">
<Select
fullWidth
onChange={(event) => set({ slugSeconds: event.target.value === '' ? null : Number(event.target.value) })}
options={SLUG_SECONDS_OPTIONS}
size="sm"
value={numberOrEmpty(draft.slugSeconds)}
/>
</Row>
</Card>
</Pane>
);
}
function SelectionPane({
draft,
set
}: {
draft: UpdateChannelRequest;
set: (patch: Partial<UpdateChannelRequest>) => void;
}) {
const isDefault = draft.streamSelectorMode === 'Default';
return (
<Pane subtitle="How audio and subtitle tracks are chosen for playback." title="Stream selection">
<Card padded={false}>
<Row first help="Default picks tracks by preference; Custom uses a selector script." label="Stream selector mode">
<Select
fullWidth
onChange={(event) =>
set({ streamSelectorMode: event.target.value as UpdateChannelRequest['streamSelectorMode'] })
}
options={[
{ label: 'Default', value: 'Default' },
{ label: 'Custom', value: 'Custom' }
]}
size="sm"
value={draft.streamSelectorMode}
/>
</Row>
{isDefault ? (
<>
<Row help="ISO 639-2 code, e.g. eng. Blank keeps the file order." label="Preferred audio language">
<Input
fullWidth
onChange={(event) => set({ preferredAudioLanguageCode: event.target.value || null })}
placeholder="eng"
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={draft.preferredAudioLanguageCode ?? ''}
/>
</Row>
<Row help="Prefer an audio track whose title contains this text." label="Preferred audio title">
<Input
fullWidth
onChange={(event) => set({ preferredAudioTitle: event.target.value || null })}
size="sm"
value={draft.preferredAudioTitle ?? ''}
/>
</Row>
<Row help="ISO 639-2 code, e.g. eng. Blank disables preference." label="Preferred subtitle language">
<Input
fullWidth
onChange={(event) => set({ preferredSubtitleLanguageCode: event.target.value || null })}
placeholder="eng"
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={draft.preferredSubtitleLanguageCode ?? ''}
/>
</Row>
<Row help="When subtitle tracks are burned in." label="Subtitle mode">
<Select
fullWidth
onChange={(event) => set({ subtitleMode: event.target.value as UpdateChannelRequest['subtitleMode'] })}
options={[
{ label: 'None', value: 'None' },
{ label: 'Forced', value: 'Forced' },
{ label: 'Default', value: 'Default' },
{ label: 'Any', value: 'Any' }
]}
size="sm"
value={draft.subtitleMode}
/>
</Row>
</>
) : (
<Row help="Name of a custom stream-selector script in the config folder." label="Stream selector">
<Input
fullWidth
onChange={(event) => set({ streamSelector: event.target.value || null })}
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={draft.streamSelector ?? ''}
/>
</Row>
)}
</Card>
</Pane>
);
}
function MusicPane({
draft,
set
}: {
draft: UpdateChannelRequest;
set: (patch: Partial<UpdateChannelRequest>) => void;
}) {
const creditsOn = draft.musicVideoCreditsMode === 'GenerateSubtitles';
return (
<Pane subtitle="Overlays and progress behavior for music video and song content." title="Music video">
<Card padded={false}>
<Row first help="Generate on-screen credits for music videos." label="Credits mode">
<Select
fullWidth
onChange={(event) =>
set({ musicVideoCreditsMode: event.target.value as UpdateChannelRequest['musicVideoCreditsMode'] })
}
options={[
{ label: 'None', value: 'None' },
{ label: 'Generate Subtitles', value: 'GenerateSubtitles' }
]}
size="sm"
value={draft.musicVideoCreditsMode}
/>
</Row>
<Row help="Name of the credits template. Only used when credits are generated." label="Credits template">
<Input
disabled={!creditsOn}
fullWidth
onChange={(event) => set({ musicVideoCreditsTemplate: event.target.value || null })}
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={draft.musicVideoCreditsTemplate ?? ''}
/>
</Row>
<Row help="Optional progress bar overlay for song content." label="Song video mode">
<Select
fullWidth
onChange={(event) => set({ songVideoMode: event.target.value as UpdateChannelRequest['songVideoMode'] })}
options={[
{ label: 'Default', value: 'Default' },
{ label: 'With Progress', value: 'WithProgress' }
]}
size="sm"
value={draft.songVideoMode}
/>
</Row>
</Card>
</Pane>
);
}
function BrandingPane({
data,
draft,
logoPreview,
onUploadLogo,
set,
uploadError,
uploading
}: {
data: ReferenceData;
draft: UpdateChannelRequest;
logoPreview: string | null;
onUploadLogo: (file: File) => void;
set: (patch: Partial<UpdateChannelRequest>) => void;
uploadError: string | null;
uploading: boolean;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const hlsDirect = draft.streamingMode === 'HttpLiveStreamingDirect';
const existingLogo = draft.logo?.urlWithContentType ?? draft.logo?.path ?? null;
const previewSrc = logoPreview ?? existingLogo;
return (
<Pane subtitle="Channel logo and the overlays applied while streaming." title="Branding">
<Card padded={false}>
<Row control={340} first help="Shown in the guide and as the on-screen bug." label="Logo">
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<ChannelLogo name={draft.name ?? ''} size={48} src={previewSrc} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<Button
loading={uploading}
onClick={() => fileInputRef.current?.click()}
size="sm"
startIcon={<Upload aria-hidden="true" size={14} />}
variant="secondary"
>
Upload logo
</Button>
{uploadError && (
<span className="ctv-field-error" role="alert">
{uploadError}
</span>
)}
</div>
<input
accept="image/*"
onChange={(event) => {
const file = event.target.files && event.target.files[0];
if (file) {
onUploadLogo(file);
}
event.target.value = '';
}}
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
/>
</div>
</Row>
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Overlay applied to the channel.'} label="Watermark">
<Select
disabled={hlsDirect}
fullWidth
onChange={(event) => set({ watermarkId: event.target.value === '' ? null : Number(event.target.value) })}
options={[
{ label: '(none)', value: '' },
...data.watermarks.map((watermark) => ({
label: watermark.name ?? `Watermark ${watermark.id}`,
value: String(watermark.id)
}))
]}
size="sm"
value={draft.watermarkId == null ? '' : String(draft.watermarkId)}
/>
</Row>
<Row help="Plays when the playout has nothing scheduled." label="Fallback filler">
<Select
fullWidth
onChange={(event) => set({ fallbackFillerId: event.target.value === '' ? null : Number(event.target.value) })}
options={[
{ label: '(none)', value: '' },
...data.fillerPresets.map((preset) => ({
label: preset.name ?? `Filler ${preset.id}`,
value: String(preset.id)
}))
]}
size="sm"
value={draft.fallbackFillerId == null ? '' : String(draft.fallbackFillerId)}
/>
</Row>
</Card>
</Pane>
);
}
/* ---------- screen ---------- */
interface LoadedState {
channelId: number;
data: ReferenceData;
playoutLocked: boolean;
}
export function ChannelEditScreen() {
const [channelId] = useState<number | null>(() => channelIdFromPathname(window.location.pathname));
const [loaded, setLoaded] = useState<LoadedState | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [draft, setDraft] = useState<UpdateChannelRequest | null>(null);
const [saved, setSaved] = useState<UpdateChannelRequest | null>(null);
const [section, setSection] = useState<SectionId>('general');
const [saving, setSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
useEffect(() => {
if (channelId === null) {
return;
}
let active = true;
Promise.all([getChannelById(channelId), getFFmpegProfiles(), getWatermarks(), getFillerPresets(), getChannels()])
.then(([channel, ffmpegProfiles, watermarks, fillerPresets, channels]) => {
if (!active) {
return;
}
const initial = draftFromChannel(channel);
setDraft(initial);
setSaved(initial);
setLoaded({
channelId,
data: { channels, ffmpegProfiles, fillerPresets, watermarks },
playoutLocked: channel.playoutSource === 'Generated' && channel.playoutCount > 0
});
})
.catch((error: unknown) => {
if (!active) {
return;
}
if (error instanceof ApiError && error.status === 404) {
setLoadError('Channel not found.');
} else {
setLoadError(messageFromError(error));
}
});
return () => {
active = false;
};
}, [channelId]);
const effectiveError = loadError ?? (channelId === null ? 'Invalid channel.' : null);
if (effectiveError) {
return (
<div className="ctv-settings-error" role="alert">
<TriangleAlert aria-hidden="true" size={16} />
<span>{effectiveError}</span>
<Button onClick={() => navigateToPath('/app/channels')} size="sm" variant="secondary">
Back to channels
</Button>
</div>
);
}
if (loaded === null || draft === null || saved === null) {
return (
<div className="ctv-settings-loading">
<span>Loading channel…</span>
</div>
);
}
const set = (patch: Partial<UpdateChannelRequest>) => {
setJustSaved(false);
setDraft((current) => (current ? { ...current, ...patch } : current));
};
const uploadLogo = async (file: File) => {
if (!file.type.startsWith('image/')) {
setUploadError('Choose an image file.');
return;
}
setUploading(true);
setUploadError(null);
try {
const uploaded = await uploadArtwork(file, 'logo');
setJustSaved(false);
setDraft((current) =>
current ? { ...current, logo: { path: uploaded.path, contentType: uploaded.contentType } } : current
);
const reader = new FileReader();
reader.onload = () => setLogoPreview(typeof reader.result === 'string' ? reader.result : null);
reader.readAsDataURL(file);
} catch (error) {
setUploadError(messageFromError(error));
} finally {
setUploading(false);
}
};
const nameValid = Boolean(draft.name?.trim());
const numberValid = Boolean(draft.number?.trim());
const valid = nameValid && numberValid;
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
const discard = () => {
setDraft(saved);
setLogoPreview(null);
setSaveError(null);
};
const save = async () => {
if (!valid || !dirty || saving) {
return;
}
setSaving(true);
setSaveError(null);
try {
const updated = await updateChannel(loaded.channelId, draft);
const next = draftFromChannel(updated);
setSaved(next);
setDraft(next);
setLogoPreview(null);
setJustSaved(true);
window.setTimeout(() => setJustSaved(false), 1800);
} catch (error) {
setSaveError(messageFromError(error));
} finally {
setSaving(false);
}
};
return (
<div className="ctv-settings">
<nav aria-label="Channel sections" className="ctv-settings-rail">
{SECTIONS.map((entry) => {
const active = entry.id === section;
return (
<button
aria-current={active ? 'true' : undefined}
className={`ctv-settings-rail-item ctv-press${active ? ' ctv-settings-rail-item-active' : ''}`}
key={entry.id}
onClick={() => setSection(entry.id)}
type="button"
>
<span className="ctv-settings-rail-icon" style={{ color: active ? 'var(--ctv-accent)' : 'var(--text-secondary)' }}>
{entry.icon}
</span>
<span className="ctv-settings-rail-text">
<span className="ctv-settings-rail-label" style={{ color: active ? 'var(--text-primary)' : 'var(--text-secondary)' }}>
{entry.label}
</span>
<span className="ctv-settings-rail-hint">{entry.hint}</span>
</span>
</button>
);
})}
</nav>
<div className="ctv-settings-body">
<div className="ctv-settings-scroll">
{section === 'general' && <GeneralPane draft={draft} set={set} />}
{section === 'playout' && (
<PlayoutPane channelId={loaded.channelId} data={loaded.data} draft={draft} playoutLocked={loaded.playoutLocked} set={set} />
)}
{section === 'streaming' && <StreamingPane data={loaded.data} draft={draft} set={set} />}
{section === 'selection' && <SelectionPane draft={draft} set={set} />}
{section === 'music' && <MusicPane draft={draft} set={set} />}
{section === 'branding' && (
<BrandingPane
data={loaded.data}
draft={draft}
logoPreview={logoPreview}
onUploadLogo={(file) => void uploadLogo(file)}
set={set}
uploadError={uploadError}
uploading={uploading}
/>
)}
</div>
{(dirty || justSaved) && (
<div className="ctv-settings-savebar-wrap">
<div className="ctv-settings-savebar ctv-lift">
{justSaved ? (
<>
<CircleCheck aria-hidden="true" color="var(--status-ok)" size={15} />
<span className="ctv-settings-savebar-text">Channel saved</span>
</>
) : (
<>
<span className="ctv-settings-savebar-text">Unsaved changes</span>
{!valid && <Badge tone="neutral">Name and number are required</Badge>}
{saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
<span className="ctv-settings-savebar-divider" />
<Button onClick={discard} size="sm" variant="ghost">
Discard
</Button>
<Button
disabled={!valid || saving}
loading={saving}
onClick={() => void save()}
size="sm"
startIcon={<Check aria-hidden="true" size={14} />}
variant="primary"
>
Save changes
</Button>
</>
)}
</div>
</div>
)}
</div>
</div>
);
}