Feed browser: Mobile video and caption fixes #6
+153
-20
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -86,33 +87,137 @@ export function videoFit(width: number, height: number): "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 [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 video = box.current?.querySelector("video");
|
||||
if (!video) return;
|
||||
const v = video();
|
||||
if (!v) return;
|
||||
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();
|
||||
video.addEventListener("loadedmetadata", read);
|
||||
return () => video.removeEventListener("loadedmetadata", 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 (
|
||||
<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 ? (
|
||||
// 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
|
||||
@@ -124,17 +229,36 @@ function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
|
||||
onLoad={(e) => setThumbSize([e.currentTarget.naturalWidth, e.currentTarget.naturalHeight])}
|
||||
/>
|
||||
) : null}
|
||||
<div className="slide-art">
|
||||
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} controls={started} />
|
||||
<div className="slide-art" onClick={auto ? togglePause : undefined}>
|
||||
<VideoPlayer
|
||||
mp4Url={media.mp4Url}
|
||||
hlsUrl={media.hlsUrl}
|
||||
poster={media.src}
|
||||
label={`Video from ${author}`}
|
||||
controls={!auto && started}
|
||||
muted={auto ? muted : undefined}
|
||||
/>
|
||||
</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 />
|
||||
</button>
|
||||
{duration ? <span className="post-dur slide-dur">{duration}</span> : null}
|
||||
{duration && !auto ? <span className="post-dur slide-dur">{duration}</span> : null}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</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. */
|
||||
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 [active, setActive] = useState(0);
|
||||
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}>
|
||||
{items.map((m, i) => (
|
||||
<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>
|
||||
@@ -190,19 +314,22 @@ 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);
|
||||
// Long text opened with "Read more" or "more". It closes when the slide leaves the screen.
|
||||
const [textOpen, setTextOpen] = useState(false);
|
||||
@@ -240,13 +367,19 @@ function PostSlide({
|
||||
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 ? <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" aria-hidden="true" />
|
||||
</>
|
||||
@@ -515,7 +648,7 @@ export function MobileFeed(p: FeedViewProps) {
|
||||
) : null}
|
||||
{p.status === "ready"
|
||||
? 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
|
||||
? [node, <FeaturedSlide key="featured" index={featuredAt} current={index === featuredAt} experts={experts} />]
|
||||
: [node];
|
||||
|
||||
@@ -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 <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
|
||||
* 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 [failed, setFailed] = useState(false);
|
||||
|
||||
@@ -80,6 +82,7 @@ export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true }:
|
||||
ref={ref}
|
||||
src={mp4Url ?? undefined}
|
||||
controls={controls}
|
||||
muted={muted}
|
||||
playsInline
|
||||
preload="none"
|
||||
poster={poster ?? undefined}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -73,6 +73,32 @@
|
||||
.slide-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; }
|
||||
.slide-play { 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. */
|
||||
.slide-track {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user