JMIDIPlayer

High performance MIDI Player for Multiplayer Piano

当前为 2025-10-03 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         JMIDIPlayer
// @namespace    seq.wtf
// @version      1.0
// @description  High performance MIDI Player for Multiplayer Piano
// @author       Seq
// @license      Beerware
// @match        *://multiplayerpiano.net/*
// @grant        none
// ==/UserScript==

// "THE BEER-WARE LICENSE" (Revision 42):
// <[email protected]> wrote this file.
// As long as you retain this notice you can do whatever you want with this stuff.
// If we meet some day, and you think this stuff is worth it, you can buy me a beer in return.
// - James

// TODO (soon™):
// - sustain(?)
// - midi time in ui
// - playback speed in ui
// - fallback for non-mppnet-style button layouts
// - nq integration? will be super choppy on legacy nq

const HEADER_LENGTH = 14;
const DEFAULT_TEMPO = 500000; // 120 bpm / 500ms/qn
const EVENT_SIZE = 8;
const EVENT_CODE = {
  NOTE_ON: 0x09,
  NOTE_OFF: 0x08,
  CONTROL_CHANGE: 0x0B,
  SET_TEMPO: 0x51,
  END_OF_TRACK: 0x2F
};

class JMIDIPlayer {
  // playback state
  #isPlaying = false;
  #currentTick = 0;
  #currentTempo = DEFAULT_TEMPO;
  #playbackWorker = null;

  // loading state & file data
  #isLoading = false;
  #totalEvents = 0;
  #totalTicks = 0;
  #songTime = 0;
  #ppqn = 0;
  #numTracks = 0;

  // configurable properties
  #playbackSpeed = 1; // multiplier

  // event listeners
  #eventListeners = {};

  constructor() {
    this.#eventListeners = {};
    this.#createWorker();
  }

  on(event, callback) {
    if (!this.#eventListeners[event]) {
      this.#eventListeners[event] = [];
    }
    this.#eventListeners[event].push(callback);
  }

  off(event, callback) {
    if (!this.#eventListeners[event]) return;
    const index = this.#eventListeners[event].indexOf(callback);
    if (index > -1) {
      this.#eventListeners[event].splice(index, 1);
    }
  }

  emit(event, data) {
    if (!this.#eventListeners[event]) return;
    for (const callback of this.#eventListeners[event]) {
      callback(data);
    }
  }

  async loadArrayBuffer(arrbuf) {
    const start = performance.now();
    this.#isLoading = true;

    return new Promise((resolve, reject) => {
      const handleMessage = (e) => {
        const msg = e.data;

        if (msg.type === 'parseComplete') {
          this.#playbackWorker.removeEventListener('message', handleMessage);
          this.#isLoading = false;
          this.#totalEvents = msg.totalEvents;
          this.#totalTicks = msg.totalTicks;
          this.#songTime = msg.songTime;
          this.#ppqn = msg.ppqn;
          this.#numTracks = msg.numTracks;

          this.emit("fileLoaded");

          const parseTime = performance.now() - start;
          resolve([0, parseTime]); // [readTime, parseTime]
        } else if (msg.type === 'parseError') {
          this.#playbackWorker.removeEventListener('message', handleMessage);
          this.unload();
          reject(new Error(msg.error));
        }
      };

      this.#playbackWorker.addEventListener('message', handleMessage);

      // transfer the buffer to the worker
      this.#playbackWorker.postMessage({
        type: 'load',
        buffer: arrbuf
      }, [arrbuf]);
    });
  }

  async loadFile(file) {
    const arrbuf = await file.arrayBuffer();
    return this.loadArrayBuffer(arrbuf);
  }

