From e37ec780a95e0dfbdd4a77463cfaa8047a0900ff Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Tue, 20 Oct 2026 10:22:39 -0600 Subject: [PATCH] o2buvioma: Autoplay mobile feed videos muted when they snap into view, with a session mute toggle and tap to pause --- src/components/MobileFeed.tsx | 173 +++++++++++++++++++++++++++++---- src/components/VideoPlayer.tsx | 7 +- src/lib/autoplay.ts | 24 +++++ src/styles/mobile.css | 26 +++++ tests/autoplay.test.ts | 46 +++++++++ 5 files changed, 254 insertions(+), 22 deletions(-) create mode 100644 src/lib/autoplay.ts create mode 100644 tests/autoplay.test.ts diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx index 6705096..c9cf5d6 100644 --- a/src/components/MobileFeed.tsx +++ b/src/components/MobileFeed.tsx @@ -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(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 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 ( -
+
{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} -
- +
+
- {started ? null : ( + {auto ? ( + + ) : null} + {showPlay ? ( <> - - {duration ? {duration} : null} + {duration && !auto ? {duration} : null} - )} + ) : null}
); } @@ -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(null); const [active, setActive] = useState(0); const onScroll = () => { @@ -173,7 +297,7 @@ function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) {
{items.map((m, i) => (
- +
))}
@@ -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 ? : } + {items.length > 1 ? ( + + ) : ( + + )}