Feed browser: Mobile immersive feed #4
@@ -54,7 +54,7 @@ function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="slide-media slide-video" ref={box}>
|
<div className="slide-media slide-video" ref={box}>
|
||||||
<div className="slide-art">
|
<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>
|
</div>
|
||||||
{started ? null : (
|
{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 }) {
|
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 media = post.media.find((m) => m.kind === "video" || m.src) ?? null;
|
||||||
const own = canDelete(post, p.feedUserId) && !!p.onDelete;
|
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 (
|
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 ? (
|
{media ? (
|
||||||
<>
|
<>
|
||||||
<SlideMedia media={media} author={post.authorName} />
|
<SlideMedia media={media} author={post.authorName} />
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface VideoPlayerProps {
|
|||||||
hlsUrl?: string | null;
|
hlsUrl?: string | null;
|
||||||
poster: string | null;
|
poster: string | null;
|
||||||
label: string;
|
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.";
|
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
|
* unsupported), else thumbnail only. Never
|
||||||
* autoplays. Any failure falls back to the thumbnail with a short note.
|
* 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 ref = useRef<HTMLVideoElement>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps)
|
|||||||
<video
|
<video
|
||||||
ref={ref}
|
ref={ref}
|
||||||
src={mp4Url ?? undefined}
|
src={mp4Url ?? undefined}
|
||||||
controls
|
controls={controls}
|
||||||
playsInline
|
playsInline
|
||||||
preload="none"
|
preload="none"
|
||||||
poster={poster ?? undefined}
|
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 { 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)); }
|
.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. */
|
/* Author and text over the bottom fade. */
|
||||||
.slide-meta {
|
.slide-meta {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -127,6 +136,8 @@
|
|||||||
/* The slide on screen: its content rises in when it snaps into place. */
|
/* The slide on screen: its content rises in when it snaps into place. */
|
||||||
.is-cur .slide-meta,
|
.is-cur .slide-meta,
|
||||||
.is-cur .slide-quote-w { animation: slide-rise .7s var(--ease) both; }
|
.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. */
|
/* 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; }
|
.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);
|
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