diff --git a/components/video-modal.html b/components/video-modal.html index 9d3104ae..156b66be 100644 --- a/components/video-modal.html +++ b/components/video-modal.html @@ -40,6 +40,9 @@
+
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);