  unload() {
    this.stop();

    if (this.#isLoading) {
      this.#isLoading = false;
    }

    this.#numTracks = 0;
    this.#ppqn = 0;
    this.#totalEvents = 0;
    this.#totalTicks = 0;
    this.#songTime = 0;
    this.#currentTick = 0;
    this.#currentTempo = DEFAULT_TEMPO / this.#playbackSpeed;

    if (this.#playbackWorker) {
      this.#playbackWorker.postMessage({
        type: 'unload'
      });
    }

    this.emit("unloaded");
  }

  play() {
    if (this.#isPlaying) return;
    if (this.#isLoading) return;
    if (this.#totalTicks === 0) throw new Error("No MIDI data loaded.");

    this.#isPlaying = true;
    this.#playbackWorker.postMessage({
      type: 'play'
    });
    this.emit("play");
  }

  pause() {
    if (!this.#isPlaying) return;

    this.#isPlaying = false;
    this.#playbackWorker.postMessage({
      type: 'pause'
    });
    this.emit("pause");
  }

  stop() {
    if (!this.#isPlaying && this.#currentTick === 0) return;

    const needsEmit = this.#currentTick > 0;

    this.#isPlaying = false;
    this.#currentTick = 0;
    this.#currentTempo = DEFAULT_TEMPO / this.#playbackSpeed;

    this.#playbackWorker.postMessage({
      type: 'stop'
    });

    if (needsEmit) this.emit("stop");
  }

  seek(tick) {
    if (this.#isLoading || this.#totalTicks === 0) return;

    tick = Math.min(Math.max(0, tick), this.#totalTicks);
    if (Number.isNaN(tick)) return;

    const wasPlaying = this.#isPlaying;
    if (wasPlaying) this.pause();

    this.#currentTick = tick;
    this.#playbackWorker.postMessage({
      type: 'seek',
      tick
    });

    this.emit("seek", {
      tick
    });

    if (wasPlaying) this.play();
  }

  #createWorker() {
    const workerCode = `
      const EVENT_SIZE = 8;
      const DEFAULT_TEMPO = 500000;
      const EVENT_CODE = { NOTE_ON: 0x09, NOTE_OFF: 0x08, CONTROL_CHANGE: 0x0B, SET_TEMPO: 0x51, END_OF_TRACK: 0x2F };
      const HEADER_LENGTH = 14;

      // Parsed MIDI data
      let tracks = [];
      let ppqn = 0;
      let tempoEvents = [];
      let totalTicks = 0;
      let numTracks = 0;
      let format = 0;

      // Playback state
      let playbackSpeed = 1;
      let isPlaying = false;
      let currentTick = 0;
      let currentTempo = DEFAULT_TEMPO;
      let trackEventPointers = [];
      let startTick = 0;
      let startTime = 0;
      let playLoopInterval = null;
      const sampleRate = 5; // ms

      function parseVarlen(view, offset) {
        let value = 0;
        let startOffset = offset;
        let checkNextByte = true;
        while (checkNextByte) {
          const currentByte = view.getUint8(offset);
          value = (value << 7) | (currentByte & 0x7F);
          ++offset;
          checkNextByte = !!(currentByte & 0x80);
        }
        return [value, offset - startOffset];
      }

      function parseTrack(view, trackOffset) {
        let eventIndex = 0;
        let capacity = 2048;
        let packedBuffer = new ArrayBuffer(capacity * EVENT_SIZE);
        let packedView = new DataView(packedBuffer);

        const trackTempoEvents = [];
        let totalTicks = 0;
        let currentTick = 0;
        let runningStatus = 0;

        const trackLength = view.getUint32(trackOffset + 4);
        let offset = trackOffset + 8;
        const endOffset = offset + trackLength;

        while (offset < endOffset) {
          const deltaTimeVarlen = parseVarlen(view, offset);
          offset += deltaTimeVarlen[1];
          currentTick += deltaTimeVarlen[0];

          let statusByte = view.getUint8(offset);
          if (statusByte < 0x80) {
            statusByte = runningStatus;
          } else {
            runningStatus = statusByte;
            ++offset;
          }

          const eventType = statusByte >> 4;
          let ignore = false;

          let eventCode, p1, p2, p3;

          switch (eventType) {
            case 0x8: // note off
            case 0x9: // note on
              eventCode = eventType;
              const note = view.getUint8(offset++);
              const velocity = view.getUint8(offset++);

              p1 = statusByte & 0x0F; // channel
              p2 = note;
              p3 = velocity;
              break;

            case 0xB: // control change
              eventCode = eventType;
              const ccNum = view.getUint8(offset++);
              const ccValue = view.getUint8(offset++);
              if (ccNum !== 64) ignore = true;

              p1 = statusByte & 0x0F; // channel
              p2 = ccNum;
              p3 = ccValue;
              break;

            case 0xA: // polyphonic key pressure
            case 0xE: // pitch wheel change
              ++offset; // fallthrough
            case 0xC: // program change
            case 0xD: // channel pressure
              ++offset;
              ignore = true;
              break;

            case 0xF: // system common / meta event
              if (statusByte === 0xFF) {
                const metaType = view.getUint8(offset++);
                const lengthVarlen = parseVarlen(view, offset);
                offset += lengthVarlen[1];

                switch (metaType) {
                  case 0x51: // set tempo
                    if (lengthVarlen[0] !== 3) {
                      ignore = true;
                    } else {
                      p1 = view.getUint8(offset);
                      p2 = view.getUint8(offset + 1);
                      p3 = view.getUint8(offset + 2);
                      const uspq = (p1 << 16) | (p2 << 8) | p3;
                      trackTempoEvents.push({ tick: currentTick, uspq: uspq });
                      eventCode = EVENT_CODE.SET_TEMPO;
                    }
                    break;
                  case 0x2F: // end of track
                    eventCode = EVENT_CODE.END_OF_TRACK;
                    offset = endOffset;
                    break;
                  default:
                    ignore = true;
                    break;
                }

                offset += lengthVarlen[0];
              } else if (statusByte === 0xF0 || statusByte === 0xF7) {
                ignore = true;
                const lengthVarlen = parseVarlen(view, offset);
                offset += lengthVarlen[0] + lengthVarlen[1];
              } else {
                ignore = true;
              }
              break;

            default:
              ignore = true;
              break;
          }

          if (!ignore) {
            if (eventIndex >= capacity) {
              capacity *= 2;
              const newBuffer = new ArrayBuffer(capacity * EVENT_SIZE);
              new Uint8Array(newBuffer).set(new Uint8Array(packedBuffer));
              packedBuffer = newBuffer;
              packedView = new DataView(packedBuffer);
            }

            const byteOffset = eventIndex * EVENT_SIZE;

            if (currentTick > 0xFFFFFFFF) {
              throw new Error(\`MIDI file too long! Track tick count exceeds maximum.\`);
            }

            packedView.setUint32(byteOffset, currentTick);
            packedView.setUint8(byteOffset + 4, eventCode);
            packedView.setUint8(byteOffset + 5, p1 || 0);
            packedView.setUint8(byteOffset + 6, p2 || 0);
            packedView.setUint8(byteOffset + 7, p3 || 0);

            ++eventIndex;
          }
        }

        packedBuffer = packedBuffer.slice(0, eventIndex * EVENT_SIZE);
        totalTicks = currentTick;

        return { packedBuffer, tempoEvents: trackTempoEvents, totalTicks };
      }

      function parseMIDI(buffer) {
        const view = new DataView(buffer);

        // HEADER
        const magic = view.getUint32(0);
        if (magic !== 0x4d546864) {
          throw new Error(\`Invalid MIDI magic! Expected 4d546864, got \${magic.toString(16).padStart(8, "0")}.\`);
        }

        const length = view.getUint32(4);
        if (length !== 6) {
          throw new Error(\`Invalid header length! Expected 6, got \${length}.\`);
        }

        format = view.getUint16(8);
        numTracks = view.getUint16(10);

        if (format === 0 && numTracks > 1) {
          throw new Error(\`Invalid track count! Format 0 MIDIs should only have 1 track, got \${numTracks}.\`);
        }

        if (format >= 2) {
          throw new Error(\`Unsupported MIDI format: \${format}.\`);
        }

        ppqn = view.getUint16(12);

        if (ppqn === 0) {
          throw new Error(\`Invalid PPQN/division value!\`);
        }

        if ((ppqn & 0x8000) !== 0) {
          throw new Error(\`SMPTE timecode format is not supported!\`);
        }

        // TRACK OFFSETS
        const trackOffsets = new Array(numTracks);
        let currentOffset = HEADER_LENGTH;

        for (let i = 0; i < numTracks; ++i) {
          if (currentOffset >= buffer.byteLength) {
            throw new Error(\`Reached EOF while looking for track \${i}. Tracks reported: \${numTracks}.\`);
          }

          const trackMagic = view.getUint32(currentOffset);
          if (trackMagic !== 0x4d54726b) {
            throw new Error(\`Invalid track \${i} magic! Expected 4d54726b, got \${trackMagic.toString(16).padStart(8, "0")}.\`);
          }

          const trackLength = view.getUint32(currentOffset + 4);
          trackOffsets[i] = currentOffset;
          currentOffset += trackLength + 8;
        }

        // PARSE TRACKS
        tracks = new Array(numTracks);
        totalTicks = 0;
        tempoEvents = [];

        for (let i = 0; i < numTracks; ++i) {
          const result = parseTrack(view, trackOffsets[i]);
          tracks[i] = {
            packedBuffer: result.packedBuffer,
            eventCount: result.packedBuffer.byteLength / EVENT_SIZE,
            view: new DataView(result.packedBuffer)
          };
          totalTicks = Math.max(totalTicks, result.totalTicks);
          tempoEvents.push(...result.tempoEvents);
        }

        // Calculate song time
        tempoEvents.sort((a, b) => a.tick - b.tick);

        const tempoMap = [{ tick: 0, uspq: DEFAULT_TEMPO }];

        for (const event of tempoEvents) {
          const lastTempo = tempoMap[tempoMap.length - 1];
          if (event.tick === lastTempo.tick) {
            lastTempo.uspq = event.uspq;
          } else {
            tempoMap.push(event);
          }
        }

        let totalMs = 0;

        for (let i = 0; i < tempoMap.length; ++i) {
          const currentTempo = tempoMap[i].uspq;
          const nextTick = (i < tempoMap.length - 1) ? tempoMap[i + 1].tick : totalTicks;
          const ticksInSegment = nextTick - tempoMap[i].tick;

          if (ticksInSegment > 0) totalMs += (ticksInSegment * (currentTempo / 1000)) / ppqn;
        }

        const songTime = totalMs / 1000;
        const totalEvents = tracks.map(t => t?.eventCount || 0).reduce((a, b) => a + b, 0);

        return { totalEvents, totalTicks, songTime, ppqn, numTracks };
      }

      function findNextEventIndex(trackIndex, tick) {
        const track = tracks[trackIndex];
        if (track.eventCount === 0) return 0;

        let low = 0;
        let high = track.eventCount;

        while (low < high) {
          const mid = Math.floor(low + (high - low) / 2);
          const eventTick = track.view.getUint32(mid * EVENT_SIZE);

          if (eventTick < tick) {
            low = mid + 1;
          } else {
            high = mid;
          }
        }
        return low;
      }

      function getCurrentTick() {
        if (!startTime) return startTick;

        const tpms = ppqn / (currentTempo / 1000);
        const ms = performance.now() - startTime;

        return Math.round(tpms * ms) + startTick;
      }

      function playLoop() {
        if (!isPlaying) {
          clearInterval(playLoopInterval);
          playLoopInterval = null;
          return;
        }

        currentTick = getCurrentTick();
        //console.log(\`PLAYING \${currentTick}\`)

        if (tracks.every((track, i) => trackEventPointers[i] >= track.eventCount) || currentTick > totalTicks) {
          isPlaying = false;
          clearInterval(playLoopInterval);
          playLoopInterval = null;
          currentTick = 0;
          startTick = 0;
          startTime = 0;
          postMessage({ type: 'endOfFile' });
          return;
        }

        const events = [];

        for (let i = 0; i < tracks.length; ++i) {
          const track = tracks[i];
          if (!track) continue;

          let ptr = trackEventPointers[i];

          while (ptr < track.eventCount && track.view.getUint32(ptr * EVENT_SIZE) <= currentTick) {
            const eventOffset = ptr * EVENT_SIZE;
            const eventTick = track.view.getUint32(eventOffset);
            const eventData = track.view.getUint32(eventOffset + 4);

            events.push({ tick: eventTick, data: eventData });

            const eventTypeCode = eventData >> 24;
            if (eventTypeCode === EVENT_CODE.SET_TEMPO) {
              const uspq = eventData & 0xFFFFFF;
              const oldTempo = currentTempo * playbackSpeed;
              const msAfterTempoEvent = ((currentTick - eventTick) * (oldTempo / 1000)) / ppqn;

              startTick = eventTick;
              startTime = performance.now() - msAfterTempoEvent;
              currentTempo = uspq / playbackSpeed;
            }

            ++trackEventPointers[i];
            ptr = trackEventPointers[i];
          }
        }

        if (events.length > 0) {
          postMessage({ type: 'events', events, currentTick });
        }
      }

      self.onmessage = function(e) {
        const msg = e.data;

        try {
          switch (msg.type) {
            case 'load':
              const result = parseMIDI(msg.buffer);
              trackEventPointers = new Array(tracks.length).fill(0);
              currentTick = 0;
              currentTempo = DEFAULT_TEMPO / playbackSpeed;
              postMessage({
                type: 'parseComplete',
                totalEvents: result.totalEvents,
                totalTicks: result.totalTicks,
                songTime: result.songTime,
                ppqn: result.ppqn,
                numTracks: result.numTracks
              });
              break;

            case 'unload':
              tracks = [];
              ppqn = 0;
              tempoEvents = [];
              totalTicks = 0;
              numTracks = 0;
              trackEventPointers = [];
              currentTick = 0;
              currentTempo = DEFAULT_TEMPO / playbackSpeed;
              isPlaying = false;
              if (playLoopInterval) {
                clearInterval(playLoopInterval);
                playLoopInterval = null;
              }
              break;

            case 'play':
              if (isPlaying) return;
              if (tracks.length === 0) return;
              isPlaying = true;
              startTime = performance.now();
              playLoopInterval = setInterval(playLoop, sampleRate);
              break;

            case 'pause':
              if (!isPlaying) return;
              isPlaying = false;
              clearInterval(playLoopInterval);
              playLoopInterval = null;
              startTick = getCurrentTick();
              currentTick = startTick;
              startTime = 0;
              postMessage({ type: 'tickUpdate', tick: currentTick });
              break;

            case 'stop':
              isPlaying = false;
              clearInterval(playLoopInterval);
              playLoopInterval = null;
              currentTick = 0;
              startTick = 0;
              startTime = 0;
              currentTempo = DEFAULT_TEMPO / playbackSpeed;
              break;

            case 'seek':
              const tick = msg.tick;

              // binary search for tempo
              if (tempoEvents.length > 0) {
                let low = 0;
                let high = tempoEvents.length - 1;
                let bestMatch = -1;

                while (low <= high) {
                  const mid = Math.floor(low + (high - low) / 2);
                  if (tempoEvents[mid].tick <= tick) {
                    bestMatch = mid;
                    low = mid + 1;
                  } else {
                    high = mid - 1;
                  }
                }

                currentTempo = ((bestMatch !== -1) ? tempoEvents[bestMatch].uspq : DEFAULT_TEMPO) / playbackSpeed;
              }

              for (let i = 0; i < tracks.length; ++i) {
                trackEventPointers[i] = findNextEventIndex(i, tick);
              }

              currentTick = tick;
              startTick = tick;
              postMessage({ type: 'tickUpdate', tick });
              break;

            case 'setPlaybackSpeed':
              const oldSpeed = playbackSpeed;
              playbackSpeed = msg.speed;

              if (isPlaying) {
                const tick = getCurrentTick();
                currentTempo = (currentTempo * oldSpeed) / playbackSpeed;
                startTick = tick;
                startTime = performance.now();
              }
              break;
          }
        } catch (error) {
          postMessage({ type: 'parseError', error: error.message });
        }
      };
    `;

    const blob = new Blob([workerCode], {
      type: 'application/javascript'
    });
    const workerUrl = URL.createObjectURL(blob);
    this.#playbackWorker = new Worker(workerUrl);

    this.#playbackWorker.onmessage = (e) => {
      const msg = e.data;

      switch (msg.type) {
        case 'events':
          this.#currentTick = msg.currentTick;
          for (const packedEvent of msg.events) {
            const eventData = packedEvent.data;
            const eventTypeCode = eventData >> 24;
            const event = {
              tick: packedEvent.tick
            };

            switch (eventTypeCode) {
              case EVENT_CODE.NOTE_ON:
              case EVENT_CODE.NOTE_OFF:
                event.type = eventTypeCode;
                event.channel = (eventData >> 16) & 0xFF;
                event.note = (eventData >> 8) & 0xFF;
                event.velocity = eventData & 0xFF;
                break;
              case EVENT_CODE.CONTROL_CHANGE:
                event.type = 0x0B;
                event.channel = (eventData >> 16) & 0xFF;
                event.ccNum = (eventData >> 8) & 0xFF;
                event.ccValue = eventData & 0xFF;
                break;
              case EVENT_CODE.SET_TEMPO:
                event.type = 0xFF;
                event.metaType = 0x51;
                event.uspq = eventData & 0xFFFFFF;
                this.#currentTempo = event.uspq / this.#playbackSpeed;
                this.emit("tempoChange");
                break;
              case EVENT_CODE.END_OF_TRACK:
                event.type = 0xFF;
                event.metaType = 0x2F;
                break;
            }

            this.emit("midiEvent", event);
          }
          break;

        case 'endOfFile':
          this.#isPlaying = false;
          this.#currentTick = 0;
          this.emit("endOfFile");
          this.emit("stop");
          break;

        case 'tickUpdate':
          this.#currentTick = msg.tick;
          break;
      }
    };

    this.#playbackWorker.onerror = (error) => {
      console.error('Worker error:', error);
    };
  }

  get isLoading() {
    return this.#isLoading;
  }

  get isPlaying() {
    return this.#isPlaying;
  }

  get trackCount() {
    return this.#numTracks;
  }

  get songTime() {
    return this.#songTime;
  }

  get ppqn() {
    return this.#ppqn;
  }

  get currentTempo() {
    return 60_000_000 / this.#currentTempo;
  }

  get totalEvents() {
    return this.#totalEvents;
  }

  get totalTicks() {
    return this.#totalTicks;
  }

  get currentTick() {
    return this.#currentTick;
  }

  get playbackSpeed() {
    return this.#playbackSpeed;
  }

  set playbackSpeed(speed) {
    speed = +speed;
    if (Number.isNaN(speed)) throw new Error("Playback speed must be a valid number!");
    if (speed <= 0) throw new Error("Playback speed must be a positive number!");

    const oldSpeed = this.#playbackSpeed;
    if (speed === oldSpeed) return;

    this.#playbackSpeed = speed;

    if (this.#playbackWorker) {
      this.#playbackWorker.postMessage({
        type: 'setPlaybackSpeed',
        speed
      });
    }
  }
}

