Загрузка данных


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