diff --git a/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs b/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs index 245101502..874f1c9a2 100644 --- a/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs +++ b/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs @@ -274,7 +274,8 @@ internal static class LibraryBrowseItemMapper null, em.EpisodeId, null, - EpisodeSubtitle(em))).ToList()); + EpisodeSubtitle(em), + em.Episode.SeasonId)).ToList()); } public static async Task> GetMusicVideos( diff --git a/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs b/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs index 67f6cffd2..c7a84a5f1 100644 --- a/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs +++ b/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs @@ -20,4 +20,5 @@ public record LibraryBrowseItemResponseModel( int? RerunCollectionId, int? MediaItemId, int? PlaylistId, - string? Subtitle = null); + string? Subtitle = null, + int? SeasonId = null); diff --git a/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs b/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs index b04b5c3b8..5afc03187 100644 --- a/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs +++ b/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs @@ -288,6 +288,10 @@ public class GetLibraryBrowseItemsHandlerTests result.Page[1].MediaItemId.ShouldBe(711); result.Page[2].MediaItemId.ShouldBe(713); + // #220: every episode carries its parent season id so the SPA can route to the season + // detail page and anchor to the episode (`/app/media/seasons/{seasonId}#episode-{id}`). + result.Page.ShouldAllBe(p => p.SeasonId == 701); + await _searchIndex.DidNotReceive().Search( Arg.Any(), Arg.Any(), @@ -297,6 +301,32 @@ public class GetLibraryBrowseItemsHandlerTests Arg.Any()); } + [Test] + public async Task Handle_Should_Leave_SeasonId_Null_For_Non_Episode_Kinds() + { + await SeedLibraryGraph(); + _searchIndex.Search( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()) + .Returns(new SearchResult( + [ + new SearchItem(LuceneSearchIndex.ShowType, 20), + new SearchItem(LuceneSearchIndex.MovieType, 10) + ], + 2)); + var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory); + + PagedLibraryBrowseItemsResponseModel result = await handler.Handle( + new GetLibraryBrowseItems("", null, null, 0, 10), + CancellationToken.None); + + result.Page.ShouldAllBe(p => p.SeasonId == null); + } + [Test] public async Task Handle_Should_Browse_Music_Videos_For_A_Specific_Artist_By_ParentId() { diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 7d1af9f06..a87bdd040 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -18053,6 +18053,13 @@ "null", "string" ] + }, + "seasonId": { + "type": [ + "null", + "integer" + ], + "format": "int32" } } }, diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md index ce2b18760..00c8403a6 100644 --- a/docs/blazor-route-parity.md +++ b/docs/blazor-route-parity.md @@ -64,7 +64,7 @@ redirect). > | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — | > | Channel editor | GAPS (external logo URL, bare create, pickers) | #212 | > | Channels-numbers / Logs | PARITY-OK / minors | #213 | -> | Search | PARITY-OK (card nav, per-card/multi-select add-to, add-all, save-as-smart-collection, 2026-07-10; mutation controls + Add-all now gated during refetch — #221) | — | +> | Search | PARITY-OK (card nav incl. episode cards — #220; per-card/multi-select add-to, add-all, save-as-smart-collection; mutation controls + Add-all gated during refetch — #221; 2026-07-11) | — | > | Media browse/detail (read paths + image browser) | PARITY-OK | — | > | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10; mutation controls gated on kind/query/page refetch — #221) | — | > | Schedules editors | **disproven — moved to Section 3** | **#207** | @@ -115,7 +115,7 @@ been added to the redirect map yet. | `/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=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/tv/episodes`(`/page/{n}`) | `EpisodeList.razor` | `/app/media?kind=episodes` | standalone SPA episode browse EXISTS (`MediaBrowseScreen`, generic grid, top-level `episodes` kind); episode cards there and on the Search screen now navigate to the season detail page and anchor/highlight the episode (`/app/media/seasons/{seasonId}#episode-{id}`), matching `Search.razor:241`'s `media/tv/seasons/{SeasonId}#episode-{EpisodeId}` link (`LibraryBrowseItemResponseModel.SeasonId`, `mediaDetailPath`); #220 | | `/media/music/artists`(`/page/{n}`) | `ArtistList.razor` | `/app/media?kind=artists` | generic browse; PR #183 / #141 | | `/media/music/artists/{ArtistId:int}` | `Artist.razor` | `/app/media/artists/{id}` | detail page (`ArtistDetailScreen`); PR #183 / #141 | | `/media/music/videos`(`/page/{n}`) | `MusicVideoList.razor` | `/app/media?kind=music-videos` | generic browse; PR #183 / #141 | diff --git a/docs/e2e-local.md b/docs/e2e-local.md index de54c04f0..cb6d62a1f 100644 --- a/docs/e2e-local.md +++ b/docs/e2e-local.md @@ -99,3 +99,66 @@ scripts/e2e-local.sh [CONFIG_DIR] - Waits (up to 120s) for the `Done migrating search index` log line. - Prints the PID and port, then **exits leaving the server running** — the caller is responsible for killing the PID when done (`kill `). + +## Seeding a local TV library for E2E + +Channels/playouts are API-seedable (step 5 above), but a **local media library is not** — there +is no `/api/*` endpoint to add a local library folder. To exercise media-browse / search / detail +screens you need real scanned items. Recipe (used to verify the #220 episode-nav PR): + +1. **Generate tiny media files on disk** — one show (one season, ~3 episodes), plus a second show + whose title *contains the first as a substring* (good substring-search sanity data), plus a + movie if you need a non-episode kind. Keep TV and movies under **separate roots** so each + library scans cleanly (a Shows library pointed at a folder that also contains movies will try + to parse the movies as shows). Each file is a 2-second `testsrc` clip: + ```bash + MEDIA=/tmp/etv-media # any scratch path + mkdir -p "$MEDIA/tv/Show Alpha/Season 01" \ + "$MEDIA/tv/Show Alpha Returns/Season 01" \ + "$MEDIA/movies/Test Movie (2020)" + for n in 01 02 03; do + ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \ + "$MEDIA/tv/Show Alpha/Season 01/Show Alpha - s01e$n.mkv" + done + ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \ + "$MEDIA/tv/Show Alpha Returns/Season 01/Show Alpha Returns - s01e01.mkv" + ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \ + "$MEDIA/movies/Test Movie (2020)/Test Movie (2020).mkv" + ``` + +2. **Attach the folders to the built-in local libraries via SQLite.** A fresh config DB already + has the seven default local libraries (`Library` rows for a single `LocalMediaSource`): `Movies` + is `Id=1`, `Shows` is `Id=2`. `LibraryPath` is just `(Path TEXT, LibraryId INT)` — insert one + row per root, pointing each at the matching library: + ```bash + DB="$CONFIG_DIR/ersatztv.sqlite3" # CONFIG_DIR from the run above; server may be running + sqlite3 "$DB" "INSERT INTO LibraryPath (Path, LibraryId) VALUES ('$MEDIA/tv', 2);" # Shows + sqlite3 "$DB" "INSERT INTO LibraryPath (Path, LibraryId) VALUES ('$MEDIA/movies', 1);" # Movies + ``` + +3. **Trigger a scan and wait for items to appear.** The scan endpoint takes an empty body: + ```bash + curl -s -X POST http://localhost:8409/api/libraries/2/scan -H 'Content-Type: application/json' -d '{}' + curl -s -X POST http://localhost:8409/api/libraries/1/scan -H 'Content-Type: application/json' -d '{}' + # poll until episodes show up (scanner runs as a background subprocess): + curl -s "http://localhost:8409/api/library/browse?mediaType=Episode&pageSize=50" + ``` + The scan runs even though `LibraryPath` was inserted after startup — the scan handler re-reads + the library from the DB. Browse (`/api/library/browse`) reads straight from the DB, so items + appear there within a few seconds. + +### Gotchas + +- **Do NOT delete the `search-index/` folder to "reset" search.** On startup the app *recreates the + index empty* (`Search index failed to initialize; will delete and recreate` → `Migrating search + index to version N`) and that migration does **not** re-index from the DB — only a **scan** + writes documents into the Lucene index. The scanner subprocess writes the index while running; a + restart never rebuilds it from existing DB rows. If you wipe `search-index/`, a *rescan of + unchanged files won't repopulate it* (the scanner skips unchanged items), so search stays empty. + The clean recovery is a fresh `CONFIG_DIR`: launch → insert `LibraryPath` → scan **once** → leave + the index alone. +- **Search query relevance is field-scoped, not free-text.** The `/api/search` default field does + **not** match bare title words: `Alpha` and `Show` return nothing for a "Show Alpha" title, while + `title:Alpha`, `Show*`, or `*Alpha*` all match. The SPA search box forwards the query verbatim, so + when driving search-result screens in E2E use a field/wildcard query (e.g. `title:Alpha`) to get + deterministic hits. (This is pre-existing ErsatzTV search behavior, independent of any SPA change.) diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 7bd68ccd1..543da12ed 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -761,6 +761,7 @@ export interface components { "mediaItemId": null | number; "playlistId": null | number; "subtitle"?: null | string; + "seasonId"?: null | number; }; "LibraryBrowseMediaType": "Movie" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "Collection" | "SmartCollection" | "MultiCollection" | "RerunCollection" | "Playlist" | "Episode" | "MusicVideo" | "Song" | "OtherVideo" | "Image" | "RemoteStream"; "LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams"; diff --git a/web/src/media/MediaPosterCard.tsx b/web/src/media/MediaPosterCard.tsx index d7cbecaf8..9ae7b152a 100644 --- a/web/src/media/MediaPosterCard.tsx +++ b/web/src/media/MediaPosterCard.tsx @@ -13,7 +13,9 @@ export function MediaPosterCard({ onToggleSelect, onOpen, height = 150, - actions + actions, + id, + highlighted }: { item: LibraryBrowseItem; selected?: boolean; @@ -21,6 +23,13 @@ export function MediaPosterCard({ onOpen?: (item: LibraryBrowseItem) => void; height?: number; actions?: ReactNode; + // Stable DOM id (e.g. `episode-{id}`) so callers can deep-link/scroll to this card. + id?: string; + // Applies a ring highlight while this card is the deep-link target (e.g. the current + // `#episode-{id}` hash) — persists for as long as the anchor matches this card, with only the + // ring's outer glow pulse fading shortly after mount (see .ctv-media-card-highlighted in + // shell.css). + highlighted?: boolean; }) { const hue = hueOf(item.title); const Icon = TYPE_ICON[item.mediaType] ?? Film; @@ -45,7 +54,8 @@ export function MediaPosterCard({ return (
): LibraryBrowseItem { + return { + artwork: '', + id: 91, + mediaType: 'Episode', + title: 'Pilot', + ...overrides + } as unknown as LibraryBrowseItem; +} + +describe('mediaDetailPath', () => { + it('routes an episode with a seasonId to the season detail page, anchored to the episode (#220)', () => { + const item = episodeItem({ seasonId: 8 } as Partial); + expect(mediaDetailPath(item)).toBe('/app/media/seasons/8#episode-91'); + }); + + it('returns null for an episode with no seasonId', () => { + const item = episodeItem({}); + expect(mediaDetailPath(item)).toBeNull(); + }); + + it('still routes a movie to its detail page', () => { + const item = { + artwork: '', + id: 5, + mediaType: 'Movie', + title: 'Blade Runner' + } as unknown as LibraryBrowseItem; + expect(mediaDetailPath(item)).toBe('/app/media/movies/5'); + }); +}); diff --git a/web/src/media/mediaKinds.ts b/web/src/media/mediaKinds.ts index 763dfeb05..96fb8f939 100644 --- a/web/src/media/mediaKinds.ts +++ b/web/src/media/mediaKinds.ts @@ -61,6 +61,8 @@ export function mediaDetailPath(item: LibraryBrowseItem): string | null { return `/app/media/seasons/${item.id}`; case 'Artist': return `/app/media/artists/${item.id}`; + case 'Episode': + return item.seasonId != null ? `/app/media/seasons/${item.seasonId}#episode-${item.id}` : null; default: return null; } diff --git a/web/src/screens/MediaDetailScreen.test.tsx b/web/src/screens/MediaDetailScreen.test.tsx index c0b97538c..5d24dad89 100644 --- a/web/src/screens/MediaDetailScreen.test.tsx +++ b/web/src/screens/MediaDetailScreen.test.tsx @@ -1,5 +1,6 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { navigateToPath } from '../routing'; import { ArtistDetailScreen, MovieDetailScreen, @@ -178,4 +179,134 @@ describe('media detail screens', () => { expect(screen.getByRole('button', { name: 'Media Info' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Troubleshoot Playback' })).toBeInTheDocument(); }); + + it('anchors and highlights the episode targeted by an #episode-{id} hash (#220)', async () => { + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const url = input.toString(); + if (url.startsWith('/api/library/browse')) { + return Promise.resolve( + jsonResponse({ + page: [ + { artwork: '', collectionType: 'Episode', id: 90, mediaType: 'Episode', title: 'Pilot' }, + { artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Second' } + ], + totalCount: 2 + }) + ); + } + if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') { + return Promise.resolve(jsonResponse([])); + } + return Promise.resolve(jsonResponse(season)); + }); + + const scrollIntoView = vi.fn(); + vi.stubGlobal('HTMLElement', HTMLElement); + Element.prototype.scrollIntoView = scrollIntoView; + window.location.hash = '#episode-91'; + + render(); + await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument()); + + const target = document.getElementById('episode-91'); + expect(target).not.toBeNull(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + expect(target?.className).toContain('ctv-media-card-highlighted'); + + const other = document.getElementById('episode-90'); + expect(other?.className).not.toContain('ctv-media-card-highlighted'); + + window.location.hash = ''; + }); + + it('updates the anchor and scrolls on same-pathname in-app navigation (synthetic popstate, review #220)', async () => { + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const url = input.toString(); + if (url.startsWith('/api/library/browse')) { + return Promise.resolve( + jsonResponse({ + page: [ + { artwork: '', collectionType: 'Episode', id: 90, mediaType: 'Episode', title: 'Pilot' }, + { artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Second' } + ], + totalCount: 2 + }) + ); + } + if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') { + return Promise.resolve(jsonResponse([])); + } + return Promise.resolve(jsonResponse(season)); + }); + + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + window.location.hash = ''; + + render(); + await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument()); + expect(scrollIntoView).not.toHaveBeenCalled(); + + // `routing.ts`'s navigateToPath is what App.tsx uses for in-app clicks (e.g. an episode card + // inside this same season grid): pushState + a synthetic `popstate`, not a real hash change, + // so `hashchange` alone would never fire. This exercises that same-pathname path directly. + navigateToPath('/app/media/seasons/8#episode-91'); + + await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1)); + const target = document.getElementById('episode-91'); + expect(target?.className).toContain('ctv-media-card-highlighted'); + + window.location.hash = ''; + }); + + it('does not re-scroll on refetch/pagination when the anchor is unchanged (review #220)', async () => { + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const url = input.toString(); + if (url.startsWith('/api/library/browse')) { + const params = new URL(url, 'http://localhost').searchParams; + const pageNum = params.get('pageNum'); + if (pageNum === '1') { + return Promise.resolve( + jsonResponse({ + page: [{ artwork: '', collectionType: 'Episode', id: 92, mediaType: 'Episode', title: 'Third' }], + totalCount: 61 + }) + ); + } + return Promise.resolve( + jsonResponse({ + page: [ + { artwork: '', collectionType: 'Episode', id: 90, mediaType: 'Episode', title: 'Pilot' }, + { artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Second' } + ], + totalCount: 61 + }) + ); + } + if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') { + return Promise.resolve(jsonResponse([])); + } + return Promise.resolve(jsonResponse(season)); + }); + + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + window.location.hash = '#episode-91'; + + render(); + await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument()); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1)); + + // Page away (target leaves the DOM) and back (a fresh `items` array is fetched, but the + // anchor value itself never changed) — must not re-trigger the scroll. + fireEvent.click(screen.getByTitle('Next page')); + await waitFor(() => expect(screen.getByText('Third')).toBeInTheDocument()); + + fireEvent.click(screen.getByTitle('Previous page')); + await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument()); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + window.location.hash = ''; + }); }); diff --git a/web/src/screens/MediaDetailScreen.tsx b/web/src/screens/MediaDetailScreen.tsx index e6490ff4b..d902cc079 100644 --- a/web/src/screens/MediaDetailScreen.tsx +++ b/web/src/screens/MediaDetailScreen.tsx @@ -211,8 +211,12 @@ function ChildGrid({ const [pageNum, setPageNum] = useState(0); const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading'); const [error, setError] = useState(null); + const [anchorId, setAnchorId] = useState(() => window.location.hash.slice(1) || null); const activeRef = useRef(true); const seqRef = useRef(0); + // The anchor value we last actually scrolled to, so a refetch/pagination that recreates + // `items` (but leaves the hash unchanged) doesn't hijack the user's scroll position. + const scrolledAnchorRef = useRef(null); useEffect(() => { activeRef.current = true; @@ -221,6 +225,43 @@ function ChildGrid({ }; }, []); + // Deep-link support (#220): a season detail's episode grid can be opened with an + // `#episode-{id}` hash (from search/browse cards, or Blazor-parity links). The SPA's own + // in-app navigation (routing.ts navigateToPath) uses `history.pushState` + a synthetic + // `popstate` dispatch rather than a real hash change — real browser hash navigation + // (address bar, back/forward across a hash-only change) fires `hashchange` instead — so both + // events must be handled to catch in-grid episode card clicks (same season pathname, new + // fragment) as well as deep links that remount this screen. + useEffect(() => { + const onHashNav = () => setAnchorId(window.location.hash.slice(1) || null); + window.addEventListener('hashchange', onHashNav); + window.addEventListener('popstate', onHashNav); + return () => { + window.removeEventListener('hashchange', onHashNav); + window.removeEventListener('popstate', onHashNav); + }; + }, []); + + useEffect(() => { + if (status !== 'success' || mediaType !== 'Episode' || !anchorId) { + return; + } + // One-shot per anchor value: only scroll the first time we see this anchor resolve + // successfully, so a later refetch/pagination (which recreates `items`) doesn't re-jump the + // page back to it. Known limitation shared with the Blazor fragment link this replaces: the + // anchor only resolves against the currently-loaded page (CHILD_PAGE_SIZE) — a target beyond + // page 1 won't be found (and so won't scroll) until that page is loaded. + if (scrolledAnchorRef.current === anchorId) { + return; + } + const target = document.getElementById(anchorId); + if (!target) { + return; + } + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + scrolledAnchorRef.current = anchorId; + }, [status, mediaType, anchorId, items]); + const load = useCallback(() => { const id = ++seqRef.current; getLibraryBrowseItems({ mediaType, pageNum, pageSize: CHILD_PAGE_SIZE, parentId }) @@ -278,9 +319,12 @@ function ChildGrid({
{items.map((item) => { const detailPath = mediaDetailPath(item); + const cardId = item.mediaType === 'Episode' ? `episode-${item.id}` : undefined; return ( navigateToPath(detailPath) : undefined} diff --git a/web/src/shell.css b/web/src/shell.css index 4a1fee71d..0a46097f2 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -2537,6 +2537,24 @@ body { box-shadow: 0 0 0 1px var(--action-primary); } +/* Deep-link target highlight (#220), e.g. `#episode-{id}` from search/browse episode cards. + The ring itself (border + 2px box-shadow) persists for as long as the hash names this card; + only the outer glow pulse fades out shortly after mount. */ +.ctv-media-card-highlighted { + border-color: var(--action-primary); + box-shadow: 0 0 0 2px var(--action-primary); + animation: ctv-media-card-highlight-pulse 2400ms ease-out 1; +} + +@keyframes ctv-media-card-highlight-pulse { + 0% { + box-shadow: 0 0 0 4px var(--action-primary); + } + 100% { + box-shadow: 0 0 0 2px var(--action-primary); + } +} + .ctv-media-card-poster { position: relative; overflow: hidden;