function handleMidiEvent(event) {
  if ((event.type >> 1) !== 4) return;
  if (event.channel === 9) return;
  const key = Object.keys(MPP.piano.keys)[event.note - 21];
  if (event.type === 0x9 && event.velocity > 0) {
    MPP.press(key, event.velocity / 127);
  } else {
    MPP.release(key);
  }
}

// svg icons
const ICON_PLAY = `<svg viewBox="0 0 16 16"><path d="M3 2 L13 8 L3 14 Z"></path></svg>`;
const ICON_PAUSE = `<svg viewBox="0 0 16 16"><path d="M3 2 H6 V14 H3 Z M10 2 H13 V14 H10 Z"></path></svg>`;
const ICON_STOP = `<svg viewBox="0 0 16 16"><path d="M3 2 H13 V12 H3 Z"></path></svg>`;

const styles = `
  .jmidi-player-window {
    position: fixed;
    top: 20px;
    left: 20px;
    width: 350px;
    background: #2d2d2d;
    border: 1px solid #555;
    border-radius: 8px;
    box-shadow: 0 5px 15px rgba(0,0,0,0.5);
    color: #eee;
    font-family: sans-serif;
    font-size: 14px;
    z-index: 99999;
    display: none; /* Hidden by default */
    border: 2px solid transparent; /* For dragover feedback */
    transition: border-color 0.2s;
  }
  .jmidi-player-window.visible {
    display: block;
  }
  .jmidi-player-window.dragover {
    border-color: #0a84ff;
  }
  .jmidi-header {
    padding: 8px 12px;
    background: #3a3a3a;
    cursor: move;
    border-top-left-radius: 8px;
    border-top-right-radius: 8px;
    border-bottom: 1px solid #555;
    user-select: none;
  }
  .jmidi-content {
    padding: 12px;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .jmidi-controls {
    display: flex;
    gap: 8px;
    align-items: center;
  }
  #jmidi-file-label {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    display: block; /* Ensures it takes up full width for ellipsis */
    text-align: center;
  }
  .jmidi-controls button, #jmidi-file-label {
    background: #555;
    border: 1px solid #777;
    color: #eee;
    padding: 6px 10px;
    border-radius: 4px;
    cursor: pointer;
  }
  .jmidi-controls button {
    width: 32px;
    height: 32px;
    padding: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .jmidi-controls button svg {
    width: 16px;
    height: 16px;
    fill: #eee;
  }
  .jmidi-controls button:hover, #jmidi-file-label:hover {
    background: #666;
  }
  .jmidi-controls button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
  .jmidi-file-input input[type="file"] {
    display: none;
  }
  .jmidi-seekbar-track {
    width: 100%;
    height: 10px;
    background-color: #444;
    border-radius: 5px;
    cursor: ew-resize;
    overflow: hidden; /* Ensures progress div corners are rounded */
    position: relative;
  }
  .jmidi-seekbar-track.jmidi-disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
  .jmidi-seekbar-progress {
    height: 100%;
    width: 0%; /* Controlled by JS */
    background-color: #0a84ff;
    border-radius: 5px;
    pointer-events: none; /* Clicks should go to the track */
  }
  .jmidi-info-area {
    min-height: 32px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 4px;
  }
  .jmidi-status-text, .jmidi-tick-display {
    display: block;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .jmidi-status-text {
    font-style: italic;
    color: #aaa;
  }
  .jmidi-tick-display {
    text-align: left;
    font-family: monospace;
  }
`;

