Greasy Fork is available in English.
Plays a sound when your Labyrinth run goes idle.
// ==UserScript==
// @name MWI Lab Ping
// @namespace https://github.com/Jukkales/MWI-Scripts
// @version 1.0.0
// @description Plays a sound when your Labyrinth run goes idle.
// @author Jukkales
// @homepageURL https://github.com/Jukkales/MWI-Scripts
// @supportURL https://github.com/Jukkales/MWI-Scripts/issues
// @license MIT
// @icon https://www.milkywayidle.com/favicon.svg
// @match https://www.milkywayidle.com/*
// @match https://milkywayidle.com/*
// @match https://test.milkywayidle.com/*
// @match https://www.milkywayidlecn.com/*
// @match https://test.milkywayidlecn.com/*
// @run-at document-start
// @noframes
// @grant none
// ==/UserScript==
(function () {
'use strict';
const NS = 'mwi-lab-ping';
const SETTINGS_KEY_PREFIX = 'mwiLabPing:settings:';
const DB_NAME = 'mwiLabPing';
const DB_STORE = 'sounds';
const TONE_VOLUME = 0.15;
const FILE_VOLUME = 0.8;
const COOLDOWN_MS = 3000;
const STEP_GAP_MS = 70;
const REPEAT_GAP_MS = 350;
const CUSTOM_TONE = 'custom';
const DEFAULT_SETTINGS = { soundEnabled: true, tone: 'alarm' };
// Each step is [frequency in Hz, duration in ms].
const TONES = {
alarm: { label: 'Alarm', repeat: 2, steps: [[988, 160], [740, 160], [988, 160], [740, 320]] },
chime: { label: 'Chime', repeat: 1, steps: [[523, 110], [659, 110], [784, 110], [1047, 380]] },
ping: { label: 'Ping', repeat: 1, steps: [[1175, 90], [1175, 90]] },
descending: { label: 'Descending', repeat: 1, steps: [[1175, 130], [880, 130], [659, 280]] },
buzzer: { label: 'Buzzer', repeat: 2, steps: [[220, 260], [180, 380]] },
};
const LAB_ACTION_HRID = '/actions/labyrinth/explore';
const INIT_MARKER = '"type":"init_character_data"';
const ACTIONS_MARKER = '"type":"actions_updated"';
// Class names carry a per-build hash suffix, so only the prefix is stable.
const ANCHOR_SELECTOR = '[class*="LabyrinthPanel_chargeLeft"]';
const GAME_BUTTON_SELECTOR = 'button[class*="Button_button"]';
const warn = (...args) => console.warn('[LabPing]', ...args);
// --- Storage -------------------------------------------------------------
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => request.result.createObjectStore(DB_STORE);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function dbRun(mode, action) {
const db = await openDb();
try {
return await new Promise((resolve, reject) => {
const tx = db.transaction(DB_STORE, mode);
const request = action(tx.objectStore(DB_STORE));
tx.onerror = () => reject(tx.error);
tx.oncomplete = () => resolve(request ? request.result : undefined);
});
} finally {
db.close();
}
}
// --- State ---------------------------------------------------------------
let characterId = null;
let settings = { ...DEFAULT_SETTINGS };
let customSound = null;
function loadSettings() {
settings = { ...DEFAULT_SETTINGS };
if (!characterId) return;
try {
const stored = localStorage.getItem(SETTINGS_KEY_PREFIX + characterId);
if (stored) Object.assign(settings, JSON.parse(stored));
} catch (error) {
warn('could not read settings:', error);
}
}
function saveSettings() {
if (!characterId) return;
try {
localStorage.setItem(SETTINGS_KEY_PREFIX + characterId, JSON.stringify(settings));
} catch (error) {
warn('could not persist settings:', error);
}
}
// Audio files are far too large for localStorage once base64 encoded.
async function loadCustomSound() {
if (customSound) URL.revokeObjectURL(customSound.url);
customSound = null;
if (!characterId) return;
try {
const record = await dbRun('readonly', (store) => store.get(characterId));
if (record?.blob) customSound = { name: record.name, url: URL.createObjectURL(record.blob) };
} catch (error) {
warn('could not read custom sound:', error);
}
}
async function saveCustomSound(file) {
if (!characterId) return;
await dbRun('readwrite', (store) => store.put({ name: file.name, blob: file }, characterId));
await loadCustomSound();
}
async function removeCustomSound() {
if (!characterId) return;
await dbRun('readwrite', (store) => store.delete(characterId));
await loadCustomSound();
}
// --- Playback ------------------------------------------------------------
let audioContext = null;
let lastPlayedAt = 0;
function scheduleStep(frequency, startAt, duration) {
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = frequency;
// Ramps instead of hard edges, which would click.
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(TONE_VOLUME, startAt + 0.01);
gain.gain.setValueAtTime(TONE_VOLUME, startAt + duration - 0.03);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
oscillator.connect(gain).connect(audioContext.destination);
oscillator.start(startAt);
oscillator.stop(startAt + duration + 0.02);
}
function playTone(tone) {
audioContext = audioContext || new (window.AudioContext || window.webkitAudioContext)();
if (audioContext.state === 'suspended') audioContext.resume();
let at = audioContext.currentTime + 0.03;
for (let pass = 0; pass < tone.repeat; pass++) {
for (const [frequency, duration] of tone.steps) {
scheduleStep(frequency, at, duration / 1000);
at += (duration + STEP_GAP_MS) / 1000;
}
at += REPEAT_GAP_MS / 1000;
}
}
function playFile() {
const audio = new Audio(customSound.url);
audio.volume = FILE_VOLUME;
audio.play()?.catch((error) => warn('playback blocked:', error));
}
function play(toneKey) {
try {
if (toneKey === CUSTOM_TONE && customSound) playFile();
else playTone(TONES[toneKey] || TONES[DEFAULT_SETTINGS.tone]);
} catch (error) {
warn('audio failed:', error);
}
}
function notify() {
if (!settings.soundEnabled) return;
const now = Date.now();
if (now - lastPlayedAt < COOLDOWN_MS) return;
lastPlayedAt = now;
play(settings.tone);
}
// --- Game state ----------------------------------------------------------
const runningActions = new Set();
let wasRunning = false;
function trackActions(actions, replaceAll) {
if (!Array.isArray(actions)) return;
if (replaceAll) runningActions.clear();
for (const action of actions) {
if (action?.actionHrid !== LAB_ACTION_HRID) continue;
if (action.isDone === false) runningActions.add(action.id);
else runningActions.delete(action.id);
}
}
function handleMessage(raw) {
// Substring test first: market and chat payloads are huge and frequent,
// and must never reach JSON.parse.
const isInit = raw.includes(INIT_MARKER);
const isActions = !isInit && raw.includes(ACTIONS_MARKER);
if (!isInit && !isActions) return;
let message;
try {
message = JSON.parse(raw);
} catch {
return;
}
if (isInit) {
const id = message.character?.id;
if (id && id !== characterId) {
characterId = id;
loadSettings();
loadCustomSound();
}
// Seed only, so reloading mid-run stays silent.
trackActions(message.characterActions, true);
wasRunning = runningActions.size > 0;
return;
}
trackActions(message.endCharacterActions, false);
const isRunning = runningActions.size > 0;
if (wasRunning && !isRunning) notify();
wasRunning = isRunning;
}
// --- Game socket ---------------------------------------------------------
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const seenEvents = new WeakSet();
const isGameSocket = (socket) =>
typeof socket?.url === 'string' && socket.url.includes('milkywayidle') && socket.url.includes('/ws');
// Hooking the event getter rather than the WebSocket constructor keeps this
// working regardless of which script owns window.WebSocket, and regardless of
// load order.
function hookSocketMessages() {
const descriptor = Object.getOwnPropertyDescriptor(pageWindow.MessageEvent.prototype, 'data');
if (!descriptor?.get || descriptor.get.hooked) return;
const original = descriptor.get;
descriptor.get = function () {
const data = original.call(this);
if (!seenEvents.has(this) && typeof data === 'string' && isGameSocket(this.currentTarget)) {
seenEvents.add(this);
try {
handleMessage(data);
} catch (error) {
warn('handler failed:', error);
}
}
return data;
};
descriptor.get.hooked = true;
Object.defineProperty(pageWindow.MessageEvent.prototype, 'data', descriptor);
}
// --- Styles --------------------------------------------------------------
const STYLE = `
.${NS}-button {
margin-left: 8px;
vertical-align: middle;
}
.${NS}-button-fallback {
padding: 2px 8px;
font: inherit;
font-size: 0.85em;
color: inherit;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 4px;
cursor: pointer;
}
.${NS}-popup {
color-scheme: dark;
position: fixed;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 240px;
padding: 12px;
font-size: 0.9em;
color: #eee;
background: #23252b;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
}
.${NS}-popup h4 {
margin: 0;
font-size: 1em;
font-weight: 600;
}
.${NS}-popup label {
cursor: pointer;
}
.${NS}-row {
display: flex;
gap: 8px;
align-items: center;
}
.${NS}-row input[type='checkbox'] {
margin: 0;
cursor: pointer;
}
.${NS}-popup select {
flex: 1;
min-width: 0;
padding: 3px 22px 3px 6px;
font: inherit;
/* Native selects ignore background-color on WebKit until appearance is off. */
appearance: none;
-webkit-appearance: none;
color: #eee !important;
background-color: #31343c !important;
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath fill='%23cccccc' d='M0 1h12L6 8z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 6px center;
background-size: 9px 6px;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 4px;
cursor: pointer;
}
/* The open dropdown is drawn by the OS and inherits nothing from the select. */
.${NS}-popup select option {
color: #eee;
background-color: #31343c;
}
.${NS}-play {
flex: 0 0 auto;
width: 24px;
height: 24px;
padding: 0;
font-size: 0.8em;
line-height: 1;
color: #eee;
background-color: #31343c;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 4px;
cursor: pointer;
}
.${NS}-play:hover {
background-color: #3c404a;
}
.${NS}-file {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.${NS}-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 150px;
font-size: 0.85em;
opacity: 0.75;
}
.${NS}-link {
padding: 0;
font: inherit;
font-size: 0.85em;
color: #7fb3ff;
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}
.${NS}-hidden {
display: none;
}
`;
function injectStyle() {
if (document.getElementById(`${NS}-style`)) return;
const style = document.createElement('style');
style.id = `${NS}-style`;
style.textContent = STYLE;
document.head.appendChild(style);
}
// --- Settings popup ------------------------------------------------------
let popup = null;
function closePopup() {
if (!popup) return;
popup.remove();
popup = null;
document.removeEventListener('mousedown', onOutsideClick, true);
document.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('scroll', closePopup, true);
window.removeEventListener('resize', closePopup);
}
function onOutsideClick(event) {
const target = event.target;
if (popup.contains(target)) return;
if (target?.closest?.(`.${NS}-button`)) return;
closePopup();
}
function onKeyDown(event) {
if (event.key === 'Escape') closePopup();
}
function buildPopup() {
const element = document.createElement('div');
element.className = `${NS}-popup`;
const options = Object.entries(TONES)
.map(([key, tone]) => `<option value="${key}">${tone.label}</option>`)
.join('');
element.innerHTML = `
<h4>Notification Settings</h4>
<label class="${NS}-row">
<input type="checkbox" data-role="enabled">
<span>Play sound when lab is idle</span>
</label>
<div class="${NS}-row">
<label for="${NS}-tone">Sound</label>
<select id="${NS}-tone" data-role="tone">
${options}
<option value="${CUSTOM_TONE}">Custom file…</option>
</select>
<button type="button" class="${NS}-play" data-role="play" title="Play">▶</button>
</div>
<div class="${NS}-file ${NS}-hidden" data-role="file-row">
<span class="${NS}-file-name" data-role="file-name"></span>
<span class="${NS}-row">
<button type="button" class="${NS}-link" data-role="pick">Choose…</button>
<button type="button" class="${NS}-link ${NS}-hidden" data-role="clear">Remove</button>
</span>
</div>
<input type="file" accept="audio/*" class="${NS}-hidden" data-role="file-input">
`;
const find = (role) => element.querySelector(`[data-role="${role}"]`);
function renderFileRow() {
find('file-row').classList.toggle(`${NS}-hidden`, settings.tone !== CUSTOM_TONE);
find('file-name').textContent = customSound ? customSound.name : 'No file selected';
find('clear').classList.toggle(`${NS}-hidden`, !customSound);
}
find('enabled').checked = settings.soundEnabled;
find('enabled').addEventListener('change', (event) => {
settings.soundEnabled = event.target.checked;
saveSettings();
});
find('tone').value = settings.tone;
find('tone').addEventListener('change', (event) => {
settings.tone = event.target.value;
saveSettings();
renderFileRow();
if (settings.tone !== CUSTOM_TONE || customSound) play(settings.tone);
});
find('play').addEventListener('click', () => play(settings.tone));
find('pick').addEventListener('click', () => find('file-input').click());
find('file-input').addEventListener('change', async (event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
try {
await saveCustomSound(file);
renderFileRow();
play(CUSTOM_TONE);
} catch (error) {
warn('could not store sound:', error);
}
});
find('clear').addEventListener('click', async () => {
await removeCustomSound();
renderFileRow();
});
renderFileRow();
return element;
}
function openPopup(button) {
closePopup();
popup = buildPopup();
document.body.appendChild(popup);
// Fixed positioning so the panel's overflow cannot clip it.
const rect = button.getBoundingClientRect();
popup.style.top = `${rect.bottom + 6}px`;
popup.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - popup.offsetWidth - 8))}px`;
document.addEventListener('mousedown', onOutsideClick, true);
document.addEventListener('keydown', onKeyDown, true);
window.addEventListener('scroll', closePopup, true);
window.addEventListener('resize', closePopup);
}
// --- Button --------------------------------------------------------------
function mountButton() {
const anchor = document.querySelector(ANCHOR_SELECTOR);
if (!anchor) {
closePopup();
return;
}
if (anchor.querySelector(`.${NS}-button`)) return;
injectStyle();
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Notification Settings';
// Borrow the game's own button classes, hash suffix and all.
const template = anchor.querySelector(GAME_BUTTON_SELECTOR) || document.querySelector(GAME_BUTTON_SELECTOR);
button.className = template ? `${template.className} ${NS}-button` : `${NS}-button ${NS}-button-fallback`;
button.addEventListener('click', (event) => {
event.stopPropagation();
if (popup) closePopup();
else openPopup(button);
});
anchor.appendChild(button);
}
// The panel is re-rendered by React, so the button needs re-mounting.
function watchDom() {
let scheduled = false;
const observer = new MutationObserver(() => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
mountButton();
});
});
observer.observe(document.body, { childList: true, subtree: true });
mountButton();
}
// --- Start ---------------------------------------------------------------
hookSocketMessages();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', watchDom, { once: true });
} else {
watchDom();
}
pageWindow.__labPing = {
play: () => play(settings.tone),
state: () => ({ characterId, settings, customSound: customSound?.name, running: [...runningActions] }),
};
})();