Promote to main: Feed browser luxury redesign #7

Merged
calcazar merged 49 commits from develop into main 2026-10-20 10:33:01 -06:00
5 changed files with 254 additions and 22 deletions
Showing only changes of commit e37ec780a9 - Show all commits
+153 -20
View File
@@ -10,6 +10,7 @@ import { CaptionText, QuoteText } from "./SlideText";
import { FeaturedCarousel } from "./FeaturedCarousel"; import { FeaturedCarousel } from "./FeaturedCarousel";
import { Logo } from "./Logo"; import { Logo } from "./Logo";
import { useTheme } from "@/lib/use-theme"; import { useTheme } from "@/lib/use-theme";
import { getSessionMuted, isAutoplayBlocked, setSessionMuted } from "@/lib/autoplay";
import { MobileDock } from "./MobileDock"; import { MobileDock } from "./MobileDock";
import { BottomSheet } from "./BottomSheet"; import { BottomSheet } from "./BottomSheet";
import { CommentsSection } from "./CommentsSection"; import { CommentsSection } from "./CommentsSection";
@@ -86,33 +87,137 @@ export function videoFit(width: number, height: number): "cover" | "contain" {
return width / height <= COVER_MAX_RATIO ? "cover" : "contain"; return width / height <= COVER_MAX_RATIO ? "cover" : "contain";
} }
function SlideMedia({ media, author }: { media: CardMedia; author: string }) { /** 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<boolean | null>(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 (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M11 5 6 9H3v6h3l5 4V5z" />
{muted ? <path d="m16 9 5 6M21 9l-5 6" /> : <path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" />}
</svg>
);
}
/**
* 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<HTMLDivElement>(null); const box = useRef<HTMLDivElement>(null);
const [started, setStarted] = useState(false); const [started, setStarted] = useState(false);
// Real video size once metadata loads; until then the thumbnail's size decides. // Real video size once metadata loads; until then the thumbnail's size decides.
const [videoSize, setVideoSize] = useState<[number, number] | null>(null); const [videoSize, setVideoSize] = useState<[number, number] | null>(null);
const [thumbSize, setThumbSize] = useState<[number, number] | null>(null); const [thumbSize, setThumbSize] = useState<[number, number] | null>(null);
const playable = isPlayableVideo(media); 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(() => { useEffect(() => {
const video = box.current?.querySelector("video"); const v = video();
if (!video) return; if (!v) return;
const read = () => { const read = () => {
if (video.videoWidth > 0 && video.videoHeight > 0) setVideoSize([video.videoWidth, video.videoHeight]); if (v.videoWidth > 0 && v.videoHeight > 0) setVideoSize([v.videoWidth, v.videoHeight]);
}; };
read(); read();
video.addEventListener("loadedmetadata", read); v.addEventListener("loadedmetadata", read);
return () => video.removeEventListener("loadedmetadata", read); return () => v.removeEventListener("loadedmetadata", read);
}, [playable, media.mp4Url, media.hlsUrl]); }, [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) { if (playable) {
const size = videoSize ?? thumbSize; const size = videoSize ?? thumbSize;
const fit = size ? videoFit(size[0], size[1]) : "cover"; const fit = size ? videoFit(size[0], size[1]) : "cover";
const duration = formatDuration(media.durationSeconds); const duration = formatDuration(media.durationSeconds);
const start = () => { const start = () => {
setStarted(true); 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 ( return (
<div className="slide-media slide-video" data-fit={fit} ref={box}> <div className="slide-media slide-video" data-fit={fit} data-autoplay={auto ? "true" : undefined} ref={box}>
{media.src ? ( {media.src ? (
// The thumbnail doubles as the blurred backdrop behind a contained video, and as the size fallback. // 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 // eslint-disable-next-line @next/next/no-img-element
@@ -124,17 +229,36 @@ function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
onLoad={(e) => setThumbSize([e.currentTarget.naturalWidth, e.currentTarget.naturalHeight])} onLoad={(e) => setThumbSize([e.currentTarget.naturalWidth, e.currentTarget.naturalHeight])}
/> />
) : null} ) : null}
<div className="slide-art"> <div className="slide-art" onClick={auto ? togglePause : undefined}>
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} controls={started} /> <VideoPlayer
mp4Url={media.mp4Url}
hlsUrl={media.hlsUrl}
poster={media.src}
label={`Video from ${author}`}
controls={!auto && started}
muted={auto ? muted : undefined}
/>
</div> </div>
{started ? null : ( {auto ? (
<button
type="button"
className="slide-mute"
data-testid="slide-mute"
aria-label={muted ? "Unmute video" : "Mute video"}
aria-pressed={!muted}
onClick={toggleMute}
>
<SoundIcon muted={muted} />
</button>
) : null}
{showPlay ? (
<> <>
<button type="button" className="post-play slide-play" aria-label="Play video" onClick={start}> <button type="button" className="post-play slide-play" aria-label="Play video" onClick={auto ? togglePause : start}>
<PlayIcon /> <PlayIcon />
</button> </button>
{duration ? <span className="post-dur slide-dur">{duration}</span> : null} {duration && !auto ? <span className="post-dur slide-dur">{duration}</span> : null}
</> </>
)} ) : null}
</div> </div>
); );
} }
@@ -154,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. */ /** 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<HTMLDivElement>(null); const track = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const onScroll = () => { const onScroll = () => {
@@ -173,7 +297,7 @@ function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) {
<div className="slide-track" data-testid="slide-track" ref={track} onScroll={onScroll}> <div className="slide-track" data-testid="slide-track" ref={track} onScroll={onScroll}>
{items.map((m, i) => ( {items.map((m, i) => (
<div className="slide-pane" key={i} aria-label={`Media ${i + 1} of ${items.length}`}> <div className="slide-pane" key={i} aria-label={`Media ${i + 1} of ${items.length}`}>
<SlideMedia media={m} author={author} /> <SlideMedia media={m} author={author} active={current && i === active} hold={hold} />
</div> </div>
))} ))}
</div> </div>
@@ -190,19 +314,22 @@ function PostSlide({
post, post,
index, index,
current, current,
hold,
p, p,
onExpandedChange, onExpandedChange,
}: { }: {
post: CardPost; post: CardPost;
index: number; index: number;
current: boolean; current: boolean;
/** A sheet is open over the feed: a playing video pauses until it closes. */
hold: boolean;
p: FeedViewProps; p: FeedViewProps;
onExpandedChange?: (index: number, open: boolean) => void; onExpandedChange?: (index: number, open: boolean) => void;
}) { }) {
const items = post.media.filter((m) => m.kind === "video" || m.src); const items = post.media.filter((m) => m.kind === "video" || m.src);
const media = items[0] ?? null; const media = items[0] ?? null;
const own = canDelete(post, p.feedUserId) && !!p.onDelete; 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 [playing, setPlaying] = useState(false);
// Long text opened with "Read more" or "more". It closes when the slide leaves the screen. // Long text opened with "Read more" or "more". It closes when the slide leaves the screen.
const [textOpen, setTextOpen] = useState(false); const [textOpen, setTextOpen] = useState(false);
@@ -240,13 +367,19 @@ function PostSlide({
data-liked={post.isLiked ? "true" : "false"} data-liked={post.isLiked ? "true" : "false"}
onClick={onTap} onClick={onTap}
aria-label={`Post by ${post.authorName}`} aria-label={`Post by ${post.authorName}`}
onPlayCapture={() => setPlaying(true)} onPlayCapture={(e) => {
if ((e.target as HTMLVideoElement).controls) setPlaying(true);
}}
onPauseCapture={() => setPlaying(false)} onPauseCapture={() => setPlaying(false)}
onEndedCapture={() => setPlaying(false)} onEndedCapture={() => setPlaying(false)}
> >
{media ? ( {media ? (
<> <>
{items.length > 1 ? <MediaTrack items={items} author={post.authorName} /> : <SlideMedia media={media} author={post.authorName} />} {items.length > 1 ? (
<MediaTrack items={items} author={post.authorName} current={current} hold={hold} />
) : (
<SlideMedia media={media} author={post.authorName} active={current} hold={hold} />
)}
<div className="slide-scrim-top" aria-hidden="true" /> <div className="slide-scrim-top" aria-hidden="true" />
<div className="slide-scrim" aria-hidden="true" /> <div className="slide-scrim" aria-hidden="true" />
</> </>
@@ -515,7 +648,7 @@ export function MobileFeed(p: FeedViewProps) {
) : null} ) : null}
{p.status === "ready" {p.status === "ready"
? p.posts.flatMap((post, i) => { ? p.posts.flatMap((post, i) => {
const node = <PostSlide key={post.key} post={post} index={slideOf(i)} current={slideOf(i) === index} p={p} onExpandedChange={onExpandedChange} />; const node = <PostSlide key={post.key} post={post} index={slideOf(i)} current={slideOf(i) === index} hold={sheet !== null} p={p} onExpandedChange={onExpandedChange} />;
return i === featuredAt - 1 return i === featuredAt - 1
? [node, <FeaturedSlide key="featured" index={featuredAt} current={index === featuredAt} experts={experts} />] ? [node, <FeaturedSlide key="featured" index={featuredAt} current={index === featuredAt} experts={experts} />]
: [node]; : [node];
+5 -2
View File
@@ -10,6 +10,8 @@ export interface VideoPlayerProps {
label: string; label: string;
/** Native controls; off until the caller's own play button starts the video. Default on. */ /** Native controls; off until the caller's own play button starts the video. Default on. */
controls?: boolean; controls?: boolean;
/** Start muted (the mobile feed's autoplay). Desktop leaves it unset. */
muted?: boolean;
} }
const UNPLAYABLE = "Video can't be played here."; const UNPLAYABLE = "Video can't be played here.";
@@ -18,9 +20,9 @@ const UNPLAYABLE = "Video can't be played here.";
* Real <video controls>. Order: direct mp4Url (plain progressive source), else * Real <video controls>. Order: direct mp4Url (plain progressive source), else
* hls.js (lazy-loaded, only for the HLS path; native HLS only where hls.js is * hls.js (lazy-loaded, only for the HLS path; native HLS only where hls.js is
* unsupported), else thumbnail only. Never * unsupported), else thumbnail only. Never
* autoplays. Any failure falls back to the thumbnail with a short note. * autoplays by itself (the mobile feed starts it). Any failure falls back to the thumbnail with a short note.
*/ */
export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true }: VideoPlayerProps) { export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true, muted }: VideoPlayerProps) {
const ref = useRef<HTMLVideoElement>(null); const ref = useRef<HTMLVideoElement>(null);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@@ -80,6 +82,7 @@ export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true }:
ref={ref} ref={ref}
src={mp4Url ?? undefined} src={mp4Url ?? undefined}
controls={controls} controls={controls}
muted={muted}
playsInline playsInline
preload="none" preload="none"
poster={poster ?? undefined} poster={poster ?? undefined}
+24
View File
@@ -0,0 +1,24 @@
/**
* Mobile feed autoplay: the session's mute choice and how a failed play() is read.
* The choice lives in memory only, for this page session: videos start muted
* (browsers only autoplay muted video) until the person unmutes one.
*/
let sessionMuted = true;
/** True until the person unmutes a video in this session. */
export function getSessionMuted(): boolean {
return sessionMuted;
}
/** Remember the person's mute choice for the videos that play after this one. */
export function setSessionMuted(muted: boolean): void {
sessionMuted = muted;
}
/**
* True when play() was refused by the browser's autoplay policy. A play() that
* was cut short by pause() (AbortError) is not a refusal.
*/
export function isAutoplayBlocked(error: unknown): boolean {
return !!error && typeof error === "object" && (error as { name?: unknown }).name === "NotAllowedError";
}
+26
View File
@@ -73,6 +73,32 @@
.slide-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; } .slide-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; }
.slide-play { z-index: 4; } .slide-play { z-index: 4; }
.slide-dur { top: 128px; right: 16px; z-index: 4; } .slide-dur { top: 128px; right: 16px; z-index: 4; }
/* Autoplay: a small frosted sound toggle on the video; a tap on the video pauses or resumes it. */
.slide-video[data-autoplay="true"] .slide-art { cursor: pointer; }
.slide-mute {
position: absolute;
top: calc(72px + env(safe-area-inset-top, 0px));
right: 16px;
z-index: 4;
display: grid;
place-items: center;
width: 36px;
height: 36px;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
appearance: none;
color: var(--on-media);
background: var(--media-glass);
-webkit-backdrop-filter: blur(16px) saturate(1.6);
backdrop-filter: blur(16px) saturate(1.6);
box-shadow: inset 0 0 0 1px var(--on-media-line);
cursor: pointer;
transition: transform .2s var(--ease);
}
.slide-mute svg { width: 18px; height: 18px; }
.slide-mute:active { transform: scale(.92); }
/* Several media items: a sideways snap track; each pane is one full-slide item. */ /* Several media items: a sideways snap track; each pane is one full-slide item. */
.slide-track { .slide-track {
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { getSessionMuted, isAutoplayBlocked, setSessionMuted } from "@/lib/autoplay";
import { VideoPlayer } from "@/components/VideoPlayer";
describe("session mute choice", () => {
it("starts muted and keeps the person's choice", () => {
expect(getSessionMuted()).toBe(true);
setSessionMuted(false);
expect(getSessionMuted()).toBe(false);
setSessionMuted(true);
expect(getSessionMuted()).toBe(true);
});
});
describe("autoplay refusal", () => {
it("only counts the browser's NotAllowedError", () => {
expect(isAutoplayBlocked({ name: "NotAllowedError" })).toBe(true);
expect(isAutoplayBlocked({ name: "AbortError" })).toBe(false);
expect(isAutoplayBlocked(null)).toBe(false);
expect(isAutoplayBlocked("NotAllowedError")).toBe(false);
});
});
describe("VideoPlayer", () => {
it("is inline and not muted unless asked (desktop unchanged)", () => {
const html = renderToStaticMarkup(createElement(VideoPlayer, { mp4Url: "https://cdn/v.mp4", poster: null, label: "v" }));
expect(html).toContain("playsInline");
expect(html).toContain("controls");
expect(html).not.toContain("muted");
});
it("renders muted inline video for the mobile autoplay", () => {
const html = renderToStaticMarkup(createElement(VideoPlayer, { mp4Url: "https://cdn/v.mp4", poster: null, label: "v", controls: false, muted: true }));
expect(html).toContain("muted");
expect(html).not.toContain("controls");
});
});
describe("mobile autoplay styles", () => {
it("has a frosted mute button", () => {
const css = readFileSync("src/styles/mobile.css", "utf8");
expect(css).toMatch(/\.slide-mute \{[^}]*backdrop-filter/);
});
});