Загрузка данных
// ==UserScript==
// @name MPP MIDI Playlist Player PRO (Tempo + Transpose)
// @namespace https://multiplayerpiano.net/
// @version 2.2
// @description Быстрый MIDI плеер с таймлайном, правильным темпом, транспонированием и защитой от кика
// @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
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
console.log('[MPP MIDI] PRO Engine v2.2 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((res) => {
const req = db.transaction(STORE, 'readonly').objectStore(STORE).getAll();
req.onsuccess = () => res(req.result || []);
});
}
async function dbPut(item) {
const db = await openDB();
return new Promise((res) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(item);
tx.oncomplete = () => res();
});
}
async function dbDelete(name) {
const db = await openDB();
return new Promise((res) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(name);
tx.oncomplete = () => res();
});
}
async function dbClear() {
const db = await openDB();
return new Promise((res) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).clear();
tx.oncomplete = () => res();
});
}
// ---------- Состояние ----------
const state = {
playlist: [],
currentIndex: -1,
playing: false,
shuffle: true,
timeoutIds: [],
lastPlayed: null,
loop: true,
pausedItem: null,
currentProgress: 0,
totalDuration: 0,
startTime: 0,
transposeOffset: 0, // Наше глобальное транспонирование
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const getMPP = () => (window.MPP && window.MPP.client) ? window.MPP : null;
const isConnected = () => getMPP() && getMPP().client.isConnected();
function clearScheduled() {
state.timeoutIds.forEach(clearTimeout);
state.timeoutIds = [];
}
function releaseAllKeys() {
try {
const m = getMPP();
if (m) Object.keys(m.piano.keys).forEach((k) => { try { window.MPP.release(k); } catch (e) {} });
} catch (e) {}
}
// Перехват стрелок для транспонирования
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.keyCode === 38 || e.keyCode === 40) { // Вверх/Вниз (октавы)
state.transposeOffset += (e.keyCode === 38 ? 12 : -12);
console.log('Transpose:', state.transposeOffset);
} else if (e.keyCode === 37 || e.keyCode === 39) { // Влево/Вправо (полутона)
state.transposeOffset += (e.keyCode === 39 ? 1 : -1);
console.log('Transpose:', state.transposeOffset);
}
});
// ---------- Парсер MIDI с поддержкой темпа ----------
function parseMidiFast(buffer) {
const view = new DataView(buffer);
let offset = 0;
const rawEvents = [];
let totalTicks = 0;
let ppqn = 480;
const trackEndTicks = [];
let tempo = 500000; // По умолчанию 120 bpm
if (view.getUint32(0) !== 0x4d546864) throw new Error("Not a valid MIDI file");
const headerLen = view.getUint32(4);
ppqn = view.getUint16(12);
const numTracks = view.getUint16(10);
offset += 8 + headerLen;
function readVarLen() {
let val = 0;
while (true) {
const b = view.getUint8(offset++);
val = (val << 7) | (b & 0x7f);
if (!(b & 0x80)) break;
}
return val;
}
for (let t = 0; t < numTracks; t++) {
if (view.getUint32(offset) !== 0x4d54726b) break;
const trackLen = view.getUint32(offset + 4);
const trackEnd = offset + 8 + trackLen;
offset += 8;
let currentTick = 0;
let runningStatus = 0;
while (offset < trackEnd) {
const delta = readVarLen();
currentTick += delta;
let status = view.getUint8(offset);
if (status < 0x80) { status = runningStatus; }
else { offset++; runningStatus = status; }
const type = status & 0xF0;
if (type === 0x90 || type === 0x80) {
const note = view.getUint8(offset++);
const vel = view.getUint8(offset++);
if (vel > 0 && vel < 13) {
if (type === 0x90) rawEvents.push({tick: currentTick, type: 'off', note});
continue;
}
rawEvents.push({ tick: currentTick, type: (type === 0x90 && vel > 0) ? 'on' : 'off', note, vel: vel/127 });
} else if (type === 0xA0 || type === 0xB0 || type === 0xE0) {
offset += 2;
} else if (type === 0xC0 || type === 0xD0) {
offset += 1;
} else if (status === 0xFF) {
const metaType = view.getUint8(offset++);
const len = readVarLen();
// Читаем темп (Set Tempo)
if (metaType === 0x51 && len === 3) {
const uspq = (view.getUint8(offset) << 16) | (view.getUint8(offset + 1) << 8) | view.getUint8(offset + 2);
tempo = uspq;
}
offset += len;
if (metaType === 0x2F) break;
} else {
offset++;
}
}
trackEndTicks.push(currentTick);
offset = trackEnd;
}
totalTicks = Math.max(...trackEndTicks, 0);
// Сортировка и фильтрация дубликатов
rawEvents.sort((a, b) => a.tick - b.tick);
const lastNoteTime = {};
const events = [];
for (const ev of rawEvents) {
if (ev.type === 'on') {
const lastTime = lastNoteTime[ev.note] || 0;
if (ev.tick - lastTime < 10) continue; // Удаляем дубликаты (10 тиков)
lastNoteTime[ev.note] = ev.tick;
}
events.push(ev);
}
// Вычисляем время в секундах на основе темпа и PPQN
const secondsPerTick = (tempo / 1000000) / ppqn;
events.forEach(e => { e.time = e.tick * secondsPerTick; });
return { events, duration: totalTicks * secondsPerTick };
}
// ---------- Воспроизведение ----------
let currentPlayToken = 0;
async function playMidiItem(item, endCallback) {
if (!isConnected()) { endCallback && endCallback(); return; }
let parsed;
try {
const start = performance.now();
parsed = parseMidiFast(item.data.buffer ? item.data.buffer : item.data);
console.log(`[MIDI] Parsed in ${(performance.now() - start).toFixed(0)}ms. Events: ${parsed.events.length}`);
} catch (e) {
console.error('[MPP MIDI] Ошибка парсинга', item.name, e);
endCallback && endCallback();
return;
}
if (!parsed.events.length) { endCallback && endCallback(); return; }
// Ускоряем отправку буфера MPP
if (window.MPP && window.MPP.client) {
clearInterval(window.MPP.client.noteFlushInterval);
window.MPP.client.noteFlushInterval = setInterval(() => {
if (window.MPP.client.noteBufferTime && window.MPP.client.noteBuffer.length > 0) {
window.MPP.client.sendArray([{
m: "n",
t: window.MPP.client.noteBufferTime + window.MPP.client.serverTimeOffset,
n: window.MPP.client.noteBuffer
}]);
window.MPP.client.noteBufferTime = 0;
window.MPP.client.noteBuffer = [];
}
}, 50);
}
const pressing = new Set();
let i = 0;
state.startTime = performance.now();
state.totalDuration = parsed.duration;
const token = ++currentPlayToken;
const noteNames = ['c', 'cs', 'd', 'ds', 'e', 'f', 'fs', 'g', 'gs', 'a', 'as', 'b'];
// NPS лимитер
let windowStart = performance.now();
let noteBudget = 400; // Максимум нот за 10 секунд
let skippedNotes = 0;
function step() {
if (token !== currentPlayToken) {
pressing.forEach(k => window.MPP.release(k));
return;
}
if (!state.playing) return;
const now = performance.now();
const elapsed = (now - state.startTime) / 1000;
state.currentProgress = elapsed;
// Обновляем бюджет
if (now - windowStart > 10000) {
windowStart = now;
noteBudget = 400;
skippedNotes = 0;
}
while (i < parsed.events.length && parsed.events[i].time <= elapsed) {
const ev = parsed.events[i++];
// Применяем наше транспонирование
const idx = ev.note - 24 + state.transposeOffset;
if (idx < 0 || idx >= 88) continue;
const octave = Math.floor(idx / 12) - 1;
const keyName = noteNames[idx % 12] + octave;
if (ev.type === 'on') {
if (!pressing.has(keyName)) {
if (noteBudget > 0) {
pressing.add(keyName);
try { window.MPP.press(keyName, ev.vel); } catch (e) {}
noteBudget--;
} else {
skippedNotes++;
}
}
} else {
if (pressing.has(keyName)) {
pressing.delete(keyName);
try { window.MPP.release(keyName); } catch (e) {}
}
}
}
if (i >= parsed.events.length) {
pressing.forEach(k => { try { window.MPP.release(k); } catch(e){} });
endCallback && endCallback();
return;
}
state.timeoutIds.push(requestAnimationFrame(step));
}
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) setTimeout(() => playNext(), 1000);
else { state.playing = false; setStatus('⏹ Остановлено'); updateUI(); }
});
}
function stopPlayback() {
state.playing = false;
state.pausedItem = null;
clearScheduled();
currentPlayToken++;
releaseAllKeys();
setStatus('⏹ Остановлено');
updateUI();
}
// ---------- UI ----------
let panel = null, statusEl = null, listEl = null, progressBar = null, fileInput = null, zipInput = null;
function setStatus(text) { if (statusEl) statusEl.textContent = text; }
setInterval(() => {
if (state.playing && progressBar && state.totalDuration > 0) {
const pct = Math.min((state.currentProgress / state.totalDuration) * 100, 100);
progressBar.style.width = pct + '%';
} else if (!state.playing && progressBar) {
progressBar.style.width = '0%';
}
}, 100);
function updateUI() {
if (!panel) return;
if (listEl) {
listEl.innerHTML = '';
if (!state.playlist.length) {
listEl.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">Плейлист пуст</div>';
} 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;
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 PRO</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 style="padding: 0 8px 8px 8px; border-bottom: 1px solid #333;">
<div style="height: 4px; background: #111; border-radius: 2px; overflow: hidden; margin-top:4px;">
<div id="mp-progress" style="height: 100%; width: 0%; background: #8f8; transition: width 0.1s linear;"></div>
</div>
</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>
`;
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; }
`;
document.head.appendChild(style);
panel = p; statusEl = p.querySelector('#mp-status'); listEl = p.querySelector('#mp-list');
progressBar = p.querySelector('#mp-progress');
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) return setStatus('Плейлист пуст'); 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; updateUI(); });
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));
let added = 0;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
setStatus(`Распаковка ${i + 1}/${entries.length}...`);
const data = await entry.async('uint8array');
let name = entry.name.split('/').pop();
while (state.playlist.some(x => x.name === name)) name = name.replace(/(\.midi?)$/i, `_1$1`);
const item = { name, data };
await dbPut(item); state.playlist.push(item); added++;
}
setStatus(`Добавлено ${added} файлов`); updateUI();
} catch (err) { setStatus('Ошибка: ' + err.message); }
zipInput.value = '';
});
fileInput.addEventListener('change', async (e) => {
for (const file of Array.from(e.target.files)) {
const buf = await file.arrayBuffer(); const data = new Uint8Array(buf);
let name = file.name;
while (state.playlist.some(x => x.name === name)) name = name.replace(/(\.midi?)$/i, `_1$1`);
const item = { name, data }; await dbPut(item); state.playlist.push(item);
}
updateUI(); fileInput.value = '';
});
p.querySelector('#mp-shuffle').checked = state.shuffle;
p.querySelector('#mp-shuffle').addEventListener('change', (e) => { state.shuffle = e.target.checked; });
p.querySelector('#mp-loop').checked = state.loop;
p.querySelector('#mp-loop').addEventListener('change', (e) => { state.loop = e.target.checked; });
}
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 PRO';
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(); togglePanel(panel.style.display !== 'flex'); });
document.body.appendChild(btn);
}
// ---------- init ----------
async function init() {
let tries = 0;
while (!window.MPP && tries < 100) { await sleep(100); tries++; }
if (!window.MPP) return;
createSettingsButton();
try { state.playlist = await dbGetAll(); updateUI(); } catch (e) {}
window.__elvMidiPlayer = {
getNowPlaying: () => (state.playing && state.currentIndex >= 0 && state.playlist[state.currentIndex]) ? state.playlist[state.currentIndex].name : null,
isPlaying: () => state.playing,
pause: () => {
if (!state.playing) return false;
state.playing = false; state.pausedItem = state.playlist[state.currentIndex];
clearScheduled(); currentPlayToken++; releaseAllKeys(); setStatus('⏸ Пауза'); updateUI(); return true;
},
resume: () => {
if (state.playing || !state.pausedItem) return false;
state.playlist.push({...state.pausedItem, _resume: true});
state.currentIndex = state.playlist.length - 1; state.playing = true;
state.pausedItem = null; updateUI();
playMidiItem(state.playlist[state.currentIndex], () => { if (state.playing && state.loop) setTimeout(playNext, 1000); else { state.playing=false; updateUI(); } });
return true;
},
restart: () => {
if (state.currentIndex < 0) return false;
stopPlayback(); state.playing = true; updateUI();
playMidiItem(state.playlist[state.currentIndex], () => { if (state.playing && state.loop) setTimeout(playNext, 1000); else { state.playing=false; updateUI(); } });
return true;
},
play: (url) => {
return (async () => {
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];
stopPlayback();
const item = { name, data, transient: true };
state.playlist.push(item); state.currentIndex = state.playlist.length - 1; state.lastPlayed = name; state.playing = true;
updateUI(); setStatus(`▶ ${name}`);
playMidiItem(item, () => { if (state.playing && state.loop) setTimeout(playNext, 1000); else { state.playing=false; updateUI(); } });
return true;
})();
},
setCursor: (xPercent, yPercent) => {
try {
if (window.MPP && window.MPP.client.isConnected()) {
window.MPP.client.sendArray([{ m: "m", x: xPercent, y: yPercent }]);
const own = window.MPP.client.getOwnParticipant();
if (own) { own.x = xPercent; own.y = yPercent; }
}
} catch (e) {}
}
};
}
if (document.readyState === 'complete' || document.readyState === 'interactive') setTimeout(init, 500);
else document.addEventListener('DOMContentLoaded', () => setTimeout(init, 500));
})();