feat(templates): branded Plyr video player for demos with download protection
CI/CD / CI · Web (tsc) (push) Successful in 1m24s
CI/CD / Deploy · full stack (push) Successful in 8m14s

- 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:
soroush.asadi
2026-06-21 16:44:55 +03:30
parent 4f04f6bf75
commit af3c73c560
5 changed files with 200 additions and 39 deletions
+92
View File
@@ -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",
+2
View File
@@ -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"