const MODULE_ID = "fizzybubble";
const SOCKET_NS = `module.${MODULE_ID}`;

const SOUNDS_BASE_DIR = `modules/${MODULE_ID}/sounds`;
const SOUND_EXT = new Set(["ogg", "mp3", "wav", "webm", "flac", "m4a", "aac"]);

const SETTING_ENABLED = "fizzyboardEnabled";
const SETTING_EXTRA_PATH = "fizzyboardExtraPath";
const SETTING_VOLUME = "fizzyboardVolume";
const SETTING_FAVORITES = "fizzyboardFavorites";

let extraPathWarnedThisSession = false;

const DURATION_CACHE = new Map();
const DURATION_INFLIGHT = new Map();

/* -------------------------------------------- */
/* Settings                                      */
/* -------------------------------------------- */

Hooks.once("init", () => {
    game.settings.register(MODULE_ID, SETTING_ENABLED, {
        name: "Enable FizzyBoard",
        hint: "Enables the FizzyBoard (needs manuel restart F5).",
        scope: "world",
        config: true,
        type: Boolean,
        default: true,
        onChange: () => {
            try { ui.controls?.render(true); } catch (_) { }
            try { if (!isFizzyBoardEnabled()) FizzyBoardApp.closeIfOpen(); } catch (_) { }
        }
    });

    game.settings.register(MODULE_ID, SETTING_EXTRA_PATH, {
        name: "Additional Sounds Path",
        hint: "Optional additional sound directory.",
        scope: "world",
        config: true,
        type: String,
        default: "",
        onChange: () => {
            extraPathWarnedThisSession = false;
            try { FizzyBoardApp.requestRefreshIfOpen(); } catch (_) { }
        }
    });

    game.settings.register(MODULE_ID, SETTING_VOLUME, {
        name: "Volume",
        hint: "Global volume for playback.",
        scope: "client",
        config: false,
        type: Number,
        default: 0.8
    });

    game.settings.register(MODULE_ID, SETTING_FAVORITES, {
        name: "Favorites",
        hint: "Internal per-user favorites list.",
        scope: "client",
        config: false,
        type: String,
        default: "[]"
    });
});

function isFizzyBoardEnabled() {
    return Boolean(game.settings.get(MODULE_ID, SETTING_ENABLED));
}

function normalizeDataPath(p) {
    const s = String(p ?? "").trim();
    if (!s) return "";
    return s.replace(/^\/+/, "").replace(/\/+$/, "");
}

/* -------------------------------------------- */
/* Settings UI: add FilePicker button            */
/* -------------------------------------------- */

Hooks.on("renderSettingsConfig", (_app, html) => {
    const settingName = `${MODULE_ID}.${SETTING_EXTRA_PATH}`;
    const input = html.find(`input[name="${settingName}"]`);
    if (!input?.length) return;

    if (input.parent().find(".fizzyboard-pickdir").length) return;

    const btn = $(
        `<button type="button" class="fizzyboard-pickdir" style="margin-left: 6px;">
       <i class="fas fa-folder-open"></i>
     </button>`
    );

    btn.on("click", async (ev) => {
        ev.preventDefault();

        const current = normalizeDataPath(input.val());
        const initial = current || "worlds";

        const fp = new FilePicker({
            type: "folder",
            activeSource: "data",
            current: initial,
            callback: (path) => {
                input.val(normalizeDataPath(path));
                input.trigger("change");
            }
        });

        fp.render(true);
    });

    input.after(btn);
});

/* -------------------------------------------- */
/* Socket: playback + loop control               */
/* -------------------------------------------- */

Hooks.once("ready", () => {
    game.socket.on(SOCKET_NS, (data) => {
        try {
            if (!data || !data.type) return;
            LoopManager.handleSocketMessage(data);
        } catch (err) {
            console.error("FizzyBoard | Socket handler failed", err);
        }
    });
});

/* -------------------------------------------- */
/* Left Controls: button injection               */
/* -------------------------------------------- */

Hooks.on("getSceneControlButtons", (controls) => {
    try {
        if (!isFizzyBoardEnabled()) return;

        controls.tokens.tools.fizzyboard = {
            name: "fizzyboard",
            title: "FizzyBoard",
            icon: "fa-solid fa-volume-high",
            button: true,
            order: 40,
            visible: () => isFizzyBoardEnabled(),
            onClick: () => FizzyBoardApp.show(),
            onChange: () => { }
        };
    } catch (err) {
        console.error("FizzyBoard | Failed to inject scene controls", err);
    }
});

