Wolfery Audio Notifier

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
})();