From 7f8dc95eca8d48cc6f5f19be8708e202fa7e6877 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:24:59 -0400 Subject: [PATCH] feat(web): make Continue Watching posters open the item page (#118) * feat(web): make Continue Watching posters open the item page Clicking a Continue Watching / Next Up poster previously started playback immediately, which made it hard to reach an item's detail page from the home screen. Posters now navigate to the item page and playback moves to an explicit play button layered over the artwork (hover/focus-revealed on desktop, always visible on touch, matching the existing card menu pattern). The card title is now its own link: for episodes it goes to the series page via series_id, for movies to the movie page. Also mocks the playback context in SectionRow.test.tsx, fixing two pre-existing failures unrelated to this change. Co-Authored-By: Claude Fable 5 * fix(web): gate hidden play overlay on hover capability, not viewport width Review feedback on the Continue Watching play button: md:opacity-0 hid the button on touch tablets at desktop widths where no hover exists to reveal it, and the visually hidden link still captured clicks at the poster center on desktop. Gate the hidden state on pointer-fine instead of md, and pair it with pointer-events-none so the hidden button cannot intercept poster clicks; hover and keyboard focus restore both visibility and interactivity together. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../components/ContinueWatchingCard.test.tsx | 42 ++++++++++++++++ web/src/components/ContinueWatchingCard.tsx | 48 ++++++++++++------- web/src/components/SectionRow.test.tsx | 4 ++ 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/web/src/components/ContinueWatchingCard.test.tsx b/web/src/components/ContinueWatchingCard.test.tsx index aa081c5b..c465245e 100644 --- a/web/src/components/ContinueWatchingCard.test.tsx +++ b/web/src/components/ContinueWatchingCard.test.tsx @@ -176,6 +176,8 @@ describe("ContinueWatchingCard", () => { expect(markup).toContain('href="/watch/ep-001"'); expect(markup).toContain('href="/item/ep-001"'); + expect(markup).toContain('href="/item/series-1"'); + expect(markup).toContain('aria-label="Play Breaking Bad"'); expect(markup).toContain("Breaking Bad"); expect(markup).toContain("Season 1 Episode 1"); expect(markup).toContain("Pilot"); @@ -183,6 +185,46 @@ describe("ContinueWatchingCard", () => { expect(markup).toContain("More actions"); }); + it("links the poster to the item page and reserves playback for the play button", () => { + const queryClient = new QueryClient(); + const markup = renderToStaticMarkup( + + + + + , + ); + + // The poster link renders first and must navigate to the item page; the + // watch href is reserved for the explicit play button. + const posterLinkIndex = markup.indexOf('href="/item/movie-001"'); + const playLinkIndex = markup.indexOf('href="/watch/movie-001"'); + expect(posterLinkIndex).toBeGreaterThan(-1); + expect(playLinkIndex).toBeGreaterThan(-1); + expect(posterLinkIndex).toBeLessThan(playLinkIndex); + expect(markup).toContain('aria-label="Play Apex"'); + }); + it("routes audiobook continue cards to the audiobook detail player", () => { const queryClient = new QueryClient(); const markup = renderToStaticMarkup( diff --git a/web/src/components/ContinueWatchingCard.tsx b/web/src/components/ContinueWatchingCard.tsx index 02780505..3ab59c86 100644 --- a/web/src/components/ContinueWatchingCard.tsx +++ b/web/src/components/ContinueWatchingCard.tsx @@ -45,6 +45,7 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { libraryId: props.libraryId, }), title: props.sectionItem.title, + seriesId: props.sectionItem.series_id, seriesTitle: props.sectionItem.series_title, seasonNumber: props.sectionItem.season_number, episodeNumber: props.sectionItem.episode_number, @@ -65,6 +66,7 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { libraryId: props.libraryId, }), title: props.detail.title, + seriesId: props.detail.series_id, seriesTitle: props.detail.series_title, seasonNumber: props.detail.season_number, episodeNumber: props.detail.episode_number, @@ -105,7 +107,14 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { card.durationSeconds > 0 ? (card.positionSeconds / card.durationSeconds) * 100 : 0; const hasPartialProgress = progressPercent > 0 && progressPercent < 100; const hasEpisodeMeta = card.seasonNumber != null && card.episodeNumber != null; - const heading = hasEpisodeMeta && card.seriesTitle ? card.seriesTitle : card.title; + const headingIsSeries = hasEpisodeMeta && !!card.seriesTitle; + const heading = headingIsSeries ? card.seriesTitle : card.title; + // The heading shows the series title for episodes, so it should navigate to + // the series page; everything else heads to the item's own page. + const headingHref = + headingIsSeries && card.seriesId + ? buildItemHref({ contentId: card.seriesId, libraryId: props.libraryId }) + : card.itemHref; const episodeLabel = hasEpisodeMeta ? `Season ${card.seasonNumber} Episode ${card.episodeNumber}` : null; @@ -195,18 +204,14 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { return (
-
- +
+
{imageSrc ? ( {heading} ) : ( @@ -223,12 +228,8 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { /> )} - {/* Play overlay */} -
-
- -
-
+ {/* Hover dim behind the play button */} +
{/* Progress bar */} {!isNextUp && progressPercent > 0 && ( @@ -244,6 +245,14 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { )}
+ + + {/* Info */} - -
{heading}
+
+ + {heading} + {episodeMeta &&
{episodeMeta}
} {premiereBadge && (
@@ -279,7 +293,7 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
)} {timeLeftLabel &&
{timeLeftLabel}
} - +
); } diff --git a/web/src/components/SectionRow.test.tsx b/web/src/components/SectionRow.test.tsx index 63778238..3380c4d3 100644 --- a/web/src/components/SectionRow.test.tsx +++ b/web/src/components/SectionRow.test.tsx @@ -57,6 +57,10 @@ vi.mock("@/hooks/useViewTransition", () => ({ useViewTransitionNavigate: () => mockNavigate, })); +vi.mock("@/playback/watchPlaybackContext", () => ({ + useWatchPlaybackController: () => ({ startPlayback: () => {} }), +})); + describe("SectionRow", () => { beforeEach(() => { latestCarouselProps = undefined;