mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
client revised
This commit is contained in:
@@ -211,7 +211,7 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
<div className="absolute inset-0 bg-primary" />
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex items-center gap-4 p-4 text-white">
|
||||
@@ -291,7 +291,9 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
||||
value={muted ? 0 : volume}
|
||||
onChange={handleVolume}
|
||||
aria-label="Volume"
|
||||
className="w-16 h-1.5"
|
||||
className="w-16 h-1.5 accent-primary cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer
|
||||
[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
const poster = thumbnailUrl ?? content?.thumbnail_url ?? undefined;
|
||||
|
||||
const vidRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -38,7 +39,13 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [overlayVisible,setOverlayVisible]= useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// Native Fullscreen API works on desktop (all browsers) and Android Chrome.
|
||||
// iOS Safari doesn't support Fullscreen API on arbitrary elements at all, so
|
||||
// pseudoFullscreen is a CSS-only (fixed inset-0) fallback that keeps our
|
||||
// custom controls instead of falling back to native <video> fullscreen.
|
||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||
const [pseudoFullscreen, setPseudoFullscreen] = useState(false);
|
||||
const isFullscreen = nativeFullscreen || pseudoFullscreen;
|
||||
// True from the moment `src` is set until the browser has actually
|
||||
// buffered enough to render a frame (or stalls mid-playback) — closes the
|
||||
// gap between "token resolved" (the `loading` from useAssetPreviewSrc
|
||||
@@ -104,6 +111,13 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
|
||||
}, []);
|
||||
|
||||
// Safari doesn't focus a <div tabIndex> on click, which silently kills
|
||||
// keyboard shortcuts afterwards — force it explicitly.
|
||||
const handleAreaClick = useCallback(() => {
|
||||
wrapRef.current?.focus({ preventScroll: true });
|
||||
togglePlay();
|
||||
}, [togglePlay]);
|
||||
|
||||
const restart = () => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
@@ -134,10 +148,24 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = document.getElementById("video-block-wrap");
|
||||
// Uses wrapRef instead of getElementById so this works correctly even if
|
||||
// an admin page ever renders more than one VideoBlock at once (an id
|
||||
// lookup would always grab the first match, toggling the wrong player).
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else el.requestFullscreen?.();
|
||||
if (isFullscreen) {
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else if (document.webkitFullscreenElement) document.webkitExitFullscreen();
|
||||
else setPseudoFullscreen(false);
|
||||
} else if (el.requestFullscreen) {
|
||||
el.requestFullscreen();
|
||||
} else if (el.webkitRequestFullscreen) {
|
||||
// Desktop Safari — supports element fullscreen via the prefixed API.
|
||||
el.webkitRequestFullscreen();
|
||||
} else {
|
||||
// iOS Safari has no element-level Fullscreen API at all.
|
||||
setPseudoFullscreen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Ignore keystrokes aimed at the seek/volume range inputs so arrow keys
|
||||
@@ -145,7 +173,10 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.target.tagName === "INPUT") return;
|
||||
const v = vidRef.current;
|
||||
switch (e.key) {
|
||||
// Fold single-char keys to lowercase so Shift/Caps-Lock doesn't break
|
||||
// letter shortcuts (e.g. Shift+F reporting as "F", not "f").
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
switch (key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
@@ -166,20 +197,49 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case "Escape":
|
||||
if (isFullscreen) {
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}, [togglePlay]);
|
||||
}, [togglePlay, toggleMute, toggleFullscreen, isFullscreen]);
|
||||
|
||||
// #video-block-wrap now wraps both the video area and the controls bar
|
||||
// below it (previously just the video), so fullscreen no longer drops the
|
||||
// seek bar / volume / fullscreen button. isFullscreen also relaxes the
|
||||
// 16/9 + max-height constraints so the video fills the screen properly.
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
// Safari (incl. desktop) still fires the prefixed webkitfullscreenchange
|
||||
// event for element-level fullscreen, so both are listened for.
|
||||
const onChange = () => {
|
||||
const active = !!(document.fullscreenElement || document.webkitFullscreenElement);
|
||||
setNativeFullscreen(active);
|
||||
// Safari doesn't focus a <div tabIndex> on click, so keyboard
|
||||
// shortcuts silently stop working after entering fullscreen unless we
|
||||
// explicitly refocus the wrapper here.
|
||||
if (active) wrapRef.current?.focus({ preventScroll: true });
|
||||
};
|
||||
document.addEventListener("fullscreenchange", onChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||
document.addEventListener("webkitfullscreenchange", onChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", onChange);
|
||||
document.removeEventListener("webkitfullscreenchange", onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// CSS pseudo-fullscreen fallback (iOS Safari) — lock page scroll and
|
||||
// reclaim keyboard focus while it's active, restore on exit/unmount.
|
||||
useEffect(() => {
|
||||
if (!pseudoFullscreen) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
wrapRef.current?.focus({ preventScroll: true });
|
||||
return () => { document.body.style.overflow = prevOverflow; };
|
||||
}, [pseudoFullscreen]);
|
||||
|
||||
// ── Asset picker handler ──────────────────────────────────────────────────
|
||||
//
|
||||
// Saves all metadata needed by the client VideoBlock at render time.
|
||||
@@ -221,16 +281,17 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
) : src ? (
|
||||
<div
|
||||
id="video-block-wrap"
|
||||
ref={wrapRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`overflow-hidden bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring ${isFullscreen ? "fixed inset-0 flex flex-col" : "rounded-lg border"}`}
|
||||
className={`overflow-hidden bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring ${isFullscreen ? "fixed inset-0 z-[100] flex flex-col" : "rounded-lg border"}`}
|
||||
>
|
||||
|
||||
{/* ── Video area ── */}
|
||||
<div
|
||||
className={`relative w-full bg-black cursor-pointer group ${isFullscreen ? "flex-1 min-h-0" : ""}`}
|
||||
style={isFullscreen ? undefined : { aspectRatio: "16/9", maxHeight: "calc(100vh - 260px)" }}
|
||||
onClick={togglePlay}
|
||||
onClick={handleAreaClick}
|
||||
>
|
||||
<video
|
||||
ref={vidRef}
|
||||
|
||||
@@ -25,13 +25,16 @@ function hasLandingPage(ad) {
|
||||
* Banner advertisement carousel — same carousel shell as Hero, so a placement
|
||||
* with several live ads (e.g. tier_plans.banner) rotates through all of them
|
||||
* instead of only ever showing the single highest-priority one.
|
||||
* Each slide's layout is driven by its own content_mode:
|
||||
* "content" — decorative panel with badge/headline/description/CTAs
|
||||
* "image" — full-bleed image, clickable through to the ad's redirect/CTA
|
||||
* Every current-shape ad (content_mode "content") always shows its image with
|
||||
* badge/headline/description layered on top, same as Hero — the whole slide
|
||||
* is clickable through to the ad's redirect_link/landing page whenever it has
|
||||
* no CTA buttons of its own. content_mode "image" is legacy-only (pre-dates
|
||||
* the mandatory badge/headline/description/image/link shape) and keeps
|
||||
* rendering as a bare clickable image with no text overlay.
|
||||
*
|
||||
* Props:
|
||||
* ads — array of advertisement objects { content_mode, badge_labels, headline, description, ctas, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-banner click on image-only ads.
|
||||
* ads — array of advertisement objects { content_mode, badge_labels, headline, description, ctas, redirect_link, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-slide click.
|
||||
*/
|
||||
export function Banner({ ads, onCtaClick }) {
|
||||
const [api, setApi] = useState();
|
||||
@@ -63,12 +66,16 @@ export function Banner({ ads, onCtaClick }) {
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
|
||||
|
||||
// Whole-slide click-through only makes sense when there's no CTA
|
||||
// row of its own to carry that responsibility instead — a landing
|
||||
// page (Page Builder content) always wins over the plain redirect.
|
||||
const clickable = ctas.length === 0 && (hasLandingPage(ad) || ad.redirect_link);
|
||||
const handleSlideClick = () => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, undefined));
|
||||
|
||||
return (
|
||||
<CarouselItem key={ad.advertisement_id}>
|
||||
{ad.content_mode !== "content" ? (
|
||||
// ── Full Image ──
|
||||
// A landing page (Page Builder content) always wins over the CTA
|
||||
// click-through — it's the only destination this ad actually has.
|
||||
// ── Full Image (legacy Image Only ads with no badge/headline/description) ──
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, ctas[0]))}
|
||||
@@ -85,21 +92,41 @@ export function Banner({ ads, onCtaClick }) {
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
// ── Content + Image ──
|
||||
<div className="tier_plans_banner w-full rounded-xl xs:p-5 sm:p-4 lg:p-10 text-white">
|
||||
<div className="relative w-full items-start flex justify-between">
|
||||
// ── Image + Content overlay ──
|
||||
<div
|
||||
role={clickable ? "button" : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={clickable ? handleSlideClick : undefined}
|
||||
onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") handleSlideClick(); } : undefined}
|
||||
className={"relative w-full rounded-xl overflow-hidden border text-left h-[clamp(180px,22vw,320px)]" + (clickable ? " cursor-pointer" : "")}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={ad.headline || "Advertisement"}
|
||||
className="absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-muted flex items-center justify-center">
|
||||
<Megaphone className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
|
||||
|
||||
<div className="relative h-full w-full flex items-center xs:p-5 sm:p-4 lg:p-10 text-white overflow-hidden">
|
||||
<div className="md:w-2xl space-y-4 xs:p-1.5 lg:p-0">
|
||||
{badgeLabels.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{badgeLabels.map((label, i) => (
|
||||
<Badge key={i} variant="outline" className="border-white/50 bg-white/10 text-white">
|
||||
<Badge key={i} variant="outline" className="pointer-events-none select-none border-white/50 bg-white/10 text-white">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ad.headline && <div className="lg:text-5xl xs:text-4xl font-tier-ads lg:leading-16">{ad.headline}</div>}
|
||||
{ad.description && <p className="leading-relaxed text-lg">{ad.description}</p>}
|
||||
{ad.headline && <div className="pointer-events-none select-none lg:text-5xl xs:text-4xl font-geist font-bold lg:leading-16">{ad.headline}</div>}
|
||||
{ad.description && <p className="pointer-events-none select-none leading-relaxed text-lg line-clamp-2">{ad.description}</p>}
|
||||
|
||||
{ctas.length > 0 && (
|
||||
<div className="flex gap-3 items-center">
|
||||
@@ -115,17 +142,6 @@ export function Banner({ ads, onCtaClick }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:absolute lg:-top-4 lg:-right-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={hasLandingPage(ad) ? "View advertisement details" : "Advertisement"}
|
||||
className={hasLandingPage(ad) ? undefined : "pointer-events-none"}
|
||||
onClick={() => hasLandingPage(ad) && setViewAd(ad)}
|
||||
>
|
||||
View <Megaphone />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,12 +25,14 @@ function hasLandingPage(ad) {
|
||||
/**
|
||||
* Hero advertisement carousel.
|
||||
* Each slide is a full-bleed background image with a bottom gradient overlay,
|
||||
* badge/headline/description/CTAs anchored bottom-left. Renders null when no
|
||||
* ads are given — callers should not fall back to placeholder copy.
|
||||
* badge/headline/description/CTAs anchored bottom-left. The whole slide is
|
||||
* clickable through to the ad's redirect_link/landing page whenever it has no
|
||||
* CTA buttons of its own. Renders null when no ads are given — callers should
|
||||
* not fall back to placeholder copy.
|
||||
*
|
||||
* Props:
|
||||
* ads — array of advertisement objects { badge_labels, headline, description, ctas, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void, called when any CTA button is clicked
|
||||
* ads — array of advertisement objects { badge_labels, headline, description, ctas, redirect_link, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-slide click.
|
||||
*/
|
||||
export function Hero({ ads, onCtaClick }) {
|
||||
const [api, setApi] = useState();
|
||||
@@ -62,10 +64,22 @@ export function Hero({ ads, onCtaClick }) {
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
|
||||
|
||||
// Whole-slide click-through only makes sense when there's no CTA
|
||||
// row of its own to carry that responsibility instead — a landing
|
||||
// page (Page Builder content) always wins over the plain redirect.
|
||||
const clickable = ctas.length === 0 && (hasLandingPage(ad) || ad.redirect_link);
|
||||
const handleSlideClick = () => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, undefined));
|
||||
|
||||
return (
|
||||
<CarouselItem key={ad.advertisement_id}>
|
||||
<Card className="border rounded-2xl overflow-hidden pl-0 py-0">
|
||||
<CardContent className="relative xs:h-64 lg:h-96 bg-muted flex items-center justify-center">
|
||||
<CardContent
|
||||
role={clickable ? "button" : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={clickable ? handleSlideClick : undefined}
|
||||
onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") handleSlideClick(); } : undefined}
|
||||
className={"relative xs:h-64 lg:h-96 bg-muted flex items-center justify-center" + (clickable ? " cursor-pointer" : "")}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
|
||||
@@ -254,7 +254,7 @@ export function AudioBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
<div className="absolute inset-0 bg-primary" />
|
||||
)}
|
||||
|
||||
{/* Mobile */}
|
||||
@@ -334,7 +334,15 @@ export function AudioBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
<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" />
|
||||
<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 accent-primary cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer
|
||||
[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center — play controls */}
|
||||
|
||||
@@ -177,7 +177,13 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [overlayVisible, setOverlayVisible] = useState(true);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// Native Fullscreen API works on desktop (all browsers) and Android Chrome.
|
||||
// iOS Safari doesn't support Fullscreen API on arbitrary elements at all, so
|
||||
// pseudoFullscreen is a CSS-only (fixed inset-0) fallback that keeps our
|
||||
// custom controls instead of falling back to native <video> fullscreen.
|
||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||
const [pseudoFullscreen, setPseudoFullscreen] = useState(false);
|
||||
const isFullscreen = nativeFullscreen || pseudoFullscreen;
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [volumePanelOpen, setVolumePanelOpen] = useState(false);
|
||||
const [keyFeedback, setKeyFeedback] = useState(null);
|
||||
@@ -343,11 +349,34 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
}, [speed]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
// Safari (incl. desktop) still fires the prefixed webkitfullscreenchange
|
||||
// event for element-level fullscreen, so both are listened for.
|
||||
const onChange = () => {
|
||||
const active = !!(document.fullscreenElement || document.webkitFullscreenElement);
|
||||
setNativeFullscreen(active);
|
||||
// Safari doesn't focus a <div tabIndex> on click, so keyboard
|
||||
// shortcuts silently stop working after entering fullscreen unless
|
||||
// we explicitly refocus the wrapper here.
|
||||
if (active) wrapRef.current?.focus({ preventScroll: true });
|
||||
};
|
||||
document.addEventListener("fullscreenchange", onChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||
document.addEventListener("webkitfullscreenchange", onChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", onChange);
|
||||
document.removeEventListener("webkitfullscreenchange", onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// CSS pseudo-fullscreen fallback (iOS Safari) — lock page scroll and
|
||||
// reclaim keyboard focus while it's active, restore on exit/unmount.
|
||||
useEffect(() => {
|
||||
if (!pseudoFullscreen) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
wrapRef.current?.focus({ preventScroll: true });
|
||||
return () => { document.body.style.overflow = prevOverflow; };
|
||||
}, [pseudoFullscreen]);
|
||||
|
||||
const resetHideTimer = useCallback(() => {
|
||||
setControlsVisible(true);
|
||||
clearTimeout(hideTimer.current);
|
||||
@@ -377,6 +406,19 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
|
||||
}, []);
|
||||
|
||||
// YouTube-style tap-to-reveal: while playing with the bar auto-hidden, the
|
||||
// first tap only brings the controls back (doesn't pause); a second tap —
|
||||
// once controls are already visible — toggles play, same as desktop where
|
||||
// onMouseMove already keeps controls visible ahead of the click.
|
||||
const handleWrapperClick = useCallback(() => {
|
||||
// Safari doesn't focus a <div tabIndex> on click, which silently kills
|
||||
// keyboard shortcuts afterwards — force it explicitly.
|
||||
wrapRef.current?.focus({ preventScroll: true });
|
||||
if (!controlsVisible) { resetHideTimer(); return; }
|
||||
togglePlay();
|
||||
resetHideTimer();
|
||||
}, [controlsVisible, resetHideTimer, togglePlay]);
|
||||
|
||||
const restart = () => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
@@ -409,13 +451,27 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
const toggleFullscreen = () => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else el.requestFullscreen?.();
|
||||
if (isFullscreen) {
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else if (document.webkitFullscreenElement) document.webkitExitFullscreen();
|
||||
else setPseudoFullscreen(false);
|
||||
} else if (el.requestFullscreen) {
|
||||
el.requestFullscreen();
|
||||
} else if (el.webkitRequestFullscreen) {
|
||||
// Desktop Safari — supports element fullscreen via the prefixed API.
|
||||
el.webkitRequestFullscreen();
|
||||
} else {
|
||||
// iOS Safari has no element-level Fullscreen API at all.
|
||||
setPseudoFullscreen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.target.tagName === "INPUT") return;
|
||||
switch (e.key) {
|
||||
// Fold single-char keys to lowercase so Shift/Caps-Lock doesn't break
|
||||
// letter shortcuts (e.g. Shift+F reporting as "F", not "f").
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
switch (key) {
|
||||
case " ": case "k":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
@@ -460,6 +516,13 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
toggleFullscreen();
|
||||
showFeedback(isFullscreen ? <Minimize2 className="size-7 text-white" /> : <Maximize2 className="size-7 text-white" />, isFullscreen ? "Exit fullscreen" : "Fullscreen");
|
||||
break;
|
||||
case "Escape":
|
||||
if (isFullscreen) {
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
showFeedback(<Minimize2 className="size-7 text-white" />, "Exit fullscreen");
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback, guard]);
|
||||
@@ -492,11 +555,11 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
<div
|
||||
ref={wrapRef}
|
||||
tabIndex={0}
|
||||
className="relative w-full rounded-lg overflow-hidden bg-black select-none outline-none"
|
||||
className={`relative w-full overflow-hidden bg-black select-none outline-none ${pseudoFullscreen ? "fixed inset-0 z-[100]" : "rounded-lg"}`}
|
||||
style={{ aspectRatio: isFullscreen ? undefined : "16/9" }}
|
||||
onMouseMove={resetHideTimer}
|
||||
onMouseLeave={() => { if (playing) setControlsVisible(false); }}
|
||||
onClick={() => { togglePlay(); resetHideTimer(); }}
|
||||
onClick={handleWrapperClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user