feat(templates): branded Plyr video player for demos with download protection
- BrandedVideoPlayer (plyr-react) plays template demo videos with FlatRender blue branding (Plyr CSS vars) and NO download control. - Download protection: no download button, native controls replaced, underlying <video> gets controlsList="nodownload" + disablePictureInPicture, and the right-click context menu is blocked. - Template detail page uses it; gallery hover-preview cards get the same nodownload / no-context-menu / no-PiP guards. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Generated
+92
@@ -36,6 +36,8 @@
|
||||
"lucide-react": "^1.16.0",
|
||||
"next": "14.2.35",
|
||||
"next-intl": "^4.12.0",
|
||||
"plyr": "^3.7.8",
|
||||
"plyr-react": "^5.3.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.76.0",
|
||||
@@ -4002,6 +4004,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
@@ -4042,6 +4055,12 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/custom-event-polyfill": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz",
|
||||
"integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -6270,6 +6289,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loadjs": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz",
|
||||
"integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -7005,6 +7030,44 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/plyr": {
|
||||
"version": "3.7.8",
|
||||
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.7.8.tgz",
|
||||
"integrity": "sha512-yG/EHDobwbB/uP+4Bm6eUpJ93f8xxHjjk2dYcD1Oqpe1EcuQl5tzzw9Oq+uVAzd2lkM11qZfydSiyIpiB8pgdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.26.1",
|
||||
"custom-event-polyfill": "^1.0.7",
|
||||
"loadjs": "^4.2.0",
|
||||
"rangetouch": "^2.0.1",
|
||||
"url-polyfill": "^1.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/plyr-react": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/plyr-react/-/plyr-react-5.3.0.tgz",
|
||||
"integrity": "sha512-m36/HrpHwg1N2rq3E31E8/kpAH55vk6qHUg17MG4uu9jbWYxnkN39lLmZQwxW7/qpDPfW5aGUJ6R3u23V0R3zA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"plyr": "^3.7.7",
|
||||
"react-aptor": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"plyr": "^3.7.7",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"plyr": {
|
||||
"optional": false
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/po-parser": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
|
||||
@@ -7264,6 +7327,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rangetouch": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz",
|
||||
"integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/re-resizable": {
|
||||
"version": "6.11.2",
|
||||
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz",
|
||||
@@ -7286,6 +7355,23 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-aptor": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aptor/-/react-aptor-2.0.0.tgz",
|
||||
"integrity": "sha512-YnCayokuhAwmBBP4Oc0bbT2l6ApfsjbY3DEEVUddIKZEBlGl1npzjHHzWnSqWuboSbMZvRqUM01Io9yiIp1wcg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-date-object": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||
@@ -8698,6 +8784,12 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-polyfill": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",
|
||||
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
"lucide-react": "^1.16.0",
|
||||
"next": "14.2.35",
|
||||
"next-intl": "^4.12.0",
|
||||
"plyr": "^3.7.8",
|
||||
"plyr-react": "^5.3.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.76.0",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Plyr, { type APITypes } from "plyr-react";
|
||||
import "plyr-react/plyr.css";
|
||||
|
||||
interface BrandedVideoPlayerProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
/** Plyr aspect ratio, e.g. "16:9" | "1:1" | "9:16". */
|
||||
ratio?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FlatRender-branded video player (Plyr via plyr-react) for template demo videos.
|
||||
*
|
||||
* Branding: accent + controls use the FlatRender blue via Plyr CSS custom
|
||||
* properties on the wrapper.
|
||||
*
|
||||
* Download protection: NO download control is exposed (Plyr replaces the native
|
||||
* controls so the browser's download menu never appears); the underlying
|
||||
* <video> gets `controlsList="nodownload"` + `disablePictureInPicture`, and the
|
||||
* right-click context menu is blocked — so the demo can't be saved through the
|
||||
* UI. (Grabbing the URL from devtools is inherent to web video and out of scope.)
|
||||
*/
|
||||
export function BrandedVideoPlayer({ src, poster, ratio = "16:9", className }: BrandedVideoPlayerProps) {
|
||||
const ref = useRef<APITypes>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Harden the underlying <video> against downloads once Plyr has mounted it.
|
||||
// Re-applied a few times because plyr-react creates the media element async.
|
||||
useEffect(() => {
|
||||
const harden = () => {
|
||||
const media = wrapRef.current?.querySelector("video");
|
||||
if (media) {
|
||||
media.setAttribute("controlsList", "nodownload noremoteplayback");
|
||||
media.disablePictureInPicture = true;
|
||||
}
|
||||
};
|
||||
harden();
|
||||
const id = window.setInterval(harden, 400);
|
||||
const stop = window.setTimeout(() => window.clearInterval(id), 3000);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
window.clearTimeout(stop);
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className={className}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
style={
|
||||
{
|
||||
"--plyr-color-main": "#2563EB",
|
||||
"--plyr-video-control-color": "#ffffff",
|
||||
"--plyr-video-control-color-hover": "#ffffff",
|
||||
"--plyr-video-background": "#0b1020",
|
||||
"--plyr-badge-background": "#2563EB",
|
||||
"--plyr-range-thumb-background": "#ffffff",
|
||||
"--plyr-control-radius": "10px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Plyr
|
||||
ref={ref}
|
||||
source={{
|
||||
type: "video",
|
||||
sources: [{ src, type: "video/mp4" }],
|
||||
poster,
|
||||
}}
|
||||
options={{
|
||||
// Deliberately NO 'download' control.
|
||||
controls: ["play-large", "play", "progress", "current-time", "mute", "volume", "fullscreen"],
|
||||
settings: [],
|
||||
ratio,
|
||||
loop: { active: true },
|
||||
tooltips: { controls: false, seek: true },
|
||||
keyboard: { focused: true, global: false },
|
||||
fullscreen: { enabled: true, iosNative: true },
|
||||
muted: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
import { getTemplatePreviewVideoSrc } from "@/lib/template-preview-media";
|
||||
import type {
|
||||
TemplateDetailAspectRatio,
|
||||
@@ -14,6 +10,7 @@ import {
|
||||
getVideoTemplateImageSrc,
|
||||
} from "@/lib/video-templates-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BrandedVideoPlayer } from "@/components/templates/BrandedVideoPlayer";
|
||||
|
||||
interface TemplateDetailPreviewProps {
|
||||
template: VideoCatalogTemplate;
|
||||
@@ -21,10 +18,12 @@ interface TemplateDetailPreviewProps {
|
||||
onSelectAspect: (aspect: TemplateDetailAspectRatio) => void;
|
||||
}
|
||||
|
||||
const ASPECT_BOX: Record<TemplateDetailAspectRatio, string> = {
|
||||
"16:9": "aspect-video",
|
||||
"1:1": "aspect-square mx-auto max-w-md",
|
||||
"9:16": "aspect-[9/16] mx-auto max-w-[300px]",
|
||||
// Plyr controls the aspect via its `ratio` prop; the wrapper only constrains the
|
||||
// width for the portrait/square formats and rounds the corners.
|
||||
const MAX_WIDTH: Record<TemplateDetailAspectRatio, string> = {
|
||||
"16:9": "",
|
||||
"1:1": "mx-auto max-w-md",
|
||||
"9:16": "mx-auto max-w-[300px]",
|
||||
};
|
||||
|
||||
export function TemplateDetailPreview({
|
||||
@@ -32,8 +31,6 @@ export function TemplateDetailPreview({
|
||||
selectedAspect,
|
||||
onSelectAspect,
|
||||
}: TemplateDetailPreviewProps) {
|
||||
const t = useTranslations("auto.componentsTemplatesTemplateDetailPreview");
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const aspectOptions = getTemplateDetailAspectRatios(template);
|
||||
const posterSrc = template.coverImageUrl ?? getVideoTemplateImageSrc(template.id);
|
||||
const videoSrc = template.previewVideoUrl ?? getTemplatePreviewVideoSrc(template.id);
|
||||
@@ -42,37 +39,16 @@ export function TemplateDetailPreview({
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-2xl bg-gray-100 shadow-lg",
|
||||
ASPECT_BOX[selectedAspect]
|
||||
"relative overflow-hidden rounded-2xl bg-black shadow-lg",
|
||||
MAX_WIDTH[selectedAspect]
|
||||
)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<video
|
||||
<BrandedVideoPlayer
|
||||
key={`${template.id}-${selectedAspect}`}
|
||||
src={videoSrc}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="h-full w-full object-cover"
|
||||
poster={posterSrc}
|
||||
ratio={selectedAspect}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={posterSrc}
|
||||
alt={t("posterAlt", { name: template.name })}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPlaying(true)}
|
||||
className="absolute left-1/2 top-1/2 flex h-16 w-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 shadow-xl transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
|
||||
aria-label={t("playPreview")}
|
||||
>
|
||||
<Play className="ml-1 h-7 w-7 fill-blue-600 text-blue-600" aria-hidden />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{aspectOptions.length > 1 ? (
|
||||
|
||||
@@ -84,6 +84,9 @@ export function VideoTemplateCompactCard({
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
controlsList="nodownload noremoteplayback"
|
||||
disablePictureInPicture
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"absolute inset-0 h-full w-full object-cover transition-opacity duration-300",
|
||||
isHovered ? "opacity-100" : "opacity-0"
|
||||
|
||||
Reference in New Issue
Block a user