Wolfery Audio Notifier

Audio notifications, per-character sounds and auto-refreshing character metadata

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Wolfery Audio Notifier
// @name:de      Wolfery Audio Benachrichtigungen
// @namespace    https://forum.wolfery.com/u/felinex/
// @version      2.0.1
// @description  Audio notifications, per-character sounds and auto-refreshing character metadata
// @description:de Audio-Benachrichtigungen, Sounds pro Charakter und automatische Aktualisierung von Charakter-Metadaten
// @icon         https://static.f-list.net/images/eicon/wolfery.png
// @license      All Rights Reserved
// @author       Felinex Gloomfort
// @match        https://wolfery.com/*
// @match        https://test.wolfery.com/*
// @match        https://mucklet.com/*
// @match        https://*.mucklet.com/*
// @grant        none
// ==/UserScript==

(function() {
'use strict';

console.log('[AudioNotifier] Script loaded (v2.0.1).');

const HARDCODED_DEFAULT_SOUND = 'https://actions.google.com/sounds/v1/alarms/beep_short.ogg';
const SETTINGS_KEY = 'AudioNotifierSettings';
const MAX_AUDIO_SIZE = 1024 * 1024;
let lastPlay = 0;
let initAttempts = 0;
let uiInstalled = false;
let modifierInstalled = false;

const defaultSettings = {
    muteWhenWindowFocused: false,
    muteForActiveChar: false,
    playOnFocusedChar: true,
    soundDefault: null,
    soundMention: null,
    soundPrivate: null,
    soundFocusChar: null,
    charSounds: {}
};

let settings = loadSettings();

let metadataRefreshStarted = false;
let settingsMetadataUiInstalled = false;
let playerTabInstallAttempts = 0;
let playerTabRegistration = null;

function normalizeName(v) {
    return String(v || '').trim().replace(/\s+/g, ' ').toLowerCase();
}

function makeNameKey(name) {
    return 'name:' + normalizeName(name);
}

function makeIdKey(id) {
    return 'id:' + String(id);
}

function entryFromLegacy(key, value) {
    if (value && typeof value === 'object' && !Array.isArray(value)) {
        return {
            name: String(value.name || key.replace(/^name:/, '')).trim(),
            charId: value.charId != null ? String(value.charId) : (value.id != null ? String(value.id) : null),
            avatar: value.avatar || null,
            sound: value.sound || null,
            alwaysPlay: !!value.alwaysPlay
        };
    }
    return {
        name: String(key.replace(/^name:/, '')).trim(),
        charId: null,
        avatar: null,
        sound: typeof value === 'string' ? value : null,
        alwaysPlay: false
    };
}

function normalizeCharSounds(source) {
    const out = {};
    const input = source && typeof source === 'object' ? source : {};
    Object.keys(input).forEach((key) => {
        const entry = entryFromLegacy(key, input[key]);
        const finalKey = entry.charId ? makeIdKey(entry.charId) : makeNameKey(entry.name || key);
        out[finalKey] = entry;
    });
    return out;
}

function loadSettings() {
    try {
        const stored = localStorage.getItem(SETTINGS_KEY);
        if (!stored) return { ...defaultSettings, charSounds: {} };
        const parsed = JSON.parse(stored);
        return {
            ...defaultSettings,
            ...parsed,
            charSounds: normalizeCharSounds(parsed.charSounds)
        };
    } catch (e) {
        console.error('[AudioNotifier] Failed to load settings', e);
        return { ...defaultSettings, charSounds: {} };
    }
}

function saveSettings() {
    try {
        localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
    } catch (e) {
        console.error('[AudioNotifier] Failed to save settings', e);
    }
}

function init() {
    initAttempts++;
    if (typeof window.app === 'undefined') {
        if (initAttempts < 60) setTimeout(init, 500);
        return;
    }

    const charLog = safeGetModule('charLog');
    if (!charLog) {
        if (initAttempts < 60) setTimeout(init, 500);
        return;
    }

    if (!uiInstalled) {
        setupSettingsUI();
        uiInstalled = true;
    }
    if (!modifierInstalled) {
        setupEventModifier(charLog);
        modifierInstalled = true;
    }
    if (!metadataRefreshStarted) {
        metadataRefreshStarted = true;
        refreshAllStoredCharacterMetadata();
    }
    if (!settingsMetadataUiInstalled) {
        settingsMetadataUiInstalled = true;
        installSettingsMetadataSupport();
    }
}

function safeGetModule(name) {
    try {
        return window.app && typeof window.app.getModule === 'function' ? window.app.getModule(name) : null;
    } catch (e) {
        return null;
    }
}

/**
 * EN: Injects the userscript settings section into a dedicated player tab using module hooks when possible.
 * DE: Fügt den Einstellungsbereich des Userscripts in einen eigenen Player-Tab ein und nutzt wenn möglich Modul-Hooks.
 */
function setupSettingsUI() {
    const isDe = navigator.language.startsWith('de');
    const t = {
        title: isDe ? 'Audio Notifier (Benutzerskript)' : 'Audio Notifier (Userscript)',
        tabTitle: isDe ? 'Audio Notifier öffnen' : 'Open Audio Notifier',
        muteWhenFocused: isDe ? 'Ton deaktivieren, wenn das Fenster fokussiert ist' : 'Disable sound when the window is focused',
        muteForActiveChar: isDe ? 'Ton für aktiven Charakter deaktivieren (Ignoriert bei Fokusverlust)' : 'Disable sound for active character (Ignored if window loses focus)',
        playOnFocusCmd: isDe ? 'Ton abspielen, wenn fokussierter Charakter schreibt (/focus)' : 'Play sound when a focused character writes (/focus)',
        hdrDefault: isDe ? '1. Standard-Sound' : '1. Default Sound',
        soundDefault: isDe ? 'Allgemeiner Ton' : 'Global Default Sound',
        hdrCategories: isDe ? '2. Spezifische Kategorien (Optional)' : '2. Specific Categories (Optional)',
        soundMention: isDe ? 'Erwähnungen' : 'Mentions',
        soundPrivate: isDe ? 'Private Nachrichten' : 'Private Messages',
        soundFocusChar: isDe ? 'Fokussierte Charaktere' : 'Focused characters',
        hdrCharSounds: isDe ? '3. Sound pro Charakter' : '3. Per-character sounds',
        charSearchPlaceholder: isDe ? 'Name suchen...' : 'Search name...',
        searchBtn: isDe ? 'Suchen' : 'Search',
        chooseBtn: isDe ? 'Sound' : 'Sound',
        uploadBtn: isDe ? 'Wählen' : 'Browse',
        replaceBtn: isDe ? 'Neu' : 'Replace',
        testBtn: '▶️',
        resetBtn: '✖',
        closeBtn: isDe ? 'Schließen' : 'Close',
        sizeWarning: isDe ? 'Datei zu groß (max 1MB).' : 'File too large (max 1MB).',
        noResults: isDe ? 'Keine Treffer' : 'No results',
        searching: isDe ? 'Suche...' : 'Searching...',
        searchHint: isDe ? 'Suche per Name, speichere ID + Avatar.' : 'Search by name, store ID + avatar.',
        alwaysPlay: isDe ? 'Immer' : 'Always',
        byId: isDe ? 'ID' : 'ID',
        byName: isDe ? 'Name' : 'Name',
        noSound: isDe ? 'Kein Sound' : 'No sound'
    };

    ensurePlayerPanelTab(t);
}

/**
 * EN: Retries player tab installation until either the module hook or the DOM fallback becomes available.
 * DE: Versucht die Installation des Player-Tabs erneut, bis entweder der Modul-Hook oder der DOM-Fallback verfügbar ist.
 */
function ensurePlayerPanelTab(t) {
    if (installPlayerPanelTab(t)) return;
    playerTabInstallAttempts++;
    if (playerTabInstallAttempts < 40) {
        setTimeout(() => ensurePlayerPanelTab(t), 500);
    }
}

/**
 * EN: Installs the Audio Notifier player tab, preferring module hooks and falling back to direct DOM integration.
 * DE: Installiert den Audio-Notifier-Player-Tab, bevorzugt Modul-Hooks und fällt sonst auf direkte DOM-Integration zurück.
 */
function installPlayerPanelTab(t) {
    if (playerTabRegistration) return true;
    return installPlayerPanelTabDomFallback(t);
}

/**
 * EN: Hook-based player tab registration is disabled in this hotfix because the current client expects a tabFactory shape that this userscript does not provide yet.
 * DE: Die hook-basierte Player-Tab-Registrierung ist in diesem Hotfix deaktiviert, weil der aktuelle Client eine tabFactory-Struktur erwartet, die dieses Userscript noch nicht korrekt bereitstellt.
 */
function registerPlayerTabHook(t) {
    return false;
}

function getPlayerTabsHost() {
    return null;
}

function createPlayerTabRegistration(t, mode) {
    let mountedEl = null;
    let mountedUi = null;
    let mountedOverlay = null;

    const mount = (el) => {
        if (!(el instanceof HTMLElement)) return null;
        mountedEl = el;
        mountedUi = buildSettingsPanel(t, () => hide());
        el.innerHTML = '';
        el.appendChild(mountedUi);
        syncSettingsRowsFromStorage();
        return mountedUi;
    };

    const unmount = () => {
        if (mountedUi && mountedUi.parentNode) {
            mountedUi.parentNode.removeChild(mountedUi);
        }
        mountedUi = null;
        mountedEl = null;
    };

    const hide = () => {
        if (mode === 'dom' && mountedOverlay) {
            mountedOverlay.style.display = 'none';
            mountedOverlay.dataset.open = '0';
        }
    };

    const item = {
        id: 'audioNotifierPlayerTab',
        _setOverlayRef(ref) {
            mountedOverlay = ref;
        },
        _hide: hide,
        render(el) {
            return mount(el);
        },
        unrender() {
            unmount();
        }
    };

    return item;
}

/**
 * EN: DOM fallback for environments where no usable playerTabs registration hook is exposed.
 * DE: DOM-Fallback für Umgebungen, in denen kein nutzbarer playerTabs-Registrierungs-Hook verfügbar ist.
 */
function installPlayerPanelTabDomFallback(t) {
    if (document.querySelector('.audio-notifier-player-tab-button')) {
        playerTabRegistration = playerTabRegistration || { mode: 'dom', dispose: () => {} };
        return true;
    }

    const tabsRoot = document.querySelector('.playertabs-tabs.playerpanel--tabs');
    const pageAnchor = document.querySelector('.panel--main .playerpanel--tabpage');
    if (!(tabsRoot instanceof HTMLElement) || !(pageAnchor instanceof HTMLElement) || !(pageAnchor.parentElement instanceof HTMLElement)) {
        return false;
    }

    const pageHost = pageAnchor.parentElement;
    if (!pageHost.style.position) pageHost.style.position = 'relative';

    const item = createPlayerTabRegistration(t, 'dom');

    const tabOuter = document.createElement('div');
    const tab = document.createElement('div');
    tab.className = 'playertabs-tab audio-notifier-player-tab';
    const button = document.createElement('button');
    button.type = 'button';
    button.className = 'iconbtn medium light audio-notifier-player-tab-button';
    button.title = t.tabTitle;
    button.setAttribute('aria-label', t.tabTitle);
    button.innerHTML = '<i aria-hidden="true" class="fa fa-bell-o"></i>';
    tab.appendChild(button);
    tabOuter.appendChild(tab);
    tabsRoot.appendChild(tabOuter);

    const page = document.createElement('div');
    page.className = 'playerpanel--tabpage audio-notifier-player-tab-page';
    page.style.width = '100%';
    page.style.height = '100%';
    page.style.position = 'absolute';
    page.style.inset = '0';
    page.style.overflow = 'auto';
    page.style.background = '#161926';
    page.style.display = 'none';
    page.style.zIndex = '20';
    page.style.padding = '0';
    page.dataset.open = '0';
    pageHost.appendChild(page);

    item._setOverlayRef(page);

    const ensureMounted = () => {
        if (!page.firstChild) {
            item.render(page);
        }
    };

    const setActive = (active) => {
        if (active) {
            ensureMounted();
            syncSettingsRowsFromStorage();
        }
        page.style.display = active ? 'block' : 'none';
        page.dataset.open = active ? '1' : '0';
        button.style.background = active ? 'rgba(193, 166, 87, 0.22)' : '';
        button.style.boxShadow = active ? 'inset 0 0 0 1px rgba(193, 166, 87, 0.65)' : '';
        button.style.color = active ? '#f4f5f6' : '';
    };

    button.addEventListener('click', (e) => {
        e.preventDefault();
        e.stopPropagation();
        const willOpen = page.dataset.open !== '1';
        setActive(willOpen);
    });

    tabsRoot.querySelectorAll('.playertabs-tab button').forEach((otherButton) => {
        if (otherButton === button) return;
        otherButton.addEventListener('click', () => setActive(false));
    });

    playerTabRegistration = {
        mode: 'dom',
        item,
        tabOuter,
        page,
        dispose: () => {
            item.unrender();
            if (tabOuter.parentNode) tabOuter.parentNode.removeChild(tabOuter);
            if (page.parentNode) page.parentNode.removeChild(page);
        }
    };

    return true;
}

/**
 * EN: Builds the full settings content shown inside the custom player tab.
 * DE: Erstellt den vollständigen Einstellungsinhalt, der im benutzerdefinierten Player-Tab angezeigt wird.
 */
function buildSettingsPanel(t, onClose) {
    const uiContainer = document.createElement('div');
    uiContainer.className = 'pad-bottom-l audio-notifier-panel';
    uiContainer.style.padding = '16px';

    const topRow = document.createElement('div');
    topRow.style.display = 'flex';
    topRow.style.alignItems = 'center';
    topRow.style.justifyContent = 'space-between';
    topRow.style.gap = '8px';
    topRow.style.marginTop = '12px';
    topRow.style.marginBottom = '12px';

    const header = document.createElement('h3');
    header.className = 'margin-bottom-m';
    header.style.margin = '0';
    header.textContent = t.title;
    topRow.appendChild(header);

    if (typeof onClose === 'function') {
        const closeBtn = document.createElement('button');
        closeBtn.type = 'button';
        closeBtn.className = 'btn small';
        closeBtn.textContent = t.closeBtn;
        closeBtn.addEventListener('click', () => onClose());
        topRow.appendChild(closeBtn);
    }

    uiContainer.appendChild(topRow);

    uiContainer.appendChild(createCheckbox(t.muteWhenFocused, settings.muteWhenWindowFocused, (v) => { settings.muteWhenWindowFocused = v; saveSettings(); }));
    uiContainer.appendChild(createCheckbox(t.muteForActiveChar, settings.muteForActiveChar, (v) => { settings.muteForActiveChar = v; saveSettings(); }));
    uiContainer.appendChild(createCheckbox(t.playOnFocusCmd, settings.playOnFocusedChar, (v) => { settings.playOnFocusedChar = v; saveSettings(); }));

    const hdrDef = document.createElement('h3');
    hdrDef.className = 'margin-bottom-s';
    hdrDef.style.marginTop = '15px';
    hdrDef.textContent = t.hdrDefault;
    uiContainer.appendChild(hdrDef);
    uiContainer.appendChild(createSoundUploader(t.soundDefault, 'soundDefault', t));

    const hdrCat = document.createElement('h3');
    hdrCat.className = 'margin-bottom-s';
    hdrCat.style.marginTop = '15px';
    hdrCat.textContent = t.hdrCategories;
    uiContainer.appendChild(hdrCat);
    uiContainer.appendChild(createSoundUploader(t.soundMention, 'soundMention', t, true));
    uiContainer.appendChild(createSoundUploader(t.soundPrivate, 'soundPrivate', t, true));
    uiContainer.appendChild(createSoundUploader(t.soundFocusChar, 'soundFocusChar', t, true));

    uiContainer.appendChild(createCharSoundSection(t));
    return uiContainer;
}

/**
 * EN: Builds a reusable checkbox row for the settings UI.
 * DE: Erstellt eine wiederverwendbare Checkbox-Zeile für die Einstellungsoberfläche.
 */
function createCheckbox(labelTxt, checked, onChange) {
    const wrapper = document.createElement('div');
    wrapper.className = 'margin-bottom-m';
    const label = document.createElement('label');
    label.className = 'checkbox';
    label.style.display = 'flex';
    label.style.alignItems = 'center';
    label.style.cursor = 'pointer';
    const input = document.createElement('input');
    input.type = 'checkbox';
    input.checked = checked;
    input.addEventListener('change', (e) => onChange(e.target.checked));
    const span = document.createElement('span');
    span.textContent = labelTxt;
    span.style.marginLeft = '8px';
    label.appendChild(input);
    label.appendChild(span);
    wrapper.appendChild(label);
    return wrapper;
}

/**
 * EN: Creates one sound upload control bound to a settings key.
 * DE: Erstellt ein Sound-Upload-Steuerelement, das an einen Einstellungswert gebunden ist.
 */
function createSoundUploader(labelTxt, settingKey, t, isOptional = false) {
    const wrapper = document.createElement('div');
    wrapper.className = 'margin-bottom-m';
    wrapper.style.display = 'flex';
    wrapper.style.alignItems = 'center';
    wrapper.style.justifyContent = 'space-between';
    wrapper.style.gap = '5px';

    const label = document.createElement('span');
    label.style.flex = '1';
    label.style.fontSize = '14px';

    const updateLabel = () => {
        const hasCustom = settings[settingKey] !== null;
        label.innerHTML = `${labelTxt} [${hasCustom ? 'Custom' : (isOptional ? 'Fallback' : 'System Default')}]`;
    };
    updateLabel();

    const controls = document.createElement('div');
    controls.style.display = 'flex';
    controls.style.gap = '5px';

    const fileInput = document.createElement('input');
    fileInput.type = 'file';
    fileInput.accept = 'audio/*';
    fileInput.style.display = 'none';

    const uploadBtn = document.createElement('button');
    uploadBtn.className = 'btn small';
    uploadBtn.textContent = t.uploadBtn;
    uploadBtn.onclick = () => fileInput.click();

    const playBtn = document.createElement('button');
    playBtn.className = 'btn small primary';
    playBtn.textContent = t.testBtn;
    playBtn.onclick = () => playSound(getEffectiveSound(settingKey), true);

    const resetBtn = document.createElement('button');
    resetBtn.className = 'btn small error';
    resetBtn.textContent = t.resetBtn;
    resetBtn.onclick = () => {
        settings[settingKey] = null;
        saveSettings();
        updateLabel();
    };

    fileInput.addEventListener('change', (e) => {
        const file = e.target.files && e.target.files[0];
        if (!file) return;
        if (file.size > MAX_AUDIO_SIZE) return alert(t.sizeWarning);
        const reader = new FileReader();
        reader.onloadend = () => {
            settings[settingKey] = reader.result;
            saveSettings();
            updateLabel();
            playSound(settings[settingKey], true);
            fileInput.value = '';
        };
        reader.readAsDataURL(file);
    });

    controls.appendChild(fileInput);
    controls.appendChild(uploadBtn);
    controls.appendChild(resetBtn);
    controls.appendChild(playBtn);
    wrapper.appendChild(label);
    wrapper.appendChild(controls);
    return wrapper;
}

/**
 * EN: Renders the per-character sound management section including search, add, replace, and reset flows.
 * DE: Rendert den Bereich für charakterbezogene Sounds mit Suche sowie Hinzufügen, Ersetzen und Zurücksetzen.
 */
function createCharSoundSection(t) {
    const wrapper = document.createElement('div');
    wrapper.style.marginTop = '15px';

    const header = document.createElement('h3');
    header.className = 'margin-bottom-s';
    header.textContent = t.hdrCharSounds;
    wrapper.appendChild(header);

    const hint = document.createElement('div');
    hint.className = 'margin-bottom-s';
    hint.style.fontSize = '12px';
    hint.style.opacity = '0.8';
    hint.textContent = t.searchHint;
    wrapper.appendChild(hint);

    const searchRow = document.createElement('div');
    searchRow.style.display = 'grid';
    searchRow.style.gridTemplateColumns = '1fr auto';
    searchRow.style.gap = '5px';
    searchRow.style.marginBottom = '6px';

    const nameInput = document.createElement('input');
    nameInput.type = 'text';
    nameInput.placeholder = t.charSearchPlaceholder;
    styleTextInput(nameInput);

    const searchBtn = document.createElement('button');
    searchBtn.className = 'btn small';
    searchBtn.textContent = t.searchBtn;

    searchRow.appendChild(nameInput);
    searchRow.appendChild(searchBtn);
    wrapper.appendChild(searchRow);

    const resultsContainer = document.createElement('div');
    resultsContainer.style.marginBottom = '10px';
    wrapper.appendChild(resultsContainer);

    const listContainer = document.createElement('div');
    wrapper.appendChild(listContainer);

    const fileInput = document.createElement('input');
    fileInput.type = 'file';
    fileInput.accept = 'audio/*';
    fileInput.style.display = 'none';
    wrapper.appendChild(fileInput);

    let pendingSelection = null;

    const runSearch = async () => {
        const q = nameInput.value.trim();
        if (!q) return;
        resultsContainer.innerHTML = `<div style="font-size:12px;opacity:.8;padding:4px 0;">${t.searching}</div>`;
        const results = await searchCharactersByName(q);
        renderResults(results);
    };

    searchBtn.addEventListener('click', runSearch);
    nameInput.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') {
            e.preventDefault();
            runSearch();
        }
    });

    fileInput.addEventListener('change', (e) => {
        const file = e.target.files && e.target.files[0];
        if (!file || !pendingSelection) return;
        if (file.size > MAX_AUDIO_SIZE) return alert(t.sizeWarning);
        const reader = new FileReader();
        reader.onloadend = () => {
            const selected = pendingSelection;
            const key = selected.charId ? makeIdKey(selected.charId) : makeNameKey(selected.name);
            settings.charSounds[key] = {
                name: selected.name,
                charId: selected.charId || null,
                avatar: selected.avatar || null,
                sound: reader.result,
                alwaysPlay: false
            };
            saveSettings();
            pendingSelection = null;
            fileInput.value = '';
            resultsContainer.innerHTML = '';
            nameInput.value = '';
            renderList();
        };
        reader.readAsDataURL(file);
    });

    function renderResults(results) {
        resultsContainer.innerHTML = '';
        if (!results.length) {
            resultsContainer.innerHTML = `<div style="font-size:12px;opacity:.8;padding:4px 0;">${t.noResults}</div>`;
            return;
        }

        results.slice(0, 8).forEach((item) => {
            const row = document.createElement('div');
            row.style.display = 'grid';
            row.style.gridTemplateColumns = '28px 1fr auto';
            row.style.gap = '6px';
            row.style.alignItems = 'center';
            row.style.padding = '4px 0';
            row.style.borderBottom = '1px solid rgba(255,255,255,0.05)';

            const avatar = document.createElement('div');
            avatar.style.width = '28px';
            avatar.style.height = '28px';
            avatar.style.borderRadius = '4px';
            avatar.style.overflow = 'hidden';
            avatar.style.background = 'rgba(255,255,255,0.08)';
            if (item.avatar) {
                const img = document.createElement('img');
                img.src = item.avatar;
                img.alt = item.name;
                img.style.width = '100%';
                img.style.height = '100%';
                img.style.objectFit = 'cover';
                avatar.appendChild(img);
            }

            const info = document.createElement('div');
            info.style.minWidth = '0';
            const name = document.createElement('div');
            name.style.fontSize = '12px';
            name.style.fontWeight = '600';
            name.style.whiteSpace = 'nowrap';
            name.style.overflow = 'hidden';
            name.style.textOverflow = 'ellipsis';
            name.textContent = item.name;
            const meta = document.createElement('div');
            meta.style.fontSize = '11px';
            meta.style.opacity = '0.75';
            meta.style.whiteSpace = 'nowrap';
            meta.style.overflow = 'hidden';
            meta.style.textOverflow = 'ellipsis';
            meta.textContent = item.charId ? `ID ${item.charId}` : t.byName;
            info.appendChild(name);
            info.appendChild(meta);

            const btn = document.createElement('button');
            btn.className = 'btn small';
            btn.textContent = t.chooseBtn;
            btn.onclick = () => {
                pendingSelection = item;
                fileInput.click();
            };

            row.appendChild(avatar);
            row.appendChild(info);
            row.appendChild(btn);
            resultsContainer.appendChild(row);
        });
    }

    function renderList() {
        listContainer.innerHTML = '';
        const keys = Object.keys(settings.charSounds);
        if (!keys.length) return;

        keys.sort((a, b) => {
            const an = settings.charSounds[a]?.name || '';
            const bn = settings.charSounds[b]?.name || '';
            return an.localeCompare(bn);
        });

        keys.forEach((key) => {
            const entry = settings.charSounds[key];
            if (!entry) return;

            const row = document.createElement('div');
            row.className = 'audio-notifier-char-row';
            row.style.display = 'grid';
            row.style.gridTemplateColumns = '28px 1fr auto';
            row.style.gap = '6px';
            row.style.alignItems = 'center';
            row.style.background = 'rgba(0,0,0,0.18)';
            row.style.padding = '5px';
            row.style.borderRadius = '4px';
            row.style.marginBottom = '5px';
            row.dataset.entryKey = key;
            row.dataset.charId = entry.charId ? String(entry.charId) : '';
            row.dataset.charName = entry.name || '';

            const avatar = document.createElement('div');
            avatar.style.width = '28px';
            avatar.style.height = '28px';
            avatar.style.borderRadius = '4px';
            avatar.style.overflow = 'hidden';
            avatar.style.background = 'rgba(255,255,255,0.08)';
            if (entry.avatar) {
                const img = document.createElement('img');
                img.className = 'audio-notifier-char-row-avatar';
                img.src = entry.avatar;
                img.alt = entry.name || key;
                img.style.width = '100%';
                img.style.height = '100%';
                img.style.objectFit = 'cover';
                avatar.appendChild(img);
            }

            const info = document.createElement('div');
            info.style.minWidth = '0';

            const title = document.createElement('div');
            title.className = 'audio-notifier-char-row-title';
            title.style.fontSize = '12px';
            title.style.fontWeight = '600';
            title.style.whiteSpace = 'nowrap';
            title.style.overflow = 'hidden';
            title.style.textOverflow = 'ellipsis';
            title.textContent = entry.name || key;

            const meta = document.createElement('div');
            meta.style.fontSize = '11px';
            meta.style.opacity = '0.75';
            meta.style.whiteSpace = 'nowrap';
            meta.style.overflow = 'hidden';
            meta.style.textOverflow = 'ellipsis';
            meta.textContent = entry.charId ? `ID ${entry.charId}` : t.byName;

            const opts = document.createElement('label');
            opts.className = 'checkbox';
            opts.style.display = 'flex';
            opts.style.alignItems = 'center';
            opts.style.gap = '4px';
            opts.style.fontSize = '11px';
            opts.style.marginTop = '2px';
            const always = document.createElement('input');
            always.type = 'checkbox';
            always.checked = !!entry.alwaysPlay;
            always.addEventListener('change', (e) => {
                entry.alwaysPlay = e.target.checked;
                saveSettings();
            });
            const alwaysTxt = document.createElement('span');
            alwaysTxt.textContent = t.alwaysPlay;
            opts.appendChild(always);
            opts.appendChild(alwaysTxt);

            info.appendChild(title);
            info.appendChild(meta);
            info.appendChild(opts);

            const controls = document.createElement('div');
            controls.style.display = 'flex';
            controls.style.gap = '4px';
            controls.style.alignItems = 'center';

            const replaceInput = document.createElement('input');
            replaceInput.type = 'file';
            replaceInput.accept = 'audio/*';
            replaceInput.style.display = 'none';
            replaceInput.addEventListener('change', (e) => {
                const file = e.target.files && e.target.files[0];
                if (!file) return;
                if (file.size > MAX_AUDIO_SIZE) return alert(t.sizeWarning);
                const reader = new FileReader();
                reader.onloadend = () => {
                    entry.sound = reader.result;
                    saveSettings();
                    renderList();
                    replaceInput.value = '';
                };
                reader.readAsDataURL(file);
            });

            const replaceBtn = document.createElement('button');
            replaceBtn.className = 'btn small';
            replaceBtn.textContent = t.replaceBtn;
            replaceBtn.onclick = () => replaceInput.click();

            const playBtn = document.createElement('button');
            playBtn.className = 'btn small primary';
            playBtn.textContent = t.testBtn;
            playBtn.onclick = () => playSound(entry.sound || getEffectiveSound('soundDefault'), true);

            const delBtn = document.createElement('button');
            delBtn.className = 'btn small error';
            delBtn.textContent = t.resetBtn;
            delBtn.onclick = () => {
                delete settings.charSounds[key];
                saveSettings();
                renderList();
            };

            controls.appendChild(replaceInput);
            controls.appendChild(replaceBtn);
            controls.appendChild(playBtn);
            controls.appendChild(delBtn);

            row.appendChild(avatar);
            row.appendChild(info);
            row.appendChild(controls);
            listContainer.appendChild(row);
        });
    }

    renderList();
    return wrapper;
}

