diff --git a/config/defaults.ts b/config/defaults.ts index dd74fb5b..11a36ccf 100644 --- a/config/defaults.ts +++ b/config/defaults.ts @@ -80,6 +80,7 @@ const defaultConfig = { disableDefaultLists: [], }, 'album-color-theme': {}, + 'ambient-mode': {}, // Disabled plugins 'shortcuts': { enabled: false, diff --git a/plugins/ambient-mode/back.ts b/plugins/ambient-mode/back.ts new file mode 100644 index 00000000..44f870d3 --- /dev/null +++ b/plugins/ambient-mode/back.ts @@ -0,0 +1,10 @@ +import { join } from 'node:path'; + +import { BrowserWindow } from 'electron'; + +import { injectCSS } from '../utils'; + + +export default (win: BrowserWindow) => { + injectCSS(win.webContents, join(__dirname, 'style.css')); +}; diff --git a/plugins/ambient-mode/front.ts b/plugins/ambient-mode/front.ts new file mode 100644 index 00000000..253d32b0 --- /dev/null +++ b/plugins/ambient-mode/front.ts @@ -0,0 +1,138 @@ +import { ConfigType } from '../../config/dynamic'; + +export default (_: ConfigType<'ambient-mode'>) => { + const interpolationTime = 3000; // interpolation time (ms) + const framerate = 30; // frame + const qualityRatio = 50; // width size (pixel) + + let unregister: (() => void) | null = null; + + const injectBlurVideo = (): (() => void) | null => { + const songVideo = document.querySelector('#song-video'); + const video = document.querySelector('#song-video .html5-video-container > video'); + const wrapper = document.querySelector('#song-video > .player-wrapper'); + + if (!songVideo) return null; + if (!video) return null; + if (!wrapper) return null; + + const blurCanvas = document.createElement('canvas'); + blurCanvas.classList.add('html5-blur-canvas'); + + const context = blurCanvas.getContext('2d', { willReadFrequently: true }); + + /* effect */ + let lastEffectWorkId: number | null = null; + let lastImageData: ImageData | null = null; + + const onSync = () => { + if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId); + + lastEffectWorkId = requestAnimationFrame(() => { + if (!context) return; + + const width = qualityRatio; + let height = Math.max(Math.floor(blurCanvas.height / blurCanvas.width * width), 1); + if (!Number.isFinite(height)) height = width; + + context.globalAlpha = 1; + if (lastImageData) { + const frameOffset = (1 / framerate) * (1000 / interpolationTime); + context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1 + context.putImageData(lastImageData, 0, 0); + context.globalAlpha = frameOffset; + } + context.drawImage(video, 0, 0, width, height); + + const nowImageData = context.getImageData(0, 0, width, height); + lastImageData = nowImageData; + + lastEffectWorkId = null; + }); + }; + + const applyVideoAttributes = () => { + const rect = video.getBoundingClientRect(); + + const newWidth = Math.floor(video.width || rect.width); + const newHeight = Math.floor(video.height || rect.height); + + if (newWidth === 0 || newHeight === 0) return; + + blurCanvas.width = qualityRatio; + blurCanvas.height = Math.floor(newHeight / newWidth * qualityRatio); + blurCanvas.style.width = `${newWidth}px`; + blurCanvas.style.height = `${newHeight}px`; + }; + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes') { + applyVideoAttributes(); + } + }); + }); + const resizeObserver = new ResizeObserver(() => { + applyVideoAttributes(); + }); + + /* hooking */ + let canvasInterval: NodeJS.Timeout | null = null; + canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / framerate))); + applyVideoAttributes(); + observer.observe(songVideo, { attributes: true }); + resizeObserver.observe(songVideo); + window.addEventListener('resize', applyVideoAttributes); + + const onPause = () => { + if (canvasInterval) clearInterval(canvasInterval); + canvasInterval = null; + }; + const onPlay = () => { + if (canvasInterval) clearInterval(canvasInterval); + canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / framerate))); + }; + songVideo.addEventListener('pause', onPause); + songVideo.addEventListener('play', onPlay); + + /* injecting */ + wrapper.prepend(blurCanvas); + + /* cleanup */ + return () => { + if (canvasInterval) clearInterval(canvasInterval); + + songVideo.removeEventListener('pause', onPause); + songVideo.removeEventListener('play', onPlay); + + observer.disconnect(); + resizeObserver.disconnect(); + window.removeEventListener('resize', applyVideoAttributes); + + wrapper.removeChild(blurCanvas); + }; + }; + + + const playerPage = document.querySelector('#player-page'); + const ytmusicAppLayout = document.querySelector('#layout'); + + const observer = new MutationObserver((mutationsList) => { + for (const mutation of mutationsList) { + if (mutation.type === 'attributes') { + const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open'); + if (isPageOpen) { + unregister?.(); + unregister = injectBlurVideo() ?? null; + } else { + unregister?.(); + unregister = null; + } + } + } + }); + + if (playerPage) { + observer.observe(playerPage, { attributes: true }); + } +}; \ No newline at end of file diff --git a/plugins/ambient-mode/style.css b/plugins/ambient-mode/style.css new file mode 100644 index 00000000..44f90164 --- /dev/null +++ b/plugins/ambient-mode/style.css @@ -0,0 +1,7 @@ +#song-video canvas.html5-blur-canvas{ + position: absolute; + left: 0; + top: 0; + + filter: blur(100px); +}