diff --git a/src/plugins/ambient-mode/index.ts b/src/plugins/ambient-mode/index.ts index a7ed6a0728..33d90dbaee 100644 --- a/src/plugins/ambient-mode/index.ts +++ b/src/plugins/ambient-mode/index.ts @@ -2,17 +2,9 @@ import style from './style.css?inline'; import { t } from '@/i18n'; import { createPlugin } from '@/utils'; +import { menu } from './menu'; +import { AmbientModePluginConfig } from './types'; -export type AmbientModePluginConfig = { - enabled: boolean; - quality: number; - buffer: number; - interpolationTime: number; - blur: number; - size: number; - opacity: number; - fullscreen: boolean; -}; const defaultConfig: AmbientModePluginConfig = { enabled: false, quality: 50, @@ -30,106 +22,7 @@ export default createPlugin({ restartNeeded: false, config: defaultConfig, stylesheets: [style], - menu: async ({ getConfig, setConfig }) => { - const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000]; - const qualityList = [10, 25, 50, 100, 200, 500, 1000]; - const sizeList = [100, 110, 125, 150, 175, 200, 300]; - const bufferList = [1, 5, 10, 20, 30]; - const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500]; - const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]; - - const config = await getConfig(); - - return [ - { - label: t('plugins.ambient-mode.menu.smoothness-transition.label'), - submenu: interpolationTimeList.map((interpolationTime) => ({ - label: t( - 'plugins.ambient-mode.menu.smoothness-transition.submenu.during', - { - interpolationTime: interpolationTime / 1000, - }, - ), - type: 'radio', - checked: config.interpolationTime === interpolationTime, - click() { - setConfig({ interpolationTime }); - }, - })), - }, - { - label: t('plugins.ambient-mode.menu.quality.label'), - submenu: qualityList.map((quality) => ({ - label: t('plugins.ambient-mode.menu.quality.submenu.pixels', { - quality, - }), - type: 'radio', - checked: config.quality === quality, - click() { - setConfig({ quality }); - }, - })), - }, - { - label: t('plugins.ambient-mode.menu.size.label'), - submenu: sizeList.map((size) => ({ - label: t('plugins.ambient-mode.menu.size.submenu.percent', { size }), - type: 'radio', - checked: config.size === size, - click() { - setConfig({ size }); - }, - })), - }, - { - label: t('plugins.ambient-mode.menu.buffer.label'), - submenu: bufferList.map((buffer) => ({ - label: t('plugins.ambient-mode.menu.buffer.submenu.buffer', { - buffer, - }), - type: 'radio', - checked: config.buffer === buffer, - click() { - setConfig({ buffer }); - }, - })), - }, - { - label: t('plugins.ambient-mode.menu.opacity.label'), - submenu: opacityList.map((opacity) => ({ - label: t('plugins.ambient-mode.menu.opacity.submenu.percent', { - opacity: opacity * 100, - }), - type: 'radio', - checked: config.opacity === opacity, - click() { - setConfig({ opacity }); - }, - })), - }, - { - label: t('plugins.ambient-mode.menu.blur-amount.label'), - submenu: blurAmountList.map((blur) => ({ - label: t('plugins.ambient-mode.menu.blur-amount.submenu.pixels', { - blurAmount: blur, - }), - type: 'radio', - checked: config.blur === blur, - click() { - setConfig({ blur }); - }, - })), - }, - { - label: t('plugins.ambient-mode.menu.use-fullscreen.label'), - type: 'checkbox', - checked: config.fullscreen, - click(item) { - setConfig({ fullscreen: item.checked }); - }, - }, - ]; - }, + menu: menu, renderer: { interpolationTime: defaultConfig.interpolationTime, @@ -142,30 +35,37 @@ export default createPlugin({ unregister: null as (() => void) | null, update: null as (() => void) | null, - observer: null as MutationObserver | null, + interval: null as NodeJS.Timeout | null, + lastMediaType: null as "video" | "image" | null, + lastVideoSource: null as string | null, + lastImageSource: null as string | null, + + async start({ getConfig }) { + const config = await getConfig(); + this.interpolationTime = config.interpolationTime; + this.buffer = config.buffer; + this.qualityRatio = config.quality; + this.size = config.size; + this.blur = config.blur; + this.opacity = config.opacity; + this.isFullscreen = config.fullscreen; + + const songImage = document.querySelector('#song-image'); + const songVideo = document.querySelector('#song-video'); + const image = songImage?.querySelector('yt-img-shadow > img'); + const video = songVideo?.querySelector('.html5-video-container > video'); + const videoWrapper = document.querySelector('#song-video > .player-wrapper'); - start() { const injectBlurImage = () => { - const songImage = document.querySelector('#song-image'); - const image = document.querySelector( - '#song-image yt-img-shadow > img', - ); + if (!songImage || !image) return null; - if (!songImage) return null; - if (!image) return null; + this.lastImageSource = image.src; const blurImage = document.createElement('img'); blurImage.classList.add('html5-blur-image'); blurImage.src = image.src; - const applyImageAttribute = () => { - const rect = image.getBoundingClientRect(); - - const newWidth = Math.floor(image.width || rect.width); - const newHeight = Math.floor(image.height || rect.height); - - if (newWidth === 0 || newHeight === 0) return; - + this.update = () => { if (this.isFullscreen) blurImage.classList.add('fullscreen'); else blurImage.classList.remove('fullscreen'); @@ -174,49 +74,26 @@ export default createPlugin({ blurImage.style.setProperty('--blur', `${this.blur}px`); blurImage.style.setProperty('--opacity', `${this.opacity}`); }; - - this.update = applyImageAttribute; - - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'attributes' || mutation.type === 'childList') { - applyImageAttribute(); - } - }); - }); - - applyImageAttribute(); - observer.observe(songImage, { attributes: true, subtree: true }); + this.update(); /* injecting */ songImage.prepend(blurImage); /* cleanup */ return () => { - observer.disconnect(); - window.removeEventListener('resize', applyImageAttribute); - if (blurImage.isConnected) blurImage.remove(); }; }; - 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'); + const injectBlurVideo = () => { + if (!songVideo || !video || !videoWrapper) return null; - if (!songVideo) return null; - if (!video) return null; - if (!wrapper) return null; + this.lastVideoSource = video.src; const blurCanvas = document.createElement('canvas'); blurCanvas.classList.add('html5-blur-canvas'); - const context = blurCanvas.getContext('2d', { - willReadFrequently: true, - }); + const context = blurCanvas.getContext('2d', { willReadFrequently: true }); /* effect */ let lastEffectWorkId: number | null = null; @@ -230,17 +107,13 @@ export default createPlugin({ if (!context) return; const width = this.qualityRatio; - let height = Math.max( - Math.floor((blurCanvas.height / blurCanvas.width) * width), - 1, - ); + let height = Math.max(Math.floor((blurCanvas.height / blurCanvas.width) * width), 1,); if (!Number.isFinite(height)) height = width; if (!height) return; context.globalAlpha = 1; if (lastImageData) { - const frameOffset = - (1 / this.buffer) * (1000 / this.interpolationTime); + const frameOffset = (1 / this.buffer) * (1000 / this.interpolationTime); context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1 context.putImageData(lastImageData, 0, 0); context.globalAlpha = frameOffset; @@ -253,7 +126,7 @@ export default createPlugin({ }); }; - const applyVideoAttributes = () => { + this.update = () => { const rect = video.getBoundingClientRect(); const newWidth = Math.floor(video.width || rect.width); @@ -262,9 +135,7 @@ export default createPlugin({ if (newWidth === 0 || newHeight === 0) return; blurCanvas.width = this.qualityRatio; - blurCanvas.height = Math.floor( - (newHeight / newWidth) * this.qualityRatio, - ); + blurCanvas.height = Math.floor((newHeight / newWidth) * this.qualityRatio); if (this.isFullscreen) blurCanvas.classList.add('fullscreen'); else blurCanvas.classList.remove('fullscreen'); @@ -274,24 +145,11 @@ export default createPlugin({ blurCanvas.style.setProperty('--blur', `${this.blur}px`); blurCanvas.style.setProperty('--opacity', `${this.opacity}`); }; - this.update = applyVideoAttributes; - - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'attributes' || mutation.type === 'childList') { - applyVideoAttributes(); - } - }); - }); + this.update(); /* hooking */ let canvasInterval: NodeJS.Timeout | null = null; - canvasInterval = setInterval( - onSync, - Math.max(1, Math.ceil(1000 / this.buffer)), - ); - applyVideoAttributes(); - observer.observe(songVideo, { attributes: true, subtree: true }); + canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / this.buffer))); const onPause = () => { if (canvasInterval) clearInterval(canvasInterval); @@ -299,16 +157,13 @@ export default createPlugin({ }; const onPlay = () => { if (canvasInterval) clearInterval(canvasInterval); - canvasInterval = setInterval( - onSync, - Math.max(1, Math.ceil(1000 / this.buffer)), - ); + canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / this.buffer))); }; songVideo.addEventListener('pause', onPause); songVideo.addEventListener('play', onPlay); /* injecting */ - wrapper.prepend(blurCanvas); + videoWrapper.prepend(blurCanvas); /* cleanup */ return () => { @@ -317,46 +172,56 @@ export default createPlugin({ songVideo.removeEventListener('pause', onPause); songVideo.removeEventListener('play', onPlay); - observer.disconnect(); - if (blurCanvas.isConnected) blurCanvas.remove(); }; }; const isVideoMode = () => { const songVideo = document.querySelector('#song-video'); - if (!songVideo) return false; + if (!songVideo) { + this.lastMediaType = "image"; + return false; + } - return getComputedStyle(songVideo).display !== 'none'; + const isVideo = getComputedStyle(songVideo).display !== 'none'; + this.lastMediaType = isVideo ? "video" : "image"; + return isVideo; }; const playerPage = document.querySelector('#player-page'); const ytmusicAppLayout = document.querySelector('#layout'); - const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open'); - if (isPageOpen) { - this.unregister?.(); - this.unregister = (isVideoMode() ? injectBlurVideo() : injectBlurImage()) ?? null; + const injectBlurElement = (force?: boolean): boolean | void => { + const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open'); + if (isPageOpen) { + const isVideo = isVideoMode(); + if (!force) { + if (this.lastMediaType === "video" && this.lastVideoSource === video?.src) return false; + if (this.lastMediaType === "image" && this.lastImageSource === image?.src) return false; + } + this.unregister?.(); + this.unregister = (isVideo ? injectBlurVideo() : injectBlurImage()) ?? null; + } else { + this.unregister?.(); + this.unregister = null; + } } + /* needed for switching between different views (e.g. miniplayer) */ const observer = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { - if (mutation.type === 'attributes' || mutation.type === 'childList') { - const isPageOpen = - ytmusicAppLayout?.hasAttribute('player-page-open'); - if (isPageOpen) { - this.unregister?.(); - this.unregister = (isVideoMode() ? injectBlurVideo() : injectBlurImage()) ?? null; - } else { - this.unregister?.(); - this.unregister = null; - } + if (mutation.type === 'attributes') { + injectBlurElement(true); + break; } } }); if (playerPage) { - observer.observe(playerPage, { attributes: true, subtree: true }); + observer.observe(playerPage, { attributes: true }); + + /* fallback ticker for when the observer isn't triggered */ + this.interval = setInterval(injectBlurElement, 1000); } }, onConfigChange(newConfig) { @@ -371,9 +236,9 @@ export default createPlugin({ this.update?.(); }, stop() { - this.observer?.disconnect(); this.update = null; this.unregister?.(); + if (this.interval) clearInterval(this.interval); }, }, }); diff --git a/src/plugins/ambient-mode/menu.ts b/src/plugins/ambient-mode/menu.ts new file mode 100644 index 0000000000..14fcf4fa99 --- /dev/null +++ b/src/plugins/ambient-mode/menu.ts @@ -0,0 +1,110 @@ +import { t } from "@/i18n"; +import { MenuContext } from "@/types/contexts"; +import { MenuItemConstructorOptions } from "electron"; +import { AmbientModePluginConfig } from "./types"; + +export interface menuParameters { + getConfig: () => AmbientModePluginConfig | Promise; + setConfig: (conf: Partial>) => void | Promise; +} + +export const menu: (ctx: MenuContext) => MenuItemConstructorOptions[] | Promise = async ({ getConfig, setConfig }: menuParameters) => { + const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000]; + const qualityList = [10, 25, 50, 100, 200, 500, 1000]; + const sizeList = [100, 110, 125, 150, 175, 200, 300]; + const bufferList = [1, 5, 10, 20, 30]; + const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500]; + const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]; + + const config = await getConfig(); + + return [ + { + label: t('plugins.ambient-mode.menu.smoothness-transition.label'), + submenu: interpolationTimeList.map((interpolationTime) => ({ + label: t( + 'plugins.ambient-mode.menu.smoothness-transition.submenu.during', + { + interpolationTime: interpolationTime / 1000, + }, + ), + type: 'radio', + checked: config.interpolationTime === interpolationTime, + click() { + setConfig({ interpolationTime }); + }, + })), + }, + { + label: t('plugins.ambient-mode.menu.quality.label'), + submenu: qualityList.map((quality) => ({ + label: t('plugins.ambient-mode.menu.quality.submenu.pixels', { + quality, + }), + type: 'radio', + checked: config.quality === quality, + click() { + setConfig({ quality }); + }, + })), + }, + { + label: t('plugins.ambient-mode.menu.size.label'), + submenu: sizeList.map((size) => ({ + label: t('plugins.ambient-mode.menu.size.submenu.percent', { size }), + type: 'radio', + checked: config.size === size, + click() { + setConfig({ size }); + }, + })), + }, + { + label: t('plugins.ambient-mode.menu.buffer.label'), + submenu: bufferList.map((buffer) => ({ + label: t('plugins.ambient-mode.menu.buffer.submenu.buffer', { + buffer, + }), + type: 'radio', + checked: config.buffer === buffer, + click() { + setConfig({ buffer }); + }, + })), + }, + { + label: t('plugins.ambient-mode.menu.opacity.label'), + submenu: opacityList.map((opacity) => ({ + label: t('plugins.ambient-mode.menu.opacity.submenu.percent', { + opacity: opacity * 100, + }), + type: 'radio', + checked: config.opacity === opacity, + click() { + setConfig({ opacity }); + }, + })), + }, + { + label: t('plugins.ambient-mode.menu.blur-amount.label'), + submenu: blurAmountList.map((blur) => ({ + label: t('plugins.ambient-mode.menu.blur-amount.submenu.pixels', { + blurAmount: blur, + }), + type: 'radio', + checked: config.blur === blur, + click() { + setConfig({ blur }); + }, + })), + }, + { + label: t('plugins.ambient-mode.menu.use-fullscreen.label'), + type: 'checkbox', + checked: config.fullscreen, + click(item: Electron.MenuItem) { + setConfig({ fullscreen: item.checked }); + }, + }, + ]; +} \ No newline at end of file diff --git a/src/plugins/ambient-mode/types.ts b/src/plugins/ambient-mode/types.ts new file mode 100644 index 0000000000..6bb3b37cf2 --- /dev/null +++ b/src/plugins/ambient-mode/types.ts @@ -0,0 +1,10 @@ +export type AmbientModePluginConfig = { + enabled: boolean; + quality: number; + buffer: number; + interpolationTime: number; + blur: number; + size: number; + opacity: number; + fullscreen: boolean; +}; \ No newline at end of file