diff --git a/css/tailwind.source.css b/css/tailwind.source.css
index 09d803cb..7349d254 100644
--- a/css/tailwind.source.css
+++ b/css/tailwind.source.css
@@ -1410,6 +1410,31 @@
box-shadow: none;
}
+ .video-modal__panel > :not(.video-modal__ambient) {
+ position: relative;
+ z-index: 1;
+ }
+
+ .video-modal__ambient {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ overflow: hidden;
+ border-radius: inherit;
+ background: var(--color-overlay-strong);
+ }
+
+ .video-modal__ambient-canvas {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ transform: scale(1.08);
+ transform-origin: center;
+ filter: blur(40px) saturate(1.25) brightness(0.8);
+ }
+
#playerModal .watch-container {
width: 100%;
max-width: var(--player-modal-inline-max, var(--layout-player-max-width));
diff --git a/js/ui/ambientBackground.js b/js/ui/ambientBackground.js
new file mode 100644
index 00000000..305952b3
--- /dev/null
+++ b/js/ui/ambientBackground.js
@@ -0,0 +1,281 @@
+// js/ui/ambientBackground.js
+
+const DEFAULT_THROTTLE_MS = 66;
+
+function getGlobalFromCanvas(canvas) {
+ const doc = canvas?.ownerDocument || (typeof document !== "undefined" ? document : null);
+ const win = doc?.defaultView || (typeof window !== "undefined" ? window : null);
+ return { doc, win };
+}
+
+function getFallbackFillColor(canvas) {
+ const { doc, win } = getGlobalFromCanvas(canvas);
+ const candidates = ["--color-overlay-strong", "--color-overlay", "--color-surface", "--color-bg"];
+ let styles = null;
+ if (doc && typeof doc.documentElement !== "undefined") {
+ const root = doc.documentElement;
+ if (win && typeof win.getComputedStyle === "function") {
+ styles = win.getComputedStyle(root);
+ }
+ }
+
+ for (const token of candidates) {
+ if (!styles) {
+ break;
+ }
+ const value = styles.getPropertyValue(token);
+ if (value && value.trim()) {
+ return value.trim();
+ }
+ }
+
+ return "#000000";
+}
+
+function resizeCanvas(canvas, video) {
+ if (!canvas) {
+ return { width: 0, height: 0 };
+ }
+ const { doc, win } = getGlobalFromCanvas(canvas);
+ const host = canvas.parentElement || canvas;
+ const rect = host?.getBoundingClientRect?.();
+ if (!rect || rect.width <= 0 || rect.height <= 0) {
+ return { width: canvas.width || 0, height: canvas.height || 0 };
+ }
+
+ const dpr = (win && win.devicePixelRatio) || 1;
+ const targetWidth = Math.max(1, Math.round(rect.width * dpr));
+ const targetHeight = Math.max(1, Math.round(rect.height * dpr));
+
+ if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
+ canvas.width = targetWidth;
+ canvas.height = targetHeight;
+ }
+
+ if (video) {
+ const readyState = Number.isFinite(video.readyState) ? video.readyState : 0;
+ if (readyState >= 1 && video.videoWidth && video.videoHeight) {
+ return { width: canvas.width, height: canvas.height };
+ }
+ }
+
+ return { width: canvas.width, height: canvas.height };
+}
+
+function drawCover(ctx, video, canvasWidth, canvasHeight) {
+ if (!ctx || !video || !canvasWidth || !canvasHeight) {
+ return;
+ }
+ const videoWidth = video.videoWidth;
+ const videoHeight = video.videoHeight;
+ if (!videoWidth || !videoHeight) {
+ return;
+ }
+
+ const videoRatio = videoWidth / videoHeight;
+ const canvasRatio = canvasWidth / canvasHeight;
+
+ let sx = 0;
+ let sy = 0;
+ let sw = videoWidth;
+ let sh = videoHeight;
+
+ if (videoRatio > canvasRatio) {
+ sh = videoHeight;
+ sw = Math.round(videoHeight * canvasRatio);
+ sx = Math.max(0, Math.round((videoWidth - sw) / 2));
+ sy = 0;
+ } else {
+ sw = videoWidth;
+ sh = Math.round(videoWidth / canvasRatio);
+ sx = 0;
+ sy = Math.max(0, Math.round((videoHeight - sh) / 2));
+ }
+
+ ctx.drawImage(video, sx, sy, sw, sh, 0, 0, canvasWidth, canvasHeight);
+}
+
+export function attachAmbientBackground(videoElement, canvasElement, options = {}) {
+ if (!videoElement || !canvasElement) {
+ return () => {};
+ }
+
+ const ctx = canvasElement.getContext("2d", { alpha: false });
+ if (!ctx) {
+ return () => {};
+ }
+
+ const { doc, win } = getGlobalFromCanvas(canvasElement);
+ const throttleMs = Number.isFinite(options.throttleMs)
+ ? Math.max(0, options.throttleMs)
+ : DEFAULT_THROTTLE_MS;
+
+ const requestFrame = win?.requestAnimationFrame?.bind(win) || ((cb) => setTimeout(() => cb(Date.now()), throttleMs));
+ const cancelFrame = win?.cancelAnimationFrame?.bind(win) || ((id) => clearTimeout(id));
+
+ let rafId = 0;
+ let running = false;
+ let destroyed = false;
+ let lastTime = 0;
+ let tainted = false;
+ let resizeBound = false;
+
+ const clearCanvas = () => {
+ if (!canvasElement) {
+ return;
+ }
+ const fillColor = getFallbackFillColor(canvasElement);
+ ctx.fillStyle = fillColor;
+ ctx.fillRect(0, 0, canvasElement.width || 0, canvasElement.height || 0);
+ };
+
+ const stop = () => {
+ running = false;
+ if (rafId) {
+ cancelFrame(rafId);
+ rafId = 0;
+ }
+ if (resizeBound && win) {
+ win.removeEventListener("resize", handleResize);
+ resizeBound = false;
+ }
+ };
+
+ const schedule = () => {
+ if (!running || destroyed) {
+ return;
+ }
+ rafId = requestFrame(step);
+ };
+
+ const step = (timestamp = (win?.performance?.now?.() ?? Date.now())) => {
+ if (!running || destroyed) {
+ return;
+ }
+ if (timestamp - lastTime < throttleMs) {
+ schedule();
+ return;
+ }
+ lastTime = timestamp;
+
+ try {
+ const { width, height } = resizeCanvas(canvasElement, videoElement);
+ if (!width || !height) {
+ schedule();
+ return;
+ }
+ drawCover(ctx, videoElement, width, height);
+ } catch (error) {
+ tainted = true;
+ stop();
+ clearCanvas();
+ return;
+ }
+
+ schedule();
+ };
+
+ const start = () => {
+ if (destroyed || tainted || running) {
+ return;
+ }
+ if (doc?.hidden) {
+ running = false;
+ return;
+ }
+ running = true;
+ lastTime = 0;
+ resizeCanvas(canvasElement, videoElement);
+ if (win && !resizeBound) {
+ win.addEventListener("resize", handleResize);
+ resizeBound = true;
+ }
+ step();
+ };
+
+ const handleResize = () => {
+ if (destroyed) {
+ return;
+ }
+ resizeCanvas(canvasElement, videoElement);
+ };
+
+ const handleVisibility = () => {
+ if (!doc) {
+ return;
+ }
+ if (doc.hidden) {
+ stop();
+ return;
+ }
+ resizeCanvas(canvasElement, videoElement);
+ if (!running && !videoElement.paused && !videoElement.ended) {
+ start();
+ }
+ };
+
+ const handlePlay = () => {
+ if (destroyed || tainted) {
+ return;
+ }
+ start();
+ };
+
+ const handlePause = () => {
+ if (destroyed) {
+ return;
+ }
+ stop();
+ };
+
+ const handleEnded = () => {
+ if (destroyed) {
+ return;
+ }
+ stop();
+ };
+
+ const handleLoadedMetadata = () => {
+ if (destroyed || tainted) {
+ return;
+ }
+ resizeCanvas(canvasElement, videoElement);
+ if (!videoElement.paused && !videoElement.ended) {
+ start();
+ }
+ };
+
+ videoElement.addEventListener("play", handlePlay);
+ videoElement.addEventListener("pause", handlePause);
+ videoElement.addEventListener("ended", handleEnded);
+ videoElement.addEventListener("emptied", handlePause);
+ videoElement.addEventListener("loadedmetadata", handleLoadedMetadata);
+
+ if (doc) {
+ doc.addEventListener("visibilitychange", handleVisibility);
+ }
+
+ resizeCanvas(canvasElement, videoElement);
+ if (!videoElement.paused && !videoElement.ended) {
+ start();
+ }
+
+ return ({ clear = true } = {}) => {
+ if (destroyed) {
+ return;
+ }
+ destroyed = true;
+ stop();
+ videoElement.removeEventListener("play", handlePlay);
+ videoElement.removeEventListener("pause", handlePause);
+ videoElement.removeEventListener("ended", handleEnded);
+ videoElement.removeEventListener("emptied", handlePause);
+ videoElement.removeEventListener("loadedmetadata", handleLoadedMetadata);
+ if (doc) {
+ doc.removeEventListener("visibilitychange", handleVisibility);
+ }
+ if (clear) {
+ clearCanvas();
+ }
+ };
+}
diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js
index 9ee79189..90c19eec 100644
--- a/js/ui/components/VideoModal.js
+++ b/js/ui/components/VideoModal.js
@@ -1,6 +1,7 @@
import { createModalAccessibility } from "./modalAccessibility.js";
import createPopover from "../overlay/popoverEngine.js";
import { createVideoMoreMenuPanel } from "./videoMenuRenderers.js";
+import { attachAmbientBackground } from "../ambientBackground.js";
import { applyDesignSystemAttributes } from "../../designSystem.js";
import { devLogger } from "../../utils/logger.js";
@@ -94,6 +95,8 @@ export class VideoModal {
this.modalPosterCleanup = null;
this.videoEventCleanup = null;
+ this.ambientCanvas = null;
+ this.detachAmbientBackground = null;
this.activeVideo = null;
@@ -147,6 +150,7 @@ export class VideoModal {
}
setVideoElement(videoElement) {
+ this.teardownAmbientGlow({ clear: false });
this.detachVideoEvents();
this.clearPosterCleanup();
@@ -161,6 +165,8 @@ export class VideoModal {
this.modalVideo = null;
}
+ this.attachAmbientGlow();
+
return this.modalVideo;
}
@@ -250,6 +256,8 @@ export class VideoModal {
this.modalMorePopover = null;
this.modalMoreMenuPanel = null;
+ this.teardownAmbientGlow({ clear: false });
+
const previousScrollRegion = this.scrollRegion;
if (previousScrollRegion && this.modalNavScrollHandler) {
previousScrollRegion.removeEventListener(
@@ -288,6 +296,8 @@ export class VideoModal {
this.modalZapBtn = playerModal.querySelector("#modalZapBtn") || null;
this.modalMoreBtn = playerModal.querySelector("#modalMoreBtn") || null;
+ this.ambientCanvas = playerModal.querySelector("#ambientCanvas") || null;
+
this.modalZapDialog = playerModal.querySelector("#modalZapDialog") || null;
this.modalZapForm = playerModal.querySelector("#modalZapForm") || null;
this.modalZapAmountInput =
@@ -356,6 +366,8 @@ export class VideoModal {
this.setShareEnabled(false);
this.resetStats();
+ this.attachAmbientGlow();
+
if (!this.activeVideo && this.playerModal) {
this.playerModal.classList.add("hidden");
this.playerModal.setAttribute("hidden", "");
@@ -407,6 +419,90 @@ export class VideoModal {
this.videoEventCleanup = null;
}
+ getAmbientFallbackColor() {
+ const canvas = this.ambientCanvas;
+ const doc =
+ canvas?.ownerDocument ||
+ this.document ||
+ (typeof document !== "undefined" ? document : null);
+ const win = doc?.defaultView || (typeof window !== "undefined" ? window : null);
+
+ if (doc && win && typeof win.getComputedStyle === "function") {
+ const styles = win.getComputedStyle(doc.documentElement);
+ const tokens = [
+ "--color-overlay-strong",
+ "--color-overlay",
+ "--color-surface",
+ "--color-bg",
+ ];
+
+ for (const token of tokens) {
+ const value = styles.getPropertyValue(token);
+ if (value && value.trim()) {
+ return value.trim();
+ }
+ }
+ }
+
+ return "#000000";
+ }
+
+ clearAmbientCanvas() {
+ const canvas = this.ambientCanvas;
+ if (!canvas || typeof canvas.getContext !== "function") {
+ return;
+ }
+ const context = canvas.getContext("2d");
+ if (!context) {
+ return;
+ }
+
+ const width = canvas.width || canvas.clientWidth || 0;
+ const height = canvas.height || canvas.clientHeight || 0;
+ if (!width || !height) {
+ return;
+ }
+
+ context.fillStyle = this.getAmbientFallbackColor();
+ context.fillRect(0, 0, width, height);
+ }
+
+ teardownAmbientGlow({ clear = true } = {}) {
+ const detach = this.detachAmbientBackground;
+ this.detachAmbientBackground = null;
+
+ if (typeof detach === "function") {
+ try {
+ detach({ clear });
+ return;
+ } catch (error) {
+ this.log("[VideoModal] Failed to teardown ambient background", error);
+ }
+ }
+
+ if (clear) {
+ this.clearAmbientCanvas();
+ }
+ }
+
+ attachAmbientGlow() {
+ if (!this.modalVideo || !this.ambientCanvas) {
+ this.clearAmbientCanvas();
+ return;
+ }
+
+ try {
+ this.detachAmbientBackground = attachAmbientBackground(
+ this.modalVideo,
+ this.ambientCanvas,
+ );
+ } catch (error) {
+ this.detachAmbientBackground = null;
+ this.log("[VideoModal] Failed to attach ambient background", error);
+ this.clearAmbientCanvas();
+ }
+ }
+
bindActionButtons() {
if (this.copyMagnetBtn) {
this.copyMagnetBtn.addEventListener("click", this.handleCopyRequest);