const playerHTML = `
  <div id="jmidi-player-window" class="jmidi-player-window">
    <div class="jmidi-header">JMIDIPlayer</div>
    <div class="jmidi-content">
      <div class="jmidi-file-input">
        <input type="file" id="jmidi-file-input" accept=".mid,.midi">
        <label for="jmidi-file-input" id="jmidi-file-label">Load MIDI File</label>
      </div>
      <div id="jmidi-seekbar-track" class="jmidi-seekbar-track jmidi-disabled">
        <div id="jmidi-seekbar-progress" class="jmidi-seekbar-progress"></div>
      </div>
      <div class="jmidi-controls">
        <button id="jmidi-play-pause-btn" disabled></button>
        <button id="jmidi-stop-btn" disabled></button>
      </div>
      <div class="jmidi-info-area">
        <span id="jmidi-status-text" class="jmidi-status-text">No file loaded.</span>
        <span id="jmidi-tick-display" class="jmidi-tick-display">0.00% | 0 / 0</span>
      </div>
    </div>
  </div>
`;

const toggleButtonHTML = `<div class="ugly-button" id="jmidi-toggle-btn">Toggle Player</div>`;

// inject ui
document.head.insertAdjacentHTML('beforeend', `<style>${styles}</style>`);
document.body.insertAdjacentHTML('beforeend', playerHTML);

