From d5da5046518b1ae532881e8ded96adbbe770d9b0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Mon, 20 Jul 2026 20:51:58 +0200 Subject: [PATCH] feat(67): add Use logo as on-screen bug toggle with preview to the channel editor --- web/src/api/watermarks.ts | 10 ++ web/src/components/forms.tsx | 7 +- web/src/screens/ChannelEditScreen.test.tsx | 141 ++++++++++++++++++++- web/src/screens/ChannelEditScreen.tsx | 84 +++++++++++- 4 files changed, 238 insertions(+), 4 deletions(-) diff --git a/web/src/api/watermarks.ts b/web/src/api/watermarks.ts index 60410dd10..b347c5e41 100644 --- a/web/src/api/watermarks.ts +++ b/web/src/api/watermarks.ts @@ -13,6 +13,16 @@ export function getWatermark(id: number): Promise { return request(`/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( + 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 { return request('/api/v1/watermarks', { body, method: 'POST' }); } diff --git a/web/src/components/forms.tsx b/web/src/components/forms.tsx index f96049bf2..834f37388 100644 --- a/web/src/components/forms.tsx +++ b/web/src/components/forms.tsx @@ -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({ > - {label && {label}} + {label && !hideLabel && {label}} ); } diff --git a/web/src/screens/ChannelEditScreen.test.tsx b/web/src/screens/ChannelEditScreen.test.tsx index 3aa1929a2..98947ed47 100644 --- a/web/src/screens/ChannelEditScreen.test.tsx +++ b/web/src/screens/ChannelEditScreen.test.tsx @@ -54,9 +54,16 @@ interface FetchOptions { channelStatus?: number; onPut?: (body: unknown) => void; putResponseOverrides?: Record; + watermarks?: Record[]; } -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(); + + 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(); + + 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(); + + 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(); + + 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); + }); + }); }); diff --git a/web/src/screens/ChannelEditScreen.tsx b/web/src/screens/ChannelEditScreen.tsx index 928f1d8bd..8bcde0d83 100644 --- a/web/src/screens/ChannelEditScreen.tsx +++ b/web/src/screens/ChannelEditScreen.tsx @@ -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); + + 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 ( @@ -723,6 +780,31 @@ function BrandingPane({ value={externalLogoUrl} /> + +
+ set({ watermarkId: next ? (logoBugTarget?.id ?? null) : null })} + size="sm" + /> + {logoBugEnabled && bugGeometry && previewSrc && !externalUrlLogo && ( + + )} +
+