(function () {
    "use strict";

    const API_BASE = "/sd-webui-sound-player";
    const LOCAL_STATE_KEY = "sd-webui-sound-player-state";
    const DEFAULT_SLOT = {
        id: "slot-1",
        file: "",
        loop: true,
        volume: 0.75,
        currentTime: 0
    };

    let config = {
        audio_dir: "",
        audio_dir_valid: false,
        recursive: true,
        files: [],
        truncated: false,
        state: { slots: [{ ...DEFAULT_SLOT }], panel: { open: false, left: null, top: null, dragged: false } }
    };
    let state = normalizeState(config.state);
    let panel = null;
    let saveTimer = 0;
    let refreshTimer = 0;
    const audioBySlot = new Map();

    function appRoot() {
        return typeof gradioApp === "function" ? gradioApp() : document;
    }

    function apiUrl(path) {
        return `${window.location.origin}${API_BASE}${path}`;
    }

    function audioUrl(file) {
        return apiUrl(`/audio?file=${encodeURIComponent(file)}`);
    }

    function newSlotId() {
        return `slot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
    }

    function normalizeSlot(slot, index) {
        const source = slot && typeof slot === "object" ? slot : {};
        const volume = Number.isFinite(Number(source.volume)) ? Number(source.volume) : 0.75;
        const currentTime = Number.isFinite(Number(source.currentTime)) ? Number(source.currentTime) : 0;
        return {
            id: String(source.id || `slot-${index + 1}`),
            file: String(source.file || ""),
            loop: source.loop !== false,
            volume: Math.min(1, Math.max(0, volume)),
            currentTime: Math.max(0, currentTime)
        };
    }

    function normalizeState(source) {
        const fallback = { slots: [{ ...DEFAULT_SLOT }], panel: { open: false, left: null, top: null, dragged: false } };
        const stateSource = source && typeof source === "object" ? source : fallback;
        const sourceSlots = Array.isArray(stateSource.slots) && stateSource.slots.length ? stateSource.slots : fallback.slots;
        const panelSource = stateSource.panel && typeof stateSource.panel === "object" ? stateSource.panel : fallback.panel;
        return {
            slots: sourceSlots.slice(0, 8).map(normalizeSlot),
            panel: {
                open: Boolean(panelSource.open),
                left: Number.isFinite(Number(panelSource.left)) ? Number(panelSource.left) : null,
                top: Number.isFinite(Number(panelSource.top)) ? Number(panelSource.top) : null,
                dragged: panelSource.dragged === true
            }
        };
    }

    function files() {
        return Array.isArray(config.files) ? config.files : [];
    }

    function selectedFileExists(file) {
        return !file || files().some((item) => item.value === file);
    }

    function firstAudioFile() {
        const list = files();
        return list.length ? list[0].value : "";
    }

    function escapeHtml(value) {
        return String(value || "")
            .replaceAll("&", "&amp;")
            .replaceAll("<", "&lt;")
            .replaceAll(">", "&gt;")
            .replaceAll('"', "&quot;")
            .replaceAll("'", "&#39;");
    }

    function formatTime(seconds) {
        if (!Number.isFinite(seconds) || seconds < 0) {
            return "0:00";
        }
        const minutes = Math.floor(seconds / 60);
        const rest = Math.floor(seconds % 60);
        return `${minutes}:${String(rest).padStart(2, "0")}`;
    }

    function setStatus(message, tone) {
        const target = panel ? panel.querySelector("[data-fmp-status]") : null;
        if (!target) {
            return;
        }
        target.textContent = message || "";
        target.dataset.fmpTone = tone || "";
    }

    function saveLocalState() {
        try {
            localStorage.setItem(LOCAL_STATE_KEY, JSON.stringify(state));
        } catch (error) {
            console.warn("[Sound Player] local state save failed", error);
        }
    }

    function scheduleStateSave() {
        saveLocalState();
        if (saveTimer) {
            return;
        }
        saveTimer = window.setTimeout(async function () {
            saveTimer = 0;
            try {
                await fetch(apiUrl("/state"), {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify(state)
                });
            } catch (error) {
                console.warn("[Sound Player] state save failed", error);
            }
        }, 900);
    }

    function readLocalState() {
        try {
            return normalizeState(JSON.parse(localStorage.getItem(LOCAL_STATE_KEY) || "null"));
        } catch (error) {
            return null;
        }
    }

    async function fetchConfig() {
        const response = await fetch(apiUrl("/config"), { cache: "no-store" });
        if (!response.ok) {
            throw new Error(`config ${response.status}`);
        }
        const nextConfig = await response.json();
        config = {
            ...config,
            ...nextConfig,
            files: Array.isArray(nextConfig.files) ? nextConfig.files : []
        };

        const savedState = normalizeState(nextConfig.state);
        const localState = readLocalState();
        state = localState && localState.slots.some((slot) => slot.file) ? localState : savedState;
        state.slots.forEach((slot) => {
            if (!selectedFileExists(slot.file)) {
                slot.currentTime = 0;
            }
        });
    }

    async function saveConfigFromPanel() {
        if (!panel) {
            return;
        }
        const folderInput = panel.querySelector("[data-fmp-folder]");
        const recursiveInput = panel.querySelector("[data-fmp-recursive]");
        const audioDir = folderInput ? folderInput.value.trim() : "";
        const recursive = recursiveInput ? recursiveInput.checked : true;
        setStatus("保存中", "");
        const response = await fetch(apiUrl("/config"), {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ audio_dir: audioDir, recursive })
        });
        if (!response.ok) {
            throw new Error(`config save ${response.status}`);
        }
        config = await response.json();
        renderPanel();
        setStatus(fileStatusText(), config.audio_dir_valid ? "" : "warn");
    }

    async function browseFolderFromPanel() {
        if (!panel) {
            return;
        }
        const folderInput = panel.querySelector("[data-fmp-folder]");
        const recursiveInput = panel.querySelector("[data-fmp-recursive]");
        setStatus("フォルダ選択中", "");
        const response = await fetch(apiUrl("/browse-folder"), {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
                audio_dir: folderInput ? folderInput.value.trim() : "",
                recursive: recursiveInput ? recursiveInput.checked : true
            })
        });
        if (!response.ok) {
            throw new Error(`folder browse ${response.status}`);
        }
        const nextConfig = await response.json();
        config = {
            ...config,
            ...nextConfig,
            files: Array.isArray(nextConfig.files) ? nextConfig.files : []
        };
        renderPanel();
        if (nextConfig.folder_picker_error) {
            setStatus("フォルダ選択を開けません", "warn");
        } else if (nextConfig.folder_picker_canceled) {
            setStatus(fileStatusText(), config.audio_dir_valid ? "" : "warn");
        } else {
            setStatus(fileStatusText(), config.audio_dir_valid ? "" : "warn");
        }
    }

    function fileOptionsHtml(selectedFile) {
        const missingSelected = selectedFile && !selectedFileExists(selectedFile);
        const options = ['<option value="">音声を選択</option>'];
        if (missingSelected) {
            options.push(`<option value="${escapeHtml(selectedFile)}" selected>見つからない: ${escapeHtml(selectedFile)}</option>`);
        }
        files().forEach((file) => {
            const selected = file.value === selectedFile ? " selected" : "";
            options.push(`<option value="${escapeHtml(file.value)}"${selected}>${escapeHtml(file.label || file.value)}</option>`);
        });
        return options.join("");
    }

    function slotHtml(slot, index) {
        const slotActionButton = index === 0
            ? '<button type="button" data-fmp-action="add-slot" title="プレイヤー追加" aria-label="プレイヤー追加">＋</button>'
            : '<button type="button" data-fmp-action="remove" title="このプレイヤーを閉じる" aria-label="このプレイヤーを閉じる">×</button>';
        return `
            <div class="fmp-slot" data-fmp-slot="${escapeHtml(slot.id)}">
                <div class="fmp-slot-top">
                    <span class="fmp-slot-number">${index + 1}</span>
                    <select data-fmp-file aria-label="音声ファイル">${fileOptionsHtml(slot.file)}</select>
                    ${slotActionButton}
                </div>
                <div class="fmp-controls">
                    <button type="button" data-fmp-action="head" title="頭出し" aria-label="頭出し">|◀</button>
                    <button class="fmp-playstop" type="button" data-fmp-action="toggle" title="再生/停止" aria-label="再生/停止">再生</button>
                    <button type="button" data-fmp-action="next" title="次のファイル" aria-label="次のファイル">▶|</button>
                    <label class="fmp-loop" title="リピート" aria-label="リピート">
                        <input type="checkbox" data-fmp-loop ${slot.loop ? "checked" : ""}>
                        <span>↻</span>
                    </label>
                    <input class="fmp-volume" type="range" min="0" max="100" step="1" value="${Math.round(slot.volume * 100)}" data-fmp-volume aria-label="音量">
                </div>
                <div class="fmp-progress">
                    <input type="range" min="0" max="1000" step="1" value="0" data-fmp-seek aria-label="再生位置">
                    <span data-fmp-time>0:00</span>
                </div>
            </div>
        `;
    }

    function fileStatusText() {
        if (!config.audio_dir_valid) {
            return "フォルダ未設定";
        }
        const count = files().length;
        const suffix = config.truncated ? "+" : "";
        return `${count}${suffix}件`;
    }

    function isAnySlotPlaying() {
        for (const audio of audioBySlot.values()) {
            if (audio && !audio.paused && !audio.ended) {
                return true;
            }
        }
        return false;
    }

    function updateLaunchers() {
        const isPlaying = isAnySlotPlaying();
        const isBackgroundPlaying = isPlaying && !state.panel.open;
        document.querySelectorAll(".fmp-launcher").forEach((button) => {
            const icon = button.querySelector(".fmp-launcher-icon");
            if (icon) {
                icon.textContent = isPlaying ? "▶" : "♪";
            }
            button.classList.toggle("fmp-playing", isPlaying);
            button.classList.toggle("fmp-background-playing", isBackgroundPlaying);
            button.title = isPlaying ? "Sound Player is playing" : "Sound Player";
            button.setAttribute("aria-label", isPlaying ? "Sound Player is playing" : "Sound Player");
        });
    }

    function minimizePanel() {
        setPanelOpen(false);
    }

    function stopAllSlots() {
        state.slots.forEach((slot) => {
            const audio = audioBySlot.get(slot.id);
            if (audio) {
                audio.pause();
                try {
                    audio.currentTime = 0;
                } catch (error) {
                    console.warn("[Sound Player] stop all seek failed", error);
                }
            }
            slot.currentTime = 0;
            updateSlotUi(slot);
        });
        updateLaunchers();
        scheduleStateSave();
    }

    async function playAllSlots() {
        if (!files().length) {
            setStatus("音声なし", "warn");
            return;
        }
        for (const slot of state.slots) {
            await playSlot(slot);
        }
        scheduleStateSave();
    }

    function addSlot() {
        state.slots.push({ ...DEFAULT_SLOT, id: newSlotId() });
        renderPanel();
        scheduleStateSave();
    }

    function closePanelAndStopAll() {
        stopAllSlots();
        setPanelOpen(false);
    }

    function ensurePanel() {
        if (panel) {
            return panel;
        }

        panel = document.createElement("section");
        panel.id = "fmp_panel";
        panel.className = "fmp-panel";
        panel.hidden = true;
        panel.innerHTML = `
            <div class="fmp-header" data-fmp-drag>
                <div class="fmp-title"><span>Sound Player</span></div>
                <div class="fmp-header-actions">
                    <button type="button" data-fmp-minimize title="最小化" aria-label="最小化">－</button>
                    <button type="button" data-fmp-close title="全停止して閉じる" aria-label="全停止して閉じる">×</button>
                </div>
            </div>
            <div class="fmp-config">
                <input type="text" data-fmp-folder aria-label="音声フォルダ" placeholder="音声フォルダ">
                <button type="button" data-fmp-browse-folder title="フォルダを選択" aria-label="フォルダを選択">📁</button>
                <button type="button" data-fmp-refresh title="音声一覧を更新" aria-label="音声一覧を更新">更新</button>
                <button type="button" data-fmp-save-folder title="保存" aria-label="保存">保存</button>
                <div class="fmp-bulk-row">
                    <button class="fmp-bulk-button" type="button" data-fmp-play-all title="全プレイヤーを再生" aria-label="全プレイヤーを再生">全再生</button>
                    <button class="fmp-bulk-button" type="button" data-fmp-stop-all title="全プレイヤーを停止" aria-label="全プレイヤーを停止">全停止</button>
                    <label class="fmp-recursive"><input type="checkbox" data-fmp-recursive>サブフォルダからも読み込み</label>
                </div>
            </div>
            <div class="fmp-status" data-fmp-status></div>
            <div class="fmp-slots" data-fmp-slots></div>
        `;
        document.body.appendChild(panel);
        bindPanelEvents(panel);
        return panel;
    }

    function applyPanelPosition() {
        if (!panel) {
            return;
        }
        if (state.panel.dragged && state.panel.left !== null && state.panel.top !== null) {
            panel.style.left = `${state.panel.left}px`;
            panel.style.top = `${state.panel.top}px`;
            panel.style.right = "auto";
            panel.style.bottom = "auto";
        } else {
            panel.style.left = "auto";
            panel.style.right = "28px";
            panel.style.top = "auto";
            panel.style.bottom = "28px";
        }
    }

    function setPanelOpen(open) {
        ensurePanel();
        state.panel.open = Boolean(open);
        panel.hidden = !state.panel.open;
        panel.classList.toggle("fmp-open", state.panel.open);
        document.querySelectorAll(".fmp-launcher").forEach((button) => {
            button.classList.toggle("fmp-active", state.panel.open);
            button.setAttribute("aria-expanded", state.panel.open ? "true" : "false");
        });
        updateLaunchers();
        if (state.panel.open) {
            applyPanelPosition();
            renderPanel();
        }
        scheduleStateSave();
    }

    function renderPanel() {
        ensurePanel();
        const folderInput = panel.querySelector("[data-fmp-folder]");
        const recursiveInput = panel.querySelector("[data-fmp-recursive]");
        const slots = panel.querySelector("[data-fmp-slots]");

        if (folderInput && document.activeElement !== folderInput) {
            folderInput.value = config.audio_dir || "";
        }
        if (recursiveInput) {
            recursiveInput.checked = config.recursive !== false;
        }
        if (slots) {
            slots.innerHTML = state.slots.map(slotHtml).join("");
        }
        setStatus(fileStatusText(), config.audio_dir_valid ? "" : "warn");
        state.slots.forEach(updateSlotUi);
        updateLaunchers();
    }

    function slotById(slotId) {
        return state.slots.find((slot) => slot.id === slotId) || null;
    }

    function slotElement(slotId) {
        return panel ? panel.querySelector(`[data-fmp-slot="${CSS.escape(slotId)}"]`) : null;
    }

    function ensureAudio(slot) {
        let audio = audioBySlot.get(slot.id);
        if (!audio) {
            audio = new Audio();
            audio.preload = "metadata";
            audio.dataset.fmpSlot = slot.id;
            audioBySlot.set(slot.id, audio);

            audio.addEventListener("loadedmetadata", function () {
                const liveSlot = slotById(slot.id);
                if (!liveSlot) {
                    return;
                }
                if (liveSlot.currentTime > 0 && Number.isFinite(audio.duration) && liveSlot.currentTime < audio.duration) {
                    try {
                        audio.currentTime = liveSlot.currentTime;
                    } catch (error) {
                        console.warn("[Sound Player] seek restore failed", error);
                    }
                }
                updateSlotUi(liveSlot);
            });

            audio.addEventListener("timeupdate", function () {
                const liveSlot = slotById(slot.id);
                if (!liveSlot) {
                    return;
                }
                liveSlot.currentTime = audio.currentTime || 0;
                updateSlotProgress(liveSlot, audio);
                scheduleStateSave();
            });

            audio.addEventListener("play", function () {
                updateSlotUi(slotById(slot.id));
                updateLaunchers();
            });
            audio.addEventListener("pause", function () {
                updateSlotUi(slotById(slot.id));
                updateLaunchers();
            });
            audio.addEventListener("ended", function () {
                const liveSlot = slotById(slot.id);
                if (!liveSlot) {
                    return;
                }
                liveSlot.currentTime = 0;
                if (liveSlot.loop) {
                    updateSlotUi(liveSlot);
                    updateLaunchers();
                    scheduleStateSave();
                    return;
                }
                nextSlot(liveSlot, { autoplay: true, wrap: false });
            });
            audio.addEventListener("error", function () {
                setStatus("再生できません", "warn");
                updateSlotUi(slotById(slot.id));
                updateLaunchers();
            });
        }

        audio.loop = slot.loop !== false;
        audio.volume = slot.volume;
        if (audio.dataset.fmpFile !== slot.file) {
            audio.pause();
            audio.dataset.fmpFile = slot.file || "";
            audio.removeAttribute("src");
            if (slot.file) {
                audio.src = audioUrl(slot.file);
                audio.load();
            }
        }
        return audio;
    }

    async function playSlot(slot) {
        if (!slot) {
            return;
        }
        if (!slot.file || !selectedFileExists(slot.file)) {
            const firstFile = firstAudioFile();
            if (!firstFile) {
                setStatus("音声なし", "warn");
                return;
            }
            slot.file = firstFile;
            slot.currentTime = 0;
            renderPanel();
            scheduleStateSave();
        }
        if (!slot.file) {
            return;
        }
        const audio = ensureAudio(slot);
        try {
            await audio.play();
            setStatus(fileStatusText(), "");
            scheduleStateSave();
        } catch (error) {
                console.warn("[Sound Player] play failed", error);
            setStatus("再生できません", "warn");
        }
        updateSlotUi(slot);
    }

    function pauseSlot(slot) {
        const audio = ensureAudio(slot);
        audio.pause();
        updateSlotUi(slot);
    }

    function stopSlot(slot) {
        const audio = ensureAudio(slot);
        audio.pause();
        try {
            audio.currentTime = 0;
        } catch (error) {
            console.warn("[Sound Player] stop seek failed", error);
        }
        slot.currentTime = 0;
        updateSlotUi(slot);
        updateLaunchers();
        scheduleStateSave();
    }

    function headSlot(slot) {
        const audio = ensureAudio(slot);
        try {
            audio.currentTime = 0;
        } catch (error) {
            console.warn("[Sound Player] head seek failed", error);
        }
        slot.currentTime = 0;
        updateSlotUi(slot);
        scheduleStateSave();
    }

    async function nextSlot(slot, options = {}) {
        const list = files();
        if (!list.length) {
            setStatus("音声なし", "warn");
            return;
        }
        const audio = ensureAudio(slot);
        const wasPlaying = options.autoplay === true || !audio.paused;
        const currentIndex = list.findIndex((file) => file.value === slot.file);
        const nextIndex = currentIndex >= 0 ? currentIndex + 1 : 0;
        if (nextIndex >= list.length && options.wrap === false) {
            slot.currentTime = 0;
            updateSlotUi(slot);
            updateLaunchers();
            scheduleStateSave();
            setStatus("最後まで再生しました", "");
            return;
        }
        const boundedNextIndex = nextIndex >= list.length ? 0 : nextIndex;
        slot.file = list[boundedNextIndex].value;
        slot.currentTime = 0;
        ensureAudio(slot);
        renderPanel();
        if (wasPlaying) {
            await playSlot(slot);
        }
        scheduleStateSave();
    }

    function updateSlotProgress(slot, audio) {
        const element = slotElement(slot.id);
        if (!element) {
            return;
        }
        const seek = element.querySelector("[data-fmp-seek]");
        const time = element.querySelector("[data-fmp-time]");
        const duration = Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : 0;
        if (seek) {
            seek.value = duration ? String(Math.round((audio.currentTime / duration) * 1000)) : "0";
        }
        if (time) {
            time.textContent = duration ? `${formatTime(audio.currentTime)} / ${formatTime(duration)}` : formatTime(audio.currentTime);
        }
    }

    function updateSlotUi(slot) {
        if (!slot) {
            return;
        }
        const element = slotElement(slot.id);
        if (!element) {
            return;
        }
        const audio = audioBySlot.get(slot.id);
        const toggle = element.querySelector('[data-fmp-action="toggle"]');
        if (toggle) {
            toggle.textContent = audio && !audio.paused ? "停止" : "再生";
        }
        const loop = element.querySelector("[data-fmp-loop]");
        if (loop) {
            loop.checked = slot.loop !== false;
        }
        const volume = element.querySelector("[data-fmp-volume]");
        if (volume) {
            volume.value = String(Math.round(slot.volume * 100));
        }
        if (audio) {
            updateSlotProgress(slot, audio);
        }
    }

    function handleSlotAction(slot, action) {
        if (!slot) {
            return;
        }
        if (action === "toggle") {
            const audio = ensureAudio(slot);
            if (audio.paused) {
                playSlot(slot);
            } else {
                stopSlot(slot);
            }
            return;
        }
        if (action === "head") {
            headSlot(slot);
            return;
        }
        if (action === "next") {
            nextSlot(slot);
            return;
        }
        if (action === "add-slot") {
            addSlot();
            return;
        }
        if (action === "remove") {
            const audio = audioBySlot.get(slot.id);
            if (audio) {
                audio.pause();
                audioBySlot.delete(slot.id);
            }
            state.slots = state.slots.filter((item) => item.id !== slot.id);
            if (!state.slots.length) {
                state.slots.push({ ...DEFAULT_SLOT, id: newSlotId() });
            }
            renderPanel();
            updateLaunchers();
            scheduleStateSave();
        }
    }

    function bindPanelEvents(target) {
        target.addEventListener("click", async function (event) {
            const close = event.target.closest("[data-fmp-close]");
            if (close) {
                closePanelAndStopAll();
                return;
            }

            const minimize = event.target.closest("[data-fmp-minimize]");
            if (minimize) {
                minimizePanel();
                return;
            }

            const playAll = event.target.closest("[data-fmp-play-all]");
            if (playAll) {
                await playAllSlots();
                return;
            }

            const stopAll = event.target.closest("[data-fmp-stop-all]");
            if (stopAll) {
                stopAllSlots();
                return;
            }

            const refresh = event.target.closest("[data-fmp-refresh]");
            if (refresh) {
                await refreshConfig();
                return;
            }

            const browseFolder = event.target.closest("[data-fmp-browse-folder]");
            if (browseFolder) {
                try {
                    await browseFolderFromPanel();
                } catch (error) {
                    console.warn("[Sound Player] folder browse failed", error);
                    setStatus("フォルダ選択失敗", "warn");
                }
                return;
            }

            const saveFolder = event.target.closest("[data-fmp-save-folder]");
            if (saveFolder) {
                try {
                    await saveConfigFromPanel();
                } catch (error) {
                    console.warn("[Sound Player] config save failed", error);
                    setStatus("保存失敗", "warn");
                }
                return;
            }

            const actionButton = event.target.closest("[data-fmp-action]");
            if (actionButton) {
                const slot = slotById(actionButton.closest("[data-fmp-slot]")?.dataset.fmpSlot || "");
                handleSlotAction(slot, actionButton.dataset.fmpAction);
            }
        });

        target.addEventListener("change", function (event) {
            const slotElementTarget = event.target.closest("[data-fmp-slot]");
            const slot = slotById(slotElementTarget?.dataset.fmpSlot || "");
            if (event.target.matches("[data-fmp-file]") && slot) {
                const audio = audioBySlot.get(slot.id);
                const wasPlaying = audio && !audio.paused;
                slot.file = event.target.value;
                slot.currentTime = 0;
                ensureAudio(slot);
                updateSlotUi(slot);
                if (wasPlaying) {
                    playSlot(slot);
                }
                scheduleStateSave();
                return;
            }
            if (event.target.matches("[data-fmp-loop]") && slot) {
                slot.loop = event.target.checked;
                ensureAudio(slot).loop = slot.loop;
                scheduleStateSave();
                return;
            }
            if (event.target.matches("[data-fmp-recursive]")) {
                config.recursive = event.target.checked;
            }
        });

        target.addEventListener("input", function (event) {
            const slotElementTarget = event.target.closest("[data-fmp-slot]");
            const slot = slotById(slotElementTarget?.dataset.fmpSlot || "");
            if (event.target.matches("[data-fmp-volume]") && slot) {
                slot.volume = Math.min(1, Math.max(0, Number(event.target.value) / 100));
                ensureAudio(slot).volume = slot.volume;
                scheduleStateSave();
                return;
            }
            if (event.target.matches("[data-fmp-seek]") && slot) {
                const audio = ensureAudio(slot);
                if (Number.isFinite(audio.duration) && audio.duration > 0) {
                    audio.currentTime = (Number(event.target.value) / 1000) * audio.duration;
                    slot.currentTime = audio.currentTime;
                    updateSlotProgress(slot, audio);
                    scheduleStateSave();
                }
            }
        });

        bindDrag(target);
    }

    function bindDrag(target) {
        const header = target.querySelector("[data-fmp-drag]");
        if (!header) {
            return;
        }
        let drag = null;
        header.addEventListener("pointerdown", function (event) {
            if (event.target.closest("button")) {
                return;
            }
            const rect = target.getBoundingClientRect();
            drag = {
                pointerId: event.pointerId,
                offsetX: event.clientX - rect.left,
                offsetY: event.clientY - rect.top
            };
            header.setPointerCapture(event.pointerId);
        });
        header.addEventListener("pointermove", function (event) {
            if (!drag || drag.pointerId !== event.pointerId) {
                return;
            }
            const width = target.offsetWidth || 360;
            const height = target.offsetHeight || 260;
            const left = Math.min(window.innerWidth - 32, Math.max(8, event.clientX - drag.offsetX));
            const top = Math.min(window.innerHeight - 32, Math.max(8, event.clientY - drag.offsetY));
            target.style.left = `${Math.min(left, window.innerWidth - Math.min(width, window.innerWidth - 16))}px`;
            target.style.top = `${Math.min(top, window.innerHeight - Math.min(height, window.innerHeight - 16))}px`;
            target.style.right = "auto";
            target.style.bottom = "auto";
        });
        header.addEventListener("pointerup", function (event) {
            if (!drag || drag.pointerId !== event.pointerId) {
                return;
            }
            drag = null;
            const rect = target.getBoundingClientRect();
            state.panel.left = rect.left;
            state.panel.top = rect.top;
            state.panel.dragged = true;
            scheduleStateSave();
        });
    }

    async function refreshConfig() {
        if (refreshTimer) {
            window.clearTimeout(refreshTimer);
            refreshTimer = 0;
        }
        setStatus("更新中", "");
        try {
            await fetchConfig();
            renderPanel();
            setStatus(fileStatusText(), config.audio_dir_valid ? "" : "warn");
        } catch (error) {
            console.warn("[Sound Player] refresh failed", error);
            setStatus("更新失敗", "warn");
        }
    }

    function ensureLauncher(tabname) {
        const root = appRoot();
        const tools = root.getElementById(`${tabname}_tools`);
        if (!tools || root.getElementById(`fmp_launcher_${tabname}`)) {
            return;
        }

        const button = document.createElement("button");
        button.id = `fmp_launcher_${tabname}`;
        button.className = "gradio-button tool fmp-launcher";
        button.type = "button";
        button.innerHTML = '<span class="fmp-launcher-icon" aria-hidden="true">♪</span>';
        button.title = "Sound Player";
        button.setAttribute("aria-label", "Sound Player");
        button.setAttribute("aria-expanded", state.panel.open ? "true" : "false");
        button.addEventListener("click", function (event) {
            event.preventDefault();
            event.stopPropagation();
            setPanelOpen(!state.panel.open);
        });

        const styleApply = root.getElementById(`${tabname}_style_apply`);
        const anchor = styleApply ? (styleApply.closest(".gradio-button") || styleApply.parentElement || styleApply) : null;
        if (anchor && anchor.parentElement) {
            anchor.insertAdjacentElement("afterend", button);
        } else {
            tools.appendChild(button);
        }
        updateLaunchers();
    }

    function mount() {
        ensureLauncher("txt2img");
        ensureLauncher("img2img");
    }

    async function init() {
        try {
            await fetchConfig();
        } catch (error) {
            console.warn("[Sound Player] config load failed", error);
            const localState = readLocalState();
            if (localState) {
                state = localState;
            }
        }
        state.panel.open = false;
        mount();
    }

    if (typeof onUiLoaded === "function") {
        onUiLoaded(init);
    } else {
        document.addEventListener("DOMContentLoaded", init);
    }
    if (typeof onUiUpdate === "function") {
        onUiUpdate(mount);
    }
})();