const buttonsContainer = document.querySelector('#buttons');
if (buttonsContainer) {
  buttonsContainer.insertAdjacentHTML('beforeend', toggleButtonHTML);
} else {
  document.body.insertAdjacentHTML('beforeend', toggleButtonHTML);
}

// ui element references
const ui = {
  window: document.getElementById('jmidi-player-window'),
  header: document.querySelector('.jmidi-header'),
  fileInput: document.getElementById('jmidi-file-input'),
  playPauseBtn: document.getElementById('jmidi-play-pause-btn'),
  stopBtn: document.getElementById('jmidi-stop-btn'),
  seekbarTrack: document.getElementById('jmidi-seekbar-track'),
  seekbarProgress: document.getElementById('jmidi-seekbar-progress'),
  statusText: document.getElementById('jmidi-status-text'),
  tickDisplay: document.getElementById('jmidi-tick-display'),
  fileLabel: document.getElementById('jmidi-file-label'),
  toggleBtn: document.getElementById('jmidi-toggle-btn')
};

const player = new JMIDIPlayer();
let isSeeking = false; // flag to prevent ui loop from updating seekbar dragging
let animStartTick = 0;
let animStartTime = 0;

// ui update & helpers
function setControlsEnabled(enabled) {
  ui.playPauseBtn.disabled = !enabled;
  ui.stopBtn.disabled = !enabled;
  ui.seekbarTrack.classList.toggle('jmidi-disabled', !enabled);
}

