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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-06-09 19:24:59 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 2933e0581f
commit 7f8dc95eca
3 changed files with 77 additions and 17 deletions
@@ -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(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ContinueWatchingCard
sectionItem={{
content_id: "movie-001",
type: "movie",
title: "Apex",
year: 2024,
genres: [],
status: "matched",
rating_imdb: 6.5,
overview: "Movie overview",
item_source: "continue_watching",
position_seconds: 600,
duration_seconds: 7200,
progress_updated_at: "2026-03-07T00:00:00Z",
poster_url: "/movie-poster.jpg",
poster_thumbhash: "",
backdrop_url: "/movie-backdrop.jpg",
backdrop_thumbhash: "",
logo_url: "",
}}
/>
</MemoryRouter>
</QueryClientProvider>,
);
// 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(
+31 -17
View File
@@ -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 (
<div className={`group/card ${containerWidth}`}>
<div className="relative">
<ViewTransitionLink
to={card.watchHref}
onClick={handleWatchClick}
className="group/play block"
>
<div className="group/media relative">
<ViewTransitionLink to={card.itemHref} className="block">
<div className={`media-card-image relative ${imageAspect} overflow-hidden rounded-xl`}>
{imageSrc ? (
<img
src={imageSrc}
alt={heading}
className="h-full w-full object-cover transition-transform duration-300 group-hover/play:scale-105"
className="h-full w-full object-cover transition-transform duration-300 group-hover/media:scale-105"
loading="lazy"
/>
) : (
@@ -223,12 +228,8 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
/>
)}
{/* Play overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors duration-150 group-hover/play:bg-black/30">
<div className="bg-primary text-primary-foreground flex h-11 w-11 items-center justify-center rounded-full opacity-0 shadow-lg transition-all duration-200 group-hover/play:scale-100 group-hover/play:opacity-100 group-focus-visible/play:opacity-100">
<Play className="ml-0.5 h-5 w-5" fill="currentColor" />
</div>
</div>
{/* Hover dim behind the play button */}
<div className="absolute inset-0 bg-black/0 transition-colors duration-150 pointer-fine:group-hover/media:bg-black/30" />
{/* Progress bar */}
{!isNextUp && progressPercent > 0 && (
@@ -244,6 +245,14 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
)}
</div>
</ViewTransitionLink>
<ViewTransitionLink
to={card.watchHref}
onClick={handleWatchClick}
aria-label={`Play ${heading}`}
className="bg-primary text-primary-foreground absolute top-1/2 left-1/2 flex h-11 w-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full opacity-100 shadow-lg transition-all duration-200 pointer-fine:pointer-events-none pointer-fine:opacity-0 pointer-fine:group-hover/media:pointer-events-auto pointer-fine:group-hover/media:opacity-100 pointer-fine:focus-visible:pointer-events-auto pointer-fine:focus-visible:opacity-100"
>
<Play className="ml-0.5 h-5 w-5" fill="currentColor" />
</ViewTransitionLink>
<MediaItemMenu
contentId={
"sectionItem" in props && props.sectionItem
@@ -264,8 +273,13 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
</div>
{/* Info */}
<ViewTransitionLink to={card.itemHref} className="block px-0.5 pt-2.5">
<div className="truncate text-[13px] font-semibold">{heading}</div>
<div className="px-0.5 pt-2.5">
<ViewTransitionLink
to={headingHref}
className="block truncate text-[13px] font-semibold hover:underline"
>
{heading}
</ViewTransitionLink>
{episodeMeta && <div className="text-muted-foreground truncate text-xs">{episodeMeta}</div>}
{premiereBadge && (
<div className="mt-1">
@@ -279,7 +293,7 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
</div>
)}
{timeLeftLabel && <div className="text-muted-foreground text-xs">{timeLeftLabel}</div>}
</ViewTransitionLink>
</div>
</div>
);
}
+4
View File
@@ -57,6 +57,10 @@ vi.mock("@/hooks/useViewTransition", () => ({
useViewTransitionNavigate: () => mockNavigate,
}));
vi.mock("@/playback/watchPlaybackContext", () => ({
useWatchPlaybackController: () => ({ startPlayback: () => {} }),
}));
describe("SectionRow", () => {
beforeEach(() => {
latestCarouselProps = undefined;