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


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