Feed browser: Mobile video and caption fixes #6
+153
-20
@@ -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];
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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-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 {
|
||||||
|
|||||||
@@ -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