From 18a335fd74d26af2497319cf2c1e6155e2ca7533 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 6 Jun 2026 17:26:22 +0200 Subject: [PATCH] feat: add continue watching carousel --- static/app.ts | 1 + static/continue_watching_carousel.ts | 170 ++++++++++++++++++ templates/components/continue_watching.gohtml | 16 +- templates/index.gohtml | 16 +- 4 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 static/continue_watching_carousel.ts diff --git a/static/app.ts b/static/app.ts index 045b8ce..f3aec40 100644 --- a/static/app.ts +++ b/static/app.ts @@ -11,5 +11,6 @@ import "./dedupe"; import "./shell"; import "./watchlist"; import "./top_pick_carousel"; +import "./continue_watching_carousel"; import "./login"; import "./schedule"; diff --git a/static/continue_watching_carousel.ts b/static/continue_watching_carousel.ts new file mode 100644 index 0000000..39bebfa --- /dev/null +++ b/static/continue_watching_carousel.ts @@ -0,0 +1,170 @@ +import { onHtmxLoad, onReady } from "./utils"; + +const carouselScrollEpsilon = 2; +const fallbackCarouselOverlap = 96; +const itemOverlapRatio = 0.45; +const minimumArrowItems = 5; + +type ContinueWatchingCarousel = { + track: HTMLElement; + previous: HTMLButtonElement; + next: HTMLButtonElement; + previousFade: HTMLElement; + nextFade: HTMLElement; +}; + +const getContinueWatchingCarousel = ( + root: HTMLElement, +): ContinueWatchingCarousel | null => { + const track = root.querySelector("[data-continue-watching-track]"); + const previous = root.querySelector( + "[data-continue-watching-prev]", + ); + const next = root.querySelector("[data-continue-watching-next]"); + const previousFade = root.querySelector( + "[data-continue-watching-prev-fade]", + ); + const nextFade = root.querySelector("[data-continue-watching-next-fade]"); + + if (!track || !previous || !next || !previousFade || !nextFade) { + return null; + } + + return { track, previous, next, previousFade, nextFade }; +}; + +const continueWatchingCarousels = (root: ParentNode = document): HTMLElement[] => + Array.from(root.querySelectorAll("[data-continue-watching-carousel]")); + +const maxScrollLeft = (track: HTMLElement): number => + Math.max(0, track.scrollWidth - track.clientWidth); + +const setControlState = ( + button: HTMLButtonElement, + fade: HTMLElement, + visible: boolean, +): void => { + button.classList.toggle("hidden", !visible); + button.classList.toggle("inline-flex", visible); + button.setAttribute("aria-hidden", String(!visible)); + button.tabIndex = visible ? 0 : -1; + fade.classList.toggle("hidden", !visible); +}; + +const updateContinueWatchingCarousel = (root: HTMLElement): void => { + const carousel = getContinueWatchingCarousel(root); + if (!carousel) { + return; + } + + const items = carousel.track.querySelectorAll("[data-continue-watching-item]"); + const maxScroll = maxScrollLeft(carousel.track); + const canScroll = maxScroll > carouselScrollEpsilon; + const allowArrows = canScroll && items.length >= minimumArrowItems; + const hasPrevious = + allowArrows && carousel.track.scrollLeft > carouselScrollEpsilon; + const hasNext = + allowArrows && carousel.track.scrollLeft < maxScroll - carouselScrollEpsilon; + + setControlState(carousel.previous, carousel.previousFade, hasPrevious); + setControlState(carousel.next, carousel.nextFade, hasNext); +}; + +const updateContinueWatchingCarousels = (root: ParentNode = document): void => { + continueWatchingCarousels(root).forEach(updateContinueWatchingCarousel); +}; + +const carouselScrollAmount = (track: HTMLElement): number => { + const firstItem = track.querySelector("[data-continue-watching-item]"); + if (!firstItem) { + return Math.max(160, track.clientWidth - fallbackCarouselOverlap); + } + + const itemWidth = firstItem.getBoundingClientRect().width; + const overlap = Math.max(fallbackCarouselOverlap, itemWidth * itemOverlapRatio); + + return Math.max(itemWidth, track.clientWidth - Math.min(itemWidth, overlap)); +}; + +const scrollContinueWatchingCarousel = ( + root: HTMLElement, + direction: -1 | 1, +): void => { + const carousel = getContinueWatchingCarousel(root); + if (!carousel) { + return; + } + + const currentScroll = carousel.track.scrollLeft; + const targetScroll = + direction < 0 + ? Math.max(0, currentScroll - carouselScrollAmount(carousel.track)) + : Math.min( + maxScrollLeft(carousel.track), + currentScroll + carouselScrollAmount(carousel.track), + ); + + carousel.track.scrollTo({ + left: targetScroll, + behavior: "smooth", + }); + + window.setTimeout(() => updateContinueWatchingCarousel(root), 350); +}; + +document.addEventListener( + "click", + (event: MouseEvent): void => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const previous = target.closest("[data-continue-watching-prev]"); + if (previous) { + event.preventDefault(); + event.stopPropagation(); + const root = previous.closest("[data-continue-watching-carousel]"); + if (root) { + scrollContinueWatchingCarousel(root, -1); + } + return; + } + + const next = target.closest("[data-continue-watching-next]"); + if (!next) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const root = next.closest("[data-continue-watching-carousel]"); + if (root) { + scrollContinueWatchingCarousel(root, 1); + } + }, + true, +); + +document.addEventListener( + "scroll", + (event: Event): void => { + const target = event.target; + if ( + !(target instanceof HTMLElement) || + !target.matches("[data-continue-watching-track]") + ) { + return; + } + + const root = target.closest("[data-continue-watching-carousel]"); + if (root) { + updateContinueWatchingCarousel(root); + } + }, + true, +); + +onReady(() => updateContinueWatchingCarousels()); +onHtmxLoad((root) => updateContinueWatchingCarousels(root)); +window.addEventListener("resize", () => updateContinueWatchingCarousels()); diff --git a/templates/components/continue_watching.gohtml b/templates/components/continue_watching.gohtml index 5909e10..8436f2f 100644 --- a/templates/components/continue_watching.gohtml +++ b/templates/components/continue_watching.gohtml @@ -1,13 +1,14 @@ {{define "continue_watching"}} -
+

Continue Watching

-
+
+
{{range .}} {{$title := .TitleOriginal}} {{if .TitleEnglish.Valid}}{{$title = .TitleEnglish.String}}{{end}} -
{{end}} diff --git a/templates/index.gohtml b/templates/index.gohtml index c78ee44..2f5eac8 100644 --- a/templates/index.gohtml +++ b/templates/index.gohtml @@ -117,16 +117,26 @@ {{end}} {{define "continue_watching_skeleton"}} -
+

Continue Watching

-
+
+
{{range (seq 3)}} -
+
{{end}}
+ + + + +
{{end}}