function resetUIForNewFile() {
  ui.playPauseBtn.innerHTML = ICON_PLAY;
  ui.stopBtn.innerHTML = ICON_STOP;
  setControlsEnabled(true);
  updateTickDisplay();
}

let currentFileName = null;
async function loadFile(file) {
  if (!file || !file.type.match(/audio\/(midi|x-midi)/)) {
    ui.statusText.textContent = `Error: Not a MIDI file.`;
    return;
  }

  player.unload();
  ui.statusText.textContent = 'Loading...';
  setControlsEnabled(false);

  try {
    currentFileName = file.name;
    const buffer = await file.arrayBuffer();
    await player.loadArrayBuffer(buffer);
  } catch (error) {
    currentFileName = null;
    ui.statusText.textContent = `Error: ${error.message}`;
    console.error("Failed to load MIDI file:", error);
    player.unload();
  }
}

function updateTickDisplay(displayTick) {
  const currentTick = displayTick !== undefined ? displayTick : player.currentTick;
  const totalTicks = player.totalTicks || 1;
  const percentage = Math.min(100, (currentTick / totalTicks) * 100);

  const clampedTick = Math.min(player.totalTicks, Math.max(0, currentTick));

  ui.tickDisplay.textContent = `${percentage.toFixed(2)}% | ${clampedTick} / ${totalTicks}`;
  ui.seekbarProgress.style.width = `${percentage}%`;
}

