feat(67): add Use logo as on-screen bug toggle with preview to the channel editor
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 channel’s own logo on the stream, using the shared preset’s 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}
|
||||
|
||||
Reference in New Issue
Block a user