/* -------------------------------------------- */
/* Helpers: discovery + grouping + duration      */
/* -------------------------------------------- */

function isSoundFile(path) {
    const ext = (path.split(".").pop() || "").toLowerCase();
    return SOUND_EXT.has(ext);
}

function safeDecode(str) {
    try { return decodeURIComponent(str); } catch { return str; }
}

function labelFromPath(path) {
    const file = path.split("/").pop() || path;
    const base = file.replace(/\.[^.]+$/, "");
    return safeDecode(base).replace(/_/g, " ");
}

function relDirWithinRoot(fullDir, rootDir) {
    if (fullDir === rootDir) return "";
    if (fullDir.startsWith(rootDir + "/")) return fullDir.slice(rootDir.length + 1);
    return fullDir;
}

function relativeFileWithinRoot(filePath, rootDir) {
    if (filePath.startsWith(rootDir + "/")) return filePath.slice(rootDir.length + 1);
    if (filePath.startsWith(rootDir)) return filePath.slice(rootDir.length).replace(/^\/+/, "");
    return filePath;
}

function makeSoundId(rootKey, relativePath) {
    return `${rootKey}:${relativePath}`;
}

/** mm:ss */
function formatDuration(seconds) {
    const s = Math.max(0, Math.floor(Number(seconds) || 0));
    const mm = String(Math.floor(s / 60)).padStart(2, "0");
    const ss = String(s % 60).padStart(2, "0");
    return `${mm}:${ss}`;
}

async function getDurationSeconds(src, timeoutMs = 3000) {
    if (DURATION_CACHE.has(src)) return DURATION_CACHE.get(src);

    if (DURATION_INFLIGHT.has(src)) return DURATION_INFLIGHT.get(src);

    const p = (async () => {
        try {
            const audio = document.createElement("audio");
            audio.preload = "metadata";
            audio.src = src;

            const dur = await new Promise((resolve, reject) => {
                const t = setTimeout(() => reject(new Error("metadata timeout")), timeoutMs);

                const cleanup = () => {
                    clearTimeout(t);
                    audio.removeEventListener("loadedmetadata", onOk);
                    audio.removeEventListener("error", onErr);
                    audio.src = "";
                };

                const onOk = () => {
                    const d = Number(audio.duration);
                    cleanup();
                    if (Number.isFinite(d) && d > 0) resolve(d);
                    else reject(new Error("invalid duration"));
                };

                const onErr = () => {
                    cleanup();
                    reject(new Error("metadata error"));
                };

                audio.addEventListener("loadedmetadata", onOk, { once: true });
                audio.addEventListener("error", onErr, { once: true });
            });

            DURATION_CACHE.set(src, dur);
            return dur;
        } catch (_) {
            DURATION_CACHE.set(src, 0);
            return 0;
        } finally {
            DURATION_INFLIGHT.delete(src);
        }
    })();

    DURATION_INFLIGHT.set(src, p);
    return p;
}

async function browseRecursive(dir, outFiles, outDirs, opts = {}) {
    let res;
    try {
        res = await FilePicker.browse("data", dir);
    } catch (e) {
        if (opts.warnOnFail) warnExtraPathOnce(dir);
        return false;
    }

    const files = Array.isArray(res?.files) ? res.files : [];
    const dirs = Array.isArray(res?.dirs) ? res.dirs : [];

    for (const f of files) outFiles.push(f);
    for (const d of dirs) outDirs.push(d);

    for (const d of dirs) {
        await browseRecursive(d, outFiles, outDirs, { warnOnFail: false });
    }

    return true;
}

function warnExtraPathOnce(path) {
    if (extraPathWarnedThisSession) return;
    extraPathWarnedThisSession = true;

    const msg = `FizzyBoard: Additional Sounds Path is not accessible and will be ignored: "${path}".`;

    try { ui.notifications?.warn(msg); } catch (_) { }
}

/* -------------------------------------------- */
/* Best-effort preload/cache                     */
/* -------------------------------------------- */

async function preloadSounds(sources) {
    if (!Array.isArray(sources) || sources.length === 0) return;

    try {
        if (typeof foundry?.audio?.AudioHelper?.preload === "function") {
            for (const src of sources) {
                try { await foundry.audio.AudioHelper.preload(src); } catch (_) { }
            }
            return;
        }

        const Cache = foundry?.audio?.AudioBufferCache;
        if (Cache) {
            const candidates = [
                Cache.instance?.load,
                Cache.instance?.get,
                Cache.load,
                Cache.get
            ].filter(fn => typeof fn === "function");

            if (candidates.length) {
                for (const src of sources) {
                    for (const fn of candidates) {
                        try { await fn.call(Cache.instance ?? Cache, src); break; } catch (_) { }
                    }
                }
            }
        }
    } catch (_) {
    }
}

