diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md index b4fe6893e..bb617695a 100644 --- a/docs/blazor-route-parity.md +++ b/docs/blazor-route-parity.md @@ -113,7 +113,7 @@ been added to the redirect map yet. | `/media/movies/{MovieId:int}` | `Movie.razor` | `/app/media/movies/{id}` | detail page (`MovieDetailScreen`); PR #183 / #141 | | `/media/tv/shows`(`/page/{n}`) | `TelevisionShowList.razor` | `/app/media?kind=shows` | generic browse; PR #183 / #141 | | `/media/tv/shows/{ShowId:int}` | `TelevisionSeasonList.razor` | `/app/media/shows/{id}` | show + season list (`ShowDetailScreen`); PR #183 / #141 | -| `/media/tv/seasons`(`/page/{n}`) | `TelevisionSeasonSearchResults.razor` | `/app/media?kind=shows` | no dedicated season-list SPA screen; covered via show drill-in; PR #183 / #141 | +| `/media/tv/seasons`(`/page/{n}`) | `TelevisionSeasonSearchResults.razor` | `/app/media?kind=seasons` | seasons browsable as a top-level kind (`MediaBrowseScreen`; also reachable via show drill-in); #209 review fix | | `/media/tv/seasons/{SeasonId:int}` | `TelevisionEpisodeList.razor` | `/app/media/seasons/{id}` | season + episode list (`SeasonDetailScreen`); PR #183 / #141 | | `/media/tv/episodes`(`/page/{n}`) | `EpisodeList.razor` | `/app/media/seasons/{id}` | no standalone SPA episode browse; covered via season detail drill-in; PR #183 / #141 | | `/media/music/artists`(`/page/{n}`) | `ArtistList.razor` | `/app/media?kind=artists` | generic browse; PR #183 / #141 | diff --git a/docs/decisions.md b/docs/decisions.md index 8882c03d2..7dc4f6dec 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -216,7 +216,12 @@ don't build screen-local pickers. Two deliberate deviations from Blazor, applied the search and browse screens: **(1) multi-select is an explicit screen-level "Select" toggle** (off = cards open, on = cards select) rather than Blazor's always-on corner-select, because `MediaPosterCard`'s select handler takes over the card's single click gesture; **(2) the -per-card menu offers collection/playlist/schedule for a single item** — a superset of Blazor's -per-card collection-only menu. "Add All" (query-wide) mirrors Blazor's two-step: materialize ids +per-card menu offers collection/playlist for a single item of any kind, plus schedule only for +shows/seasons/artists** — collection/playlist is a superset of Blazor's per-card collection-only +menu, while the schedule target is gated to exactly the kinds Blazor's +`AddProgramScheduleItem.ForMediaItem` call sites offer, because the server validator +(`ProgramScheduleItemCommandBase.CollectionTypeMustBeValid`) accepts only the +TelevisionShow/TelevisionSeason/Artist per-media-item CollectionTypes and 422s the rest. +"Add All" (query-wide) mirrors Blazor's two-step: materialize ids via `GET /api/search/all-items`, then reuse the id-list add endpoints — no query-based add command exists server-side. Issues #208/#209. diff --git a/web/src/media/addTo/AddToMenu.test.tsx b/web/src/media/addTo/AddToMenu.test.tsx index 09f0e4f2c..781858684 100644 --- a/web/src/media/addTo/AddToMenu.test.tsx +++ b/web/src/media/addTo/AddToMenu.test.tsx @@ -8,6 +8,7 @@ function jsonResponse(body: unknown, status = 200): Response { } const movieItem = { artwork: '', id: 12, mediaType: 'Movie', title: 'Blade Runner' } as unknown as LibraryBrowseItem; +const showItem = { artwork: '', id: 42, mediaType: 'TelevisionShow', title: 'The Office' } as unknown as LibraryBrowseItem; function mockApi() { vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { @@ -35,9 +36,9 @@ describe('AddToMenu', () => { cleanup(); }); - it('opens the menu and shows the requested targets', () => { + it('opens the menu and shows the requested targets for a schedulable kind', () => { mockApi(); - render(); + render(); fireEvent.click(screen.getByRole('button', { name: 'Add to…' })); expect(screen.getByRole('menuitem', { name: /Add to collection/ })).toBeTruthy(); @@ -47,12 +48,35 @@ describe('AddToMenu', () => { it('hides the schedule target when more than one item is selected', () => { mockApi(); - render(); + render(); fireEvent.click(screen.getByRole('button', { name: 'Add to…' })); expect(screen.queryByRole('menuitem', { name: /Add to schedule/ })).toBeNull(); }); + it('hides the schedule target for a non-schedulable kind (movie)', () => { + mockApi(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add to…' })); + expect(screen.getByRole('menuitem', { name: /Add to collection/ })).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: /Add to playlist/ })).toBeTruthy(); + expect(screen.queryByRole('menuitem', { name: /Add to schedule/ })).toBeNull(); + }); + + it('closes the menu on Escape and returns focus to the trigger', () => { + mockApi(); + render(); + + const trigger = screen.getByRole('button', { name: 'Add to…' }); + fireEvent.click(trigger); + expect(screen.getByRole('menu')).toBeTruthy(); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('menu')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + it('respects an explicit targets list', () => { mockApi(); render(); diff --git a/web/src/media/addTo/AddToMenu.tsx b/web/src/media/addTo/AddToMenu.tsx index 77f35fd7d..fc220c3d4 100644 --- a/web/src/media/addTo/AddToMenu.tsx +++ b/web/src/media/addTo/AddToMenu.tsx @@ -5,6 +5,7 @@ import type { LibraryBrowseItem } from '../../api'; import { AddToCollectionDialog } from './AddToCollectionDialog'; import { AddToPlaylistDialog } from './AddToPlaylistDialog'; import { AddToScheduleDialog } from './AddToScheduleDialog'; +import { collectionTypeForMediaType } from './scheduleItem'; export type AddToTarget = 'collection' | 'playlist' | 'schedule'; @@ -35,15 +36,26 @@ export function AddToMenu({ items, targets = DEFAULT_TARGETS, onDone, compact = setMenuOpen(false); } }; + // Escape closes the popover and returns focus to the trigger button. + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setMenuOpen(false); + rootRef.current?.querySelector(':scope > button')?.focus(); + } + }; document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); }; }, [menuOpen]); - // Schedule adds a single media item; only offer it when exactly one item is in play. - const scheduleItem = items.length === 1 ? items[0] : null; + // Schedule adds a single media item, and the server only accepts shows / seasons / artists as + // per-media-item schedule items — only offer it when exactly one schedulable item is in play. + const scheduleItem = + items.length === 1 && collectionTypeForMediaType(items[0].mediaType) !== null ? items[0] : null; const visibleTargets = targets.filter((target) => target !== 'schedule' || scheduleItem !== null); const disabled = items.length === 0; diff --git a/web/src/media/addTo/AddToPlaylistDialog.test.tsx b/web/src/media/addTo/AddToPlaylistDialog.test.tsx index 9dc137d7c..ec0d72001 100644 --- a/web/src/media/addTo/AddToPlaylistDialog.test.tsx +++ b/web/src/media/addTo/AddToPlaylistDialog.test.tsx @@ -13,7 +13,10 @@ const groups = [ ]; const playlistsByGroup: Record = { - '1': [{ id: 10, isSystem: false, name: 'Morning', playlistGroupId: 1 }], + '1': [ + { id: 10, isSystem: false, name: 'Morning', playlistGroupId: 1 }, + { id: 11, isSystem: true, name: 'System Reserved', playlistGroupId: 1 } + ], '2': [{ id: 20, isSystem: false, name: 'Evening', playlistGroupId: 2 }] }; @@ -82,6 +85,29 @@ describe('AddToPlaylistDialog', () => { expect(onAdded).toHaveBeenCalledWith('Morning'); }); + it('filters out system playlists and groups', async () => { + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? 'GET').toUpperCase(); + if (url === '/api/playlists/groups' && method === 'GET') { + return Promise.resolve( + jsonResponse([...groups, { id: 9, isSystem: true, name: 'System Group', playlistCount: 1 }]) + ); + } + const playlistsMatch = /^\/api\/playlists\?playlistGroupId=(\d+)$/.exec(url); + if (playlistsMatch && method === 'GET') { + return Promise.resolve(jsonResponse(playlistsByGroup[playlistsMatch[1]] ?? [])); + } + return Promise.resolve(new Response(null, { status: 204 })); + }); + + render( {}} open />); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Morning' })).toBeTruthy()); + expect(screen.queryByRole('option', { name: 'System Reserved' })).toBeNull(); + expect(screen.queryByRole('option', { name: 'System Group' })).toBeNull(); + }); + it('reloads playlists when the group changes', async () => { mockApi(); render( {}} open />); diff --git a/web/src/media/addTo/AddToPlaylistDialog.tsx b/web/src/media/addTo/AddToPlaylistDialog.tsx index 3c02eca11..fcb0fbc81 100644 --- a/web/src/media/addTo/AddToPlaylistDialog.tsx +++ b/web/src/media/addTo/AddToPlaylistDialog.tsx @@ -66,8 +66,11 @@ function AddToPlaylistDialogBody({ onClose, items, onAdded }: AddToPlaylistDialo return; } - setPlaylists(result); - setSelectedPlaylist(result.length > 0 ? String(result[0].id) : ''); + // System playlists are ErsatzTV-managed (e.g. per-channel scheduling playlists) and + // must not be edited directly — mirror Blazor by hiding them from the picker. + const editable = result.filter((playlist) => !playlist.isSystem); + setPlaylists(editable); + setSelectedPlaylist(editable.length > 0 ? String(editable[0].id) : ''); setPlaylistsLoading(false); }) .catch((error: unknown) => { @@ -87,13 +90,14 @@ function AddToPlaylistDialogBody({ onClose, items, onAdded }: AddToPlaylistDialo return; } - setGroups(result); + const editable = result.filter((group) => !group.isSystem); + setGroups(editable); setLoadError(null); setLoading(false); - if (result.length > 0) { - setSelectedGroup(String(result[0].id)); - loadPlaylists(result[0].id); + if (editable.length > 0) { + setSelectedGroup(String(editable[0].id)); + loadPlaylists(editable[0].id); } }) .catch((error: unknown) => { diff --git a/web/src/media/addTo/AddToScheduleDialog.test.tsx b/web/src/media/addTo/AddToScheduleDialog.test.tsx index 9cbb6f39b..5d318237c 100644 --- a/web/src/media/addTo/AddToScheduleDialog.test.tsx +++ b/web/src/media/addTo/AddToScheduleDialog.test.tsx @@ -123,15 +123,29 @@ describe('AddToScheduleDialog', () => { expect(onAdded).toHaveBeenCalledWith('Weekends'); }); - it('maps a Movie item to the Movie collection type', async () => { + // The server validator (ProgramScheduleItemCommandBase.CollectionTypeMustBeValid) only accepts + // shows / seasons / artists as per-media-item schedule items; a movie must hit the defensive + // "can't be added" path instead of POSTing a payload the server would 422. + it('refuses a non-schedulable kind (movie) without posting', async () => { const { calls } = mockApi(); render( {}} open />); + await waitFor(() => expect(screen.getByText(/can’t be added to a schedule/)).toBeTruthy()); + const submit = screen.getByRole('button', { name: /Add to schedule/ }); + expect(submit).toHaveProperty('disabled', true); + fireEvent.click(submit); + expect(calls.some((call) => call.method === 'POST')).toBe(false); + }); + + it('maps an Artist item to the Artist collection type', async () => { + const { calls } = mockApi(); + render( {}} open />); + await waitFor(() => expect(screen.getByRole('option', { name: 'Weekdays' })).toBeTruthy()); fireEvent.click(screen.getByRole('button', { name: /Add to schedule/ })); await waitFor(() => expect(calls.some((call) => call.url === '/api/schedules/1/items')).toBe(true)); const addCall = calls.find((call) => call.url === '/api/schedules/1/items'); - expect(addCall?.body).toMatchObject({ collectionType: 'Movie', mediaItemId: 7 }); + expect(addCall?.body).toMatchObject({ collectionType: 'Artist', mediaItemId: 7 }); }); }); diff --git a/web/src/media/addTo/scheduleItem.ts b/web/src/media/addTo/scheduleItem.ts index 7da02a68d..2ea111596 100644 --- a/web/src/media/addTo/scheduleItem.ts +++ b/web/src/media/addTo/scheduleItem.ts @@ -8,21 +8,13 @@ type CollectionType = components['schemas']['CollectionType']; // "Add all results" flow supplies this so callers never page through every result). export type AddToItems = LibraryBrowseItem[] | { requestOverride: AddItemsToCollectionRequest }; -// Each addable browse kind maps 1:1 onto the CollectionType member of the same name (the -// CollectionType enum carries a per-kind member for every media-item subtype). Non-addable kinds -// (Collection / SmartCollection / MultiCollection / RerunCollection / Playlist) are not schedulable -// as a single media item, so they have no mapping here. This mirrors how Blazor's per-list pages -// call AddProgramScheduleItem.ForMediaItem — e.g. the show page passes CollectionType.TelevisionShow, -// TelevisionSeasonList.razor passes CollectionType.TelevisionSeason. +// Only shows, seasons and artists are schedulable as a single media item. This matches both +// Blazor's AddProgramScheduleItem.ForMediaItem call sites (only the show / season / artist list +// pages offer "Add to schedule") and the server validator +// ProgramScheduleItemCommandBase.CollectionTypeMustBeValid, which 422s every other +// per-media-item CollectionType. Every other kind maps to null (not schedulable). const MEDIA_TYPE_TO_COLLECTION_TYPE: Partial> = { Artist: 'Artist', - Episode: 'Episode', - Image: 'Image', - Movie: 'Movie', - MusicVideo: 'MusicVideo', - OtherVideo: 'OtherVideo', - RemoteStream: 'RemoteStream', - Song: 'Song', TelevisionSeason: 'TelevisionSeason', TelevisionShow: 'TelevisionShow' }; diff --git a/web/src/screens/MediaBrowseScreen.test.tsx b/web/src/screens/MediaBrowseScreen.test.tsx index b0b184610..50812c246 100644 --- a/web/src/screens/MediaBrowseScreen.test.tsx +++ b/web/src/screens/MediaBrowseScreen.test.tsx @@ -82,6 +82,54 @@ describe('MediaBrowseScreen', () => { }); }); + it('shows a success toast after a bulk add', async () => { + mockFetch(); + render(); + + await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: 'Select' })); + fireEvent.click(screen.getByText('Blade Runner')); + fireEvent.click(screen.getByRole('button', { name: /Add to collection/ })); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Favorites' })).toBeTruthy()); + fireEvent.click(screen.getAllByRole('button', { name: /Add to collection/ }).at(-1)!); + + await waitFor(() => expect(screen.getByText('Added to “Favorites”')).toBeInTheDocument()); + // Selection cleared alongside the toast. + expect(screen.queryByText(/selected/)).toBeNull(); + }); + + it('hides the per-tile Add-to menu while select mode is on', async () => { + mockFetch(); + render(); + + await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument()); + expect(screen.getAllByRole('button', { name: 'Add to…' })).toHaveLength(items.length); + + fireEvent.click(screen.getByRole('button', { name: 'Select' })); + expect(screen.queryByRole('button', { name: 'Add to…' })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Done' })); + expect(screen.getAllByRole('button', { name: 'Add to…' })).toHaveLength(items.length); + }); + + it('offers a Seasons kind that browses TelevisionSeason items', async () => { + const fetchSpy = mockFetch(); + render(); + + await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument()); + expect(screen.getByRole('option', { name: 'Seasons' })).toBeTruthy(); + + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'seasons' } }); + + await waitFor(() => { + const seasonCall = fetchSpy.mock.calls.find(([url]) => + String(url).startsWith('/api/library/browse') && String(url).includes('mediaType=TelevisionSeason') + ); + expect(seasonCall).toBeTruthy(); + }); + }); + it('selects every loaded item with Select all on page', async () => { mockFetch(); render(); diff --git a/web/src/screens/MediaBrowseScreen.tsx b/web/src/screens/MediaBrowseScreen.tsx index 5cc4636ae..12247b839 100644 --- a/web/src/screens/MediaBrowseScreen.tsx +++ b/web/src/screens/MediaBrowseScreen.tsx @@ -11,7 +11,7 @@ import { Search, TriangleAlert } from 'lucide-react'; -import { Button, Card, IconButton, Input, Select, Spinner } from '../components'; +import { Button, Card, IconButton, Input, Select, Spinner, Toast } from '../components'; import { getLibraryBrowseItems, messageFromLibraryBrowseError, @@ -35,10 +35,12 @@ interface MediaKind { mediaType: LibraryBrowseMediaType; } -// Matches the legacy Blazor media IA (no top-level Seasons page; seasons live under a show). +// Matches the legacy Blazor media IA, plus a top-level Seasons kind (Blazor only reaches seasons +// through a show drill-in; the browse endpoint supports them directly, so we expose them). const KINDS: MediaKind[] = [ { slug: 'movies', label: 'Movies', mediaType: 'Movie' }, { slug: 'shows', label: 'TV Shows', mediaType: 'TelevisionShow' }, + { slug: 'seasons', label: 'Seasons', mediaType: 'TelevisionSeason' }, { slug: 'episodes', label: 'Episodes', mediaType: 'Episode' }, { slug: 'artists', label: 'Artists', mediaType: 'Artist' }, { slug: 'music-videos', label: 'Music Videos', mediaType: 'MusicVideo' }, @@ -57,6 +59,8 @@ type BrowseState = | { items: []; error: string; status: 'error'; totalCount: 0 } | { items: []; error: null; status: 'loading'; totalCount: 0 }; +type Notice = { tone: 'ok' | 'error'; message: string }; + export function MediaBrowseScreen() { const params = new URLSearchParams(window.location.search); const initialKind = kindFromSlug(params.get('kind')); @@ -72,6 +76,7 @@ export function MediaBrowseScreen() { const [selectMode, setSelectMode] = useState(false); const [selected, setSelected] = useState>(new Map()); const [bulkDialog, setBulkDialog] = useState<'collection' | 'playlist' | null>(null); + const [notice, setNotice] = useState(null); const activeRef = useRef(true); const seqRef = useRef(0); @@ -163,6 +168,11 @@ export function MediaBrowseScreen() { const clearSelection = () => setSelected(new Map()); + const onBulkAdded = (name: string) => { + clearSelection(); + setNotice({ tone: 'ok', message: `Added to “${name}”` }); + }; + const selectedItems = Array.from(selected.values()); const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / PAGE_SIZE)) : 1; @@ -240,15 +250,17 @@ export function MediaBrowseScreen() { )} + {notice && setNotice(null)} tone={notice.tone} />} + setBulkDialog(null)} open={bulkDialog === 'collection'} /> setBulkDialog(null)} open={bulkDialog === 'playlist'} /> @@ -282,7 +294,11 @@ export function MediaBrowseScreen() { const detailPath = mediaDetailPath(item); return ( } + actions={ + selectMode ? undefined : ( + setNotice({ tone: 'ok', message })} /> + ) + } item={item} key={`${item.mediaType}-${item.id}`} onOpen={detailPath ? () => navigateToPath(detailPath) : undefined} diff --git a/web/src/screens/SearchScreen.test.tsx b/web/src/screens/SearchScreen.test.tsx index dc69ace88..db02525e9 100644 --- a/web/src/screens/SearchScreen.test.tsx +++ b/web/src/screens/SearchScreen.test.tsx @@ -143,6 +143,21 @@ describe('SearchScreen', () => { expect(addCall?.body).toEqual({ ...emptyBuckets, movieIds: [12] }); }); + it('clears the selection when the query changes', async () => { + mockApi(); + renderWithQuery(); + + await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: /Select/ })); + fireEvent.click(screen.getByText('Blade Runner')); + await waitFor(() => expect(screen.getByText('1 selected')).toBeTruthy()); + + fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } }); + + // The debounced query change drops the now-stale selection. + await waitFor(() => expect(screen.queryByText('1 selected')).toBeNull()); + }); + it('adds all query results (not just the loaded page) to a collection', async () => { const { calls } = mockApi(); renderWithQuery(); diff --git a/web/src/screens/SearchScreen.tsx b/web/src/screens/SearchScreen.tsx index 547d9d8b3..b3b28775c 100644 --- a/web/src/screens/SearchScreen.tsx +++ b/web/src/screens/SearchScreen.tsx @@ -25,7 +25,7 @@ const PAGE_SIZE = 50; interface GroupDef { key: keyof SearchResults; label: string; - // MediaBrowse slug for "See all"; null for kinds without a top-level browse page (seasons). + // MediaBrowse slug for "See all"; null for kinds without a top-level browse page. slug: string | null; } @@ -33,7 +33,7 @@ interface GroupDef { const GROUPS: GroupDef[] = [ { key: 'movies', label: 'Movies', slug: 'movies' }, { key: 'shows', label: 'TV Shows', slug: 'shows' }, - { key: 'seasons', label: 'Seasons', slug: null }, + { key: 'seasons', label: 'Seasons', slug: 'seasons' }, { key: 'episodes', label: 'Episodes', slug: 'episodes' }, { key: 'artists', label: 'Artists', slug: 'artists' }, { key: 'musicVideos', label: 'Music Videos', slug: 'music-videos' }, @@ -86,6 +86,8 @@ export function SearchScreen() { const handle = window.setTimeout(() => { const trimmed = queryInput.trim(); setQuery(trimmed); + // A new query means a new result set — drop any stale selection (mirrors MediaBrowseScreen). + setSelected(new Map()); // Keep the URL shareable without remounting. const next = trimmed ? `/app/search?query=${encodeURIComponent(trimmed)}` : '/app/search'; window.history.replaceState(null, '', next); diff --git a/web/src/shell.css b/web/src/shell.css index 4cbf386a1..f51cc1482 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -1,3 +1,11 @@ +/* Base typography on : Dialog (and any overlay) portals into document.body, outside + .ctv-app-shell — without this, portaled UI falls back to the browser default font. */ +body { + font-family: var(--font-sans, system-ui, sans-serif); + font-size: var(--text-sm, 13px); + color: var(--text-primary, #f0ebe4); +} + .ctv-app-shell { min-height: 100vh; display: grid; @@ -2610,7 +2618,10 @@ border-radius: var(--radius-xs, 6px); background: transparent; color: var(--text-primary); - font: var(--text-xs) / 1.2 var(--font-sans); + font: inherit; + font-size: var(--text-xs, 12px); + font-weight: var(--weight-medium, 500); + line-height: 1.2; text-align: left; cursor: pointer; }