Feed browser: Mobile immersive feed #4
@@ -54,7 +54,7 @@ function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
|
||||
return (
|
||||
<div className="slide-media slide-video" ref={box}>
|
||||
<div className="slide-art">
|
||||
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} />
|
||||
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} controls={started} />
|
||||
</div>
|
||||
{started ? null : (
|
||||
<>
|
||||
@@ -85,9 +85,19 @@ function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
|
||||
function PostSlide({ post, index, current, p }: { post: CardPost; index: number; current: boolean; p: FeedViewProps }) {
|
||||
const media = post.media.find((m) => m.kind === "video" || m.src) ?? null;
|
||||
const own = canDelete(post, p.feedUserId) && !!p.onDelete;
|
||||
const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}`;
|
||||
// While a video plays, the author and text step aside so the native controls stay clear.
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}`;
|
||||
return (
|
||||
<section className={cls} data-index={index} data-testid="feed-slide" aria-label={`Post by ${post.authorName}`}>
|
||||
<section
|
||||
className={cls}
|
||||
data-index={index}
|
||||
data-testid="feed-slide"
|
||||
aria-label={`Post by ${post.authorName}`}
|
||||
onPlayCapture={() => setPlaying(true)}
|
||||
onPauseCapture={() => setPlaying(false)}
|
||||
onEndedCapture={() => setPlaying(false)}
|
||||
>
|
||||
{media ? (
|
||||
<>
|
||||
<SlideMedia media={media} author={post.authorName} />
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface VideoPlayerProps {
|
||||
hlsUrl?: string | null;
|
||||
poster: string | null;
|
||||
label: string;
|
||||
/** Native controls; off until the caller's own play button starts the video. Default on. */
|
||||
controls?: boolean;
|
||||
}
|
||||
|
||||
const UNPLAYABLE = "Video can't be played here.";
|
||||
@@ -18,7 +20,7 @@ const UNPLAYABLE = "Video can't be played here.";
|
||||
* unsupported), else thumbnail only. Never
|
||||
* autoplays. Any failure falls back to the thumbnail with a short note.
|
||||
*/
|
||||
export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps) {
|
||||
export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true }: VideoPlayerProps) {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
@@ -77,7 +79,7 @@ export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps)
|
||||
<video
|
||||
ref={ref}
|
||||
src={mp4Url ?? undefined}
|
||||
controls
|
||||
controls={controls}
|
||||
playsInline
|
||||
preload="none"
|
||||
poster={poster ?? undefined}
|
||||
|
||||
@@ -61,6 +61,15 @@
|
||||
.slide-scrim { bottom: 0; height: 58%; background: linear-gradient(180deg, var(--media-fade-0), var(--media-fade)); }
|
||||
.slide-scrim-top { top: 0; height: 150px; background: linear-gradient(180deg, var(--media-fade-top), var(--media-fade-0)); }
|
||||
|
||||
/* A playing video: the author, text and fades step aside so the native controls are clear. */
|
||||
.slide-meta,
|
||||
.slide-scrim,
|
||||
.slide-scrim-top { transition: opacity .3s var(--ease); }
|
||||
.slide-playing .slide-meta,
|
||||
.slide-playing .slide-scrim,
|
||||
.slide-playing .slide-scrim-top { opacity: 0; pointer-events: none; }
|
||||
.slide-playing .slide-meta > * { pointer-events: none; }
|
||||
|
||||
/* Author and text over the bottom fade. */
|
||||
.slide-meta {
|
||||
position: absolute;
|
||||
@@ -127,6 +136,8 @@
|
||||
/* The slide on screen: its content rises in when it snaps into place. */
|
||||
.is-cur .slide-meta,
|
||||
.is-cur .slide-quote-w { animation: slide-rise .7s var(--ease) both; }
|
||||
/* The rise animation's fill would keep the meta visible; a playing video takes it off. */
|
||||
.is-cur.slide-playing .slide-meta { animation: none; }
|
||||
|
||||
/* Own-post menu: frosted "..." at the top right, the card's delete confirm under it. */
|
||||
.slide-menu { position: absolute; top: 70px; right: 12px; z-index: 6; }
|
||||
|
||||
@@ -119,3 +119,13 @@ describe("one step per gesture", () => {
|
||||
expect(stepTarget(2, 0, 5)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("video slide before play", () => {
|
||||
it("shows only the frosted play button, no native controls", () => {
|
||||
const html = render({});
|
||||
const video = /<video[^>]*>/.exec(html)?.[0] ?? "";
|
||||
expect(video).not.toBe("");
|
||||
expect(video).not.toContain("controls");
|
||||
expect(html).toContain('class="post-play slide-play"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user