async function loadSoundGroups() {
    const extraRaw = normalizeDataPath(game.settings.get(MODULE_ID, SETTING_EXTRA_PATH));

    const roots = [{ key: "module", name: "(Module)", dir: SOUNDS_BASE_DIR }];
    if (extraRaw) roots.push({ key: "custom", name: "Custom", dir: extraRaw });

    const byGroup = new Map();
    const allSoundFiles = [];

    for (const root of roots) {
        const allFiles = [];
        const allDirs = [];

        const warnOnFail = root.key === "custom";
        const ok = await browseRecursive(root.dir, allFiles, allDirs, { warnOnFail });
        if (!ok) continue;

        const soundFiles = allFiles.filter(isSoundFile);
        allSoundFiles.push(...soundFiles);

        const rootGroupKey = `${root.key}::`;
        if (!byGroup.has(rootGroupKey)) {
            byGroup.set(rootGroupKey, { name: `${root.name}/(root)`, sounds: [] });
        }

        for (const src of soundFiles) {
            const relFile = relativeFileWithinRoot(src, root.dir);
            const parts = src.split("/");
            parts.pop();
            const dirFull = parts.join("/");
            const dirRel = relDirWithinRoot(dirFull, root.dir);

            const groupKey = `${root.key}::${dirRel}`;
            if (!byGroup.has(groupKey)) {
                const displayDir = dirRel ? dirRel : "(root)";
                byGroup.set(groupKey, { name: `${root.name}/${displayDir}`, sounds: [] });
            }

            const cachedSec = DURATION_CACHE.get(src) ?? 0;
            const durationStr = cachedSec > 0 ? formatDuration(cachedSec) : "";

            byGroup.get(groupKey).sounds.push({
                id: makeSoundId(root.key, relFile),
                src,
                label: labelFromPath(src),
                path: src,
                durationStr
            });
        }

        // Keep empty directories visible
        for (const d of allDirs) {
            const dirRel = relDirWithinRoot(d, root.dir);
            const groupKey = `${root.key}::${dirRel}`;
            if (!byGroup.has(groupKey)) {
                const displayDir = dirRel ? dirRel : "(root)";
                byGroup.set(groupKey, { name: `${root.name}/${displayDir}`, sounds: [] });
            }
        }
    }

    await preloadSounds(allSoundFiles);

    const rootOrder = { module: 0, custom: 1 };

    const groups = [...byGroup.entries()]
        .map(([groupKey, value]) => {
            const [rootKey] = groupKey.split("::");
            return {
                dirRel: groupKey,
                name: value.name,
                rootKey,
                sortKey: `${rootOrder[rootKey] ?? 9}::${value.name.toLowerCase()}`,
                sounds: value.sounds
            };
        })
        .sort((a, b) => a.sortKey.localeCompare(b.sortKey));

    for (const g of groups) g.sounds.sort((a, b) => a.label.localeCompare(b.label));
    return groups;
}

/* -------------------------------------------- */
/* Favorites                                     */
/* -------------------------------------------- */

function getFavorites() {
    try {
        const raw = String(game.settings.get(MODULE_ID, SETTING_FAVORITES) ?? "[]");
        const arr = JSON.parse(raw);
        return Array.isArray(arr) ? arr : [];
    } catch {
        return [];
    }
}

function setFavorites(list) {
    const unique = [...new Set(Array.isArray(list) ? list : [])];
    return game.settings.set(MODULE_ID, SETTING_FAVORITES, JSON.stringify(unique));
}

/* -------------------------------------------- */
/* Loop Manager                                  */
/* -------------------------------------------- */

