diff --git a/src/plugins/built-in/animatedBackground/backgroundLayers.ts b/src/plugins/built-in/animatedBackground/backgroundLayers.ts new file mode 100644 index 00000000..35d30a7d --- /dev/null +++ b/src/plugins/built-in/animatedBackground/backgroundLayers.ts @@ -0,0 +1,70 @@ +import type { PluginAPI } from "@/plugins/core/types"; +import { waitForElm } from "@/seqta/utils/waitForElm"; + +export const ANIMATED_BG_MARKER = "bsplus-animated-bg"; + +const LAYER_CLASSES = [ + ["bg", ANIMATED_BG_MARKER], + ["bg", "bg2", ANIMATED_BG_MARKER], + ["bg", "bg3", ANIMATED_BG_MARKER], +] as const; + +export function updateAnimationSpeed(speed: number) { + const bgElements = document.querySelectorAll(`.bg.${ANIMATED_BG_MARKER}`); + Array.from(bgElements).forEach((element, index) => { + const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5; + (element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`; + }); +} + +function countAnimatedLayers(container: HTMLElement): number { + return container.querySelectorAll(`:scope > div.bg.${ANIMATED_BG_MARKER}`).length; +} + +export function ensureAnimatedBackgroundLayers( + container: HTMLElement, + menu: HTMLElement, + speed: number, +): void { + const count = countAnimatedLayers(container); + if (count >= 3) { + updateAnimationSpeed(speed); + return; + } + + container + .querySelectorAll(`:scope > div.bg.${ANIMATED_BG_MARKER}`) + .forEach((el) => el.remove()); + + for (const classes of LAYER_CLASSES) { + const bk = document.createElement("div"); + classes.forEach((cls) => bk.classList.add(cls)); + container.insertBefore(bk, menu); + } + + updateAnimationSpeed(speed); +} + +export function removeAnimatedBackgroundLayers(): void { + document + .querySelectorAll(`div.bg.${ANIMATED_BG_MARKER}`) + .forEach((el) => el.remove()); +} + +export async function syncAnimatedBackground( + api: PluginAPI<{ speed: number }>, +): Promise { + try { + const [container, menu] = await Promise.all([ + waitForElm("#container", true), + waitForElm("#menu", true), + ]); + ensureAnimatedBackgroundLayers( + container as HTMLElement, + menu as HTMLElement, + api.settings.speed, + ); + } catch { + // #container / #menu not ready yet + } +} diff --git a/src/plugins/built-in/animatedBackground/index.ts b/src/plugins/built-in/animatedBackground/index.ts index 40dff577..ed8abfb3 100644 --- a/src/plugins/built-in/animatedBackground/index.ts +++ b/src/plugins/built-in/animatedBackground/index.ts @@ -6,7 +6,11 @@ import { Setting, } from "@/plugins/core/settingsHelpers"; import styles from "./styles.css?inline"; -import { waitForElm } from "@/seqta/utils/waitForElm"; +import { + removeAnimatedBackgroundLayers, + syncAnimatedBackground, + updateAnimationSpeed, +} from "./backgroundLayers"; const settings = defineSettings({ speed: numberSetting({ @@ -36,48 +40,35 @@ const animatedBackgroundPlugin: Plugin = { settings: instance.settings, run: async (api) => { - const [container, menu] = await Promise.all([ - waitForElm("#container", true), - waitForElm("#menu", true), - ]); + await syncAnimatedBackground(api); - const backgrounds = [ - { classes: ["bg"] }, - { classes: ["bg", "bg2"] }, - { classes: ["bg", "bg3"] }, - ]; + const speedUnregister = api.settings.onChange("speed", updateAnimationSpeed); - backgrounds.forEach(({ classes }) => { - const bk = document.createElement("div"); - classes.forEach((cls) => bk.classList.add(cls)); - container.insertBefore(bk, menu); + const pageChangeUnregister = api.seqta.onPageChange(() => { + void syncAnimatedBackground(api); }); - // Set initial speed - updateAnimationSpeed(api.settings.speed); + const pageshowHandler = (event: PageTransitionEvent) => { + if (event.persisted) void syncAnimatedBackground(api); + }; + window.addEventListener("pageshow", pageshowHandler); - // Listen for speed changes - const speedUnregister = api.settings.onChange( - "speed", - updateAnimationSpeed, - ); + const containerObserver = new MutationObserver(() => { + void syncAnimatedBackground(api); + }); + const container = document.getElementById("container"); + if (container) { + containerObserver.observe(container, { childList: true }); + } - // Return cleanup function return () => { speedUnregister.unregister(); - // Remove background elements - const backgrounds = document.getElementsByClassName("bg"); - Array.from(backgrounds).forEach((element) => element.remove()); + pageChangeUnregister.unregister(); + window.removeEventListener("pageshow", pageshowHandler); + containerObserver.disconnect(); + removeAnimatedBackgroundLayers(); }; }, }; -function updateAnimationSpeed(speed: number) { - const bgElements = document.getElementsByClassName("bg"); - Array.from(bgElements).forEach((element, index) => { - const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5; - (element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`; - }); -} - export default animatedBackgroundPlugin; diff --git a/src/plugins/built-in/backgroundMusic/index.ts b/src/plugins/built-in/backgroundMusic/index.ts index 207cf72f..8c30a839 100644 --- a/src/plugins/built-in/backgroundMusic/index.ts +++ b/src/plugins/built-in/backgroundMusic/index.ts @@ -32,7 +32,6 @@ const store = localforage.createInstance({ let currentAudio: HTMLAudioElement | null = null; let currentObjectUrl: string | null = null; -let cleanupRegistered = false; let pendingGestureCancel: (() => void) | null = null; let visibilityResumeTimeout: number | null = null; @@ -54,45 +53,54 @@ function stopAndCleanupAudio(): void { } } -function ensureGestureStart(handler: () => void): () => void { - const eventTypes = ["pointerdown", "keydown", "touchstart"]; // broad user gesture coverage +function disarmGesturePlayback(): void { + if (pendingGestureCancel) { + pendingGestureCancel(); + pendingGestureCancel = null; + } +} + +function armGesturePlayback(handler: () => void): void { + disarmGesturePlayback(); + const eventTypes = ["pointerdown", "keydown", "touchstart"] as const; const listener = () => { + disarmGesturePlayback(); handler(); - for (const type of eventTypes) { - window.removeEventListener(type, listener); - } }; for (const type of eventTypes) { window.addEventListener(type, listener, { once: true, passive: true }); } - return () => { + pendingGestureCancel = () => { for (const type of eventTypes) { window.removeEventListener(type, listener); } }; } -async function startPlayback(volume: number): Promise { +async function startPlayback(volume: number): Promise { const blob = await loadAudioBlob(); - if (!blob) return; + if (!blob) return false; - stopAndCleanupAudio(); - - currentObjectUrl = URL.createObjectURL(blob); - const audio = new Audio(currentObjectUrl); - audio.loop = true; - audio.volume = Math.max(0, Math.min(1, volume)); - audio.preload = "auto"; - audio.crossOrigin = "anonymous"; - audio.style.display = "none"; - document.body.appendChild(audio); - currentAudio = audio; + if (!currentAudio) { + stopAndCleanupAudio(); + currentObjectUrl = URL.createObjectURL(blob); + const audio = new Audio(currentObjectUrl); + audio.loop = true; + audio.volume = Math.max(0, Math.min(1, volume)); + audio.preload = "auto"; + audio.crossOrigin = "anonymous"; + audio.style.display = "none"; + document.body.appendChild(audio); + currentAudio = audio; + } else { + currentAudio.volume = Math.max(0, Math.min(1, volume)); + } try { - // Attempt immediate play; may be blocked until gesture - await audio.play(); + await currentAudio.play(); + return true; } catch { - // Ignore; will be started after gesture if enabled + return false; } } @@ -109,73 +117,84 @@ const backgroundMusicPlugin: Plugin = { run: async (api) => { await api.storage.loaded; - // react to specific setting changes - api.settings.onChange("volume" as any, (value: any) => { - const vol = (typeof value === "number" ? value : 0.5) as number; + const tryStart = async () => { + const blob = await loadAudioBlob(); + if (!blob) return; + + const vol = (api.settings as { volume?: number }).volume ?? 0.5; + const played = await startPlayback(vol); + if (played) { + disarmGesturePlayback(); + } else { + armGesturePlayback(() => void tryStart()); + } + }; + + api.settings.onChange("volume" as never, (value: unknown) => { + const vol = typeof value === "number" ? value : 0.5; if (currentAudio) currentAudio.volume = Math.max(0, Math.min(1, vol)); }); - api.settings.onChange("pauseOnHidden" as any, (value: any) => { - const pauseOnHidden = (typeof value === "boolean" ? value : true) as boolean; - // If the setting is disabled and audio is currently paused due to tab being hidden, resume it - if (!pauseOnHidden && currentAudio && currentAudio.paused && document.visibilityState === "hidden") { - currentAudio.play().catch(() => {}); + api.settings.onChange("pauseOnHidden" as never, (value: unknown) => { + const pauseOnHidden = typeof value === "boolean" ? value : true; + if ( + !pauseOnHidden && + currentAudio?.paused && + document.visibilityState === "visible" + ) { + void tryStart(); } }); - // Note: Stop button/event removed by user; no stop handling needed + armGesturePlayback(() => void tryStart()); + void tryStart(); - // Start if we have audio and autoplay is enabled - const tryStart = async () => { - const vol = (api.settings as any).volume ?? 0.5; - await startPlayback(vol); - }; - - // Always arm gesture start and attempt immediate start - const cancel = ensureGestureStart(() => { tryStart(); }); - cleanupRegistered = true; - (window as any).__betterseqta_bg_music_cancel__ = cancel; - tryStart(); - - // Pause on tab hide, resume on show with a small delay (if enabled) const visHandler = () => { - if (!currentAudio) return; - const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true; + if (!currentAudio) { + if (document.visibilityState === "visible") void tryStart(); + return; + } + + const pauseOnHidden = + (api.settings as { pauseOnHidden?: boolean }).pauseOnHidden ?? true; if (!pauseOnHidden) return; + if (document.visibilityState === "hidden") { if (visibilityResumeTimeout !== null) { clearTimeout(visibilityResumeTimeout); visibilityResumeTimeout = null; } currentAudio.pause(); - } else if (document.visibilityState === "visible") { + } else { if (visibilityResumeTimeout !== null) { clearTimeout(visibilityResumeTimeout); } visibilityResumeTimeout = window.setTimeout(() => { visibilityResumeTimeout = null; - currentAudio?.play().catch(() => {}); + void tryStart(); }, 200); } }; document.addEventListener("visibilitychange", visHandler); - // Allow uploads to trigger refresh - const uploadedHandler = () => { - const vol = (api.settings as any).volume ?? 0.5; - startPlayback(vol); - }; + const pageshowHandler = () => void tryStart(); + window.addEventListener("pageshow", pageshowHandler); + + const uploadedHandler = () => void tryStart(); window.addEventListener("betterseqta-background-music-updated", uploadedHandler); return () => { document.removeEventListener("visibilitychange", visHandler); - window.removeEventListener("betterseqta-background-music-updated", uploadedHandler); - if (cleanupRegistered && (window as any).__betterseqta_bg_music_cancel__) { - (window as any).__betterseqta_bg_music_cancel__(); - (window as any).__betterseqta_bg_music_cancel__ = undefined; + window.removeEventListener("pageshow", pageshowHandler); + window.removeEventListener( + "betterseqta-background-music-updated", + uploadedHandler, + ); + disarmGesturePlayback(); + if (visibilityResumeTimeout !== null) { + clearTimeout(visibilityResumeTimeout); + visibilityResumeTimeout = null; } - if (pendingGestureCancel) { pendingGestureCancel(); pendingGestureCancel = null; } - if (visibilityResumeTimeout !== null) { clearTimeout(visibilityResumeTimeout); visibilityResumeTimeout = null; } stopAndCleanupAudio(); }; },