function styleTextInput(input) {
    input.style.flex = '1';
    input.style.padding = '4px';
    input.style.background = '#1d2233';
    input.style.color = '#fff';
    input.style.border = '1px solid #303753';
    input.style.borderRadius = '4px';
    input.style.minWidth = '0';
}

async function searchCharactersByName(query) {
    const text = String(query || '').trim();
    if (!text) return [];

    const module = getOfficialSearchCharModule();
    if (!module) {
        console.debug('[AudioNotifier] SearchChar modules not available');
        return [];
    }

    const { player, charsAwake } = module;
    let exactChar = null;

    // Official search behavior / Offizielles Suchverhalten:
    // Use getChar() for multi-word exact lookup / Nutze getChar() für mehrteilige Exaktsuche
    if (text.split(/\s+/).length > 1 && player?.getPlayer) {
        try {
            exactChar = await player.getPlayer().call('getChar', { charName: text });
        } catch (err) {
            if (!isCharNotFoundError(err)) {
                console.debug('[AudioNotifier] getChar failed', err);
            }
        }
    }

    const activeChar = typeof player?.getActiveChar === 'function' ? player.getActiveChar() : null;
    const watchesModel = typeof charsAwake?.getWatches === 'function' ? charsAwake.getWatches() : null;
    const watches = watchesModel && watchesModel.props
        ? Object.keys(watchesModel.props)
            .map((k) => watchesModel.props[k]?.char)
            .filter(Boolean)
        : [];

    const merged = mergeUniqueChars([
        exactChar ? [exactChar] : null,
        typeof player?.getChars === 'function' ? player.getChars() : null,
        activeChar?.inRoom?.chars || null,
        typeof charsAwake?.getCollection === 'function' ? charsAwake.getCollection() : null,
        watches
    ]);

    return merged
        .filter((char) => char && matchesSearchChar(char, text))
        .map((char) => mapCharacterResult(char))
        .filter(Boolean)
        .sort(compareSearchCharResults(text))
        .slice(0, 10);
}