function animationLoop() {
  if (player.isPlaying) {
    const ppqn = player.ppqn;
    const tempoBPM = player.currentTempo;
    if (ppqn > 0 && tempoBPM > 0) {
      const ticksPerSecond = (ppqn * tempoBPM) / 60;
      const elapsedMs = performance.now() - animStartTime;
      const elapsedTicks = (elapsedMs / 1000) * ticksPerSecond;
      const visualTick = Math.floor(animStartTick + elapsedTicks);
      updateTickDisplay(visualTick);
    }
  }
  requestAnimationFrame(animationLoop);
}
animationLoop();

// initial button states
ui.playPauseBtn.innerHTML = ICON_PLAY;
ui.stopBtn.innerHTML = ICON_STOP;

// player events
player.on('fileLoaded', () => {
  ui.statusText.textContent = `Ready: ${player.trackCount} tracks`;
  ui.fileLabel.textContent = currentFileName;
  resetUIForNewFile();
});

player.on('play', () => {
  ui.statusText.textContent = 'Playing...';
  ui.playPauseBtn.innerHTML = ICON_PAUSE;
  // Set animation anchor point
  animStartTime = performance.now();
  animStartTick = player.currentTick;
});

player.on('pause', () => {
  ui.statusText.textContent = 'Paused.';
  ui.playPauseBtn.innerHTML = ICON_PLAY;
});

