From 738187d1d43b69d8a99dd887a289a5a4aad66f3b Mon Sep 17 00:00:00 2001 From: jmartin Date: Fri, 24 Jul 2026 12:24:47 -0400 Subject: [PATCH] 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. --- client/src/components/VimeoEmbed.tsx | 120 +++++++++++++++++++++++++++ client/src/pages/ProjectPage.tsx | 50 ++++++----- server/src/services/vimeo-import.ts | 30 +++---- 3 files changed, 161 insertions(+), 39 deletions(-) create mode 100644 client/src/components/VimeoEmbed.tsx diff --git a/client/src/components/VimeoEmbed.tsx b/client/src/components/VimeoEmbed.tsx new file mode 100644 index 0000000..450f93e --- /dev/null +++ b/client/src/components/VimeoEmbed.tsx @@ -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 | null = null; + +function loadVimeoSdk(): Promise { + if (window.Vimeo?.Player) return Promise.resolve(); + if (playerSdkPromise) return playerSdkPromise; + playerSdkPromise = new Promise((resolve, reject) => { + const existing = document.querySelector( + '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(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 ( + + {poster ? ( + + ) : ( +
+ )} + + + ▶ + + Watch on Vimeo ↗ + + This video can’t be embedded here (Vimeo privacy). Opens on Vimeo as a last resort. + + + + ); + } + + return ( +