const LoopManager = (() => {
    const loops = new Map(); // soundId -> { timerId }

    function _stop(soundId) {
        const entry = loops.get(soundId);
        if (!entry) return;
        clearInterval(entry.timerId);
        loops.delete(soundId);
    }

    function _start(soundId, src, intervalMs, volume) {
        _stop(soundId);
        const playOnce = () => {
            foundry.audio.AudioHelper.play({ src, volume, autoplay: true, loop: false }, true);
        };
        playOnce();
        const timerId = setInterval(playOnce, intervalMs);
        loops.set(soundId, { timerId });
    }

    function setLoop(soundId, src, enabled, intervalSeconds, volume) {
        if (!enabled) return _stop(soundId);
        const secs = Number(intervalSeconds);
        const safeSecs = Number.isFinite(secs) && secs > 0 ? secs : 5;
        _start(soundId, src, Math.round(safeSecs * 1000), volume);
    }

    function stopAll() {
        for (const soundId of [...loops.keys()]) _stop(soundId);
    }

    function handleSocketMessage(data) {
        if (data.type === "FIZZYBOARD_PLAY_ONCE") {
            const { src, volume = 0.8 } = data;
            foundry.audio.AudioHelper.play({ src, volume, autoplay: true, loop: false }, true);
            return;
        }
        if (data.type === "FIZZYBOARD_LOOP_SET") {
            const { soundId, src, enabled, intervalSeconds, volume = 0.8 } = data;
            setLoop(soundId, src, Boolean(enabled), intervalSeconds, volume);
            return;
        }
        if (data.type === "FIZZYBOARD_LOOP_STOP") {
            const { soundId } = data;
            if (soundId === "*") stopAll();
            else _stop(soundId);
        }
    }

    return { setLoop, stopAll, handleSocketMessage };
})();

/* -------------------------------------------- */
/* UI: ApplicationV2 + HandlebarsApplicationMixin */
/* -------------------------------------------- */

const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;

class FizzyBoardApp extends HandlebarsApplicationMixin(ApplicationV2) {
    static _instance;

    #groups = [];
    #loading = true;
    #collapsed = new Map(); // groupKey -> bool
    #loopUI = new Map();    // soundId -> { enabled, seconds }
    #volume = 0.8;
    #query = "";

    #durationTaskRunning = false;

    static show() {
        if (!isFizzyBoardEnabled()) {
            ui.notifications?.warn("FizzyBoard is disabled in Module Settings.");
            return;
        }
        if (!this._instance) this._instance = new FizzyBoardApp();
        this._instance.render(true);
    }

    static closeIfOpen() {
        if (!this._instance) return;
        try { this._instance.close(); } catch (_) { }
        this._instance = null;
    }

    static requestRefreshIfOpen() {
        if (!this._instance) return;
        this._instance.refreshSounds();
    }

    static DEFAULT_OPTIONS = foundry.utils.mergeObject(
        foundry.utils.deepClone(super.DEFAULT_OPTIONS),
        {
            id: "fizzyboard-app",
            classes: ["fizzyboard-app"],
            position: { width: 600, height: 740 },
            window: { title: "FizzyBoard", resizable: true }
        }
    );

    static PARTS = {
        main: { template: `modules/${MODULE_ID}/templates/fizzyboard.hbs` }
    };

