feat(67): add Use logo as on-screen bug toggle with preview to the channel editor

This commit is contained in:
2026-07-20 20:51:58 +02:00
parent 520b113d66
commit d5da504651
4 changed files with 238 additions and 4 deletions
+10
View File
@@ -13,6 +13,16 @@ export function getWatermark(id: number): Promise<WatermarkDetail> {
return request<WatermarkDetail>(`/api/v1/watermarks/${id}`);
}
// Identifies THE default logo-driven preset. imageSource alone is not enough once a second
// ChannelLogo preset exists (the design invites creating one for per-channel geometry), and
// getWatermarks() sorts by name — so prefer the seeded name, then fall back deterministically.
export function findLogoBugWatermark<T extends { id: number; imageSource: string; name: null | string }>(
watermarks: T[]
): T | null {
const logoDriven = watermarks.filter((watermark) => watermark.imageSource === 'ChannelLogo');
return logoDriven.find((watermark) => watermark.name === 'Channel Bug') ?? logoDriven[0] ?? null;
}
export function createWatermark(body: CreateWatermarkRequest): Promise<WatermarkDetail> {
return request<WatermarkDetail>('/api/v1/watermarks', { body, method: 'POST' });
}
+6 -1
View File
@@ -232,6 +232,10 @@ export interface SwitchProps {
checked?: boolean;
onChange?: (next: boolean) => void;
label?: string;
// When true, `label` still becomes the accessible name (aria-label) but is not rendered as
// visible text — for callers that already show the same caption elsewhere (e.g. a settings
// Row label) and would otherwise duplicate it.
hideLabel?: boolean;
disabled?: boolean;
size?: ControlSize;
style?: CSSProperties;
@@ -241,6 +245,7 @@ export function Switch({
checked = false,
onChange,
label,
hideLabel = false,
disabled = false,
size = 'md',
style
@@ -269,7 +274,7 @@ export function Switch({
>
<span className="ctv-switch-knob" />
</button>
{label && <span>{label}</span>}
{label && !hideLabel && <span>{label}</span>}
</span>
);
}
+139 -2
View File
@@ -54,9 +54,16 @@ interface FetchOptions {
channelStatus?: number;
onPut?: (body: unknown) => void;
putResponseOverrides?: Record<string, unknown>;
watermarks?: Record<string, unknown>[];
}
function mockApi({ channelOverrides = {}, channelStatus = 200, onPut, putResponseOverrides = {} }: FetchOptions = {}) {
function mockApi({
channelOverrides = {},
channelStatus = 200,
onPut,
putResponseOverrides = {},
watermarks = [{ id: 2, name: 'Corner bug', imageSource: 'Custom' }]
}: FetchOptions = {}) {
const loadedChannel = { ...channel, ...channelOverrides };
return vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
@@ -79,7 +86,25 @@ function mockApi({ channelOverrides = {}, channelStatus = 200, onPut, putRespons
}
if (url === '/api/v1/watermarks') {
return Promise.resolve(json([{ id: 2, name: 'Corner bug' }]));
return Promise.resolve(json(watermarks));
}
if (url.startsWith('/api/v1/watermarks/')) {
const id = Number(url.slice('/api/v1/watermarks/'.length));
const found = watermarks.find((watermark) => watermark.id === id);
return found
? Promise.resolve(
json({
horizontalMargin: 1,
location: 'TopLeft',
opacity: 80,
size: 'Scaled',
verticalMargin: 1,
width: 5,
...found
})
)
: Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
}
if (url === '/api/v1/filler-presets') {
@@ -335,4 +360,116 @@ describe('ChannelEditScreen', () => {
expect(await screen.findByDisplayValue('legacy-selector.py')).toBeInTheDocument();
});
});
describe('use logo as on-screen bug toggle', () => {
it('ticks the logo-bug toggle when the channel uses a ChannelLogo watermark', async () => {
mockApi({
channelOverrides: { watermarkId: 9 },
watermarks: [
{ id: 2, imageSource: 'Custom', name: 'Corner bug' },
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
]
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
expect(await screen.findByRole('switch', { name: 'Use logo as on-screen bug' })).toHaveAttribute(
'aria-checked',
'true'
);
});
it('clears watermarkId when the logo-bug toggle is switched off', async () => {
const puts: unknown[] = [];
mockApi({
channelOverrides: { watermarkId: 9 },
onPut: (body) => puts.push(body),
watermarks: [
{ id: 2, imageSource: 'Custom', name: 'Corner bug' },
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
]
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
fireEvent.click(await screen.findByRole('switch', { name: 'Use logo as on-screen bug' }));
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect((puts[0] as { watermarkId: null | number }).watermarkId).toBeNull();
});
// Regression: a search-by-imageSource implementation binds to the alphabetically-first
// ChannelLogo preset (getWatermarks sorts by name), silently repointing this channel.
it('reflects a non-first ChannelLogo preset without repointing the channel', async () => {
const puts: unknown[] = [];
mockApi({
channelOverrides: { watermarkId: 9 },
onPut: (body) => puts.push(body),
// 'Alpha Bug' sorts before 'Channel Bug', so a search-by-imageSource would bind to id 4.
watermarks: [
{ id: 4, imageSource: 'ChannelLogo', name: 'Alpha Bug' },
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
]
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
expect(await screen.findByRole('switch', { name: 'Use logo as on-screen bug' })).toHaveAttribute(
'aria-checked',
'true'
);
// Change something unrelated so the form is dirty and Save is enabled — the toggle itself
// must NOT have touched watermarkId.
fireEvent.click(screen.getByRole('button', { name: /^General/ }));
fireEvent.change(screen.getByDisplayValue('Cartoons'), { target: { value: 'Renamed channel' } });
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect((puts[0] as { watermarkId: null | number }).watermarkId).toBe(9);
});
// The guarantee protecting the existing production channels.
it('leaves watermarkId unchanged when an untouched channel is re-saved', async () => {
const puts: unknown[] = [];
mockApi({
channelOverrides: { watermarkId: 2 },
onPut: (body) => puts.push(body),
watermarks: [
{ id: 2, imageSource: 'Custom', name: 'Sponsor bug' },
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
]
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
expect(screen.getByRole('switch', { name: 'Use logo as on-screen bug' })).toHaveAttribute(
'aria-checked',
'false'
);
// Change something unrelated so the form is dirty and Save is enabled.
fireEvent.click(screen.getByRole('button', { name: /^General/ }));
fireEvent.change(screen.getByDisplayValue('Cartoons'), { target: { value: 'Renamed channel' } });
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect((puts[0] as { watermarkId: null | number }).watermarkId).toBe(2);
});
});
});
+83 -1
View File
@@ -12,9 +12,10 @@ import {
Upload
} from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, ChannelLogo, Input, Select, Switch } from '../components';
import { Badge, BugPreview, Button, Card, ChannelLogo, Input, Select, Switch, type BugPreviewGeometry } from '../components';
import {
ApiError,
findLogoBugWatermark,
getChannelById,
getChannels,
getChannelStreamSelectors,
@@ -22,6 +23,7 @@ import {
getFillerPresets,
getLanguages,
getMusicVideoCreditsTemplates,
getWatermark,
getWatermarks,
messageFromError,
updateChannel,
@@ -672,6 +674,61 @@ function BrandingPane({
const trimmedUrl = externalLogoUrl.trim();
const previewSrc = trimmedUrl ? externalLogoUrl : (logoPreview ?? existingLogo);
// Reflect the referenced row — never a search — so a channel bound to a second ChannelLogo
// preset reads correctly and is never silently repointed.
const referenced = data.watermarks.find((watermark) => watermark.id === draft.watermarkId) ?? null;
const logoBugEnabled = referenced?.imageSource === 'ChannelLogo';
const logoBugTarget = findLogoBugWatermark(data.watermarks);
// An external-URL logo is resolved by WatermarkSelector to the URL itself and then File.Exists-ed,
// which is never true, so no bug renders (#502). Don't promise one in the preview.
const externalUrlLogo = trimmedUrl.length > 0;
// Keyed by the watermark id it was fetched for, so a stale response (or a disabled toggle) is
// filtered out by comparing against the CURRENT draft.watermarkId at render time — no reset
// branch is needed inside the effect (avoids a synchronous setState-in-effect).
const [fetchedGeometry, setFetchedGeometry] = useState<null | ({
horizontalMargin: number;
id: number;
location: BugPreviewGeometry['location'];
opacity: number;
size: BugPreviewGeometry['size'];
verticalMargin: number;
width: number;
})>(null);
useEffect(() => {
if (!logoBugEnabled || draft.watermarkId == null) {
return;
}
let cancelled = false;
const watermarkId = draft.watermarkId;
void getWatermark(watermarkId)
.then((watermark) => {
if (!cancelled) {
setFetchedGeometry({
horizontalMargin: watermark.horizontalMargin,
id: watermarkId,
location: watermark.location,
opacity: watermark.opacity,
size: watermark.size,
verticalMargin: watermark.verticalMargin,
width: watermark.width
});
}
})
.catch(() => {
// Leave any prior geometry as-is; the id-match check below keeps stale data from a
// different watermark from being shown.
});
return () => {
cancelled = true;
};
}, [draft.watermarkId, logoBugEnabled]);
const bugGeometry = fetchedGeometry && fetchedGeometry.id === draft.watermarkId ? fetchedGeometry : null;
return (
<Pane subtitle="Channel logo and the overlays applied while streaming." title="Branding">
<Card padded={false}>
@@ -723,6 +780,31 @@ function BrandingPane({
value={externalLogoUrl}
/>
</Row>
<Row
control={340}
help={
logoBugTarget == null
? 'No logo-driven watermark preset exists yet.'
: externalUrlLogo
? 'An external logo URL cannot be used as the on-screen bug — upload an image instead (see #502).'
: 'Overlays this channels own logo on the stream, using the shared presets position and size.'
}
label="Use logo as on-screen bug"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Switch
checked={logoBugEnabled}
disabled={hlsDirect || logoBugTarget == null}
hideLabel
label="Use logo as on-screen bug"
onChange={(next) => set({ watermarkId: next ? (logoBugTarget?.id ?? null) : null })}
size="sm"
/>
{logoBugEnabled && bugGeometry && previewSrc && !externalUrlLogo && (
<BugPreview {...bugGeometry} src={previewSrc} />
)}
</div>
</Row>
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Overlay applied to the channel.'} label="Watermark">
<Select
disabled={hlsDirect}