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:
committed by
GitHub
co-authored by
Claude Fable 5
parent
2933e0581f
commit
7f8dc95eca
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ vi.mock("@/hooks/useViewTransition", () => ({
|
||||
useViewTransitionNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
vi.mock("@/playback/watchPlaybackContext", () => ({
|
||||
useWatchPlaybackController: () => ({ startPlayback: () => {} }),
|
||||
}));
|
||||
|
||||
describe("SectionRow", () => {
|
||||
beforeEach(() => {
|
||||
latestCarouselProps = undefined;
|
||||
|
||||
Reference in New Issue
Block a user