    async _prepareContext(options) {
        const context = await super._prepareContext(options);

        this.#volume = Number(game.settings.get(MODULE_ID, SETTING_VOLUME)) || 0.8;

        if (this.#loading) {
            this.#groups = await loadSoundGroups();
            this.#loading = false;

            this.#collapsed.clear();
            for (const g of this.#groups) this.#collapsed.set(g.dirRel, true);
            this.#collapsed.set("__favorites__", false);

            this.#loopUI.clear();
            for (const g of this.#groups) {
                for (const s of g.sounds) {
                    this.#loopUI.set(s.id, { enabled: false, seconds: 5 });
                }
            }
        } else {
            for (const g of this.#groups) {
                for (const s of g.sounds) {
                    if (!this.#loopUI.has(s.id)) this.#loopUI.set(s.id, { enabled: false, seconds: 5 });
                }
            }
        }

        const favs = getFavorites();
        const q = this.#query.trim().toLowerCase();

        const matches = (sound) => {
            if (!q) return true;
            return (
                String(sound.label).toLowerCase().includes(q) ||
                String(sound.path).toLowerCase().includes(q) ||
                String(sound.id).toLowerCase().includes(q)
            );
        };

        const filteredGroups = this.#groups
            .map(g => {
                const sounds = g.sounds.filter(matches).map(s => {
                    const loop = this.#loopUI.get(s.id) ?? { enabled: false, seconds: 5 };
                    return {
                        ...s,
                        isFavorite: favs.includes(s.id),
                        loopEnabled: loop.enabled,
                        loopSeconds: loop.seconds
                    };
                });
                return { ...g, sounds, collapsed: this.#collapsed.get(g.dirRel) ?? true };
            })
            .filter(g => (q ? g.sounds.length > 0 : true));

        const allSoundsById = new Map();
        for (const g of this.#groups) for (const s of g.sounds) allSoundsById.set(s.id, s);

        const favSounds = favs
            .map(id => allSoundsById.get(id))
            .filter(Boolean)
            .filter(matches)
            .map(s => {
                const loop = this.#loopUI.get(s.id) ?? { enabled: false, seconds: 5 };
                return { ...s, isFavorite: true, loopEnabled: loop.enabled, loopSeconds: loop.seconds };
            });

        const groupsOut = [];
        if (favSounds.length > 0) {
            groupsOut.push({
                dirRel: "__favorites__",
                name: "★ Favorites",
                sounds: favSounds,
                collapsed: this.#collapsed.get("__favorites__") ?? false
            });
        }
        groupsOut.push(...filteredGroups);

        context.isGM = game.user.isGM;
        context.loading = this.#loading;
        context.volumePct = Math.round(this.#volume * 100);
        context.query = this.#query;
        context.groups = groupsOut;

        return context;
    }

    async refreshSounds() {
        this.#loading = true;
        this.#groups = [];
        this.#durationTaskRunning = false;
        this.render(true);
    }

    async close(options) {
        try { LoopManager.stopAll(); } finally { return super.close(options); }
    }

    async #populateDurationsAsync() {
        if (this.#durationTaskRunning) return;
        this.#durationTaskRunning = true;

        try {
            const srcs = new Set();
            for (const g of this.#groups) for (const s of g.sounds) {
                if (!DURATION_CACHE.has(s.src)) srcs.add(s.src);
            }

            const list = [...srcs];
            if (list.length === 0) return;

            const CONCURRENCY = 6;
            let index = 0;
            let updated = 0;

            const worker = async () => {
                while (index < list.length) {
                    const i = index++;
                    const src = list[i];
                    const sec = await getDurationSeconds(src);
                    if (sec > 0) updated++;
                }
            };

            const workers = Array.from({ length: Math.min(CONCURRENCY, list.length) }, worker);
            await Promise.all(workers);

            if (updated > 0) {
                for (const g of this.#groups) {
                    for (const s of g.sounds) {
                        const sec = DURATION_CACHE.get(s.src) ?? 0;
                        s.durationStr = sec > 0 ? formatDuration(sec) : "";
                    }
                }
                this.render(false);
            }
        } finally {
            this.#durationTaskRunning = false;
        }
    }

    _onRender(context, options) {
        super._onRender(context, options);

        this.#populateDurationsAsync();

        const part = this.element?.querySelector?.('[data-application-part="main"]') ?? this.element;
        if (!part) return;

        const search = part.querySelector("[data-fizzy-search]");
        if (search) {
            search.value = this.#query;
            let raf = null;
            search.addEventListener("input", () => {
                const next = String(search.value ?? "");
                if (raf) cancelAnimationFrame(raf);
                raf = requestAnimationFrame(() => {
                    this.#query = next;
                    this.render(false);
                });
            });

            const clearBtn = part.querySelector("[data-fizzy-search-clear]");
            if (clearBtn) {
                clearBtn.addEventListener("click", () => {
                    this.#query = "";
                    this.render(false);
                });
            }
        }

        part.querySelectorAll("[data-fizzy-toggle-group]").forEach((btn) => {
            btn.addEventListener("click", () => {
                const key = btn.dataset.fizzyToggleGroup ?? "";
                this.#collapsed.set(key, !(this.#collapsed.get(key) ?? true));
                this.render(false);
            });
        });

        part.querySelectorAll("[data-fizzy-refresh]").forEach((btn) => {
            btn.addEventListener("click", () => this.refreshSounds());
        });

        const vol = part.querySelector("[data-fizzy-volume]");
        if (vol) {
            vol.addEventListener("input", () => {
                const pct = Number(vol.value);
                const v = Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) / 100 : 0.8;
                this.#volume = v;
                game.settings.set(MODULE_ID, SETTING_VOLUME, v);
                const out = part.querySelector("[data-fizzy-volume-out]");
                if (out) out.textContent = `${Math.round(v * 100)}%`;
            });
        }

        part.querySelectorAll("[data-fizzy-fav]").forEach((btn) => {
            btn.addEventListener("click", async () => {
                const soundId = btn.dataset.fizzyFav;
                if (!soundId) return;

                const favs = getFavorites();
                const next = favs.includes(soundId) ? favs.filter(x => x !== soundId) : [...favs, soundId];
                await setFavorites(next);
                this.render(false);
            });
        });

        part.querySelectorAll("[data-fizzy-preview]").forEach((btn) => {
            btn.addEventListener("click", () => {
                const src = btn.dataset.fizzyPreview;
                foundry.audio.AudioHelper.play({ src, volume: this.#volume, autoplay: true, loop: false }, true);
            });
        });

        part.querySelectorAll("[data-fizzy-broadcast-once]").forEach((btn) => {
            btn.addEventListener("click", () => {
                if (!game.user.isGM) {
                    ui.notifications?.warn("Only the GM can broadcast to everyone.");
                    return;
                }
                const src = btn.dataset.fizzyBroadcastOnce;
                foundry.audio.AudioHelper.play({ src, volume: this.#volume, autoplay: true, loop: false }, true);
                game.socket.emit(SOCKET_NS, { type: "FIZZYBOARD_PLAY_ONCE", src, volume: this.#volume });
            });
        });

        part.querySelectorAll("[data-fizzy-loop-toggle]").forEach((cb) => {
            cb.addEventListener("change", () => {
                const soundId = cb.dataset.fizzyLoopToggle;
                const enabled = cb.checked;

                const secondsInput = part.querySelector(`[data-fizzy-loop-seconds="${CSS.escape(soundId)}"]`);
                const seconds = secondsInput ? Number(secondsInput.value) : 5;

                this.#loopUI.set(soundId, { enabled, seconds: Number.isFinite(seconds) && seconds > 0 ? seconds : 5 });
                this.render(false);

                if (!enabled) LoopManager.setLoop(soundId, "", false, 0, this.#volume);
            });
        });

        part.querySelectorAll("[data-fizzy-loop-seconds]").forEach((inp) => {
            inp.addEventListener("change", () => {
                const soundId = inp.dataset.fizzyLoopSeconds;
                const state = this.#loopUI.get(soundId) ?? { enabled: false, seconds: 5 };
                const secs = Number(inp.value);
                this.#loopUI.set(soundId, { enabled: state.enabled, seconds: Number.isFinite(secs) && secs > 0 ? secs : 5 });
            });
        });

        part.querySelectorAll("[data-fizzy-preview-loop]").forEach((btn) => {
            btn.addEventListener("click", () => {
                const soundId = btn.dataset.fizzyPreviewLoop;
                const src = btn.dataset.fizzySrc;
                const state = this.#loopUI.get(soundId) ?? { enabled: false, seconds: 5 };
                if (!state.enabled) return;
                LoopManager.setLoop(soundId, src, true, state.seconds, this.#volume);
            });
        });

        part.querySelectorAll("[data-fizzy-preview-loop-stop]").forEach((btn) => {
            btn.addEventListener("click", () => {
                const soundId = btn.dataset.fizzyPreviewLoopStop;
                LoopManager.setLoop(soundId, "", false, 0, this.#volume);
            });
        });

        part.querySelectorAll("[data-fizzy-all-loop]").forEach((btn) => {
            btn.addEventListener("click", () => {
                if (!game.user.isGM) {
                    ui.notifications?.warn("Only the GM can broadcast to everyone.");
                    return;
                }
                const soundId = btn.dataset.fizzyAllLoop;
                const src = btn.dataset.fizzySrc;
                const state = this.#loopUI.get(soundId) ?? { enabled: false, seconds: 5 };
                if (!state.enabled) return;

                LoopManager.setLoop(soundId, src, true, state.seconds, this.#volume);
                game.socket.emit(SOCKET_NS, {
                    type: "FIZZYBOARD_LOOP_SET",
                    soundId, src,
                    enabled: true,
                    intervalSeconds: state.seconds,
                    volume: this.#volume
                });
            });
        });

        part.querySelectorAll("[data-fizzy-all-loop-stop]").forEach((btn) => {
            btn.addEventListener("click", () => {
                if (!game.user.isGM) {
                    ui.notifications?.warn("Only the GM can broadcast to everyone.");
                    return;
                }
                const soundId = btn.dataset.fizzyAllLoopStop;

                LoopManager.setLoop(soundId, "", false, 0, this.#volume);
                game.socket.emit(SOCKET_NS, { type: "FIZZYBOARD_LOOP_STOP", soundId });
            });
        });
    }
}

globalThis.FizzyBoardApp = FizzyBoardApp;