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