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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user