Загрузка данных
// ==UserScript==
// @name MPP MIDI Playlist Player
// @namespace https://multiplayerpiano.net/
// @version 1.2
// @description Загрузка ZIP/MIDI, плейлист, случайное воспроизведение + API для ElvBot
// @match https://multiplayerpiano.net/*
// @match https://www.multiplayerpiano.net/*
// @match https://multiplayerpiano.com/*
// @grant none
// @require https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js
// @require https://cdn.jsdelivr.net/npm/@tonejs/midi@2.0.28/build/Midi.js
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
console.log('[MPP MIDI] script loaded');
// ---------- IndexedDB ----------
const DB_NAME = 'mpp-midi-player';
const STORE = 'midis';
let dbPromise = null;
function openDB() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: 'name' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return dbPromise;
}
async function dbGetAll() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const req = tx.objectStore(STORE).getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
async function dbPut(item) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(item);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function dbDelete(name) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(name);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function dbClear() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// ---------- Состояние ----------
const state = {
playlist: [],
currentIndex: -1,
playing: false,
shuffle: true,
timeoutIds: [],
lastPlayed: null,
loop: true,
pausedItem: null, // элемент, на котором стоит пауза (для resume)
};
// ---------- Утилиты ----------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function clearScheduled() {
state.timeoutIds.forEach((id) => clearTimeout(id));
state.timeoutIds = [];
}
function schedule(fn, ms) {
const id = setTimeout(fn, ms);
state.timeoutIds.push(id);
return id;
}
function getMPP() {
return window.MPP && window.MPP.client ? window.MPP : null;
}
function isConnected() {
const m = getMPP();
return m && m.client && m.client.isConnected && m.client.isConnected();
}
function releaseAllKeys() {
try {
const m = getMPP();
if (m) {
Object.keys(m.piano.keys).forEach((k) => {
try { window.MPP.release(k); } catch (e) {}
});
}
} catch (e) {}
}
// ---------- Парсинг MIDI ----------
function parseMidiToNotes(arrayBuffer) {
const midi = new Midi(arrayBuffer);
const events = [];
midi.tracks.forEach((track) => {
if (track.channel === 9) return; // ударные
track.notes.forEach((note) => {
events.push({
time: note.time,
duration: note.duration,
midi: note.midi,
velocity: note.velocity,
});
});
});
events.sort((a, b) => a.time - b.time);
return { events, duration: midi.duration };
}
const NOTE_NAMES = ['c', 'cs', 'd', 'ds', 'e', 'f', 'fs', 'g', 'gs', 'a', 'as', 'b'];
const LOWEST_MIDI = 21;
function midiToKeyName(midiNote) {
const idx = midiNote - LOWEST_MIDI;
if (idx < 0 || idx >= 88) return null;
const octave = Math.floor(idx / 12) - 1;
const name = NOTE_NAMES[idx % 12];
return name + octave;
}
// ---------- Воспроизведение ----------
// Возвращает Promise, который резолвится, когда трек доигран/остановлен
let currentPlayToken = 0;
async function playMidiItem(item, endCallback) {
if (!isConnected()) {
console.warn('[MPP MIDI] Не подключён к серверу');
endCallback && endCallback();
return;
}
let parsed;
try {
parsed = parseMidiToNotes(item.data.buffer ? item.data.buffer : item.data);
} catch (e) {
console.error('[MPP MIDI] Ошибка парсинга', item.name, e);
endCallback && endCallback();
return;
}
const { events } = parsed;
if (!events.length) {
endCallback && endCallback();
return;
}
const timeline = [];
events.forEach((ev) => {
const key = midiToKeyName(ev.midi);
if (!key) return;
timeline.push({ t: ev.time, type: 'on', key, vel: ev.velocity });
timeline.push({ t: ev.time + ev.duration, type: 'off', key });
});
timeline.sort((a, b) => a.t - b.t);
const pressing = new Set();
let i = 0;
const startTime = performance.now();
const token = ++currentPlayToken;
function step() {
// Если запустили другой трек или остановили — выходим
if (token !== currentPlayToken) {
pressing.forEach((k) => { try { window.MPP.release(k); } catch (e) {} });
pressing.clear();
return;
}
if (!state.playing) return;
const elapsed = (performance.now() - startTime) / 1000;
while (i < timeline.length && timeline[i].t <= elapsed) {
const ev = timeline[i++];
if (ev.type === 'on') {
if (!pressing.has(ev.key)) {
pressing.add(ev.key);
try {
window.MPP.press(ev.key, Math.max(0.1, Math.min(1, ev.vel)));
} catch (e) {}
}
} else {
if (pressing.has(ev.key)) {
pressing.delete(ev.key);
try { window.MPP.release(ev.key); } catch (e) {}
}
}
}
if (i >= timeline.length) {
pressing.forEach((k) => { try { window.MPP.release(k); } catch (e) {} });
pressing.clear();
endCallback && endCallback();
return;
}
state.timeoutIds.push(setTimeout(step, 10));
}
step();
}
// ---------- Плейлист ----------
function nextIndex() {
if (!state.playlist.length) return -1;
if (state.shuffle) {
if (state.playlist.length === 1) return 0;
let idx;
do {
idx = Math.floor(Math.random() * state.playlist.length);
} while (state.playlist[idx].name === state.lastPlayed);
return idx;
}
return (state.currentIndex + 1) % state.playlist.length;
}
async function playNext() {
if (!state.playlist.length) {
state.playing = false;
updateUI();
return;
}
clearScheduled();
state.playing = true;
state.pausedItem = null;
updateUI();
const idx = nextIndex();
if (idx < 0) { state.playing = false; updateUI(); return; }
state.currentIndex = idx;
const item = state.playlist[idx];
state.lastPlayed = item.name;
setStatus(`▶ ${item.name}`);
await playMidiItem(item, () => {
if (state.playing && state.loop) {
schedule(() => playNext(), 1000);
} else {
state.playing = false;
setStatus('⏹ Остановлено');
updateUI();
}
});
}
function stopPlayback() {
state.playing = false;
state.pausedItem = null;
clearScheduled();
currentPlayToken++; // отменяем текущий playMidiItem
releaseAllKeys();
setStatus('⏹ Остановлено');
updateUI();
}
// ---------- UI ----------
let panel = null;
let statusEl = null;
let listEl = null;
let fileInput = null;
let zipInput = null;
function setStatus(text) {
if (statusEl) statusEl.textContent = text;
}
function updateUI() {
if (!panel) return;
if (listEl) {
listEl.innerHTML = '';
if (!state.playlist.length) {
const empty = document.createElement('div');
empty.className = 'mp-empty';
empty.textContent = 'Плейлист пуст';
listEl.appendChild(empty);
} else {
state.playlist.forEach((item, i) => {
const row = document.createElement('div');
row.className = 'mp-row';
if (i === state.currentIndex && state.playing) row.classList.add('mp-playing');
const name = document.createElement('span');
name.className = 'mp-name';
name.textContent = item.name;
name.title = item.name;
const del = document.createElement('button');
del.className = 'mp-del';
del.textContent = '✕';
del.title = 'Удалить';
del.addEventListener('click', async (e) => {
e.stopPropagation();
if (!item.transient) await dbDelete(item.name);
state.playlist.splice(i, 1);
if (state.currentIndex >= state.playlist.length) state.currentIndex = state.playlist.length - 1;
updateUI();
});
row.appendChild(name);
row.appendChild(del);
row.addEventListener('dblclick', () => {
state.currentIndex = i - 1;
state.playing = false;
playNext();
});
listEl.appendChild(row);
});
}
}
const btnPlay = panel.querySelector('#mp-btn-play');
const btnStop = panel.querySelector('#mp-btn-stop');
if (btnPlay) btnPlay.textContent = state.playing ? '⏸ Играет...' : '▶ Играть';
if (btnStop) btnStop.disabled = !state.playing;
}
function createPanel() {
const p = document.createElement('div');
p.id = 'mpp-midi-panel';
p.style.cssText = `
position: fixed; top: 40px; right: 6px; width: 320px; max-height: 70vh;
background: rgba(20,20,30,0.96); color: #ddd; border: 1px solid #555;
border-radius: 6px; z-index: 30000; display: none; flex-direction: column;
font: 12px/1.4 monospace; box-shadow: 0 4px 20px rgba(0,0,0,0.6);
`;
p.innerHTML = `
<div style="padding:8px; border-bottom:1px solid #444; display:flex; justify-content:space-between; align-items:center;">
<b>MIDI Player</b>
<button id="mp-close" style="background:#333;color:#ddd;border:1px solid #555;border-radius:4px;cursor:pointer;padding:2px 8px;">✕</button>
</div>
<div style="padding:8px; display:flex; flex-wrap:wrap; gap:6px; border-bottom:1px solid #333;">
<button id="mp-btn-play" class="mp-btn">▶ Играть</button>
<button id="mp-btn-stop" class="mp-btn" disabled>⏹ Стоп</button>
<label class="mp-btn" style="cursor:pointer;">📁 ZIP<input id="mp-zip" type="file" accept=".zip" style="display:none;"></label>
<label class="mp-btn" style="cursor:pointer;">🎵 MIDI<input id="mp-midi" type="file" accept=".mid,.midi" multiple style="display:none;"></label>
<button id="mp-clear" class="mp-btn">🗑 Очистить</button>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;">
<input id="mp-shuffle" type="checkbox" checked> Случайно
</label>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;">
<input id="mp-loop" type="checkbox" checked> Повтор
</label>
</div>
<div id="mp-status" style="padding:6px 8px; color:#8f8; border-bottom:1px solid #333; min-height:16px;">Готов</div>
<div id="mp-list" style="flex:1; overflow-y:auto; padding:4px;"></div>
<div style="padding:6px 8px; border-top:1px solid #333; font-size:10px; color:#888;">
Двойной клик по треку — играть с него
</div>
`;
document.body.appendChild(p);
const style = document.createElement('style');
style.textContent = `
#mpp-midi-panel .mp-btn {
background:#2a2a3a; color:#ddd; border:1px solid #555; border-radius:4px;
padding:4px 8px; cursor:pointer; font:12px monospace;
}
#mpp-midi-panel .mp-btn:hover { background:#3a3a4a; }
#mpp-midi-panel .mp-btn:disabled { opacity:0.5; cursor:not-allowed; }
#mpp-midi-panel .mp-row { display:flex; align-items:center; padding:3px 4px; border-radius:3px; }
#mpp-midi-panel .mp-row:hover { background:#2a2a3a; }
#mpp-midi-panel .mp-row.mp-playing { background:#1e4a1e; }
#mpp-midi-panel .mp-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
#mpp-midi-panel .mp-del { background:none; border:none; color:#f66; cursor:pointer; font-size:12px; padding:0 4px; }
#mpp-midi-panel .mp-del:hover { color:#faa; }
#mpp-midi-panel .mp-empty { color:#888; text-align:center; padding:20px; }
`;
document.head.appendChild(style);
panel = p;
statusEl = p.querySelector('#mp-status');
listEl = p.querySelector('#mp-list');
zipInput = p.querySelector('#mp-zip');
fileInput = p.querySelector('#mp-midi');
p.querySelector('#mp-close').addEventListener('click', () => togglePanel(false));
p.querySelector('#mp-btn-play').addEventListener('click', () => {
if (state.playing) {
stopPlayback();
} else {
if (!state.playlist.length) {
setStatus('Плейлист пуст');
return;
}
playNext();
}
});
p.querySelector('#mp-btn-stop').addEventListener('click', () => stopPlayback());
p.querySelector('#mp-clear').addEventListener('click', async () => {
if (!confirm('Очистить весь плейлист?')) return;
stopPlayback();
await dbClear();
state.playlist = [];
state.currentIndex = -1;
state.lastPlayed = null;
updateUI();
setStatus('Плейлист очищен');
});
p.querySelector('#mp-shuffle').addEventListener('change', (e) => {
state.shuffle = e.target.checked;
localStorage.setItem('mpp-midi-shuffle', String(state.shuffle));
});
p.querySelector('#mp-loop').addEventListener('change', (e) => {
state.loop = e.target.checked;
localStorage.setItem('mpp-midi-loop', String(state.loop));
});
zipInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
setStatus('Распаковка ZIP...');
try {
const zip = await JSZip.loadAsync(file);
const entries = Object.values(zip.files).filter((f) =>
!f.dir && /\.(mid|midi)$/i.test(f.name)
);
if (!entries.length) {
setStatus('В ZIP нет .mid/.midi');
zipInput.value = '';
return;
}
let added = 0;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
setStatus(`Распаковка ${i + 1}/${entries.length}: ${entry.name}`);
const data = await entry.async('uint8array');
const name = entry.name.split('/').pop();
let finalName = name;
let n = 1;
while (state.playlist.some((x) => x.name === finalName)) {
finalName = name.replace(/(\.midi?)$/i, `_${n}$1`);
n++;
}
const item = { name: finalName, data };
await dbPut(item);
state.playlist.push(item);
added++;
}
setStatus(`Добавлено ${added} файлов`);
updateUI();
} catch (err) {
console.error(err);
setStatus('Ошибка ZIP: ' + err.message);
}
zipInput.value = '';
});
fileInput.addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
if (!files.length) return;
for (const file of files) {
const buf = await file.arrayBuffer();
const data = new Uint8Array(buf);
let name = file.name;
let n = 1;
while (state.playlist.some((x) => x.name === name)) {
name = file.name.replace(/(\.midi?)$/i, `_${n}$1`);
n++;
}
const item = { name, data };
await dbPut(item);
state.playlist.push(item);
}
setStatus(`Добавлено ${files.length} файлов`);
updateUI();
fileInput.value = '';
});
const savedShuffle = localStorage.getItem('mpp-midi-shuffle');
if (savedShuffle !== null) {
state.shuffle = savedShuffle === 'true';
p.querySelector('#mp-shuffle').checked = state.shuffle;
}
const savedLoop = localStorage.getItem('mpp-midi-loop');
if (savedLoop !== null) {
state.loop = savedLoop === 'true';
p.querySelector('#mp-loop').checked = state.loop;
}
}
function togglePanel(show) {
if (!panel) createPanel();
panel.style.display = show ? 'flex' : 'none';
}
function createSettingsButton() {
const btn = document.createElement('button');
btn.id = 'mpp-midi-btn';
btn.textContent = '🎹';
btn.title = 'MIDI Player';
btn.style.cssText = `
position: fixed; top: 6px; right: 96px; z-index: 15000;
width: 26px; height: 26px; padding: 0; cursor: pointer;
background: #111; color: #fff; border: 1px solid #444;
border-radius: 4px; font-size: 14px;
`;
btn.addEventListener('click', () => {
if (!panel) createPanel();
const isVisible = panel.style.display === 'flex';
togglePanel(!isVisible);
});
document.body.appendChild(btn);
}
// ---------- Инициализация ----------
async function init() {
let tries = 0;
while (!window.MPP && tries < 100) {
await sleep(100);
tries++;
}
if (!window.MPP) {
console.warn('[MPP MIDI] window.MPP не найден');
return;
}
createSettingsButton();
try {
const items = await dbGetAll();
state.playlist = items;
updateUI();
} catch (e) {
console.error('[MPP MIDI] Ошибка загрузки плейлиста', e);
}
// ---------- Публичный API для ElvBot ----------
window.__elvMidiPlayer = {
// --- статус ---
getNowPlaying: () => {
if (!state.playing || state.currentIndex < 0 || !state.playlist[state.currentIndex]) {
return null;
}
return state.playlist[state.currentIndex].name;
},
isPlaying: () => state.playing,
playlistSize: () => state.playlist.length,
// --- управление ---
// play(url): скачивает MIDI по URL, добавляет как transient и запускает
play: (url) => {
return (async () => {
if (!url || !/^https?:\/\//i.test(url)) {
throw new Error('URL must start with http(s)://');
}
const r = await fetch(url);
if (!r.ok) throw new Error('HTTP ' + r.status);
const buf = await r.arrayBuffer();
const data = new Uint8Array(buf);
const name = (url.split('/').pop() || 'remote.mid').split('?')[0];
// Останавливаем текущее и отменяем playMidiItem
stopPlayback();
const item = { name, data, transient: true };
state.playlist.push(item);
state.currentIndex = state.playlist.length - 1;
state.lastPlayed = name;
state.playing = true;
state.pausedItem = null;
updateUI();
setStatus(`▶ ${name}`);
// Запускаем
playMidiItem(item, () => {
if (state.playing && state.loop) {
schedule(() => playNext(), 1000);
} else {
state.playing = false;
setStatus('⏹ Остановлено');
updateUI();
}
});
return true;
})();
},
pause: () => {
if (!state.playing) return false;
state.playing = false;
clearScheduled();
currentPlayToken++; // отменяем текущий playMidiItem
releaseAllKeys();
// запоминаем, где остановились
if (state.currentIndex >= 0 && state.playlist[state.currentIndex]) {
state.pausedItem = state.playlist[state.currentIndex];
}
setStatus('⏸ Пауза');
updateUI();
return true;
},
resume: () => {
if (state.playing) return false;
// Возобновляем с начала текущего трека (у нас нет seek, поэтому с 0)
const item = state.pausedItem ||
(state.currentIndex >= 0 ? state.playlist[state.currentIndex] : null);
if (!item) return false;
state.playing = true;
state.pausedItem = null;
updateUI();
setStatus(`▶ ${item.name}`);
playMidiItem(item, () => {
if (state.playing && state.loop) {
schedule(() => playNext(), 1000);
} else {
state.playing = false;
setStatus('⏹ Остановлено');
updateUI();
}
});
return true;
},
restart: () => {
const item = state.currentIndex >= 0 ? state.playlist[state.currentIndex] : null;
if (!item) return false;
stopPlayback();
state.playing = true;
state.pausedItem = null;
updateUI();
setStatus(`▶ ${item.name}`);
playMidiItem(item, () => {
if (state.playing && state.loop) {
schedule(() => playNext(), 1000);
} else {
state.playing = false;
setStatus('⏹ Остановлено');
updateUI();
}
});
return true;
},
// --- список ---
list: () => state.playlist.map((x) => x.name),
};
console.log('[MPP MIDI] Плеер загружен. Треков:', state.playlist.length);
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(init, 500);
} else {
document.addEventListener('DOMContentLoaded', () => setTimeout(init, 500));
}
})();