diff --git a/src/plugins/built-in/backgroundMusic/BackgroundMusicSetting.svelte b/src/plugins/built-in/backgroundMusic/BackgroundMusicSetting.svelte new file mode 100644 index 00000000..2b92b906 --- /dev/null +++ b/src/plugins/built-in/backgroundMusic/BackgroundMusicSetting.svelte @@ -0,0 +1,117 @@ + + +
triggerSelect()} + ondragover={(e) => { e.stopPropagation(); dragging = true }} + ondragleave={() => dragging = false} + ondrop={onDrop} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + triggerSelect() + } + }} + role="button" + tabindex="0" +> +
+
+ {'\ued47'} + {filename ? 'Change audio' : 'Upload audio'} +
+ {#if filename} +
+ {filename}{#if durationText} • {durationText}{/if} +
+ {/if} +
+ {#if filename} + + {/if} + + {#if dragging} +
+ {/if} +
+ + diff --git a/src/plugins/built-in/backgroundMusic/index.ts b/src/plugins/built-in/backgroundMusic/index.ts new file mode 100644 index 00000000..e383adea --- /dev/null +++ b/src/plugins/built-in/backgroundMusic/index.ts @@ -0,0 +1,171 @@ +import type { Plugin } from "@/plugins/core/types"; +import { componentSetting, defineSettings, numberSetting } from "@/plugins/core/settingsHelpers"; +import styles from "./styles.css?inline"; +import BackgroundMusicSetting from "./BackgroundMusicSetting.svelte"; +import localforage from "localforage"; + +const settings = defineSettings({ + uploader: componentSetting({ + title: "Background Music", + description: "Upload a .wav audio file to play in the background.", + component: BackgroundMusicSetting, + }), + volume: numberSetting({ + title: "Volume", + description: "Set background music volume", + default: 0.5, + min: 0, + max: 1, + step: 0.05, + }), +}); + +const store = localforage.createInstance({ + name: "background-music-store", + storeName: "music", +}); + +let currentAudio: HTMLAudioElement | null = null; +let currentObjectUrl: string | null = null; +let cleanupRegistered = false; +let pendingGestureCancel: (() => void) | null = null; +let visibilityResumeTimeout: number | null = null; + +async function loadAudioBlob(): Promise { + const blob = await store.getItem("audio-blob"); + return blob && blob instanceof Blob ? blob : null; +} + +function stopAndCleanupAudio(): void { + if (currentAudio) { + currentAudio.pause(); + currentAudio.src = ""; + currentAudio.remove(); + currentAudio = null; + } + if (currentObjectUrl) { + URL.revokeObjectURL(currentObjectUrl); + currentObjectUrl = null; + } +} + +function ensureGestureStart(handler: () => void): () => void { + const eventTypes = ["pointerdown", "keydown", "touchstart"]; // broad user gesture coverage + const listener = () => { + handler(); + for (const type of eventTypes) { + window.removeEventListener(type, listener); + } + }; + for (const type of eventTypes) { + window.addEventListener(type, listener, { once: true, passive: true }); + } + return () => { + for (const type of eventTypes) { + window.removeEventListener(type, listener); + } + }; +} + +async function startPlayback(volume: number): Promise { + const blob = await loadAudioBlob(); + if (!blob) return; + + 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; + + try { + // Attempt immediate play; may be blocked until gesture + await audio.play(); + } catch { + // Ignore; will be started after gesture if enabled + } +} + +const backgroundMusicPlugin: Plugin = { + id: "background-music", + name: "Background Music", + description: "Play your own music in the background while SEQTA is open.", + version: "1.0.0", + settings, + styles, + disableToggle: true, + defaultEnabled: false, + + 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; + if (currentAudio) currentAudio.volume = Math.max(0, Math.min(1, vol)); + }); + + // Note: Stop button/event removed by user; no stop handling needed + + // 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 + const visHandler = () => { + if (!currentAudio) return; + if (document.visibilityState === "hidden") { + if (visibilityResumeTimeout !== null) { + clearTimeout(visibilityResumeTimeout); + visibilityResumeTimeout = null; + } + currentAudio.pause(); + } else if (document.visibilityState === "visible") { + if (visibilityResumeTimeout !== null) { + clearTimeout(visibilityResumeTimeout); + } + visibilityResumeTimeout = window.setTimeout(() => { + visibilityResumeTimeout = null; + currentAudio?.play().catch(() => {}); + }, 200); + } + }; + document.addEventListener("visibilitychange", visHandler); + + // Allow uploads to trigger refresh + const uploadedHandler = () => { + const vol = (api.settings as any).volume ?? 0.5; + startPlayback(vol); + }; + 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; + } + if (pendingGestureCancel) { pendingGestureCancel(); pendingGestureCancel = null; } + if (visibilityResumeTimeout !== null) { clearTimeout(visibilityResumeTimeout); visibilityResumeTimeout = null; } + stopAndCleanupAudio(); + }; + }, +}; + +export default backgroundMusicPlugin; + + diff --git a/src/plugins/built-in/backgroundMusic/styles.css b/src/plugins/built-in/backgroundMusic/styles.css new file mode 100644 index 00000000..5d7572c9 --- /dev/null +++ b/src/plugins/built-in/backgroundMusic/styles.css @@ -0,0 +1,2 @@ +.background-music-hidden{display:none} + diff --git a/src/plugins/index.ts b/src/plugins/index.ts index f4c5e989..0c4751c0 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -8,6 +8,7 @@ import animatedBackgroundPlugin from "./built-in/animatedBackground"; import assessmentsAveragePlugin from "./built-in/assessmentsAverage"; import profilePicturePlugin from "./built-in/profilePicture"; import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; +import backgroundMusicPlugin from "./built-in/backgroundMusic"; //import testPlugin from './built-in/test'; // Heavy plugins (lazy-loaded only when enabled) @@ -24,6 +25,7 @@ pluginManager.registerPlugin(notificationCollectorPlugin); pluginManager.registerPlugin(timetablePlugin); pluginManager.registerPlugin(profilePicturePlugin); pluginManager.registerPlugin(assessmentsOverviewPlugin); +pluginManager.registerPlugin(backgroundMusicPlugin); //pluginManager.registerPlugin(testPlugin); // Register heavy plugins with lazy loading