function getOfficialSearchCharModule() {
    const player =
        safeGetModule('player') ||
        window.player ||
        window.muckletClient?.player ||
        null;

    const charsAwake =
        safeGetModule('charsAwake') ||
        window.charsAwake ||
        window.muckletClient?.charsAwake ||
        null;

    if (!player || !charsAwake) return null;
    return { player, charsAwake };
}

function isCharNotFoundError(err) {
    return !!(
        err && (
            err.code === 'core.charNotFound' ||
            err.errorCode === 'core.charNotFound' ||
            err.message === 'core.charNotFound'
        )
    );
}

function mergeUniqueChars(sources) {
    const out = [];
    const seen = new Set();

    for (const source of sources) {
        for (const char of toCharArray(source)) {
            if (!char) continue;

            const id = char.id != null ? String(char.id) : null;
            const label = getCharacterLabel(char);
            const key = id ? `id:${id}` : `name:${normalizeSearchText(label)}`;

            if (!key || seen.has(key)) continue;
            seen.add(key);
            out.push(char);
        }
    }

    return out;
}

function toCharArray(source) {
    if (!source) return [];
    if (Array.isArray(source)) return source.filter(Boolean);
    if (typeof source.toArray === 'function') return source.toArray().filter(Boolean);
    if (source.props && typeof source.props === 'object') return Object.values(source.props).filter(Boolean);
    if (source.models && Array.isArray(source.models)) return source.models.filter(Boolean);
    if (source.items && Array.isArray(source.items)) return source.items.filter(Boolean);
    return [];
}

