mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// components/blocks/Banner.jsx
|
||||
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// Height per size — controls the strip's visual weight, not its width (always full-width).
|
||||
const SIZE_HEIGHT = {
|
||||
sm: "h-20",
|
||||
md: "h-32",
|
||||
lg: "h-48",
|
||||
};
|
||||
|
||||
// ── Banner ───────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Generic banner advertisement block.
|
||||
* Full-width horizontal strip, image-led with optional headline overlay.
|
||||
* Click anywhere on the banner triggers the first available CTA (or just tracks
|
||||
* the click if no CTA exists) — banners don't carry their own button row.
|
||||
*
|
||||
* Props:
|
||||
* ad — advertisement object { headline, ctas, image, image_url, advertisement_id }
|
||||
* size — "sm" | "md" | "lg" (default "md")
|
||||
* onCtaClick — (ad, cta) => void, called on click. cta may be undefined if the ad has none.
|
||||
*/
|
||||
export function Banner({ ad, size, onCtaClick }) {
|
||||
if (!ad) return null;
|
||||
|
||||
const imageSrc = ad.image?.file_url || ad.image_url || null;
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
const resolvedSize = ad.size || size || "md";
|
||||
const heightClass = SIZE_HEIGHT[resolvedSize] ?? SIZE_HEIGHT.md;
|
||||
|
||||
const handleClick = () => onCtaClick?.(ad, ctas[0]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={`relative w-full rounded-lg bg-muted overflow-hidden flex items-center justify-center text-left ${heightClass}`}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Megaphone className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{ad.headline && (
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 flex items-end p-4">
|
||||
<p className="text-white font-medium text-sm sm:text-base">{ad.headline}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BannerSkeleton ───────────────────────────────────────────────────────────
|
||||
|
||||
export function BannerSkeleton({ size = "md" }) {
|
||||
return <Skeleton className={`w-full rounded-lg ${SIZE_HEIGHT[size] ?? SIZE_HEIGHT.md}`} />;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// components/blocks/Hero.jsx
|
||||
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// ── Hero ─────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Generic hero advertisement block.
|
||||
* Two-column layout: badge/headline/description/CTAs on the left, image on the right.
|
||||
* Renders null when no ad is provided — callers should not fall back to placeholder copy.
|
||||
*
|
||||
* Props:
|
||||
* ad — advertisement object { badge_label, headline, description, ctas, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void, called when any CTA button is clicked
|
||||
*/
|
||||
export function Hero({ ad, onCtaClick }) {
|
||||
if (!ad) return null;
|
||||
|
||||
const imageSrc = ad.image?.file_url || ad.image_url || null;
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
|
||||
return (
|
||||
<div className="flex xs:flex-col lg:flex-row items-center gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
{ad.badge_label && (
|
||||
<Badge variant="outline">
|
||||
<Megaphone /> {ad.badge_label}
|
||||
</Badge>
|
||||
)}
|
||||
{ad.headline && (
|
||||
<div className="font-bold text-4xl leading-12">
|
||||
{ad.headline}
|
||||
</div>
|
||||
)}
|
||||
{ad.description && (
|
||||
<p className="max-w-lg">
|
||||
{ad.description}
|
||||
</p>
|
||||
)}
|
||||
{ctas.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{ctas.map((cta, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant={cta.variant === "outline" ? "outline" : "default"}
|
||||
onClick={() => onCtaClick?.(ad, cta)}
|
||||
>
|
||||
{cta.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted w-xl aspect-video flex items-center justify-center overflow-hidden">
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Megaphone className="size-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── HeroSkeleton ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function HeroSkeleton() {
|
||||
return (
|
||||
<div className="flex xs:flex-col lg:flex-row items-center gap-6">
|
||||
<div className="flex flex-col gap-3 w-full max-w-lg">
|
||||
<Skeleton className="h-6 w-32 rounded-full" />
|
||||
<Skeleton className="h-10 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="rounded-lg w-xl aspect-video" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// components/blocks/Popup.jsx
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
|
||||
// ── Popup ────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Generic popup advertisement block.
|
||||
* Modal-style placement shown on page load — wraps ResponsiveModal so it gets
|
||||
* dialog/drawer behavior for free. Caller owns the `open` state (typically set
|
||||
* to true once an active popup ad resolves from the API).
|
||||
*
|
||||
* Props:
|
||||
* ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
|
||||
* open — boolean, modal visibility
|
||||
* onOpenChange — (open: boolean) => void
|
||||
* onCtaClick — (ad, cta) => void, called when a footer CTA button is clicked
|
||||
*/
|
||||
export function Popup({ ad, open, onOpenChange, onCtaClick }) {
|
||||
if (!ad) return null;
|
||||
|
||||
const imageSrc = ad.image?.file_url || ad.image_url || null;
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={ad.headline || "Announcement"}
|
||||
description={ad.description || undefined}
|
||||
footer={
|
||||
ctas.length > 0 ? (
|
||||
<>
|
||||
{ctas.map((cta, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant={cta.variant === "outline" ? "outline" : "default"}
|
||||
onClick={() => onCtaClick?.(ad, cta)}
|
||||
>
|
||||
{cta.label}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{imageSrc && (
|
||||
<div className="rounded-lg bg-muted aspect-video flex items-center justify-center overflow-hidden">
|
||||
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</ResponsiveModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// components/blocks/Sidebar.jsx
|
||||
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Generic sidebar advertisement block.
|
||||
* Compact vertical card — image on top, optional short headline/description and
|
||||
* a single CTA below. Meant to sit in a narrow column (sidebars, rail layouts),
|
||||
* not stretch full-width like Hero/Banner.
|
||||
*
|
||||
* Props:
|
||||
* ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void, called when the CTA button (or card, if no CTA) is clicked
|
||||
*/
|
||||
export function Sidebar({ ad, onCtaClick }) {
|
||||
if (!ad) return null;
|
||||
|
||||
const imageSrc = ad.image?.file_url || ad.image_url || null;
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
const primaryCta = ctas[0];
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (!primaryCta) onCtaClick?.(ad, undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full max-w-xs rounded-lg border bg-card overflow-hidden flex flex-col cursor-pointer"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="aspect-square bg-muted flex items-center justify-center overflow-hidden">
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Megaphone className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(ad.headline || ad.description || primaryCta) && (
|
||||
<div className="p-3 flex flex-col gap-1.5">
|
||||
{ad.headline && <p className="text-sm font-medium leading-snug">{ad.headline}</p>}
|
||||
{ad.description && <p className="text-xs text-muted-foreground line-clamp-2">{ad.description}</p>}
|
||||
{primaryCta && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-1 w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCtaClick?.(ad, primaryCta);
|
||||
}}
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SidebarSkeleton ──────────────────────────────────────────────────────────
|
||||
|
||||
export function SidebarSkeleton() {
|
||||
return (
|
||||
<div className="w-full max-w-xs rounded-lg border bg-card overflow-hidden flex flex-col">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<div className="p-3 flex flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-8 w-full mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtTime = (s) => {
|
||||
if (!s || isNaN(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
|
||||
};
|
||||
|
||||
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
||||
|
||||
// ─── AudioBlock (Client — secure) ────────────────────────────────────────────
|
||||
//
|
||||
// S3/Garage:
|
||||
// 1. POST /client/media/token → get JWT token
|
||||
// 2. fetch(streamUrl, { credentials: "include" }) → get raw bytes
|
||||
// 3. URL.createObjectURL(blob) → blob:http://... URL
|
||||
// 4. <audio src="blob:..."> → real URL never visible in DOM
|
||||
//
|
||||
// Chibisafe:
|
||||
// → content.url used directly (Chibisafe CDN, no proxy needed)
|
||||
//
|
||||
// Direct (legacy):
|
||||
// → content.url / content.src used directly, no token flow
|
||||
//
|
||||
// content shape: { asset_id?, url?, storage_provider?, title?, artist?, tag?, thumbnail? }
|
||||
|
||||
export function AudioBlock({ content }) {
|
||||
const audioRef = useRef(null);
|
||||
|
||||
// ── Stream state ──────────────────────────────────────────────────────────
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState(null);
|
||||
const [fetchLoading, setFetchLoading] = useState(false);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
|
||||
// ── Player state ──────────────────────────────────────────────────────────
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [speedIdx, setSpeedIdx] = useState(2); // 1×
|
||||
|
||||
const assetId = content?.asset_id;
|
||||
const storageProvider = content?.storage_provider;
|
||||
const directUrl = content?.url ?? content?.src ?? null;
|
||||
const title = content?.title ?? "Audio";
|
||||
const artist = content?.artist ?? "";
|
||||
const tag = content?.tag ?? "";
|
||||
// For S3 assets, thumbnailUrl is set from the token response (presigned URL).
|
||||
// For other providers, fall back to the raw content.thumbnail value.
|
||||
const thumbnail = thumbnailUrl ?? content?.thumbnail ?? null;
|
||||
|
||||
// ── Resolve stream URL → set as audio src directly ───────────────────────
|
||||
useEffect(() => {
|
||||
setBlobUrl(null);
|
||||
setThumbnailUrl(null);
|
||||
setFetchError(false);
|
||||
setPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
|
||||
// Legacy direct URL — no asset_id
|
||||
if (!assetId && directUrl) {
|
||||
setBlobUrl(directUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!assetId) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setFetchLoading(true);
|
||||
try {
|
||||
// Chibisafe — use raw URL directly (CDN is public, no token needed)
|
||||
if (storageProvider === "chibisafe") {
|
||||
if (!directUrl) throw new Error("No URL in content");
|
||||
if (!cancelled) setBlobUrl(directUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// S3 — get token; response also includes presigned thumbnail URL
|
||||
const { data } = await api.post("/client/media/token", { asset_id: assetId });
|
||||
if (cancelled) return;
|
||||
const { token, thumbnail_url } = data?.data ?? {};
|
||||
if (!token) throw new Error("No token returned");
|
||||
|
||||
if (!cancelled) {
|
||||
setBlobUrl(`${API_BASE}/client/media/stream/${token}`);
|
||||
if (thumbnail_url) setThumbnailUrl(thumbnail_url);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error("[AudioBlock] load failed", err);
|
||||
setFetchError(true);
|
||||
} finally {
|
||||
if (!cancelled) setFetchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [assetId, storageProvider, directUrl]);
|
||||
|
||||
// ── Audio events ──────────────────────────────────────────────────────────
|
||||
|
||||
const onTimeUpdate = useCallback(() => setCurrentTime(audioRef.current?.currentTime ?? 0), []);
|
||||
const onLoadedMeta = useCallback(() => setDuration(audioRef.current?.duration ?? 0), []);
|
||||
const onEnded = useCallback(() => setPlaying(false), []);
|
||||
const onProgress = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
if (el?.buffered.length && el.duration) {
|
||||
setBuffered((el.buffered.end(el.buffered.length - 1) / el.duration) * 100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Controls ──────────────────────────────────────────────────────────────
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
if (playing) { el.pause(); setPlaying(false); }
|
||||
else { el.play(); setPlaying(true); }
|
||||
};
|
||||
|
||||
const seek = (e) => {
|
||||
const el = audioRef.current;
|
||||
const bar = e.currentTarget;
|
||||
const pct = (e.clientX - bar.getBoundingClientRect().left) / bar.offsetWidth;
|
||||
el.currentTime = pct * duration;
|
||||
};
|
||||
|
||||
const skip = (secs) => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.currentTime = Math.min(Math.max(0, el.currentTime + secs), duration);
|
||||
};
|
||||
|
||||
const handleVolume = (e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
setVolume(v);
|
||||
if (audioRef.current) audioRef.current.volume = v;
|
||||
setMuted(v === 0);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.muted = !muted;
|
||||
setMuted(!muted);
|
||||
};
|
||||
|
||||
const cycleSpeed = () => {
|
||||
const next = (speedIdx + 1) % SPEEDS.length;
|
||||
setSpeedIdx(next);
|
||||
if (audioRef.current) audioRef.current.playbackRate = SPEEDS[next];
|
||||
};
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
// ── States ────────────────────────────────────────────────────────────────
|
||||
|
||||
if (fetchLoading) {
|
||||
return (
|
||||
<div className="w-full rounded-xl border border-border bg-card flex items-center justify-center h-28">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<div className="w-full rounded-xl border border-border bg-card flex items-center justify-center h-24 gap-2 text-muted-foreground text-sm">
|
||||
<Music2 className="size-4" /> Audio unavailable.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!blobUrl) return null;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="w-full rounded-xl overflow-hidden border border-border bg-card text-card-foreground shadow-sm">
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={blobUrl}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMeta}
|
||||
onEnded={onEnded}
|
||||
onProgress={onProgress}
|
||||
preload="auto"
|
||||
/>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="xs:opacity-0 lg:opacity-100 select-none absolute top-4 right-5 z-20">
|
||||
<img src="/philpro-white-single.png" alt="Logo" className="h-6 w-auto" />
|
||||
</div>
|
||||
|
||||
{thumbnail ? (
|
||||
<div
|
||||
className="absolute inset-0 scale-110"
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnail})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
filter: "blur(24px) brightness(0.35)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
)}
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="relative z-10 flex flex-col gap-4 pb-6 text-white sm:hidden">
|
||||
<div className="w-full px-4 pt-4">
|
||||
<div className="w-full h-72 aspect-square rounded-lg overflow-hidden bg-black/30 shadow-xl dark:border">
|
||||
{thumbnail ? (
|
||||
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Music2 className="w-10 h-10 text-white/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-4">
|
||||
{tag && (
|
||||
<div className="text-xs font-semibold rounded-full uppercase px-3 py-1 bg-card text-card-foreground border w-fit mb-1">
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-lg font-semibold leading-tight">{title}</p>
|
||||
{artist && <p className="text-sm text-white/60">{artist}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="relative z-10 hidden sm:flex items-center gap-4 p-4 text-white">
|
||||
<div className="shrink-0 w-48 h-48 rounded-md overflow-hidden bg-black/25">
|
||||
{thumbnail ? (
|
||||
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Music2 className="w-7 h-7 text-white/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
{tag && (
|
||||
<div className="text-xs font-semibold rounded-full uppercase px-3 py-1 bg-card text-card-foreground border w-fit">
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
<p className="w-sm line-clamp-3 text-lg font-semibold leading-tight">{title}</p>
|
||||
{artist && <p className="text-sm text-white/60 truncate">{artist}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Controls ── */}
|
||||
<div className="px-4 pb-4 pt-3 space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xs tabular-nums text-card-foreground w-8 shrink-0">
|
||||
{fmtTime(currentTime)}
|
||||
</span>
|
||||
<div
|
||||
className="flex-1 h-1.5 rounded-full bg-muted cursor-pointer relative group"
|
||||
onClick={seek}
|
||||
role="slider"
|
||||
aria-label="Seek"
|
||||
aria-valuenow={Math.round(progress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 rounded-full bg-muted-foreground/25 transition-[width] duration-300" style={{ width: `${buffered}%` }} />
|
||||
<div className="h-full rounded-full bg-primary transition-all relative" style={{ width: `${progress}%` }} />
|
||||
<div className="absolute top-1/2 w-3 h-3 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${progress}%`, transform: "translate(-50%, -50%)" }} />
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-card-foreground w-8 shrink-0 text-right">
|
||||
{fmtTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 items-center">
|
||||
{/* Left — volume */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={toggleMute} aria-label={muted ? "Unmute" : "Mute"} className="w-7 h-7 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors">
|
||||
{muted || volume === 0 ? <VolumeOff className="size-4" /> : <Volume2 className="size-4" />}
|
||||
</button>
|
||||
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolume} aria-label="Volume" className="w-16 h-1.5" />
|
||||
</div>
|
||||
|
||||
{/* Center — play controls */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button onClick={() => skip(-10)} aria-label="Rewind 10 seconds" className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors">
|
||||
<RotateCcw className="size-4" />
|
||||
</button>
|
||||
<button onClick={togglePlay} aria-label={playing ? "Pause" : "Play"} className="p-3 rounded-full flex items-center justify-center bg-primary text-primary-foreground hover:opacity-80 transition-opacity active:scale-95 shadow-md">
|
||||
{playing ? <Pause className="xs:size-4 lg:size-5" /> : <Play className="xs:size-4 lg:size-5" />}
|
||||
</button>
|
||||
<button onClick={() => skip(10)} aria-label="Forward 10 seconds" className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors">
|
||||
<RotateCw className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right — speed */}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={cycleSpeed} aria-label={`Playback speed ${SPEEDS[speedIdx]}x`} className="h-7 px-2 rounded text-sm font-medium text-foreground hover:bg-muted transition-colors tabular-nums">
|
||||
{SPEEDS[speedIdx]}x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from "react";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
export function CodeBlock({ content }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const code = content.code ?? "";
|
||||
const language = content.language ?? "text";
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (!code) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground italic">
|
||||
Empty code block
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden border border-zinc-700 dark:border-zinc-600 bg-zinc-950 text-zinc-100 my-2">
|
||||
{/* ── Header bar ── */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-700">
|
||||
<span className="text-[11px] font-mono text-zinc-400 uppercase tracking-widest select-none">
|
||||
{language}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 text-xs text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
>
|
||||
{copied
|
||||
? <Check className="size-3.5 text-emerald-400" />
|
||||
: <Copy className="size-3.5" />
|
||||
}
|
||||
<span>{copied ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Code area ── */}
|
||||
<pre className="overflow-x-auto p-4 text-sm leading-relaxed" style={{ margin: 0, background: "transparent" }}>
|
||||
<code className="font-mono whitespace-pre">{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { ZoomableImage } from "@/modules/admin/components/courses/LessonsPreview";
|
||||
|
||||
export function ImageBlock({ content }) {
|
||||
if (!content.url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
No image
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ZoomableImage url={content.url} alt={content.alt} />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { MARKDOWN_STYLES } from "@/components/generic/Blocks/Admin/MarkdownBlock";
|
||||
|
||||
export function MarkdownBlock({ content }) {
|
||||
const body = content.body ?? "";
|
||||
|
||||
if (!body.trim()) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground italic py-2">
|
||||
Empty markdown block
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{MARKDOWN_STYLES}</style>
|
||||
<div className="md-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
|
||||
|
||||
export function TextBlock({ content }) {
|
||||
if (!content.body) {
|
||||
return <div className="text-xs text-muted-foreground italic py-2">Empty text block</div>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<style>{WYSIWYG_STYLES}</style>
|
||||
<div
|
||||
className="wysiwyg-preview text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: content.body }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
|
||||
import { ImageBlock } from "./ImageBlock";
|
||||
|
||||
export function TextImageBlock({ content }) {
|
||||
const imgLeft = content.image_position === "left";
|
||||
return (
|
||||
<>
|
||||
<style>{WYSIWYG_STYLES}</style>
|
||||
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
|
||||
{imgLeft && <ImageBlock content={content} />}
|
||||
<div
|
||||
className="wysiwyg-preview text-sm w-full"
|
||||
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
|
||||
/>
|
||||
{!imgLeft && <ImageBlock content={content} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
|
||||
import { VideoBlock } from "./VideoBlock";
|
||||
|
||||
export function TextVideoBlock({ content }) {
|
||||
const vidLeft = content.video_position === "left";
|
||||
return (
|
||||
<>
|
||||
<style>{WYSIWYG_STYLES}</style>
|
||||
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
|
||||
{vidLeft && <VideoBlock content={content} />}
|
||||
<div
|
||||
className="wysiwyg-preview text-sm w-full"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>",
|
||||
}}
|
||||
/>
|
||||
{!vidLeft && <VideoBlock content={content} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Play, Pause, SkipBack, Volume2, VolumeX, Maximize2, Minimize2, Settings, VideoIcon,
|
||||
Volume1, SkipForward, Gauge, X, RotateCcw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtTime = (s) => {
|
||||
if (!s || isNaN(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
|
||||
};
|
||||
|
||||
const PLAYBACK_SPEEDS = ["0.5", "0.75", "Normal", "1.25", "1.5", "2"];
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
||||
|
||||
// ─── Tooltip control button ───────────────────────────────────────────────────
|
||||
|
||||
function CtrlBtn({ label, onClick, children, className = "" }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={`text-white/80 hover:text-white transition-colors flex items-center justify-center ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-sm">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Settings panel ───────────────────────────────────────────────────────────
|
||||
|
||||
function SettingsPanel({ speed, onSpeed, onClose }) {
|
||||
const [tab, setTab] = useState(null);
|
||||
|
||||
const Row = ({ icon: Icon, label, value, onClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-white/10 transition-colors text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2.5 text-white/90">
|
||||
<Icon className="size-4 text-white/50" />
|
||||
{label}
|
||||
</span>
|
||||
<div className="text-white/50 flex items-center gap-2">
|
||||
<p>{value}</p><ChevronRight className="size-4" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
const OptionList = ({ options, current, onSelect }) => (
|
||||
<div className="py-1">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt} type="button"
|
||||
onClick={() => { onSelect(opt); setTab(null); }}
|
||||
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-white/10 flex items-center justify-between ${current === opt ? "text-white font-medium" : "text-white/60"}`}
|
||||
>
|
||||
{opt}
|
||||
{current === opt && <span className="text-white text-xs">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="hidden lg:block absolute bottom-12 right-2 z-20 w-70 rounded-xl overflow-hidden shadow-2xl border border-white/10"
|
||||
style={{ background: "rgba(18,18,18,0.96)", backdropFilter: "blur(16px)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{tab === null && (
|
||||
<>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/30 px-4 pt-3 pb-1">Settings</p>
|
||||
<Row icon={Gauge} label="Playback speed" value={speed} onClick={() => setTab("speed")} />
|
||||
<div className="h-2" />
|
||||
</>
|
||||
)}
|
||||
{tab !== null && (
|
||||
<>
|
||||
<button type="button" onClick={() => setTab(null)} className="flex items-center gap-1.5 px-4 pt-3 pb-1 text-sm text-white/40 hover:text-white/70 transition-colors w-full">
|
||||
<ChevronLeft className="size-4" /> Playback speed
|
||||
</button>
|
||||
<OptionList options={PLAYBACK_SPEEDS} current={speed} onSelect={onSpeed} />
|
||||
<div className="h-1" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="lg:hidden absolute bottom-0 left-0 right-0 z-20 rounded-t-2xl border-t border-white/10 overflow-hidden"
|
||||
style={{ background: "rgba(18,18,18,0.98)", backdropFilter: "blur(20px)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 pt-2.5 pb-1">
|
||||
<div className="w-8 h-1 rounded-full bg-white/20 mx-auto" />
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); onClose(); }} className="absolute right-3 top-3 text-white/40 hover:text-white transition-colors">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{tab === null && (
|
||||
<>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/30 px-4 pt-2 pb-1">Settings</p>
|
||||
<Row icon={Gauge} label="Playback speed" value={speed} onClick={() => setTab("speed")} />
|
||||
<div className="h-safe pb-4" />
|
||||
</>
|
||||
)}
|
||||
{tab !== null && (
|
||||
<>
|
||||
<button type="button" onClick={() => setTab(null)} className="flex items-center gap-1.5 px-4 pt-3 pb-1 text-sm text-white/40 hover:text-white/70 transition-colors w-full">
|
||||
<ChevronLeft className="size-4" /> Playback speed
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2 px-4 py-3">
|
||||
{PLAYBACK_SPEEDS.map((opt) => (
|
||||
<button
|
||||
key={opt} type="button"
|
||||
onClick={() => { onSpeed(opt); setTab(null); }}
|
||||
className={`px-4 py-1.5 rounded-full text-sm border transition-colors ${speed === opt ? "bg-white text-black border-white font-medium" : "bg-white/10 text-white/70 border-white/10 hover:bg-white/20"}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pb-4" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── VideoBlock (Client — secure) ────────────────────────────────────────────
|
||||
//
|
||||
// S3/Garage:
|
||||
// 1. POST /client/media/token → get JWT token
|
||||
// 2. fetch(streamUrl, { credentials: "include" }) → get raw bytes
|
||||
// 3. URL.createObjectURL(blob) → blob:http://... URL
|
||||
// 4. <video src="blob:..."> → real URL never visible in DOM
|
||||
//
|
||||
// Chibisafe:
|
||||
// → content.url used directly (Chibisafe CDN, no proxy needed)
|
||||
//
|
||||
// content shape: { asset_id, url, storage_provider, thumbnail_url? }
|
||||
|
||||
export function VideoBlock({ content }) {
|
||||
const wrapRef = useRef(null);
|
||||
const vidRef = useRef(null);
|
||||
|
||||
// ── Stream state ──────────────────────────────────────────────────────────
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const [fetchLoading, setFetchLoading] = useState(false);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
|
||||
// ── Player state ──────────────────────────────────────────────────────────
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [totalDuration, setTotalDuration] = useState(0);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [overlayVisible, setOverlayVisible] = useState(true);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [volumePanelOpen, setVolumePanelOpen] = useState(false);
|
||||
const [keyFeedback, setKeyFeedback] = useState(null);
|
||||
const [speed, setSpeed] = useState("Normal");
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [buffering, setBuffering] = useState(false);
|
||||
const [hoverProgress, setHoverProgress] = useState(null);
|
||||
|
||||
const hideTimer = useRef(null);
|
||||
const keyFeedbackTimer = useRef(null);
|
||||
const previewVidRef = useRef(null);
|
||||
|
||||
const assetId = content?.asset_id;
|
||||
const storageProvider = content?.storage_provider;
|
||||
const poster = content?.thumbnail_url ?? undefined;
|
||||
|
||||
// ── Resolve stream URL → set as video src directly ───────────────────────
|
||||
//
|
||||
// Previously we fetched all bytes into a Blob and used URL.createObjectURL().
|
||||
// That blob URL could be opened in a new tab and saved with "Save Video As...".
|
||||
// Now we set the stream URL directly as <video src> — no blob is ever created.
|
||||
// The backend blocks direct browser navigation (Sec-Fetch-Mode: navigate → 401)
|
||||
// and the token is IP-bound, so sharing the URL is ineffective.
|
||||
useEffect(() => {
|
||||
if (!assetId) return;
|
||||
|
||||
setBlobUrl(null);
|
||||
setFetchError(false);
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
setCurrentTime(0);
|
||||
setTotalDuration(0);
|
||||
setOverlayVisible(true);
|
||||
setSettingsOpen(false);
|
||||
setEnded(false);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setFetchLoading(true);
|
||||
try {
|
||||
// Chibisafe — use raw URL directly
|
||||
if (storageProvider === "chibisafe") {
|
||||
const raw = content?.url;
|
||||
if (!raw) throw new Error("No URL in content");
|
||||
if (!cancelled) setBlobUrl(raw);
|
||||
return;
|
||||
}
|
||||
|
||||
// S3 — get token then stream directly; no blob download
|
||||
const { data } = await api.post("/client/media/token", { asset_id: assetId });
|
||||
if (cancelled) return;
|
||||
const { token } = data?.data ?? {};
|
||||
if (!token) throw new Error("No token returned");
|
||||
|
||||
if (!cancelled) setBlobUrl(`${API_BASE}/client/media/stream/${token}`);
|
||||
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error("[VideoBlock] load failed", err);
|
||||
setFetchError(true);
|
||||
} finally {
|
||||
if (!cancelled) setFetchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [assetId, storageProvider]);
|
||||
|
||||
// ── Video events ──────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const v = vidRef.current;
|
||||
if (!v || !blobUrl) return;
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
setCurrentTime(v.currentTime);
|
||||
if (v.duration) setProgress((v.currentTime / v.duration) * 100);
|
||||
};
|
||||
const onLoaded = () => setTotalDuration(v.duration);
|
||||
const onEnded = () => { setPlaying(false); setOverlayVisible(false); setEnded(true); };
|
||||
const onWaiting = () => setBuffering(true);
|
||||
const onCanPlay = () => setBuffering(false);
|
||||
const onProgress = () => {
|
||||
if (v.buffered.length && v.duration) {
|
||||
setBuffered((v.buffered.end(v.buffered.length - 1) / v.duration) * 100);
|
||||
}
|
||||
};
|
||||
|
||||
v.addEventListener("waiting", onWaiting);
|
||||
v.addEventListener("canplay", onCanPlay);
|
||||
v.addEventListener("timeupdate", onTimeUpdate);
|
||||
v.addEventListener("loadedmetadata", onLoaded);
|
||||
v.addEventListener("ended", onEnded);
|
||||
v.addEventListener("progress", onProgress);
|
||||
|
||||
if (v.readyState >= 1 && v.duration) setTotalDuration(v.duration);
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("waiting", onWaiting);
|
||||
v.removeEventListener("canplay", onCanPlay);
|
||||
v.removeEventListener("timeupdate", onTimeUpdate);
|
||||
v.removeEventListener("loadedmetadata", onLoaded);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
v.removeEventListener("progress", onProgress);
|
||||
};
|
||||
}, [blobUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
v.playbackRate = speed === "Normal" ? 1 : parseFloat(speed);
|
||||
}, [speed]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener("fullscreenchange", onChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||
}, []);
|
||||
|
||||
const resetHideTimer = useCallback(() => {
|
||||
setControlsVisible(true);
|
||||
clearTimeout(hideTimer.current);
|
||||
if (playing) {
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setControlsVisible(false);
|
||||
setSettingsOpen(false);
|
||||
}, 3000);
|
||||
}
|
||||
}, [playing]);
|
||||
|
||||
useEffect(() => {
|
||||
resetHideTimer();
|
||||
return () => clearTimeout(hideTimer.current);
|
||||
}, [playing, resetHideTimer]);
|
||||
|
||||
const showFeedback = useCallback((icon, label) => {
|
||||
setKeyFeedback((prev) => ({ icon, label, key: (prev?.key ?? 0) + 1 }));
|
||||
clearTimeout(keyFeedbackTimer.current);
|
||||
keyFeedbackTimer.current = setTimeout(() => setKeyFeedback(null), 800);
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) { v.play(); setPlaying(true); setOverlayVisible(false); setEnded(false); }
|
||||
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
|
||||
}, []);
|
||||
|
||||
const restart = () => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
setPlaying(false);
|
||||
setOverlayVisible(true);
|
||||
};
|
||||
|
||||
const handleSeek = (e) => {
|
||||
const v = vidRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
v.currentTime = (parseFloat(e.target.value) / 100) * v.duration;
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setVolume(val);
|
||||
if (vidRef.current) { vidRef.current.volume = val; vidRef.current.muted = val === 0; }
|
||||
setMuted(val === 0);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
v.muted = !v.muted;
|
||||
setMuted(v.muted);
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else el.requestFullscreen?.();
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.target.tagName === "INPUT") return;
|
||||
switch (e.key) {
|
||||
case " ": case "k":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
showFeedback(playing ? <Pause className="size-7 text-white" /> : <Play className="size-7 text-white" />, playing ? "Pause" : "Play");
|
||||
resetHideTimer();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (vidRef.current) vidRef.current.currentTime = Math.min(vidRef.current.currentTime + 5, vidRef.current.duration);
|
||||
showFeedback(<SkipForward className="size-7 text-white" />, "+5s");
|
||||
resetHideTimer();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
if (vidRef.current) vidRef.current.currentTime = Math.max(vidRef.current.currentTime - 5, 0);
|
||||
showFeedback(<SkipBack className="size-7 text-white" />, "-5s");
|
||||
resetHideTimer();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (vidRef.current) {
|
||||
const nv = Math.min(volume + 0.1, 1);
|
||||
vidRef.current.volume = nv; setVolume(nv); setMuted(false);
|
||||
showFeedback(<Volume2 className="size-7 text-white" />, `${Math.round(nv * 100)}%`);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (vidRef.current) {
|
||||
const nv = Math.max(volume - 0.1, 0);
|
||||
vidRef.current.volume = nv; setVolume(nv); setMuted(nv === 0);
|
||||
showFeedback(<Volume1 className="size-7 text-white" />, `${Math.round(nv * 100)}%`);
|
||||
}
|
||||
break;
|
||||
case "m":
|
||||
e.preventDefault();
|
||||
toggleMute();
|
||||
showFeedback(muted ? <Volume2 className="size-7 text-white" /> : <VolumeX className="size-7 text-white" />, muted ? "Unmuted" : "Muted");
|
||||
break;
|
||||
case "f":
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
showFeedback(isFullscreen ? <Minimize2 className="size-7 text-white" /> : <Maximize2 className="size-7 text-white" />, isFullscreen ? "Exit fullscreen" : "Fullscreen");
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback]);
|
||||
|
||||
// ── States ────────────────────────────────────────────────────────────────
|
||||
|
||||
if (!assetId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
||||
<VideoIcon className="h-4 w-4" /> No video
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center aspect-video rounded-lg bg-black/90">
|
||||
<div className="w-10 h-10 rounded-full border-4 border-white/20 border-t-white animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError || !blobUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center aspect-video rounded-lg bg-black/80 gap-2">
|
||||
<VideoIcon className="h-8 w-8 text-white/30" />
|
||||
<p className="text-sm text-white/50">Video unavailable.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div
|
||||
ref={wrapRef}
|
||||
tabIndex={0}
|
||||
className="relative w-full rounded-lg overflow-hidden bg-black select-none outline-none"
|
||||
style={{ aspectRatio: isFullscreen ? undefined : "16/9" }}
|
||||
onMouseMove={resetHideTimer}
|
||||
onMouseLeave={() => { if (playing) setControlsVisible(false); }}
|
||||
onClick={() => { togglePlay(); resetHideTimer(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<video
|
||||
ref={vidRef}
|
||||
src={blobUrl}
|
||||
poster={poster}
|
||||
preload="auto"
|
||||
playsInline
|
||||
controlsList="nodownload nofullscreen noremoteplayback"
|
||||
disablePictureInPicture
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className={`w-full h-full ${isFullscreen ? "object-contain" : "object-cover"}`}
|
||||
/>
|
||||
|
||||
{/* Centre overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 pointer-events-none ${overlayVisible ? "opacity-100" : "opacity-0"}`}
|
||||
style={{ background: "rgba(0,0,0,0.3)" }}
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center border border-white/25">
|
||||
{playing ? <Pause className="size-6 text-white" /> : <Play className="size-6 text-white ml-0.5" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buffering spinner */}
|
||||
{buffering && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="w-12 h-12 rounded-full border-4 border-white/20 border-t-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard feedback */}
|
||||
{keyFeedback && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div
|
||||
key={keyFeedback.key}
|
||||
className="flex flex-col items-center gap-1 px-5 py-3 rounded-2xl border border-white/10"
|
||||
style={{ background: "rgba(0,0,0,0.65)", backdropFilter: "blur(12px)", animation: "fadeInOut 0.8s ease forwards" }}
|
||||
>
|
||||
<span className="leading-none">{keyFeedback.icon}</span>
|
||||
<span className="text-white text-sm font-medium tracking-wide">{keyFeedback.label}</span>
|
||||
</div>
|
||||
<style>{`@keyframes fadeInOut{0%{opacity:0;transform:scale(.85)}20%{opacity:1;transform:scale(1)}70%{opacity:1;transform:scale(1)}100%{opacity:0;transform:scale(.95)}}`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End overlay */}
|
||||
{ended && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 pointer-events-none" style={{ background: "rgba(0,0,0,0.55)" }}>
|
||||
<button
|
||||
type="button" aria-label="Replay"
|
||||
className="pointer-events-auto w-16 h-16 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm border border-white/25 flex items-center justify-center transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
v.currentTime = 0; v.play();
|
||||
setPlaying(true); setEnded(false); setOverlayVisible(false);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="size-7 text-white" />
|
||||
</button>
|
||||
<span className="text-white/70 text-sm">Replay</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings panel */}
|
||||
{settingsOpen && (
|
||||
<SettingsPanel speed={speed} onSpeed={setSpeed} onClose={() => setSettingsOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Controls bar */}
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 transition-opacity duration-300 ${controlsVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ background: "linear-gradient(to top, rgba(0,0,0,0.98) 0%, rgba(0,0,0,0.4) 80%, transparent 100%)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-3 pb-4">
|
||||
<div
|
||||
className="relative lg:h-2 xs:h-1 group cursor-pointer"
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1);
|
||||
const time = pct * (vidRef.current?.duration ?? 0);
|
||||
setHoverProgress({ x: e.clientX - rect.left, pct: pct * 100, time });
|
||||
if (previewVidRef.current && isFinite(time) && time >= 0) {
|
||||
previewVidRef.current.currentTime = time;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setHoverProgress(null)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/25 rounded-full" />
|
||||
<div className="absolute inset-y-0 left-0 bg-white/40 rounded-full transition-[width] duration-300" style={{ width: `${buffered}%` }} />
|
||||
<div className="absolute inset-y-0 left-0 bg-white rounded-full" style={{ width: `${progress}%` }} />
|
||||
<div className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white shadow-md -ml-1.5 opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${progress}%` }} />
|
||||
<input type="range" min="0" max="100" step="0.1" value={progress} onChange={handleSeek} aria-label="Seek" className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
|
||||
{/* Scrubber preview — also uses blob URL */}
|
||||
{hoverProgress && (
|
||||
<div
|
||||
className="hidden lg:flex absolute bottom-5 flex-col items-center pointer-events-none z-30"
|
||||
style={{ left: `${hoverProgress.x}px`, transform: "translateX(-50%)" }}
|
||||
>
|
||||
<div className="rounded-md overflow-hidden border border-white/20 shadow-xl" style={{ width: 160, height: 90 }}>
|
||||
<video ref={previewVidRef} src={blobUrl} preload="auto" muted playsInline disablePictureInPicture onContextMenu={(e) => e.preventDefault()} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<span className="text-white text-xs mt-1 font-medium tabular-nums drop-shadow">{fmtTime(hoverProgress.time)}</span>
|
||||
<div className="w-2 h-2 bg-black/60 rotate-45 -mt-1 border-r border-b border-white/20" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-2.5 pb-3">
|
||||
<CtrlBtn label={playing ? "Pause" : "Play"} onClick={togglePlay}>
|
||||
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
|
||||
</CtrlBtn>
|
||||
<CtrlBtn label="Restart" onClick={restart}>
|
||||
<SkipBack className="size-4" />
|
||||
</CtrlBtn>
|
||||
|
||||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||||
{volumePanelOpen && (
|
||||
<div
|
||||
className="lg:hidden absolute bottom-9 left-1/2 -translate-x-1/2 z-30 rounded-xl border border-white/10 px-4 py-3 flex flex-col items-center gap-2"
|
||||
style={{ background: "rgba(18,18,18,0.96)", backdropFilter: "blur(16px)" }}
|
||||
>
|
||||
<span className="text-[10px] text-white/40 uppercase tracking-widest">Volume</span>
|
||||
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolumeChange} aria-label="Volume" className="accent-white cursor-pointer" style={{ writingMode: "vertical-lr", direction: "rtl", height: "80px", width: "auto" }} />
|
||||
<span className="text-xs text-white/50 tabular-nums">{muted ? "0" : Math.round(volume * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
<CtrlBtn
|
||||
label={muted ? "Unmute" : "Mute"}
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 1024) setVolumePanelOpen((o) => !o);
|
||||
else toggleMute();
|
||||
}}
|
||||
>
|
||||
{muted ? <VolumeX className="size-4 sm:size-5" /> : <Volume2 className="size-4 sm:size-5" />}
|
||||
</CtrlBtn>
|
||||
</div>
|
||||
|
||||
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolumeChange} aria-label="Volume" className="hidden lg:block w-16 accent-white cursor-pointer" onClick={(e) => e.stopPropagation()} />
|
||||
|
||||
<span className="text-white/60 text-sm tabular-nums ml-1.5">
|
||||
{fmtTime(currentTime)} / {fmtTime(totalDuration)}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{speed !== "Normal" && (
|
||||
<span className="text-sm text-white/60 bg-white/10 px-1.5 py-0.5 rounded-sm font-mono">{speed}x</span>
|
||||
)}
|
||||
<CtrlBtn label="Settings" onClick={(e) => { e.stopPropagation(); setSettingsOpen((o) => !o); }} className={settingsOpen ? "text-white" : ""}>
|
||||
<Settings className={`size-5 transition-transform duration-300 ${settingsOpen ? "rotate-45" : ""}`} />
|
||||
</CtrlBtn>
|
||||
<CtrlBtn label={isFullscreen ? "Exit fullscreen" : "Fullscreen"} onClick={toggleFullscreen}>
|
||||
{isFullscreen ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
|
||||
</CtrlBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user