terry-staging #11
@@ -20,6 +20,14 @@ type AttachmentDownloadPillProps = {
|
|||||||
* Defaults to false (fixed 30px) for standalone images/videos.
|
* Defaults to false (fixed 30px) for standalone images/videos.
|
||||||
*/
|
*/
|
||||||
adaptive?: boolean;
|
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({
|
export function AttachmentDownloadPill({
|
||||||
@@ -28,6 +36,7 @@ export function AttachmentDownloadPill({
|
|||||||
leadingLabel,
|
leadingLabel,
|
||||||
className = "absolute left-2 top-2",
|
className = "absolute left-2 top-2",
|
||||||
adaptive = false,
|
adaptive = false,
|
||||||
|
size = "sm",
|
||||||
}: AttachmentDownloadPillProps) {
|
}: AttachmentDownloadPillProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { showToast } = useToast();
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={isDownloading}
|
disabled={isDownloading}
|
||||||
className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/80 ${
|
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}`}
|
||||||
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}`}
|
|
||||||
aria-label={
|
aria-label={
|
||||||
isDownloading ? t("downloading") : `Download ${attachment.filename}`
|
isDownloading ? t("downloading") : `Download ${attachment.filename}`
|
||||||
}
|
}
|
||||||
aria-busy={isDownloading}
|
aria-busy={isDownloading}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`flex items-center justify-center bg-[#545454]/50 transition group-hover:bg-[#545454]/70 ${
|
className={`flex items-center justify-center bg-[#545454]/50 transition group-hover:bg-[#545454]/70 ${squareCls}`}
|
||||||
adaptive
|
|
||||||
? "h-[clamp(22px,18cqw,30px)] w-[clamp(22px,18cqw,30px)]"
|
|
||||||
: "h-[30px] w-[30px]"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isDownloading ? (
|
{isDownloading ? (
|
||||||
<LoaderCircle
|
<LoaderCircle
|
||||||
className={`${
|
className={`${iconCls} animate-spin`}
|
||||||
adaptive
|
|
||||||
? "h-[clamp(13px,11cqw,18px)] w-[clamp(13px,11cqw,18px)]"
|
|
||||||
: "h-[18px] w-[18px]"
|
|
||||||
} animate-spin`}
|
|
||||||
strokeWidth={2.3}
|
strokeWidth={2.3}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DownloadCloudIcon
|
<DownloadCloudIcon className={iconCls} />
|
||||||
className={
|
|
||||||
adaptive
|
|
||||||
? "h-[clamp(13px,11cqw,18px)] w-[clamp(13px,11cqw,18px)]"
|
|
||||||
: "h-[18px] w-[18px]"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className={`flex items-center gap-1 ${textBoxCls}`}>
|
||||||
className={`flex items-center gap-0.5 ${
|
|
||||||
adaptive
|
|
||||||
? "h-[clamp(22px,18cqw,30px)] px-[clamp(6px,6cqw,10px)]"
|
|
||||||
: "h-[30px] px-2.5"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isDownloading ? (
|
{isDownloading ? (
|
||||||
t("downloading")
|
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 { LoaderCircle, Play, X } from "lucide-react";
|
||||||
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
|
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useI18n } from "../../../i18n";
|
import { useI18n } from "../../../i18n";
|
||||||
import type { Attachment, Post } from "../../../types/post";
|
import type { Attachment, Post } from "../../../types/post";
|
||||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
|
import { MessageInlineVideo } from "../MessageInlineVideo";
|
||||||
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
import { CollapsibleText } from "../CollapsibleText";
|
import { CollapsibleText } from "../CollapsibleText";
|
||||||
@@ -56,7 +57,6 @@ function VideoAttachmentCard({
|
|||||||
}) {
|
}) {
|
||||||
const { openVideo } = useVideoPlayer();
|
const { openVideo } = useVideoPlayer();
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl;
|
const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl;
|
||||||
const duration = formatDuration(attachment.durationSec);
|
const duration = formatDuration(attachment.durationSec);
|
||||||
const previewVideoUrl = attachment.url.includes("#")
|
const previewVideoUrl = attachment.url.includes("#")
|
||||||
@@ -73,26 +73,11 @@ function VideoAttachmentCard({
|
|||||||
style={compact ? undefined : { aspectRatio: videoRatio(attachment) }}
|
style={compact ? undefined : { aspectRatio: videoRatio(attachment) }}
|
||||||
>
|
>
|
||||||
{playing && !compact ? (
|
{playing && !compact ? (
|
||||||
<>
|
<MessageInlineVideo
|
||||||
<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}
|
postId={postId}
|
||||||
attachment={attachment}
|
attachment={attachment}
|
||||||
leadingLabel={duration}
|
leadingLabel={duration}
|
||||||
className="absolute left-2 top-2 z-20"
|
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{posterUrl ? (
|
{posterUrl ? (
|
||||||
@@ -141,7 +126,7 @@ function VideoAttachmentCard({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (compact) openVideo(attachment, 0);
|
if (compact) openVideo(attachment, 0, undefined, postId);
|
||||||
else setPlaying(true);
|
else setPlaying(true);
|
||||||
}}
|
}}
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
@@ -370,7 +355,7 @@ export function VideoBubble({ post }: { post: Post }) {
|
|||||||
onClose={() => setListOpen(false)}
|
onClose={() => setListOpen(false)}
|
||||||
onPick={(att) => {
|
onPick={(att) => {
|
||||||
setListOpen(false);
|
setListOpen(false);
|
||||||
openVideo(att, 0);
|
openVideo(att, 0, undefined, post.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -8,14 +8,11 @@ import {
|
|||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 type { Attachment } from "../../../types/post";
|
||||||
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
import { useI18n } from "../../../i18n";
|
|
||||||
import { useToast } from "../../Toast";
|
|
||||||
import { BubbleImage } from "../BubbleImage";
|
import { BubbleImage } from "../BubbleImage";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
import { downloadAttachment } from "../utils/downloadFile";
|
|
||||||
|
|
||||||
type LightboxState = {
|
type LightboxState = {
|
||||||
images: Attachment[];
|
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({
|
function Filmstrip({
|
||||||
images,
|
images,
|
||||||
index,
|
index,
|
||||||
@@ -261,15 +214,20 @@ function LightboxView({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{postId ? (
|
{postId ? (
|
||||||
<LightboxDownloadButton postId={postId} attachment={current} />
|
<AttachmentDownloadPill
|
||||||
|
postId={postId}
|
||||||
|
attachment={current}
|
||||||
|
size="lg"
|
||||||
|
className=""
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
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"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,14 +10,32 @@ import {
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import type { Attachment } from "../../../types/post";
|
import type { Attachment } from "../../../types/post";
|
||||||
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
|
import { MessageInlineVideo } from "../MessageInlineVideo";
|
||||||
|
|
||||||
|
type OnClose = (finalTime: number) => void;
|
||||||
|
|
||||||
type PlayerState = {
|
type PlayerState = {
|
||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
|
onClose?: OnClose;
|
||||||
|
/** Post the video belongs to, needed for the download pill. */
|
||||||
|
postId?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
type Ctx = {
|
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;
|
closeVideo: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,8 +52,12 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
|||||||
const [state, setState] = useState<PlayerState>(null);
|
const [state, setState] = useState<PlayerState>(null);
|
||||||
|
|
||||||
const openVideo = useCallback(
|
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), []);
|
const closeVideo = useCallback(() => setState(null), []);
|
||||||
@@ -47,7 +69,11 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
|||||||
<PlayerView
|
<PlayerView
|
||||||
attachment={state.attachment}
|
attachment={state.attachment}
|
||||||
startAt={state.currentTime}
|
startAt={state.currentTime}
|
||||||
onClose={closeVideo}
|
postId={state.postId}
|
||||||
|
onClose={(finalTime) => {
|
||||||
|
state.onClose?.(finalTime);
|
||||||
|
setState(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</VideoPlayerContext.Provider>
|
</VideoPlayerContext.Provider>
|
||||||
@@ -57,17 +83,28 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
|||||||
function PlayerView({
|
function PlayerView({
|
||||||
attachment,
|
attachment,
|
||||||
startAt,
|
startAt,
|
||||||
|
postId,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
startAt: number;
|
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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") close();
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
@@ -101,42 +138,51 @@ function PlayerView({
|
|||||||
body.style.overflow = prev.overflow;
|
body.style.overflow = prev.overflow;
|
||||||
window.scrollTo(0, scrollY);
|
window.scrollTo(0, scrollY);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [close]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const v = videoRef.current;
|
|
||||||
if (!v) return;
|
|
||||||
if (startAt > 0) v.currentTime = startAt;
|
|
||||||
v.play().catch(() => {});
|
|
||||||
}, [startAt]);
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95"
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95"
|
||||||
onClick={onClose}
|
onClick={close}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
>
|
>
|
||||||
|
{postId ? (
|
||||||
|
<AttachmentDownloadPill
|
||||||
|
postId={postId}
|
||||||
|
attachment={attachment}
|
||||||
|
size="lg"
|
||||||
|
className="absolute left-4 top-4 z-10"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
<video
|
<div
|
||||||
ref={videoRef}
|
className="relative h-full w-full"
|
||||||
src={attachment.url}
|
|
||||||
poster={attachment.posterUrl}
|
|
||||||
controls
|
|
||||||
playsInline
|
|
||||||
className="max-h-[92vh] max-w-[96vw] outline-none"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MessageInlineVideo
|
||||||
|
postId={postId ?? ""}
|
||||||
|
attachment={attachment}
|
||||||
|
initialTime={startAt}
|
||||||
|
autoPlay
|
||||||
|
hideDownload
|
||||||
|
hideFullscreen
|
||||||
|
size="lg"
|
||||||
|
onTimeUpdate={(t) => {
|
||||||
|
lastTimeRef.current = t;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user