Audio notifications, per-character sounds and auto-refreshing character metadata
// ==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);
})();