function matchesSearchChar(char, query) {
    const label = normalizeSearchText(getCharacterLabel(char));
    const parts = normalizeSearchText(query).split(/\s+/).filter(Boolean);
    if (!label || !parts.length) return false;
    return parts.every((part) => label.includes(part));
}

function compareSearchCharResults(query) {
    const q = normalizeSearchText(query);

    return (a, b) => {
        const aLabel = normalizeSearchText(a.name || '');
        const bLabel = normalizeSearchText(b.name || '');

        const aExact = aLabel === q ? 1 : 0;
        const bExact = bLabel === q ? 1 : 0;
        if (aExact !== bExact) return bExact - aExact;

        const aStarts = aLabel.startsWith(q) ? 1 : 0;
        const bStarts = bLabel.startsWith(q) ? 1 : 0;
        if (aStarts !== bStarts) return bStarts - aStarts;

        const aPos = aLabel.indexOf(q);
        const bPos = bLabel.indexOf(q);
        const aScore = aPos === -1 ? 9999 : aPos;
        const bScore = bPos === -1 ? 9999 : bPos;
        if (aScore !== bScore) return aScore - bScore;

        return aLabel.localeCompare(bLabel);
    };
}

function mapCharacterResult(char) {
    if (!char) return null;
    const first = char.name || '';
    const last = char.surname || '';
    const name = `${first} ${last}`.trim() || char.fullName || char.label || null;
    if (!name) return null;
    return {
        name,
        charId: char.id != null ? String(char.id) : null,
        avatar: extractAvatarUrl(char) || null
    };
}