player.on('stop', () => {
  ui.statusText.textContent = 'Stopped.';
  ui.playPauseBtn.innerHTML = ICON_PLAY;
  player.seek(0);
  animStartTick = 0;
  updateTickDisplay();
});

player.on('tempoChange', () => {
  animStartTime = performance.now();
  animStartTick = player.currentTick;
});


player.on('endOfFile', () => {
  ui.statusText.textContent = 'Finished.';
  ui.playPauseBtn.innerHTML = ICON_PLAY;
  updateTickDisplay();
});

player.on('unloaded', () => {
  ui.statusText.textContent = 'No file loaded.';
  ui.fileLabel.textContent = 'Load MIDI File';
  currentFileName = null;
  ui.tickDisplay.textContent = '0.00% | 0 / 0';
  setControlsEnabled(false);
  updateTickDisplay();
});

player.on('midiEvent', handleMidiEvent);

ui.toggleBtn.addEventListener('click', () => {
  ui.window.classList.toggle('visible');
});

// file input
ui.fileInput.addEventListener('change', (e) => {
  if (e.target.files.length > 0) {
    loadFile(e.target.files[0]);
  }
});

// drag'n'drop
ui.window.addEventListener('dragover', (e) => {
  e.preventDefault();
  ui.window.classList.add('dragover');
});
ui.window.addEventListener('dragleave', () => {
  ui.window.classList.remove('dragover');
});
ui.window.addEventListener('drop', (e) => {
  e.preventDefault();
  ui.window.classList.remove('dragover');
  if (e.dataTransfer.files.length > 0) {
    loadFile(e.dataTransfer.files[0]);
  }
});

// player controls
ui.playPauseBtn.addEventListener('click', () => {
  if (player.isPlaying) {
    player.pause();
    ui.playPauseBtn.innerHTML = ICON_PLAY;
  } else {
    player.play();
    ui.playPauseBtn.innerHTML = ICON_PAUSE;
  }
});

ui.stopBtn.addEventListener('click', () => {
  player.stop();
  ui.playPauseBtn.innerHTML = ICON_PLAY;
});

// seekbar
function seekFromEvent(e) {
  if (player.totalTicks === 0) return;
  const rect = ui.seekbarTrack.getBoundingClientRect();
  const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
  const percentage = x / rect.width;
  const tick = Math.round(percentage * player.totalTicks);

  player.seek(tick);
  updateTickDisplay(); // update ui immediately
}

ui.seekbarTrack.addEventListener('mousedown', (e) => {
  if (player.totalTicks === 0) return;
  isSeeking = true;
  seekFromEvent(e);
});

document.addEventListener('mousemove', (e) => {
  if (isSeeking) {
    seekFromEvent(e);
  }
});

document.addEventListener('mouseup', () => {
  if (isSeeking) {
    isSeeking = false;
  }
});

// draggable window
let isDragging = false;
let offsetX, offsetY;

ui.header.addEventListener('mousedown', (e) => {
  isDragging = true;
  const rect = ui.window.getBoundingClientRect();
  offsetX = e.clientX - rect.left;
  offsetY = e.clientY - rect.top;

  e.preventDefault();
});

document.addEventListener('mousemove', (e) => {
  if (isDragging) {
    ui.window.style.left = `${e.clientX - offsetX}px`;
    ui.window.style.top = `${e.clientY - offsetY}px`;
  }
});

document.addEventListener('mouseup', () => {
  isDragging = false;
});