terry-staging #11
@@ -20,6 +20,14 @@ type AttachmentDownloadPillProps = {
|
||||
* Defaults to false (fixed 30px) for standalone images/videos.
|
||||
*/
|
||||
adaptive?: boolean;
|
||||
/**
|
||||
* Visual size. `sm` (default, 30 px tall) for inline tiles where space is
|
||||
* tight. `lg` (44 px tall with a 24 px icon and 14 px text) for overlay
|
||||
* surfaces like the image lightbox or fullscreen video player where the
|
||||
* affordance can breathe and should feel touch-friendly.
|
||||
* Ignored when `adaptive` is set.
|
||||
*/
|
||||
size?: "sm" | "lg";
|
||||
};
|
||||
|
||||
export function AttachmentDownloadPill({
|
||||
@@ -28,6 +36,7 @@ export function AttachmentDownloadPill({
|
||||
leadingLabel,
|
||||
className = "absolute left-2 top-2",
|
||||
adaptive = false,
|
||||
size = "sm",
|
||||
}: AttachmentDownloadPillProps) {
|
||||
const { t } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
@@ -46,52 +55,56 @@ export function AttachmentDownloadPill({
|
||||
}
|
||||
};
|
||||
|
||||
const isLg = !adaptive && size === "lg";
|
||||
|
||||
const fontCls = adaptive
|
||||
? "text-[clamp(10px,7cqw,12px)]"
|
||||
: isLg
|
||||
? "text-[14px] font-medium"
|
||||
: "text-[12px]";
|
||||
|
||||
const squareCls = adaptive
|
||||
? "h-[clamp(22px,18cqw,30px)] w-[clamp(22px,18cqw,30px)]"
|
||||
: isLg
|
||||
? "h-[44px] w-[44px]"
|
||||
: "h-[30px] w-[30px]";
|
||||
|
||||
const iconCls = adaptive
|
||||
? "h-[clamp(13px,11cqw,18px)] w-[clamp(13px,11cqw,18px)]"
|
||||
: isLg
|
||||
? "h-[22px] w-[22px]"
|
||||
: "h-[18px] w-[18px]";
|
||||
|
||||
const textBoxCls = adaptive
|
||||
? "h-[clamp(22px,18cqw,30px)] px-[clamp(6px,6cqw,10px)]"
|
||||
: isLg
|
||||
? "h-[44px] px-4"
|
||||
: "h-[30px] px-2.5";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/80 ${
|
||||
adaptive ? "text-[clamp(10px,7cqw,12px)]" : "text-[12px]"
|
||||
} text-white shadow-lg ring-1 ring-inset ring-white/20 backdrop-blur-md transition hover:bg-black/90 disabled:cursor-wait ${className}`}
|
||||
className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/80 ${fontCls} text-white shadow-lg ring-1 ring-inset ring-white/20 backdrop-blur-md transition hover:bg-black/90 disabled:cursor-wait ${className}`}
|
||||
aria-label={
|
||||
isDownloading ? t("downloading") : `Download ${attachment.filename}`
|
||||
}
|
||||
aria-busy={isDownloading}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center justify-center bg-[#545454]/50 transition group-hover:bg-[#545454]/70 ${
|
||||
adaptive
|
||||
? "h-[clamp(22px,18cqw,30px)] w-[clamp(22px,18cqw,30px)]"
|
||||
: "h-[30px] w-[30px]"
|
||||
}`}
|
||||
className={`flex items-center justify-center bg-[#545454]/50 transition group-hover:bg-[#545454]/70 ${squareCls}`}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<LoaderCircle
|
||||
className={`${
|
||||
adaptive
|
||||
? "h-[clamp(13px,11cqw,18px)] w-[clamp(13px,11cqw,18px)]"
|
||||
: "h-[18px] w-[18px]"
|
||||
} animate-spin`}
|
||||
className={`${iconCls} animate-spin`}
|
||||
strokeWidth={2.3}
|
||||
/>
|
||||
) : (
|
||||
<DownloadCloudIcon
|
||||
className={
|
||||
adaptive
|
||||
? "h-[clamp(13px,11cqw,18px)] w-[clamp(13px,11cqw,18px)]"
|
||||
: "h-[18px] w-[18px]"
|
||||
}
|
||||
/>
|
||||
<DownloadCloudIcon className={iconCls} />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`flex items-center gap-0.5 ${
|
||||
adaptive
|
||||
? "h-[clamp(22px,18cqw,30px)] px-[clamp(6px,6cqw,10px)]"
|
||||
: "h-[30px] px-2.5"
|
||||
}`}
|
||||
>
|
||||
<span className={`flex items-center gap-1 ${textBoxCls}`}>
|
||||
{isDownloading ? (
|
||||
t("downloading")
|
||||
) : (
|
||||
|
||||
368
src/components/messageStream/MessageInlineVideo.tsx
Normal file
368
src/components/messageStream/MessageInlineVideo.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { Maximize2, Pause, Play } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import type { Attachment } from "../../types/post";
|
||||
import { AttachmentDownloadPill } from "./AttachmentDownloadPill";
|
||||
import { useVideoPlayer } from "./overlays/VideoPlayer";
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(Math.floor(n)).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formatClock(sec: number): string {
|
||||
if (!Number.isFinite(sec) || sec < 0) return "0:00";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${pad2(s)}`;
|
||||
}
|
||||
|
||||
type Size = "sm" | "lg";
|
||||
|
||||
type SizeTokens = {
|
||||
/** Padding / gap of the bottom controls bar. */
|
||||
bar: string;
|
||||
/** Square hit area of the play/pause and fullscreen buttons. */
|
||||
btn: string;
|
||||
/** Lucide icon inside the bar buttons. */
|
||||
btnIcon: string;
|
||||
/** Center play affordance shown while paused. */
|
||||
centerBox: string;
|
||||
/** Lucide icon inside the centered play affordance. */
|
||||
centerIcon: string;
|
||||
/** Hit area of the scrub bar wrapper. */
|
||||
scrubRow: string;
|
||||
/** Visible fill rail. */
|
||||
scrubRail: string;
|
||||
/** Drag handle dot. */
|
||||
scrubHandle: string;
|
||||
/** Time text. */
|
||||
timeText: string;
|
||||
};
|
||||
|
||||
const TOKENS: Record<Size, SizeTokens> = {
|
||||
sm: {
|
||||
bar: "gap-2 px-3 pb-2 pt-6 text-[12px]",
|
||||
btn: "h-7 w-7",
|
||||
btnIcon: "h-4 w-4",
|
||||
centerBox: "h-14 w-14 md:h-16 md:w-16",
|
||||
centerIcon: "h-6 w-6",
|
||||
scrubRow: "h-5",
|
||||
scrubRail: "h-1",
|
||||
scrubHandle: "h-3 w-3",
|
||||
timeText: "",
|
||||
},
|
||||
lg: {
|
||||
bar: "gap-3 px-5 pb-4 pt-10 text-[14px]",
|
||||
btn: "h-10 w-10",
|
||||
btnIcon: "h-5 w-5",
|
||||
centerBox: "h-20 w-20 md:h-24 md:w-24",
|
||||
centerIcon: "h-8 w-8",
|
||||
scrubRow: "h-7",
|
||||
scrubRail: "h-1.5",
|
||||
scrubHandle: "h-4 w-4",
|
||||
timeText: "text-[14px]",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Cross-platform inline video player with custom controls. Disables every
|
||||
* native control overlay (iOS Safari's `playsInline` UI is otherwise
|
||||
* impossible to fully tame via CSS) and reimplements the essentials:
|
||||
*
|
||||
* - Centered play affordance while paused.
|
||||
* - Bottom bar with play/pause, current time, scrub bar, total time, and
|
||||
* a fullscreen button.
|
||||
* - Tap on the video toggles play/pause.
|
||||
* - Click or drag on the scrub bar seeks live to that point (mouse +
|
||||
* touch, via pointer events with pointer capture).
|
||||
*
|
||||
* The download pill is preserved at the top-left so it lives alongside the
|
||||
* existing visual language of the message bubbles.
|
||||
*/
|
||||
export function MessageInlineVideo({
|
||||
postId,
|
||||
attachment,
|
||||
initialTime = 0,
|
||||
autoPlay = true,
|
||||
leadingLabel,
|
||||
hideDownload = false,
|
||||
hideFullscreen = false,
|
||||
onTimeUpdate,
|
||||
size = "sm",
|
||||
}: {
|
||||
postId: string;
|
||||
attachment: Attachment;
|
||||
initialTime?: number;
|
||||
autoPlay?: boolean;
|
||||
leadingLabel?: string;
|
||||
/** Suppress the top-left download pill (overlay supplies its own). */
|
||||
hideDownload?: boolean;
|
||||
/** Suppress the fullscreen button (when we are already in fullscreen). */
|
||||
hideFullscreen?: boolean;
|
||||
/** Reports the playhead to the parent on every native `timeupdate`. */
|
||||
onTimeUpdate?: (currentTime: number) => void;
|
||||
/**
|
||||
* `sm` (default) for bubble-sized inline players. `lg` for surfaces like
|
||||
* the fullscreen overlay where controls should feel touch-friendly.
|
||||
*/
|
||||
size?: Size;
|
||||
}) {
|
||||
const { openVideo } = useVideoPlayer();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const scrubRef = useRef<HTMLDivElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(initialTime);
|
||||
const [duration, setDuration] = useState(attachment.durationSec ?? 0);
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
|
||||
const t = TOKENS[size];
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onTime = () => {
|
||||
setCurrentTime(v.currentTime);
|
||||
onTimeUpdate?.(v.currentTime);
|
||||
};
|
||||
// `seeked` fires even when the video is paused, so an external
|
||||
// `currentTime = ...` (e.g. syncing back from fullscreen) reaches state
|
||||
// immediately instead of waiting for the next `timeupdate`.
|
||||
const onSeeked = () => {
|
||||
setCurrentTime(v.currentTime);
|
||||
onTimeUpdate?.(v.currentTime);
|
||||
};
|
||||
const onMeta = () => {
|
||||
if (Number.isFinite(v.duration)) setDuration(v.duration);
|
||||
if (initialTime > 0) {
|
||||
try {
|
||||
v.currentTime = initialTime;
|
||||
} catch {
|
||||
// Ignore out-of-range seeks.
|
||||
}
|
||||
}
|
||||
};
|
||||
v.addEventListener("play", onPlay);
|
||||
v.addEventListener("pause", onPause);
|
||||
v.addEventListener("timeupdate", onTime);
|
||||
v.addEventListener("seeked", onSeeked);
|
||||
v.addEventListener("loadedmetadata", onMeta);
|
||||
return () => {
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
v.removeEventListener("timeupdate", onTime);
|
||||
v.removeEventListener("seeked", onSeeked);
|
||||
v.removeEventListener("loadedmetadata", onMeta);
|
||||
};
|
||||
}, [initialTime, onTimeUpdate]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) v.play().catch(() => {});
|
||||
else v.pause();
|
||||
}, []);
|
||||
|
||||
const seekToClientX = useCallback((clientX: number) => {
|
||||
const el = scrubRef.current;
|
||||
const v = videoRef.current;
|
||||
if (!el || !v || !Number.isFinite(v.duration)) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
const next = ratio * v.duration;
|
||||
try {
|
||||
v.currentTime = next;
|
||||
} catch {
|
||||
// Ignore out-of-range seeks.
|
||||
}
|
||||
setCurrentTime(next);
|
||||
}, []);
|
||||
|
||||
const onScrubPointerDown = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
setIsScrubbing(true);
|
||||
seekToClientX(e.clientX);
|
||||
},
|
||||
[seekToClientX],
|
||||
);
|
||||
|
||||
const onScrubPointerMove = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!isScrubbing) return;
|
||||
seekToClientX(e.clientX);
|
||||
},
|
||||
[isScrubbing, seekToClientX],
|
||||
);
|
||||
|
||||
const onScrubPointerEnd = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!isScrubbing) return;
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// Pointer may already have been released.
|
||||
}
|
||||
setIsScrubbing(false);
|
||||
},
|
||||
[isScrubbing],
|
||||
);
|
||||
|
||||
const goFullscreen = useCallback(
|
||||
(e: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const resumeAt = Number.isFinite(v.currentTime) ? v.currentTime : 0;
|
||||
v.pause();
|
||||
openVideo(
|
||||
attachment,
|
||||
resumeAt,
|
||||
(finalTime) => {
|
||||
const inline = videoRef.current;
|
||||
if (!inline || !Number.isFinite(finalTime)) return;
|
||||
// Update React state synchronously so the progress bar paints the
|
||||
// new playhead in the next frame, before the <video> seek round-
|
||||
// trip emits its own events (paused videos don't fire timeupdate
|
||||
// and `seeked` can lag ~hundreds of ms).
|
||||
setCurrentTime(finalTime);
|
||||
onTimeUpdate?.(finalTime);
|
||||
const apply = () => {
|
||||
try {
|
||||
inline.currentTime = finalTime;
|
||||
} catch {
|
||||
// Ignore out-of-range seeks.
|
||||
}
|
||||
};
|
||||
if (inline.readyState >= 1) apply();
|
||||
else inline.addEventListener("loadedmetadata", apply, { once: true });
|
||||
},
|
||||
postId,
|
||||
);
|
||||
},
|
||||
[attachment, openVideo, postId],
|
||||
);
|
||||
|
||||
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const remaining = Math.max(0, duration - currentTime);
|
||||
const handleOffset = size === "lg" ? 8 : 6;
|
||||
|
||||
return (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={attachment.url}
|
||||
poster={attachment.posterUrl}
|
||||
playsInline
|
||||
autoPlay={autoPlay}
|
||||
onClick={togglePlay}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
|
||||
{hideDownload ? null : (
|
||||
<AttachmentDownloadPill
|
||||
postId={postId}
|
||||
attachment={attachment}
|
||||
leadingLabel={leadingLabel}
|
||||
className="absolute left-2 top-2 z-20"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isPlaying ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePlay();
|
||||
}}
|
||||
className="absolute inset-0 z-10 flex items-center justify-center"
|
||||
aria-label="Play"
|
||||
>
|
||||
<span
|
||||
className={`flex items-center justify-center rounded-full bg-black/60 text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition group-hover:bg-black/70 ${t.centerBox}`}
|
||||
>
|
||||
<Play className={`translate-x-0.5 fill-white ${t.centerIcon}`} />
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 z-20 flex items-center bg-gradient-to-t from-black/85 via-black/45 to-transparent leading-none text-white ${t.bar}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlay}
|
||||
className={`flex shrink-0 items-center justify-center text-white transition hover:scale-105 ${t.btn}`}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className={`fill-white ${t.btnIcon}`} />
|
||||
) : (
|
||||
<Play className={`translate-x-0.5 fill-white ${t.btnIcon}`} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className={`shrink-0 tabular-nums text-white ${t.timeText}`}>
|
||||
{formatClock(currentTime)}
|
||||
</span>
|
||||
|
||||
<div
|
||||
ref={scrubRef}
|
||||
role="slider"
|
||||
aria-label="Seek"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration || 0}
|
||||
aria-valuenow={currentTime}
|
||||
tabIndex={0}
|
||||
onPointerDown={onScrubPointerDown}
|
||||
onPointerMove={onScrubPointerMove}
|
||||
onPointerUp={onScrubPointerEnd}
|
||||
onPointerCancel={onScrubPointerEnd}
|
||||
className={`group relative min-w-0 flex-1 cursor-pointer touch-none select-none ${t.scrubRow}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-x-0 top-1/2 -translate-y-1/2 overflow-hidden rounded-full bg-white/25 ${t.scrubRail}`}
|
||||
>
|
||||
<div
|
||||
className={`h-full bg-white ${
|
||||
isScrubbing ? "" : "transition-[width] duration-150 ease-out"
|
||||
}`}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute top-1/2 -translate-y-1/2 rounded-full bg-white shadow-lg transition-transform duration-150 ease-out ${
|
||||
isScrubbing ? "scale-125" : "scale-100"
|
||||
} ${t.scrubHandle}`}
|
||||
style={{ left: `calc(${progressPct}% - ${handleOffset}px)` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={`shrink-0 tabular-nums text-white/80 ${t.timeText}`}>
|
||||
-{formatClock(remaining)}
|
||||
</span>
|
||||
|
||||
{hideFullscreen ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goFullscreen}
|
||||
className={`flex shrink-0 items-center justify-center text-white transition hover:scale-105 ${t.btn}`}
|
||||
aria-label="Fullscreen"
|
||||
>
|
||||
<Maximize2 className={t.btnIcon} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { LoaderCircle, Play, X } from "lucide-react";
|
||||
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import type { Attachment, Post } from "../../../types/post";
|
||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||
import { MessageInlineVideo } from "../MessageInlineVideo";
|
||||
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
||||
import { autolink } from "../utils/autolink";
|
||||
import { CollapsibleText } from "../CollapsibleText";
|
||||
@@ -56,7 +57,6 @@ function VideoAttachmentCard({
|
||||
}) {
|
||||
const { openVideo } = useVideoPlayer();
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl;
|
||||
const duration = formatDuration(attachment.durationSec);
|
||||
const previewVideoUrl = attachment.url.includes("#")
|
||||
@@ -73,26 +73,11 @@ function VideoAttachmentCard({
|
||||
style={compact ? undefined : { aspectRatio: videoRatio(attachment) }}
|
||||
>
|
||||
{playing && !compact ? (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={attachment.url}
|
||||
poster={attachment.posterUrl}
|
||||
controls
|
||||
controlsList="nodownload noplaybackrate noremoteplayback"
|
||||
disablePictureInPicture
|
||||
playsInline
|
||||
autoPlay
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="ark-message-video absolute inset-0 h-full w-full"
|
||||
/>
|
||||
<AttachmentDownloadPill
|
||||
postId={postId}
|
||||
attachment={attachment}
|
||||
leadingLabel={duration}
|
||||
className="absolute left-2 top-2 z-20"
|
||||
/>
|
||||
</>
|
||||
<MessageInlineVideo
|
||||
postId={postId}
|
||||
attachment={attachment}
|
||||
leadingLabel={duration}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{posterUrl ? (
|
||||
@@ -141,7 +126,7 @@ function VideoAttachmentCard({
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (compact) openVideo(attachment, 0);
|
||||
if (compact) openVideo(attachment, 0, undefined, postId);
|
||||
else setPlaying(true);
|
||||
}}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
@@ -370,7 +355,7 @@ export function VideoBubble({ post }: { post: Post }) {
|
||||
onClose={() => setListOpen(false)}
|
||||
onPick={(att) => {
|
||||
setListOpen(false);
|
||||
openVideo(att, 0);
|
||||
openVideo(att, 0, undefined, post.id);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -8,14 +8,11 @@ import {
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronLeft, ChevronRight, LoaderCircle, X } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import type { Attachment } from "../../../types/post";
|
||||
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import { useToast } from "../../Toast";
|
||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||
import { BubbleImage } from "../BubbleImage";
|
||||
import { autolink } from "../utils/autolink";
|
||||
import { downloadAttachment } from "../utils/downloadFile";
|
||||
|
||||
type LightboxState = {
|
||||
images: Attachment[];
|
||||
@@ -78,50 +75,6 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) {
|
||||
);
|
||||
}
|
||||
|
||||
function LightboxDownloadButton({
|
||||
postId,
|
||||
attachment,
|
||||
}: {
|
||||
postId: string;
|
||||
attachment: Attachment;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (isDownloading) return;
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadAttachment(postId, attachment.id, attachment.filename);
|
||||
} catch {
|
||||
showToast(t("downloadFail"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload();
|
||||
}}
|
||||
disabled={isDownloading}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 disabled:cursor-wait"
|
||||
aria-label={isDownloading ? t("downloading") : t("download")}
|
||||
aria-busy={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<LoaderCircle className="h-5 w-5 animate-spin" strokeWidth={2.3} />
|
||||
) : (
|
||||
<DownloadCloudIcon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Filmstrip({
|
||||
images,
|
||||
index,
|
||||
@@ -261,15 +214,20 @@ function LightboxView({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{postId ? (
|
||||
<LightboxDownloadButton postId={postId} attachment={current} />
|
||||
<AttachmentDownloadPill
|
||||
postId={postId}
|
||||
attachment={current}
|
||||
size="lg"
|
||||
className=""
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-white/20"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,14 +10,32 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import type { Attachment } from "../../../types/post";
|
||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||
import { MessageInlineVideo } from "../MessageInlineVideo";
|
||||
|
||||
type OnClose = (finalTime: number) => void;
|
||||
|
||||
type PlayerState = {
|
||||
attachment: Attachment;
|
||||
currentTime: number;
|
||||
onClose?: OnClose;
|
||||
/** Post the video belongs to, needed for the download pill. */
|
||||
postId?: string;
|
||||
} | null;
|
||||
|
||||
type Ctx = {
|
||||
openVideo: (attachment: Attachment, currentTime?: number) => void;
|
||||
/**
|
||||
* Open the fullscreen player. `onClose` (optional) is invoked with the
|
||||
* playhead at the moment the user dismisses the overlay, so callers can
|
||||
* sync the original inline `<video>` back to the time the user actually
|
||||
* watched until.
|
||||
*/
|
||||
openVideo: (
|
||||
attachment: Attachment,
|
||||
currentTime?: number,
|
||||
onClose?: OnClose,
|
||||
postId?: string,
|
||||
) => void;
|
||||
closeVideo: () => void;
|
||||
};
|
||||
|
||||
@@ -34,8 +52,12 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
||||
const [state, setState] = useState<PlayerState>(null);
|
||||
|
||||
const openVideo = useCallback(
|
||||
(attachment: Attachment, currentTime = 0) =>
|
||||
setState({ attachment, currentTime }),
|
||||
(
|
||||
attachment: Attachment,
|
||||
currentTime = 0,
|
||||
onClose?: OnClose,
|
||||
postId?: string,
|
||||
) => setState({ attachment, currentTime, onClose, postId }),
|
||||
[],
|
||||
);
|
||||
const closeVideo = useCallback(() => setState(null), []);
|
||||
@@ -47,7 +69,11 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
||||
<PlayerView
|
||||
attachment={state.attachment}
|
||||
startAt={state.currentTime}
|
||||
onClose={closeVideo}
|
||||
postId={state.postId}
|
||||
onClose={(finalTime) => {
|
||||
state.onClose?.(finalTime);
|
||||
setState(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</VideoPlayerContext.Provider>
|
||||
@@ -57,17 +83,28 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
||||
function PlayerView({
|
||||
attachment,
|
||||
startAt,
|
||||
postId,
|
||||
onClose,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
startAt: number;
|
||||
onClose: () => void;
|
||||
postId?: string;
|
||||
onClose: (finalTime: number) => void;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
// Track the playhead reported by the embedded `MessageInlineVideo` so we
|
||||
// can hand it back to the caller when the user dismisses the overlay.
|
||||
const lastTimeRef = useRef<number>(startAt);
|
||||
|
||||
const close = useCallback(() => {
|
||||
const finalTime = Number.isFinite(lastTimeRef.current)
|
||||
? lastTimeRef.current
|
||||
: startAt;
|
||||
onClose(finalTime);
|
||||
}, [onClose, startAt]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
@@ -101,42 +138,51 @@ function PlayerView({
|
||||
body.style.overflow = prev.overflow;
|
||||
window.scrollTo(0, scrollY);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (startAt > 0) v.currentTime = startAt;
|
||||
v.play().catch(() => {});
|
||||
}, [startAt]);
|
||||
}, [close]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95"
|
||||
onClick={onClose}
|
||||
onClick={close}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{postId ? (
|
||||
<AttachmentDownloadPill
|
||||
postId={postId}
|
||||
attachment={attachment}
|
||||
size="lg"
|
||||
className="absolute left-4 top-4 z-10"
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
close();
|
||||
}}
|
||||
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-white/20"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={attachment.url}
|
||||
poster={attachment.posterUrl}
|
||||
controls
|
||||
playsInline
|
||||
className="max-h-[92vh] max-w-[96vw] outline-none"
|
||||
<div
|
||||
className="relative h-full w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
>
|
||||
<MessageInlineVideo
|
||||
postId={postId ?? ""}
|
||||
attachment={attachment}
|
||||
initialTime={startAt}
|
||||
autoPlay
|
||||
hideDownload
|
||||
hideFullscreen
|
||||
size="lg"
|
||||
onTimeUpdate={(t) => {
|
||||
lastTimeRef.current = t;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user