Always embed Vimeo first; link out only on player error
Prefer on-site player.vimeo.com embeds. Use the Vimeo Player API to detect privacy blocks and fall back to Watch on Vimeo only then.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { mediaSrc } from "../lib/api";
|
||||
|
||||
type Props = {
|
||||
videoId: string;
|
||||
title: string;
|
||||
watchUrl: string;
|
||||
poster?: string;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Vimeo?: {
|
||||
Player: new (
|
||||
el: HTMLIFrameElement | HTMLElement,
|
||||
opts?: { id?: string | number; url?: string }
|
||||
) => {
|
||||
on: (event: string, cb: (data?: unknown) => void) => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let playerSdkPromise: Promise<void> | null = null;
|
||||
|
||||
function loadVimeoSdk(): Promise<void> {
|
||||
if (window.Vimeo?.Player) return Promise.resolve();
|
||||
if (playerSdkPromise) return playerSdkPromise;
|
||||
playerSdkPromise = new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>(
|
||||
'script[src="https://player.vimeo.com/api/player.js"]'
|
||||
);
|
||||
if (existing) {
|
||||
existing.addEventListener("load", () => resolve());
|
||||
existing.addEventListener("error", () => reject(new Error("Vimeo SDK failed")));
|
||||
if (window.Vimeo?.Player) resolve();
|
||||
return;
|
||||
}
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://player.vimeo.com/api/player.js";
|
||||
s.async = true;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error("Vimeo SDK failed"));
|
||||
document.body.appendChild(s);
|
||||
});
|
||||
return playerSdkPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Always embeds first. Only if Vimeo reports a privacy/error does the UI
|
||||
* fall back to opening the video on vimeo.com (last resort).
|
||||
*/
|
||||
export function VimeoEmbed({ videoId, title, watchUrl, poster }: Props) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [blocked, setBlocked] = useState(false);
|
||||
const embedSrc = `https://player.vimeo.com/video/${videoId}?title=0&byline=0&portrait=0`;
|
||||
|
||||
useEffect(() => {
|
||||
let player: { destroy: () => void } | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
setBlocked(false);
|
||||
|
||||
loadVimeoSdk()
|
||||
.then(() => {
|
||||
if (cancelled || !iframeRef.current || !window.Vimeo?.Player) return;
|
||||
player = new window.Vimeo.Player(iframeRef.current);
|
||||
player.on("error", (data: unknown) => {
|
||||
// Privacy / password / unavailable — leave site only as last resort
|
||||
console.warn("Vimeo player error", data);
|
||||
if (!cancelled) setBlocked(true);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// SDK load failure still keep iframe; browser may still play
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
try {
|
||||
player?.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, [videoId]);
|
||||
|
||||
if (blocked) {
|
||||
return (
|
||||
<a href={watchUrl} target="_blank" rel="noreferrer" className="project-page__video-link">
|
||||
{poster ? (
|
||||
<img src={mediaSrc(poster)} alt="" className="project-page__video-poster" />
|
||||
) : (
|
||||
<div className="project-page__video-poster project-page__video-poster--empty" />
|
||||
)}
|
||||
<span className="project-page__video-cta">
|
||||
<span className="project-page__play" aria-hidden>
|
||||
▶
|
||||
</span>
|
||||
Watch on Vimeo ↗
|
||||
<span className="project-page__video-note">
|
||||
This video can’t be embedded here (Vimeo privacy). Opens on Vimeo as a last resort.
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={embedSrc}
|
||||
title={title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
||||
allowFullScreen
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Lightbox from "yet-another-react-lightbox";
|
||||
import "yet-another-react-lightbox/styles.css";
|
||||
import { api, type Project, mediaSrc } from "../lib/api";
|
||||
import { usePageTitle } from "../lib/pageTitle";
|
||||
import { VimeoEmbed } from "../components/VimeoEmbed";
|
||||
import "./ProjectPage.scss";
|
||||
|
||||
export function ProjectPage() {
|
||||
@@ -101,24 +102,39 @@ export function ProjectPage() {
|
||||
{videos.length > 0 && (
|
||||
<div className="project-page__videos">
|
||||
{videos.map((v) => {
|
||||
const watchUrl = v.externalUrl || v.url;
|
||||
// Vimeo "nowhere" privacy stores watch-page URL (no player.vimeo.com) → link card
|
||||
const showEmbed =
|
||||
v.videoSource === "youtube" ||
|
||||
(v.videoSource === "vimeo" && v.url.includes("player.vimeo.com"));
|
||||
const watchUrl =
|
||||
v.externalUrl ||
|
||||
(v.videoSource === "vimeo" && v.videoId
|
||||
? `https://vimeo.com/${v.videoId}`
|
||||
: v.url);
|
||||
const vimeoId =
|
||||
v.videoId ||
|
||||
(v.url.match(/vimeo\.com\/(?:video\/)?(\d+)/)?.[1] ?? null);
|
||||
|
||||
return (
|
||||
<div key={v.id} className="project-page__video">
|
||||
{showEmbed ? (
|
||||
{v.videoSource === "vimeo" && vimeoId ? (
|
||||
<VimeoEmbed
|
||||
videoId={vimeoId}
|
||||
title={v.caption || project.title}
|
||||
watchUrl={watchUrl}
|
||||
poster={poster}
|
||||
/>
|
||||
) : v.videoSource === "youtube" ? (
|
||||
<iframe
|
||||
src={mediaSrc(v.url)}
|
||||
title={v.caption || project.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
||||
allowFullScreen
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
) : v.videoSource === "self_hosted" ? (
|
||||
<video src={mediaSrc(v.url)} controls playsInline poster={poster || undefined} />
|
||||
<video
|
||||
src={mediaSrc(v.url)}
|
||||
controls
|
||||
playsInline
|
||||
poster={poster || undefined}
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
href={watchUrl}
|
||||
@@ -135,21 +151,15 @@ export function ProjectPage() {
|
||||
<span className="project-page__play" aria-hidden>
|
||||
▶
|
||||
</span>
|
||||
Watch on {v.videoSource === "vimeo" || watchUrl.includes("vimeo.com")
|
||||
? "Vimeo"
|
||||
: v.videoSource === "youtube" || watchUrl.includes("youtu")
|
||||
? "YouTube"
|
||||
: "external site"}{" "}
|
||||
↗
|
||||
Watch video ↗
|
||||
</span>
|
||||
{(v.videoSource === "vimeo" || watchUrl.includes("vimeo.com")) && (
|
||||
<span className="project-page__video-note">
|
||||
This clip’s Vimeo privacy settings block on-site embedding
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
{v.caption && <p>{v.caption}</p>}
|
||||
{v.caption &&
|
||||
!String(v.caption).includes("opens on Vimeo") &&
|
||||
!String(v.caption).includes("embedding disabled") && (
|
||||
<p>{v.caption}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user