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 "yet-another-react-lightbox/styles.css";
|
||||||
import { api, type Project, mediaSrc } from "../lib/api";
|
import { api, type Project, mediaSrc } from "../lib/api";
|
||||||
import { usePageTitle } from "../lib/pageTitle";
|
import { usePageTitle } from "../lib/pageTitle";
|
||||||
|
import { VimeoEmbed } from "../components/VimeoEmbed";
|
||||||
import "./ProjectPage.scss";
|
import "./ProjectPage.scss";
|
||||||
|
|
||||||
export function ProjectPage() {
|
export function ProjectPage() {
|
||||||
@@ -101,24 +102,39 @@ export function ProjectPage() {
|
|||||||
{videos.length > 0 && (
|
{videos.length > 0 && (
|
||||||
<div className="project-page__videos">
|
<div className="project-page__videos">
|
||||||
{videos.map((v) => {
|
{videos.map((v) => {
|
||||||
const watchUrl = v.externalUrl || v.url;
|
const watchUrl =
|
||||||
// Vimeo "nowhere" privacy stores watch-page URL (no player.vimeo.com) → link card
|
v.externalUrl ||
|
||||||
const showEmbed =
|
(v.videoSource === "vimeo" && v.videoId
|
||||||
v.videoSource === "youtube" ||
|
? `https://vimeo.com/${v.videoId}`
|
||||||
(v.videoSource === "vimeo" && v.url.includes("player.vimeo.com"));
|
: v.url);
|
||||||
|
const vimeoId =
|
||||||
|
v.videoId ||
|
||||||
|
(v.url.match(/vimeo\.com\/(?:video\/)?(\d+)/)?.[1] ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={v.id} className="project-page__video">
|
<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
|
<iframe
|
||||||
src={mediaSrc(v.url)}
|
src={mediaSrc(v.url)}
|
||||||
title={v.caption || project.title}
|
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
|
allowFullScreen
|
||||||
referrerPolicy="strict-origin-when-cross-origin"
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
/>
|
/>
|
||||||
) : v.videoSource === "self_hosted" ? (
|
) : 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
|
<a
|
||||||
href={watchUrl}
|
href={watchUrl}
|
||||||
@@ -135,21 +151,15 @@ export function ProjectPage() {
|
|||||||
<span className="project-page__play" aria-hidden>
|
<span className="project-page__play" aria-hidden>
|
||||||
▶
|
▶
|
||||||
</span>
|
</span>
|
||||||
Watch on {v.videoSource === "vimeo" || watchUrl.includes("vimeo.com")
|
Watch video ↗
|
||||||
? "Vimeo"
|
|
||||||
: v.videoSource === "youtube" || watchUrl.includes("youtu")
|
|
||||||
? "YouTube"
|
|
||||||
: "external site"}{" "}
|
|
||||||
↗
|
|
||||||
</span>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -273,38 +273,30 @@ export async function runVimeoImport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed only when Vimeo allows it (embed_privacy: anywhere / whitelist)
|
// Always store player embed URL — UI embeds first; falls back to Vimeo
|
||||||
const canEmbed =
|
// only if the player reports a privacy/error at runtime.
|
||||||
!video.embed_privacy ||
|
if (video.embed_privacy && video.embed_privacy !== "anywhere") {
|
||||||
video.embed_privacy === "anywhere" ||
|
log(
|
||||||
video.embed_privacy === "whitelist";
|
lines,
|
||||||
|
onLog,
|
||||||
|
` note: Vimeo embed_privacy is “${video.embed_privacy}” — player will embed if allowed, else last-resort link`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.media.create({
|
await prisma.media.create({
|
||||||
data: {
|
data: {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
type: "video",
|
type: "video",
|
||||||
// Non-embeddable clips use the watch page URL (no player.vimeo.com iframe)
|
url: `https://player.vimeo.com/video/${video.id}`,
|
||||||
url: canEmbed
|
|
||||||
? `https://player.vimeo.com/video/${video.id}`
|
|
||||||
: sourceUrl,
|
|
||||||
externalUrl: sourceUrl,
|
externalUrl: sourceUrl,
|
||||||
videoSource: "vimeo",
|
videoSource: "vimeo",
|
||||||
videoId: String(video.id),
|
videoId: String(video.id),
|
||||||
caption: canEmbed
|
caption: title,
|
||||||
? title
|
|
||||||
: `${title} (opens on Vimeo — embedding disabled by privacy settings)`,
|
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
width: video.width,
|
width: video.width,
|
||||||
height: video.height,
|
height: video.height,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!canEmbed) {
|
|
||||||
log(
|
|
||||||
lines,
|
|
||||||
onLog,
|
|
||||||
` note: embed privacy “${video.embed_privacy}” — link-out card instead of iframe`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thumbnailId) {
|
if (thumbnailId) {
|
||||||
await prisma.project.update({
|
await prisma.project.update({
|
||||||
|
|||||||
Reference in New Issue
Block a user