function getCharacterLabel(char) {
    if (!char) return '';
    return `${char.name || ''} ${char.surname || ''}`.trim()
        || char.fullName
        || char.label
        || '';
}

function normalizeSearchText(value) {
    return String(value || '')
        .normalize('NFD')
        .replace(/[\u0300-\u036f]/g, '')
        .toLowerCase()
        .replace(/\s+/g, ' ')
        .trim();
}

function extractAvatarUrl(char) {
    const candidates = [
        char && char.avatar && char.avatar.href,
        char && char.avatar && char.avatar.url,
        char && char.avatar && char.avatar.src,
        char && char.icon && char.icon.href,
        char && char.icon && char.icon.url,
        char && char.icon && char.icon.src,
        char && char.avatarUrl,
        char && char.iconUrl,
        char && char.avatar,
        char && char.icon,
        char && char.image && char.image.href,
        char && char.image && char.image.url,
        char && char.imageUrl,
        char && char.portrait && char.portrait.href,
        char && char.img && char.img.href,
        char && char.img && char.img.url,
        char && char.imgUrl
    ].filter((value) => typeof value === 'string' && value.trim());

    for (const raw of candidates) {
        const url = normalizeAvatarCandidate(raw);
        if (url) return url;
    }

    return null;
}

function normalizeAvatarCandidate(raw) {
    const value = String(raw || '').trim();
    if (!value) return null;

    if (/^https?:\/\/file\.wolfery\.com\/core\/char\/avatar\//i.test(value)) {
        return ensureAvatarThumb(value);
    }

    if (/^https?:\/\/file\.wolfery\.com\/core\/char\/img\//i.test(value)) {
        return ensureAvatarThumb(value.replace(/\/core\/char\/img\//i, '/core/char/avatar/'));
    }

    if (/^\/core\/char\/avatar\//i.test(value)) {
        return ensureAvatarThumb('https://file.wolfery.com' + value);
    }

    if (/^\/core\/char\/img\//i.test(value)) {
        return ensureAvatarThumb('https://file.wolfery.com' + value.replace(/\/core\/char\/img\//i, '/core/char/avatar/'));
    }

    if (/^[a-z0-9]+$/i.test(value)) {
        return `https://file.wolfery.com/core/char/avatar/${value}?thumb=l`;
    }

    if (/\/char\/avatar\//i.test(value)) {
        return ensureAvatarThumb(value);
    }

    if (/\/char\/img\//i.test(value)) {
        return ensureAvatarThumb(value.replace(/\/char\/img\//i, '/char/avatar/'));
    }

    return null;
}

function ensureAvatarThumb(url) {
    const clean = String(url || '').replace(/[?&]thumb=[^&]+/ig, '');
    return clean + (clean.includes('?') ? '&thumb=l' : '?thumb=l');
}

function findMatchingEntry(char) {
    if (!char) return null;
    const charId = char.id != null ? String(char.id) : null;
    if (charId) {
        const idKey = makeIdKey(charId);
        if (settings.charSounds[idKey]) return idKey;
    }
    const fullName = normalizeName(`${char.name || ''} ${char.surname || ''}`.trim());
    const justName = normalizeName(char.name || '');
    return Object.keys(settings.charSounds).find((key) => {
        const entry = settings.charSounds[key];
        if (!entry) return false;
        if (entry.charId && charId && String(entry.charId) === charId) return true;
        const n = normalizeName(entry.name || key.replace(/^name:/, ''));
        return n && (n === fullName || n === justName);
    }) || null;
}

function hydrateEntryFromEvent(key, char) {
    const entry = settings.charSounds[key];
    if (!entry || !char) return key;

    const nextName = `${char.name || ''} ${char.surname || ''}`.trim() || entry.name;
    const nextId = char.id != null ? String(char.id) : entry.charId;
    const nextAvatar = extractAvatarUrl(char) || entry.avatar;

    entry.name = nextName;
    entry.charId = nextId;
    entry.avatar = nextAvatar;

    if (nextId) {
        const idKey = makeIdKey(nextId);
        if (idKey !== key) {
            settings.charSounds[idKey] = { ...entry };
            delete settings.charSounds[key];
            saveSettings();
            return idKey;
        }
    }

    saveSettings();
    return key;
}

function getEffectiveSound(settingKey) {
    if (settings[settingKey]) return settings[settingKey];
    if (settings.soundDefault) return settings.soundDefault;
    return HARDCODED_DEFAULT_SOUND;
}

function isDirectTypeForCtrl(ev, ctrl, mod) {
    if (!['whisper', 'message', 'address'].includes(ev.type)) return false;
    if (mod.targeted) return true;
    if (ev.target && ev.target.id === ctrl.id) return true;
    if (Array.isArray(ev.targets)) return ev.targets.some(t => t && t.id === ctrl.id);
    return false;
}

function isSpeechType(ev) {
    return ['say', 'pose', 'ooc', 'whisper', 'message', 'address'].includes(ev.type);
}


/**
 * EN: Refreshes stored character names and avatars from currently available client data and official search results.
 * DE: Aktualisiert gespeicherte Charakternamen und Avatare aus verfügbaren Client-Daten und offiziellen Suchtreffern.
 */
function refreshAllStoredCharacterMetadata() {
    const entries = Object.entries(settings.charSounds || {});
    if (!entries.length) return;

    Promise.all(entries.map(async ([key, entry]) => {
        const refreshed = await refreshStoredCharacterEntry(entry);
        if (!refreshed) return null;
        return [key, refreshed];
    })).then((pairs) => {
        let changed = false;
        for (const pair of pairs) {
            if (!pair) continue;
            const [oldKey, refreshed] = pair;
            const nextKey = refreshed.charId ? makeIdKey(refreshed.charId) : makeNameKey(refreshed.name || oldKey);
            const prevSerialized = JSON.stringify(settings.charSounds[oldKey] || null);
            const nextSerialized = JSON.stringify(refreshed);
            if (oldKey !== nextKey) {
                delete settings.charSounds[oldKey];
                changed = true;
            }
            if (prevSerialized != nextSerialized || !settings.charSounds[nextKey]) {
                settings.charSounds[nextKey] = refreshed;
                changed = true;
            }
        }
        if (changed) saveSettings();
        syncSettingsRowsFromStorage();
    }).catch((err) => {
        console.debug('[AudioNotifier] Failed to refresh stored character metadata', err);
    });
}

/**
 * EN: Refreshes one stored character entry while preserving local sound-related preferences.
 * DE: Aktualisiert einen gespeicherten Charaktereintrag und behält lokale Sound-Einstellungen bei.
 */
async function refreshStoredCharacterEntry(entry) {
    if (!entry) return null;

    const current = {
        name: String(entry.name || '').trim(),
        charId: entry.charId != null ? String(entry.charId) : null,
        avatar: entry.avatar || null,
        sound: entry.sound || null,
        alwaysPlay: !!entry.alwaysPlay
    };

    const resolved = await resolveCharacterMetadata(current);
    if (!resolved) return current;

    return {
        ...current,
        name: resolved.name || current.name,
        charId: resolved.charId || current.charId,
        avatar: resolved.avatar || current.avatar
    };
}

/**
 * EN: Resolves the most current character metadata by preferring live client models and falling back to character search.
 * DE: Ermittelt die aktuellsten Charakter-Metadaten, bevorzugt Live-Modelle des Clients und nutzt notfalls die Charaktersuche.
 */
async function resolveCharacterMetadata(entry) {
    const player = safeGetModule('player');
    const charsAwake = safeGetModule('charsAwake');
    const activeChar = player && typeof player.getActiveChar === 'function' ? player.getActiveChar() : null;

    const liveChars = mergeUniqueChars([
        typeof player?.getChars === 'function' ? player.getChars() : null,
        activeChar?.inRoom?.chars || null,
        typeof charsAwake?.getCollection === 'function' ? charsAwake.getCollection() : null
    ]);

    if (entry.charId) {
        const byId = liveChars.find((char) => String(char?.id) === String(entry.charId));
        if (byId) return mapCharacterResult(byId);
    }

    if (entry.name) {
        const results = await searchCharactersByName(entry.name);
        const exact = results.find((char) => normalizeName(char.name) === normalizeName(entry.name));
        if (exact) return exact;
    }

    return null;
}

/**
 * EN: Installs DOM listeners that keep visible settings rows synchronized with refreshed metadata and avatar recovery.
 * DE: Installiert DOM-Listener, die sichtbare Einstellungszeilen mit aktualisierten Metadaten und Avatar-Reparatur synchron halten.
 */
function installSettingsMetadataSupport() {
    document.addEventListener('error', onAudioNotifierAvatarError, true);
    setTimeout(syncSettingsRowsFromStorage, 1200);
}

function disposeAudioNotifierUi() {
    if (playerTabRegistration && typeof playerTabRegistration.dispose === 'function') {
        try {
            playerTabRegistration.dispose();
        } catch (err) {
            console.debug('[AudioNotifier] Failed to dispose custom UI', err);
        }
    }
    playerTabRegistration = null;
}

/**
 * EN: Handles broken avatar images by re-resolving character metadata and retrying with the newest avatar URL.
 * DE: Behandelt defekte Avatar-Bilder, indem die Charakter-Metadaten neu aufgelöst und mit der neuesten Avatar-URL erneut geladen werden.
 */
function onAudioNotifierAvatarError(event) {
    const img = event.target;
    if (!(img instanceof HTMLImageElement)) return;
    if (!img.classList.contains('audio-notifier-char-row-avatar')) return;

    const row = img.closest('.audio-notifier-char-row');
    if (!(row instanceof HTMLElement)) return;

    const currentKey = row.dataset.entryKey || '';
    let entry = currentKey ? settings.charSounds[currentKey] : null;
    if (!entry) entry = findEntryFromRowDataset(row);
    if (!entry) return;

    refreshStoredCharacterEntry(entry).then((refreshed) => {
        if (!refreshed) return;

        const oldKey = currentKey || (entry.charId ? makeIdKey(entry.charId) : makeNameKey(entry.name));
        const nextKey = refreshed.charId ? makeIdKey(refreshed.charId) : makeNameKey(refreshed.name || oldKey);

        if (oldKey !== nextKey) {
            delete settings.charSounds[oldKey];
        }
        settings.charSounds[nextKey] = refreshed;
        saveSettings();
        updateOpenSettingsRow(row, nextKey, refreshed);

        if (refreshed.avatar && img.src !== refreshed.avatar) {
            img.src = refreshed.avatar;
        }
    }).catch((err) => {
        console.debug('[AudioNotifier] Failed to refresh avatar after image error', err);
    });
}

function findEntryFromRowDataset(row) {
    const charId = row.dataset.charId || '';
    const charName = row.dataset.charName || '';

    if (charId) {
        const byId = settings.charSounds[makeIdKey(charId)];
        if (byId) return byId;
    }

    if (charName) {
        const byName = settings.charSounds[makeNameKey(charName)];
        if (byName) return byName;
    }

    return null;
}

/**
 * EN: Updates already rendered settings rows from the latest in-memory settings state.
 * DE: Aktualisiert bereits gerenderte Einstellungszeilen anhand des neuesten Zustands im Speicher.
 */
function syncSettingsRowsFromStorage() {
    document.querySelectorAll('.audio-notifier-char-row').forEach((row) => {
        if (!(row instanceof HTMLElement)) return;
        const currentKey = row.dataset.entryKey || '';
        const entry = settings.charSounds[currentKey] || findEntryFromRowDataset(row);
        if (!entry) return;
        const nextKey = entry.charId ? makeIdKey(entry.charId) : makeNameKey(entry.name || currentKey);
        updateOpenSettingsRow(row, nextKey, entry);
    });
}

function updateOpenSettingsRow(row, key, entry) {
    row.dataset.entryKey = key;
    row.dataset.charId = entry.charId ? String(entry.charId) : '';
    row.dataset.charName = entry.name || '';

    const title = row.querySelector('.audio-notifier-char-row-title');
    if (title) {
        title.textContent = entry.name || key;
    }

    const avatar = row.querySelector('.audio-notifier-char-row-avatar');
    if (avatar instanceof HTMLImageElement && entry.avatar) {
        avatar.src = entry.avatar;
        avatar.alt = entry.name || key;
    }
}


/**
 * EN: Hooks into the official char log event modifier pipeline to decide when notification sounds should play.
 * DE: Hängt sich in die offizielle Event-Modifier-Pipeline des Char-Logs ein, um über Benachrichtigungssounds zu entscheiden.
 */
function setupEventModifier(charLog) {
    charLog.addEventModifier({
        id: 'userscriptSoundNotifier',
        sortOrder: 100,
        callback: (ev, ctrl, mod) => {
            if (mod.muted) return;
            if (ev.char && ev.char.id === ctrl.id) return;

            const hasWindowFocus = document.hasFocus();
            if (settings.muteWhenWindowFocused && hasWindowFocus) return;

            if (settings.muteForActiveChar && hasWindowFocus) {
                const player = safeGetModule('player');
                const activeChar = player && typeof player.getActiveChar === 'function' ? player.getActiveChar() : null;
                if (activeChar && activeChar.id === ctrl.id) return;
            }

            let entryKey = findMatchingEntry(ev.char);
            if (entryKey && ev.char) entryKey = hydrateEntryFromEvent(entryKey, ev.char);
            const entry = entryKey ? settings.charSounds[entryKey] : null;

            let triggerFired = false;
            let soundToPlay = null;

            if (entry && entry.alwaysPlay && entry.sound && isSpeechType(ev)) {
                triggerFired = true;
                soundToPlay = entry.sound;
            }

            if (!triggerFired && mod.mentioned) {
                triggerFired = true;
                soundToPlay = getEffectiveSound('soundMention');
            }

            if (!triggerFired && isDirectTypeForCtrl(ev, ctrl, mod)) {
                triggerFired = true;
                soundToPlay = getEffectiveSound('soundPrivate');
            }

            if (!triggerFired && settings.playOnFocusedChar && ev.char) {
                const charFocus = safeGetModule('charFocus');
                if (charFocus && typeof charFocus.hasFocus === 'function' && charFocus.hasFocus(ctrl.id, ev.char.id)) {
                    triggerFired = true;
                    soundToPlay = getEffectiveSound('soundFocusChar');
                }
            }

            if (triggerFired && entry && entry.sound) {
                soundToPlay = entry.sound;
            }

            if (triggerFired) playSound(soundToPlay);
        }
    });
}

/**
 * EN: Plays the configured notification sound while respecting spam protection unless explicitly bypassed.
 * DE: Spielt den konfigurierten Benachrichtigungssound ab und berücksichtigt dabei den Spam-Schutz, außer wenn dieser bewusst umgangen wird.
 */
function playSound(audioSrc, bypassSpamCheck = false) {
    const now = Date.now();
    if (!bypassSpamCheck && (now - lastPlay < 1000)) return;
    if (!bypassSpamCheck) lastPlay = now;

    try {
        const player = new Audio(audioSrc || HARDCODED_DEFAULT_SOUND);
        player.volume = 0.5;
        player.play().catch((e) => {
            console.error('[AudioNotifier] Playback failed. Browser blocking autoplay?', e);
        });
    } catch (e) {
        console.error('[AudioNotifier] Invalid audio source', e);
    }
}

document.addEventListener('click', function unlockAudio() {
    new Audio('data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA').play().catch(() => {});
    document.removeEventListener('click', unlockAudio);
}, { once: true });

window.addEventListener('beforeunload', disposeAudioNotifierUi);

setTimeout(init, 1000);
})();