diff --git a/public/dev/landscape.mp4 b/public/dev/landscape.mp4 new file mode 100644 index 0000000..abc4d49 Binary files /dev/null and b/public/dev/landscape.mp4 differ diff --git a/public/dev/portrait.mp4 b/public/dev/portrait.mp4 new file mode 100644 index 0000000..c6f26df Binary files /dev/null and b/public/dev/portrait.mp4 differ diff --git a/src/app/globals.css b/src/app/globals.css index f3c5b0f..cfb3f78 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -73,6 +73,8 @@ --media-fade-top: rgba(0,0,0,.45); --media-fade-0: rgba(0,0,0,0); --media-glass: rgba(0,0,0,.28); + /* Behind an open caption: darker than the fade, so long text reads over any photo. */ + --media-scrim: rgba(0,0,0,.72); --media-shadow: rgba(0,0,0,.35); /* HexAvatar initials: dark muted gradients, same in both themes. */ diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx index 74e7a78..c9cf5d6 100644 --- a/src/components/MobileFeed.tsx +++ b/src/components/MobileFeed.tsx @@ -6,9 +6,11 @@ import { GrowButton, SweepButton } from "./Buttons"; import { HexAvatar } from "./HexAvatar"; import { PlayIcon, PostMenu } from "./PostCard"; import { VideoPlayer } from "./VideoPlayer"; +import { CaptionText, QuoteText } from "./SlideText"; import { FeaturedCarousel } from "./FeaturedCarousel"; import { Logo } from "./Logo"; import { useTheme } from "@/lib/use-theme"; +import { getSessionMuted, isAutoplayBlocked, setSessionMuted } from "@/lib/autoplay"; import { MobileDock } from "./MobileDock"; import { BottomSheet } from "./BottomSheet"; import { CommentsSection } from "./CommentsSection"; @@ -41,7 +43,7 @@ export function isDoubleTap(last: number | null, now: number, windowMs = DOUBLE_ export function isTapTarget(target: EventTarget | null): boolean { const el = target as Element | null; if (!el || typeof el.closest !== "function") return true; - return !el.closest("button, a, video, input, textarea, select, [role='menu'], [role='menuitem']"); + return !el.closest("button, a, video, input, textarea, select, [role='menu'], [role='menuitem'], [data-expanded-text]"); } /** Index of the featured experts slide among `postCount` posts, or -1 when it is not shown. */ @@ -76,29 +78,187 @@ function setRef(ref: Ref | undefined, value: T | null) { else if (ref) (ref as { current: T | null }).current = value; } -function SlideMedia({ media, author }: { media: CardMedia; author: string }) { +/** Aspect ratio (width / height) at or below which a video fills the slide (4:5 or taller). */ +export const COVER_MAX_RATIO = 0.8; + +/** "cover" for portrait and near-portrait video, "contain" for wider, "cover" while the size is unknown. */ +export function videoFit(width: number, height: number): "cover" | "contain" { + if (!(width > 0) || !(height > 0)) return "cover"; + return width / height <= COVER_MAX_RATIO ? "cover" : "contain"; +} + +/** True when the person asked for less motion. Updates when the setting changes; false on the server. */ +function useReducedMotion(): boolean | null { + const [reduced, setReduced] = useState(null); + useEffect(() => { + if (typeof window.matchMedia !== "function") return setReduced(false); + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + const read = () => setReduced(mq.matches); + read(); + mq.addEventListener?.("change", read); + return () => mq.removeEventListener?.("change", read); + }, []); + return reduced; +} + +function SoundIcon({ muted }: { muted: boolean }) { + return ( + + ); +} + +/** + * One media item of a slide. A video plays by itself, muted and inline, while + * `active` (its slide and pane are on screen) unless the person asked for less + * motion or the browser refuses: then the play button starts it as before. + * `hold` (a sheet is open) pauses a playing video and resumes it after. + */ +function SlideMedia({ media, author, active = false, hold = false }: { media: CardMedia; author: string; active?: boolean; hold?: boolean }) { const box = useRef(null); const [started, setStarted] = useState(false); + // Real video size once metadata loads; until then the thumbnail's size decides. + const [videoSize, setVideoSize] = useState<[number, number] | null>(null); + const [thumbSize, setThumbSize] = useState<[number, number] | null>(null); const playable = isPlayableVideo(media); + const reduced = useReducedMotion(); + // The browser refused autoplay: fall back to the play button. + const [blocked, setBlocked] = useState(false); + const auto = reduced === false && !blocked; + const [muted, setMuted] = useState(getSessionMuted); + // Paused by a tap: it stays paused until tapped again or the slide leaves. + const [userPaused, setUserPaused] = useState(false); + const userPausedRef = useRef(false); + const resumeAfterHold = useRef(false); + const video = () => box.current?.querySelector("video") ?? null; + useEffect(() => { + const v = video(); + if (!v) return; + const read = () => { + if (v.videoWidth > 0 && v.videoHeight > 0) setVideoSize([v.videoWidth, v.videoHeight]); + }; + read(); + v.addEventListener("loadedmetadata", read); + return () => v.removeEventListener("loadedmetadata", read); + }, [playable, media.mp4Url, media.hlsUrl]); + + const autoplay = useCallback((v: HTMLVideoElement) => { + v.muted = getSessionMuted(); + // Like a reel, an autoplayed video loops while it is on screen. + v.loop = true; + setMuted(v.muted); + v.play().catch((err: unknown) => { + if (!isAutoplayBlocked(err)) return; + // Sound not allowed yet: play muted this time, keeping the session's choice. + if (!v.muted) { + v.muted = true; + setMuted(true); + v.play().catch((again: unknown) => { + if (isAutoplayBlocked(again)) setBlocked(true); + }); + } else setBlocked(true); + }); + }, []); + + useEffect(() => { + const v = video(); + if (!v || !playable) return; + if (!active) { + userPausedRef.current = false; + setUserPaused(false); + resumeAfterHold.current = false; + v.pause(); + return; + } + if (hold) { + if (!v.paused) { + resumeAfterHold.current = true; + v.pause(); + } + return; + } + if (resumeAfterHold.current) { + resumeAfterHold.current = false; + void v.play().catch(() => {}); + return; + } + if (auto && !userPausedRef.current) autoplay(v); + }, [active, hold, auto, playable, autoplay]); + if (playable) { + const size = videoSize ?? thumbSize; + const fit = size ? videoFit(size[0], size[1]) : "cover"; const duration = formatDuration(media.durationSeconds); const start = () => { setStarted(true); - void box.current?.querySelector("video")?.play().catch(() => {}); + const v = video(); + if (!v) return; + v.muted = false; + void v.play().catch(() => {}); }; + // Autoplay: a tap on the video pauses or resumes it (no native controls). + const togglePause = () => { + const v = video(); + if (!v) return; + const pause = !v.paused; + userPausedRef.current = pause; + setUserPaused(pause); + if (pause) v.pause(); + else void v.play().catch(() => {}); + }; + const toggleMute = () => { + const v = video(); + const next = !muted; + if (v) v.muted = next; + setSessionMuted(next); + setMuted(next); + }; + const showPlay = auto ? userPaused : !started; return ( -
-
- +
+ {media.src ? ( + // The thumbnail doubles as the blurred backdrop behind a contained video, and as the size fallback. + // eslint-disable-next-line @next/next/no-img-element + setThumbSize([e.currentTarget.naturalWidth, e.currentTarget.naturalHeight])} + /> + ) : null} +
+
- {started ? null : ( + {auto ? ( + + ) : null} + {showPlay ? ( <> - - {duration ? {duration} : null} + {duration && !auto ? {duration} : null} - )} + ) : null}
); } @@ -118,7 +278,7 @@ function SlideMedia({ media, author }: { media: CardMedia; author: string }) { } /** Several media items in one slide: a sideways scroll-snap track with a dot per item. */ -function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) { +function MediaTrack({ items, author, current, hold }: { items: CardMedia[]; author: string; current: boolean; hold: boolean }) { const track = useRef(null); const [active, setActive] = useState(0); const onScroll = () => { @@ -137,7 +297,7 @@ function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) {
{items.map((m, i) => (
- +
))}
@@ -150,13 +310,39 @@ function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) { ); } -function PostSlide({ post, index, current, p }: { post: CardPost; index: number; current: boolean; p: FeedViewProps }) { +function PostSlide({ + post, + index, + current, + hold, + p, + onExpandedChange, +}: { + post: CardPost; + index: number; + current: boolean; + /** A sheet is open over the feed: a playing video pauses until it closes. */ + hold: boolean; + p: FeedViewProps; + onExpandedChange?: (index: number, open: boolean) => void; +}) { const items = post.media.filter((m) => m.kind === "video" || m.src); const media = items[0] ?? null; const own = canDelete(post, p.feedUserId) && !!p.onDelete; - // While a video plays, the author and text step aside so the native controls stay clear. + // While a video plays with native controls (play button, not autoplay), the author and text step aside. const [playing, setPlaying] = useState(false); - const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}`; + // Long text opened with "Read more" or "more". It closes when the slide leaves the screen. + const [textOpen, setTextOpen] = useState(false); + useEffect(() => { + if (!current) setTextOpen(false); + }, [current]); + useEffect(() => { + onExpandedChange?.(index, textOpen); + }, [index, textOpen, onExpandedChange]); + useEffect(() => () => onExpandedChange?.(index, false), [index, onExpandedChange]); + const cls = + `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}` + + `${own ? " slide-own" : ""}${textOpen ? " slide-text-open" : ""}`; const lastTap = useRef(null); const [burst, setBurst] = useState(0); useEffect(() => { @@ -181,20 +367,24 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number; data-liked={post.isLiked ? "true" : "false"} onClick={onTap} aria-label={`Post by ${post.authorName}`} - onPlayCapture={() => setPlaying(true)} + onPlayCapture={(e) => { + if ((e.target as HTMLVideoElement).controls) setPlaying(true); + }} onPauseCapture={() => setPlaying(false)} onEndedCapture={() => setPlaying(false)} > {media ? ( <> - {items.length > 1 ? : } + {items.length > 1 ? ( + + ) : ( + + )} - {media && post.text ?

{post.text}

: null} + {media && post.text ? : null}
); @@ -313,6 +503,13 @@ function LoadSlide({ index, current, sentinelRef }: { index: number; current: bo export function MobileFeed(p: FeedViewProps) { const reel = useRef(null); const [current, setCurrent] = useState(0); + // The slide whose long text is open: the feed holds still so swipes scroll the text. + const [expandedAt, setExpandedAt] = useState(null); + const locked = useRef(false); + locked.current = expandedAt !== null; + const onExpandedChange = useCallback((at: number, open: boolean) => { + setExpandedAt((cur) => (open ? at : cur === at ? null : cur)); + }, []); // The open sheet: the comments of one post, the composer, Discover (more), one of its panels, or none. const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | { kind: "more" } | { kind: "panel"; panel: DiscoverPanel } | null>(null); const closeSheet = useCallback(() => setSheet(null), []); @@ -366,6 +563,8 @@ export function MobileFeed(p: FeedViewProps) { let lastStep = 0; let from = 0; const onWheel = (e: WheelEvent) => { + // Open text scrolls natively; the reel is overflow-hidden meanwhile. + if (locked.current) return; if (e.ctrlKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return; e.preventDefault(); const now = performance.now(); @@ -377,11 +576,11 @@ export function MobileFeed(p: FeedViewProps) { }; let startY: number | null = null; const onTouchStart = (e: TouchEvent) => { - startY = e.touches.length === 1 ? e.touches[0].clientY : null; + startY = e.touches.length === 1 && !locked.current ? e.touches[0].clientY : null; from = at(); }; const onTouchEnd = (e: TouchEvent) => { - if (startY === null || e.touches.length) return; + if (startY === null || e.touches.length || locked.current) return; const dy = startY - (e.changedTouches[0]?.clientY ?? startY); startY = null; if (Math.abs(dy) >= SWIPE_MIN) go(stepTarget(from, dy, count())); @@ -430,6 +629,7 @@ export function MobileFeed(p: FeedViewProps) { className="reel" data-testid="feed-reel" data-current={index} + data-locked={expandedAt !== null ? "true" : undefined} ref={(el) => { reel.current = el; setRef(p.listRef, el); @@ -448,7 +648,7 @@ export function MobileFeed(p: FeedViewProps) { ) : null} {p.status === "ready" ? p.posts.flatMap((post, i) => { - const node = ; + const node = ; return i === featuredAt - 1 ? [node, ] : [node]; diff --git a/src/components/SlideText.tsx b/src/components/SlideText.tsx new file mode 100644 index 0000000..95a9c4a --- /dev/null +++ b/src/components/SlideText.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; + +/** Quote sizes for a text-only post, largest first. The smallest one clamps and offers "Read more". */ +export const QUOTE_SIZES = [31, 26, 22, 19] as const; +/** Space kept under a clamped quote for the "Read more" button (44px tall plus its gap). */ +export const QUOTE_MORE_RESERVE = 52; + +export interface QuoteFit { + size: number; + /** Lines shown before "Read more"; 0 when the whole text fits. */ + lines: number; +} + +/** + * The first quote size whose rendered text fits, or the smallest size with as + * many whole lines as fit above the "Read more" button. `fits` measures the + * rendered text at a size; `linesAt` counts the lines that fit at the smallest one. + */ +export function pickQuoteFit(fits: (size: number) => boolean, linesAt: (size: number) => number): QuoteFit { + for (const size of QUOTE_SIZES) if (fits(size)) return { size, lines: 0 }; + const size = QUOTE_SIZES[QUOTE_SIZES.length - 1]; + return { size, lines: Math.max(1, Math.floor(linesAt(size))) }; +} + +/** Re-runs `measure` when the box resizes, web fonts finish loading, or the theme changes. */ +function useRemeasure(box: React.RefObject, measure: () => void, deps: unknown[]) { + useLayoutEffect(() => { + measure(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + useEffect(() => { + const el = box.current; + if (!el) return; + let frame = 0; + const later = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(measure); + }; + const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(later) : null; + ro?.observe(el); + const fonts = typeof document !== "undefined" ? document.fonts : undefined; + void fonts?.ready.then(later); + fonts?.addEventListener?.("loadingdone", later); + const mo = typeof MutationObserver !== "undefined" ? new MutationObserver(later) : null; + mo?.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class"] }); + return () => { + cancelAnimationFrame(frame); + ro?.disconnect(); + mo?.disconnect(); + fonts?.removeEventListener?.("loadingdone", later); + }; + }, [box, measure]); +} + +/** + * Text-only post: the serif quote steps down 31 → 26 → 22 → 19px until it fits + * between the top bar and the author block. When it still does not fit, it + * clamps and ends with "Read more", which opens the full text over the slide. + */ +export function QuoteText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) { + const box = useRef(null); + const inner = useRef(null); + const quote = useRef(null); + const moreBtn = useRef(null); + const closeBtn = useRef(null); + const [fit, setFit] = useState({ size: QUOTE_SIZES[0], lines: 0 }); + + const measure = useCallback(() => { + const b = box.current; + const q = quote.current; + const w = inner.current; + if (!b || !q || !w || !b.clientHeight) return; + const apply = (f: QuoteFit) => { + q.style.fontSize = `${f.size}px`; + q.style.webkitLineClamp = f.lines ? String(f.lines) : ""; + q.classList.toggle("is-clamped", f.lines > 0); + b.classList.toggle("is-clamped", f.lines > 0); + }; + const result = pickQuoteFit( + (size) => { + apply({ size, lines: 0 }); + return w.offsetHeight <= b.clientHeight + 0.5; + }, + () => { + // Height left for the text: the box minus the quote mark, its gap and the button. + const other = w.offsetHeight - q.offsetHeight; + const lineHeight = parseFloat(getComputedStyle(q).lineHeight) || 19 * 1.2; + return (b.clientHeight - other - QUOTE_MORE_RESERVE) / lineHeight; + }, + ); + apply(result); + setFit((cur) => (cur.size === result.size && cur.lines === result.lines ? cur : result)); + }, []); + useRemeasure(box, measure, [text]); + + // Focus follows the reader: into it on open, back to "Read more" on close. + const wasOpen = useRef(false); + useEffect(() => { + if (open) closeBtn.current?.focus({ preventScroll: true }); + else if (wasOpen.current) moreBtn.current?.focus({ preventScroll: true }); + wasOpen.current = open; + }, [open]); + + return ( + <> +
+
+ +

+ {text} +

+
+ {fit.lines ? ( + + ) : null} +
+ {open ? ( +
{ + if (e.key === "Escape") onOpenChange(false); + }} + > +
+

{text}

+
+ +
+ ) : null} + + ); +} + +/** + * Caption over a photo or video: three lines, then "more" when it is longer. + * Open, the whole caption shows over the media on a darker scrim, scrolling + * inside the slide when it is very long, with "less" to collapse it. + */ +export function CaptionText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) { + const caption = useRef(null); + const [long, setLong] = useState(false); + const measure = useCallback(() => { + const c = caption.current; + // Measured only while clamped: an open caption is long by definition. + if (!c || c.classList.contains("is-open") || !c.clientHeight) return; + setLong(c.scrollHeight > c.clientHeight + 1); + }, []); + useRemeasure(caption, measure, [text]); + useEffect(() => { + if (!open && caption.current) caption.current.scrollTop = 0; + }, [open]); + return ( + <> +

+ {text} +

+ {long || open ? ( + + ) : null} + + ); +} diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index ff2220a..9d53f8a 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -10,6 +10,8 @@ export interface VideoPlayerProps { label: string; /** Native controls; off until the caller's own play button starts the video. Default on. */ controls?: boolean; + /** Start muted (the mobile feed's autoplay). Desktop leaves it unset. */ + muted?: boolean; } const UNPLAYABLE = "Video can't be played here."; @@ -18,9 +20,9 @@ const UNPLAYABLE = "Video can't be played here."; * Real