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
@@ -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
src={videoSrc}
autoPlay
muted
loop
playsInline
className="h-full w-full object-cover"
/>
) : (
<>
{/* 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>
</>
)}
<BrandedVideoPlayer
key={`${template.id}-${selectedAspect}`}
src={videoSrc}
poster={posterSrc}
ratio={selectedAspect}
/>
</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"