TealMIDIPlayer

MIDI Player bot for MPP. (Based off of Teal's MIDI player)

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         TealMIDIPlayer
// @name:pt-BR   Tocador de MIDIs do Teal
// @homepage     <gone>
// @version      2.5.0
// @description  MIDI Player bot for MPP. (Based off of Teal's MIDI player)
// @description:pt-BR  Bot tocador de MIDIs para MPP. (Baseado no tocador de MIDIs do Teal)
// @author       sopb b (ccjt)
// @match        *://multiplayerpiano.net/*
// @match        *://multiplayerpiano.org/*
// @match        *://multiplayerpiano.com/*
// @match        *://piano.mpp.community/*
// @match        *://mpp.8448.space/*
// @match        *://mpp.7458.space/*
// @match        *://www.multiplayerpiano.dev/*
// @match        *://mpp.smp-meow.net/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        GM_info
// @license      MIT
// @namespace    https://greasyfork.org/users/1459137
// ==/UserScript==
 
 
if (!localStorage.getItem('tmp_prefix')) localStorage.tmp_prefix = 'p.'
let prefix = localStorage.tmp_prefix
 
const sep = '-'
 
// script info
const SCRIPT = GM_info.script
const name = SCRIPT.name
const version = SCRIPT.version
const author = SCRIPT.author
const link = SCRIPT.homepage
 
 
// JMIDIPlayer
// "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
 
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;
	#timeMap = [];
 
	// 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.#timeMap = msg.timeMap;
 
					const parseTime = performance.now() - start;
					this.emit("fileLoaded", { parseTime });
					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.#timeMap = [];
		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);
          result.tempoEvents.forEach(event => tempoEvents.push(event));
        }
 
        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;
        const timeMap = [{ tick: 0, time: 0, uspq: DEFAULT_TEMPO }];
 
        for (let i = 0; i < tempoMap.length; ++i) {
          const lastTimeData = timeMap[timeMap.length-1];
          const lastUspq = lastTimeData.uspq;
          const currentTempoEvent = tempoMap[i];
 
          const ticksSinceLast = currentTempoEvent.tick - lastTimeData.tick;
          const msSinceLast = (ticksSinceLast * (lastUspq / 1000)) / ppqn;
          const cumulativeTime = lastTimeData.time + msSinceLast;
 
          timeMap.push({
            tick: currentTempoEvent.tick,
            time: cumulativeTime,
            uspq: currentTempoEvent.uspq
          });
        }
 
        const lastTimeData = timeMap[timeMap.length - 1];
        const ticksInFinalSegment = totalTicks - lastTimeData.tick;
        const msInFinalSegment = (ticksInFinalSegment * (lastTimeData.uspq / 1000)) / ppqn;
        totalMs = lastTimeData.time + msInFinalSegment;
 
        const songTime = totalMs / 1000;
        const totalEvents = tracks.map(t => t?.eventCount || 0).reduce((a, b) => a + b, 0);
 
        return { totalEvents, totalTicks, songTime, ppqn, numTracks, timeMap };
      }
 
      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();
 
        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 eventPointers = [];
        let totalEventsToPlay = 0;
 
        for (let i = 0; i < tracks.length; ++i) {
          const track = tracks[i];
          if (!track) continue;
 
          let ptr = trackEventPointers[i];
          const startPtr = ptr;
 
          while (ptr < track.eventCount && track.view.getUint32(ptr * EVENT_SIZE) <= currentTick) {
            const eventData = track.view.getUint32((ptr * EVENT_SIZE) + 4);
            const eventTypeCode = eventData >> 24;
 
            // handle tempo changes immediately
            if (eventTypeCode === EVENT_CODE.SET_TEMPO) {
              const eventTick = track.view.getUint32(ptr * EVENT_SIZE);
              const uspq = eventData & 0xFFFFFF;
              const oldTempo = currentTempo * playbackSpeed;
              const msAfterTempoEvent = ((currentTick - eventTick) * (oldTempo / 1000)) / ppqn;
 
              startTick = eventTick;
              startTime = performance.now() - msAfterTempoEvent;
              currentTempo = uspq / playbackSpeed;
            }
            ++ptr;
          }
 
          const numEventsInTrack = ptr - startPtr;
          if (numEventsInTrack > 0) {
            eventPointers.push({ trackIndex: i, start: startPtr, count: numEventsInTrack });
            totalEventsToPlay += numEventsInTrack;
          }
        }
 
        if (totalEventsToPlay > 0) {
          const buffer = new ArrayBuffer(totalEventsToPlay * EVENT_SIZE);
          const destView = new Uint8Array(buffer);
          let destOffset = 0;
 
          for (const pointer of eventPointers) {
            const track = tracks[pointer.trackIndex];
            const sourceByteOffset = pointer.start * EVENT_SIZE;
            const sourceByteLength = pointer.count * EVENT_SIZE;
 
            const sourceView = new Uint8Array(track.packedBuffer, sourceByteOffset, sourceByteLength);
 
            destView.set(sourceView, destOffset);
            destOffset += sourceByteLength;
 
            trackEventPointers[pointer.trackIndex] += pointer.count;
          }
          postMessage({ type: 'events', buffer: buffer, currentTick }, [buffer]);
        }
      }
 
      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,
                timeMap: result.timeMap
              });
              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) {
          console.error(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;
 
					const view = new DataView(msg.buffer);
					const numEvents = msg.buffer.byteLength / EVENT_SIZE;
 
					for (let i = 0; i < numEvents; i++) {
						const byteOffset = i * EVENT_SIZE;
						const eventTick = view.getUint32(byteOffset);
						const eventData = view.getUint32(byteOffset + 4);
 
						const eventTypeCode = eventData >> 24;
						const event = {
							tick: eventTick
						};
 
						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);
		};
	}
 
	getTimeAtTick(tick) {
		if (!this.#timeMap || this.#timeMap.length === 0 || this.#ppqn === 0) {
			return 0;
		}
 
		let low = 0;
		let high = this.#timeMap.length - 1;
		let bestMatchIndex = 0;
 
		while (low <= high) {
			const mid = Math.floor(low + (high - low) / 2);
			const midTick = this.#timeMap[mid].tick;
 
			if (midTick <= tick) {
				bestMatchIndex = mid;
				low = mid + 1;
			} else {
				high = mid - 1;
			}
		}
 
		const segment = this.#timeMap[bestMatchIndex];
 
		const ticksSinceSegmentStart = tick - segment.tick;
		const msSinceSegmentStart = (ticksSinceSegmentStart * (segment.uspq / 1000)) / this.#ppqn;
 
		const totalMs = segment.time + msSinceSegmentStart;
		return totalMs / 1000;
	}
 
 
	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
			});
		}
	}
}
 
let player = new JMIDIPlayer()
player.isLoaded = ()=>!player.isLoading && player.trackCount > 0

const charLimit = 512
function send(...msgs) {
	const arrow = '↱ ',
	subarrow = "↳ ",
	lineConnector = '❘';
	const lineLimit = 3;
	msgs = msgs.join(` ${sep} `)
	msgs = arrow + msgs
	msgs = msgs.split('\n')

	if (msgs.length > lineLimit)
		MPP.chat.send(`${arrow}[long (multi-line) message (${msgs.length} lines)]`)

	msgs.forEach((msg, msgI)=>{
		// switch functions depending on amount of messages
		let sendFunc =
		msgs.length > lineLimit ?
		(text, bypassArrow = false)=>{
			MPP.chat.receive({
				t: Date.now(),
				p: {
					id: MPP.client.getOwnParticipant().id,
					_id: MPP.client.getOwnParticipant()._id,
					color: MPP.client.getOwnParticipant().color,
					name: `(Only visible to you) ${sep} ${MPP.client.getOwnParticipant().name}`,
				},
				a: ((bypassArrow || msgI === 0) ? '' : subarrow) + text
			})
		}
		:
		(text, bypassArrow = false)=>{
			MPP.chat.send(((bypassArrow || msgI === 0) ? '' : subarrow) + text);
		}
 
		// send connector line if text is empty
		if (msg.replaceAll(' ', '').length === 0) {
			sendFunc(lineConnector, true)
			return
		}

		if (msg.length > charLimit) {
			let totalString = [];
			let splitter = msg.split(' ').length <= 8 ? '' : ' '
			let split = msg.split(splitter);

			if (Math.ceil(msg.length / charLimit) > lineLimit)
				MPP.chat.send(`${arrow}[long message (${Math.ceil(msg.length / charLimit)} lines)]`)

            split.forEach(subsplit => {
                totalString.push(subsplit)
                if ((subarrow + totalString.join(splitter)).length > charLimit) {
                    totalString.pop();
                    sendFunc(totalString.join(splitter));
                    totalString = [];
                }
            })
			if (totalString.length > 0) sendFunc(totalString.join(splitter));
		} else {
			sendFunc(msg)
		}
	})
}

function midiLoading() {
	if (!player.isPlaying) MPP.press('as3', 1);
	setTimeout(() => {
		MPP.release('as3');
		if (!player.isPlaying) MPP.press('cs4', 1)
	}, 250);
	setTimeout(() => {
		MPP.release('cs4');
		if (!player.isPlaying) MPP.press('fs4', 1)
	}, 500);
	setTimeout(() => {
		MPP.release('fs4');
	}, 750);
	setTimeout(() => {
		MPP.release('c4')
	}, 1000)
}
 
let loadnotes
function loadNotes(start) {
	if (start) {
		midiLoading()
		loadnotes = setInterval(midiLoading, 1e3)
 
	} else clearInterval(loadnotes)
}
function validUrl(url) {
	let result
	try {
		new URL(url)
		result = true
	} catch {
		result = false
	}
	return result
}
const signal = new AbortController().signal
async function loadStuff(a) {
	function validMidi(arrbuf) {
		const decoder = new TextDecoder('utf8');
		let textdata = decoder.decode(arrbuf);
		if (textdata.startsWith('MThd'))
			return true
		else
			return false
	}
 
	let out = await a.arrayBuffer();
	if (validMidi(out))
		return out
	else
		throw new Error('The file provided is invalid, as it doesn\'t start with the header "MThd".')
}
 
let playing = {}
async function playMidiFromUrl(url) {
	eventsplayed = 0
	let fetchtime
	let fetchstart
	let parsetime
	loadNotes(true)
	setTimeout(() => { }, 50)
	let result
	fetchtime = 0
	fetchstart = Date.now()
	console.log('trying to play', url)
	if (validUrl(url)) {
		if (player.isPlaying) {
			player.stop();
			player.unload()
		}
		try {
			fetch(url, {
				method: 'get',
				signal: signal
			}).then(async (a) => {
				try {
					let out = await loadStuff(a)
					return out
				} catch (err) {
					send("There was an error when playing the file.", `Error: \`${err.message}\``)
					console.log(err)
					loadNotes(false)
					return false
				}
			}).then(async a => {
				fetchtime = Date.now() - fetchstart
				if (a) {
					let parsestart = Date.now()
					try {
						await player.loadArrayBuffer(a)
						player.play()
						parsetime = Date.now() - parsestart
					}
					catch (err) {
						parsetime = Date.now() - parsestart
						result = false
						send("There was an error when playing the file.", `Error: \`${err}\``)
						console.err(err)
						loadNotes(false)
						queue.shift()
						if (queue.length > 0) {
							setTimeout(() => {
								send(`Downloading next song on the queue... (\`${getFileName(queue[0])}\`)`)
								playMidiFromUrl(queue[0])
							}, 1e3)
						} else {
							send('Queue is now empty')
						}
						return
					}
					console.log(`Fetch time: ${fetchtime}ms\nParse time: ${parsetime}ms`)
					send(`Fetched MIDI in ${fetchtime}ms.`, `Parsed MIDI in ${parsetime}ms.`, `Now playing \`${decodeURIComponent(url.split("/")[url.split("/").length - 1])}\`.`)
					playing.name = getFileName(url)
					playing.url = url
					loadNotes(false)
				} else {
					loadNotes(false)
					return
				}
			})
		} catch (err) {
			result = false
			send('Error')
			loadNotes(false)
			queue.shift()
			if (queue.length > 0) {
				setTimeout(() => {
					send(`Downloading next song on the queue... (\`${getFileName(queue[0])}\`)`)
					playMidiFromUrl(queue[0])
				}, 1e3)
			} else {
				send('Queue is now empty')
			}

		}
	} else {
		result = false
		send("There was an error when playing the file.", "Error: `Invalid URL`")
		queue.shift()
		if (queue.length > 0) {
			setTimeout(() => {
				send(`Downloading next song on the queue... (\`${getFileName(queue[0])}\`)`)
				playMidiFromUrl(queue[0])
			}, 1e3)
		} else {
			send('Queue is now empty')
		}
	}
	loadNotes(false)
	return result
}
let looping = false
let sustain = false
let transpose = 0
let volume = 1
let jevents = {
	noteon: 9,
	noteoff: 8,
	ctrlChange: 0x0B,
	setTempo: 0x51,
	end: 0x2F,
	meta: 0xFF
};
let eventsplayed = 0
let keys = Object.keys(MPP.piano.keys)
let currenttick
player.on('midiEvent', (event) => {
	eventsplayed++
	currenttick = event.tick
	if (event.type == jevents.noteon && event.velocity !== 0 && event.channel !== 9) {
		if (volume > 1) {
			if (volume % 1 !== 0)
				MPP.press(keys[event.note - 21 + transpose], (event.velocity / 127) * volume % 1)
			for (let i = 0; i < Math.trunc(volume); i++) {
				MPP.press(keys[event.note - 21 + transpose], (event.velocity / 127) * 1)
			}
		} else {
			MPP.press(keys[event.note - 21 + transpose], (event.velocity / 127) * volume)
		}
	} else if (event.type == jevents.noteoff || event.velocity == 0) {
		if (!sustain) MPP.release(keys[event.note - 21 + transpose])
	}
})
player.on('endOfFile', async () => {
	if (looping) {
		setTimeout(() => {
			eventsplayed = 0;
			player.seek(0);
			player.play();
		}, 15)
	} else {
		loadNotes(false)
		send(`Finished playing track. Duration: \`${player.songTime.toFixed(2)}s\``, ` Played \`${eventsplayed}\` out of \`${player.totalEvents}\` (${(eventsplayed / player.totalEvents * 100).toFixed(2)}%) events.`)

		player.unload()
		playing = {}
		queue.splice(0, 1)
		if (queue.length > 0) {
			setTimeout(() => {
				send(`Downloading next song on the queue... (\`${getFileName(queue[0])}\`)`)
				playMidiFromUrl(queue[0])
			}, 500)
		} else {
			send('Queue is now empty.')
		}
		eventsplayed = 0
		keys.forEach(key => {
			MPP.release(key)
		})
	}
})

function getFileName(url) {
	let filename = ""
	filename = url.split('/')
	filename = filename[filename.length - 1]
	filename = decodeURIComponent(filename)
	return filename
}

let queue = []
const categories = {
	'midi': {
		icon: '🎹',
		label: 'MIDI'
	},
	'info': {
		icon: '📎',
		label: 'Info'
	},
	'pref': {
		icon: '🔧',
		label: 'Preferences'
	}
}
const cmds = {
	help: {
		aliases: ['h'],
		category: 'info',
		about: "Shows a list of categories, a list of commands inside a category, or the info of a specific command.",
		func: (...args) => {
			let ogcmd = args[0]
			if (args.length == 1)
				send(`Categories (currently ${public ? 'public' : 'private'})`, createcmdstr(), `Use \`${ogcmd} <categ.>\` to get a list of commands from a specific category.`)
			else {
				let categs = []
				let targetCateg;
				const categ = args[1].toLowerCase();
				for (let i = 0; i < Object.keys(categories).length; i++) {
					const name = Object.keys(categories)[i]
					const info = Object.values(categories)[i]
					categs.push(name.toLowerCase())
					categs.push(info.label.toLowerCase())
					
					if (categ === info.label.toLowerCase() || categ === name.toLowerCase()) {
						targetCateg = info.label
					}
				}

				if (categs.includes(categ)) {
					send(`Commands in ${targetCateg}`, createcmdstr(targetCateg), `Use \`${ogcmd} <command>\` to get info about a specific command.`)
					return

				} else if (Object.keys(cmds).includes(args[1])) {
					const cmdinfo = cmds[args[1]]
					const aliases = cmdinfo.aliases.length > 0 ? `\`${prefix}${cmdinfo.aliases.join("`, `" + prefix)}\`` : "*(none)*"

					send(`*\`${prefix}${args[1]}\`*`, `Category: ${categories[cmdinfo.category].label.toLowerCase()}`, `Description: ${cmdinfo.about}`, `Aliases: ${aliases}`, `Owner only? ${cmdinfo.locked ? 'yes' : 'no'}`)
					return

				} else {
					for (let i = 0; i < Object.keys(cmds).length; i++) {
						const cmd = Object.keys(cmds)[i]
						const info = Object.values(cmds)[i]

						if (info.aliases.includes(args[1])) {
							let aliases = info.aliases.length > 0 ? `\`${prefix}${info.aliases.join("`, `" + prefix)}\`` : "*(none)*"
							send(`*\`${prefix}${cmd}\`* (searched for ${args[1]})`, `Category: ${categories[info.category].label.toLowerCase()}`, `Description: ${info.about}`, `Aliases: ${aliases}`, `Owner only? ${info.locked ? 'yes' : 'no'}`)
							return
						}
					}
				}
				// fallback
				send("That category (or command) does not exist.")
			}
		}
	},
	about: {
		aliases: ['ab'],
		category: 'info',
		about: "Shows you basic information about this script.",
		func: () => {
			send(`${name} v${version} by ${author}`, `ccjt's site: ${link}`, `JMIDIPlayer module originally made by seq.wtf`, `Get this userscript at https://greasyfork.org/en/scripts/554578-tealmidiplayer-jmp-only`)
		}
	},
	play: {
		aliases: ['p'],
		category: 'midi',
		about: "Clears the queue and plays a MIDI file from the internet via a link.",
		func: (...args) => {
			let ogcmd = args[0]
			if (args.length === 1)
				send(`Please specify a direct download URL to the desired MIDI file to play.`, `Usage: \`${ogcmd} <URL>\``)
			else {
				let callback = () => {
					queue = []
					player.stop()
					keys.forEach(key => {
						MPP.release(key)
					})
					send(`Downloading \`${getFileName(args[1])}\`...`)
					queue.push(args[1])
					playMidiFromUrl(args[1])
				}
				if (player.isPlaying)
					setTimeout(callback, 250);
				else
					callback();
			}
		}
	},
	queueup: {
		aliases: ['addtoqueue', 'queuenext', 'next', 'qa', 'qu', 'up', 'addnext', 'nextup', 'nu'],
		category: 'midi',
		about: "Adds a MIDI file link to the track queue.",
		func: (...args) => {
			let ogcmd = args[0]
			if (args.length === 1)
				send(`Please specify a direct download URL to the desired MIDI file to add to the queue.`, `Usage: \`${ogcmd} <URL>\``)
			else {
				if (queue.length > 0) {
					queue.push(args[1])
					send(`Added \`${getFileName(args[1])}\` to queue.`, `Amount of queued tracks: ${queue.length}`)
				} else {
					cmds.play.func(...args)
				}
			}
		}
	},
	stop: {
		aliases: ['s'],
		category: 'midi',
		about: "Stops the current track and clears the queue.",
		func: () => {
			if (!player.isLoaded() && !player.isPlaying) {
				send("Nothing to stop.")
				return
			}

			queue = []
			loadNotes(false)
			player.stop()
			player.unload()
			keys.forEach(key => {
				MPP.release(key)
			})
			send("Stopped playing.")
		}
	},
	skip: {
		aliases: ['sk'],
		category: 'midi',
		about: "Skips to the next track in the queue.",
		func: () => {
			if (queue.length === 0)
				send('The queue is already empty!')

			else if (queue.length === 1) {
				queue = []
				player.stop()
				player.unload()
				keys.forEach(key => {
					MPP.release(key)
				})

				send('Skipped track.', 'Queue is now empty.')

			} else if (queue.length > 1) {
				queue.shift();
				player.stop()
				player.unload()
				keys.forEach(key => {
					MPP.release(key)
				})
				playMidiFromUrl(queue[0])

				setTimeout(()=>{
					send('Skipped track.', `Downloading \`${getFileName(queue[0])}\`...`)
				},250)
			}
		}
	},
	sustain: {
		aliases: ['sus'],
		category: 'midi',
		about: 'Toggles sustain on or off.',
		func: () => {
			sustain = !sustain
			send(`Sustain is now ${sustain ? 'on' : 'off'}.`)
			if (!sustain) {
				keys.forEach(key => {
					MPP.release(key)
				})
			}
		}
	},
	volume: {
		aliases: ['vol', 'v'],
		category: 'midi',
		about: "Adjusts the track's volume.",
		func: (...args) => {
			let ogcmd = args[0]
			const minVol = 0, maxVol = 3;
			if (args.length === 1) {
				send(`Volume is currently set to \`${volume}\`.`, `Please specify a volume to set to.`, `Range: \`${minVol.toFixed(1)} to ${maxVol.toFixed(1)}\``, `Usage: \`${ogcmd} <volume from ${minVol.toFixed(1)} to ${maxVol.toFixed(1)}>\``)
			} else {
				args[1] = parseFloat(args[1])
				if (args[1] >= minVol && args[1] <= maxVol) {
					volume = args[1]
					send(`Volume set to \`${volume}\`.`)
				} else {
					send(`That value is out of range. Please specify a value between \`${minVol.toFixed(1)}\` and \`${maxVol.toFixed(1)}\`.`)
				}
			}
		}
	},
	speed: {
		aliases: ['sp', 'ps'],
		category: 'midi',
		about: "Changes the playback speed.",
		func: (...args) => {
			let ogcmd = args[0]
			const minSpeed = 0.1, maxSpeed = 2;
			if (args.length === 1) {
				send(`Playback speed is currently set to \`x${player.playbackSpeed} (${Math.trunc(player.playbackSpeed * 100)}%)\`.`, `Please specify a speed to set to.`, `Range: \`x${minSpeed.toFixed(1)} (${minSpeed * 100}%) to ${maxSpeed.toFixed(1)}x (${maxSpeed * 100}%)\``, `Usage: \`${ogcmd} <value from ${minSpeed.toFixed(1)} to ${maxSpeed.toFixed(1)}>\``)
			} else {
				args[1] = parseFloat(args[1])
				if (args[1] >= minSpeed && args[1] <= maxSpeed) {
					player.playbackSpeed = args[1]
					send(`Playback speed set to \`x${player.playbackSpeed} (${Math.trunc(player.playbackSpeed * 100)}%)\`.`)
				} else {
					send(`That value is out of range. Please specify a value between \`${minSpeed.toFixed(1)}\` and \`${maxSpeed.toFixed(1)}\`.`)
				}
			}
		}
	},
	toggle: {
		aliases: ['to', 'pause', 'pa'],
		category: 'midi',
		about: "Switches between pausing and playing a track.",
		func: () => {
			if (player.isLoaded()) {
				if (player.isPlaying) {
					player.pause()
					keys.forEach(key => {
						MPP.release(key)
					})
					send("Paused track.")
				} else {
					player.play()
					send("Resumed track.")
				}
			} else send("Nothing loaded yet.")
		}
	},
	resume: {
		aliases: ['re'],
		category: 'midi',
		about: "Resumes a track.",
		func: () => {
			if (player.isLoaded()) {
				player.play()
				send("Resumed track.")
			} else send("Nothing loaded yet.")
		}
	},
	transpose: {
		aliases: ['tr'],
		category: 'midi',
		about: "Changes the transposition (key) of the current track.",
		func: (...args) => {
			let ogcmd = args[0]
			if (args.length === 1)
				send(`Transposition (key) is currently set to \`${transpose}\`.`, `Please specify a value between \`-24\` and \`36\`.`, `Usage: \`${ogcmd} <value>\``)
			else {
				args[1] = parseInt(args[1])
				if (isNaN(args[1]))
					send("Please specify a *number*.")
				else
					if (args[1] > 36 || args[1] < -24)
						send("Please specify a value under `36` and above `-24`.")
					else {
						transpose = args[1]
						send(`Transposition (key) is now set to \`${transpose}\`.`)
					}
			}
		}
	},
	loop: {
		aliases: ['l'],
		category: 'midi',
		about: "Toggles between looping and not looping.",
		func: () => {
			looping = !looping
			if (looping) send("Now looping track.")
			else send("Stopped looping track.")
		}
	},
	track: {
		aliases: ["t"],
		category: 'info',
		about: "Shows info about the track that's currently playing.",
		func: () => {
			if (player.isLoaded()) {
				// format progress bar
				let remaining = ((currenttick * (60 * 1000 / (player.currentTempo * player.ppqn)) / 1000) / player.songTime) * 100
				let progressbar = []
				for (let i = 0; i < Math.trunc(remaining / 10); i++) {
					progressbar.push('█')
				}
 
				// define song time info
				let secondsstr = ""
				let played = currenttick * (60 * 1000 / (player.currentTempo * player.ppqn)) / 1000
				let playedsecondsstr = ""
				let songtime = player.songTime
 
				// format song time
				if (songtime % 60 > 0) secondsstr = ` ${parseInt(songtime % 60)}s`
				if (played % 60 > 0) playedsecondsstr = ` ${parseInt(played % 60)}s`
 
				// format song length
				let length = songtime > 60 ? `${parseInt(songtime / 60)}m ${secondsstr}` : `${songtime.toFixed(3)}s`
				let lengthplayed = played > 60 ? `${parseInt(played / 60)}m ${playedsecondsstr}` : `${played.toFixed(3)}s`
				
				// aliases
				let totalevents = player.totalEvents
				let bpm = Math.trunc(player.currentTempo)
				let playbackspeed = player.playbackSpeed
 
 
				// dayum, he chonkeh!
				send( 
					`\`${(player.isLoaded() && !player.isPlaying) ? `PAUSED ${sep} ` : ''}[${progressbar.join('').padEnd(10, " ")}]`, 
					`${remaining.toFixed(2)}% (${lengthplayed}) played`,
					`Track name: ${playing.name}`,
					`Track BPM: ${bpm}`,
					`Track length: ${length}`,
					`Played ${eventsplayed} out of ${totalevents} events (${(eventsplayed / totalevents).toFixed(2)}%)`,
					`Looping: ${looping ? 'yes' : 'no'}`,
					`Playback speed: x${playbackspeed} (${Math.trunc(playbackspeed * 100)}%)\``
				)
			} else send("Nothing loaded yet.")
		}
	},
	notequota: {
		aliases: ['nq'],
		category: 'info',
		about: "Shows NoteQuota status.",
		func: () => {
			send(`Maximum points: ${MPP.noteQuota.max}\nCurrent points: ${MPP.noteQuota.points} (${Math.trunc((MPP.noteQuota.points / MPP.noteQuota.max) * 100)}%)`)
		}
	}, 
	queue: {
		aliases: ['q'],
		category: 'info',
		about: "Shows the list of queued tracks.",
		func: () => {
			let qFormatted = [];
			for (let i = 0; i < queue.length; i++) {
				qFormatted.push(getFileName(queue[i])) 
			}
			send(`[${queue.length}] Queue${looping ? " (currently looping, won't go to next)" : ''}: ${queue.length > 0 ? `\`${qFormatted.join('`, `')}\`` : '*(empty)*'}`)
		}
	},
	prefix: {
		locked: true,
		aliases: ['pre'],
		category: 'pref',
		about: "Defines the prefix of the MIDI player.",
		func: (...args) => {
			if (args.length <= 1) {
				send(`Prefix is currently set to \`${prefix}\`.`, "Please specify a prefix to set to. (Note: anything in the prefix that's after a space is ignored.)")
			} else {
				if (args[1].length >= 40) {
					send(`Prefix too long! (wanted less than 40 characters, but got ${args[1].length} characters instead)`)
					return
				}
				localStorage.tmp_prefix = prefix = args[1].toLowerCase()
				send(`Prefix is now set to ${prefix === '' ? 'nothing' : `\`${prefix}\``}. Use \`${prefix}help\` to see commands.`)
			}
		}
	},
	public: {
		locked: true,
		category: 'pref',
		aliases: ['pub', 'pu'],
		about: "Toggles between commands being public or private.",
		func: () => {
			public = localStorage.tmp_public = !public
			send(`Commands are now ${public ? 'public' : 'private'}.`)
		}
	},
	checkupdate: {
		locked: true,
		category: 'pref',
		aliases: ['updatecheck', 'updates', 'update', 'check', 'upd', 'chk'],
		about: "Checks for updates.",
		func: async () => {
			let status = await checkUpdate(false, true)
			switch (status) {
				case 'stable':
					send(`You are using a stable version of ${name} (${version}). `)
					break;
				case 'outdated':
					send(`You are using an outdated version of ${name} (${version}). Please update here: https://greasyfork.org/en/scripts/554578-tealmidiplayer-jmp-only`)
					break;
				case 'beta':
					send(`You are using an unreleased version of ${name} (${version}). Expect instability and bugs.`)
					break;

				default:
					send(`Unknown update status "${status}"`)
					break;
			}
		}
	}
}
function createcmdstr(categ) {
	if (!categ) {
		let result = []
		for (let i = 0; i < Object.keys(categories).length; i++) {
			const name = Object.keys(categories)[i]
			const info = Object.values(categories)[i]

			// expected output: "<categ. emoji> <categ. label/name> (`<categ internal name>`)"
			result.push(`${info.icon} ${info.label} (\`${name}\`)`)
		}

		return result.join(', ')
	} else {
		categ = categ.toLowerCase()

		let targetCateg;

		let categs = []
		for (let i = 0; i < Object.keys(categories).length; i++) {
			const name = Object.keys(categories)[i]
			const info = Object.values(categories)[i]
			categs.push(name.toLowerCase())
			categs.push(info.label.toLowerCase())
			if (categ === info.label.toLowerCase() || categ === name.toLowerCase())
				targetCateg = name
		}

		if (!categs.includes(categ)) return 'Invalid category';

		let result = []
		for (let i = 0; i < Object.keys(cmds).length; i++) {
			const cmd = Object.keys(cmds)[i]
			const cmdinfo = Object.values(cmds)[i]

			// console.log(result)
			// skip cmd if its category isn't the target category
			if (cmdinfo.category !== targetCateg) continue;

			// expected output: "`;<cmd>`"
			let str = `\`${prefix}${cmd}\``

			// expected output: "`;<cmd>` (`;<alias>`)"
			// or alternatively: "`;<cmd>` (`;<alias1>`, `;<alias2>`, ...)"
			if (cmdinfo.aliases.length > 0) {
				str += ` (\`${prefix}${cmdinfo.aliases.join(`\`, \`${prefix}`)}\`)`
			}
			result.push(str)
		}

		return result.join(', ')
	}
}

if (!localStorage.getItem('tmp_public')) localStorage.tmp_public = 'false'
let public = localStorage.tmp_public === 'true';
MPP.client.on('a', (data) => {
	if (!public && data.p._id !== MPP.client.getOwnParticipant()._id) return;
	let args = data.a.split(' ')
	let cmd = args[0].toLowerCase()
	if (cmd.startsWith(prefix)) {
		cmd = cmd.substring(prefix.length)
		if (Object.keys(cmds).includes(cmd)) {
			cmds[cmd].func(...args)
		} else {
			for (let i = -1; ++i < Object.keys(cmds).length;) {
				if (Object.values(cmds)[i].aliases.includes(cmd)) {
					if (cmds[Object.keys(cmds)[i]].locked && data.p._id !== MPP.client.getOwnParticipant()._id) {
						send('You do not have permission to use this command.')
						return
					}
					cmds[Object.keys(cmds)[i]].func(...args)
				}
			}
		}
	}
})
 
// custom msg handler
MPP.client.sendArray([{ m: "+custom" }])
MPP.client.on('custom', (evtn) => {
	const data = evtn.data
	if (data.script !== 'tmp') return
	switch (data.type) {
		case 'ann.':
			notif(`<h2>Announcement</h2><br>${typeof data.text === 'string' ? data.text : '<missing>'}<br><small>(click to close)</small>`, true, 60e3)
			break;

		default:
			console.log(`unknown notif "${data.type}"`, data)
			break;
	}
})

// set to utf-8
let metaTag = document.createElement('meta')
metaTag.charset = 'UTF-8'
document.head.prepend(metaTag)

// notif API
function notif(html, clickable = false, timeout = 15e3) {
	let notif = document.createElement('a')
	notif.innerHTML = html
	notif.style.cssText = `
		color: #fff;
		background-color: #666a;
		box-shadow: 0px 0px 15px 5px #0008;
		backdrop-filter: saturate(150%) blur(16px);
		font-size: 24px;
		font-family: Arial, monospace;
		padding: 5px;
		margin: 15px;
		border-radius: 10px;
		position: fixed;
		left: 50%;
		transform: translate(-50%, -200%);
		transition: opacity 1s ease, transform .75s ease;
		white-space: pre;
		z-index: calc(infinity);
		text-align: center;
		${clickable ? 'cursor: pointer;' : 'cursor: default;'}
	`
	const hidenotif = () => {
		if (!notif.isConnected) return
		notif.style.opacity = "0%"
		notif.style.transform = "translate(-50%, -100%)"
		setTimeout(() => {
			notif.remove()
		}, 1.5e3)
	}
	if (clickable) notif.onmousedown = hidenotif
	document.body.prepend(notif)
	setTimeout(() => {
		notif.style.transform = "translate(-50%, 0%)"
		setTimeout(hidenotif, timeout)
	}, 50)
	return notif
}

// load notif
const loadNotif = notif(
	`<b>${name} by ${author}</b> loaded.\nUse <b><i>${prefix}</i>help</b> to see commands${public ? ' <i>(Commands are currently public!)</i>' : ''}.\n<small><i>(click to close popup)</i></small>`,
	true,
	5e3
)
 
 
// update check
async function checkUpdate(isInterval, silent = false) {
	let r
	try {
		// try using main url
		r = await fetch(`https://update.greasyfork.org/scripts/554578/JMIDIPlayer.user.js?nocache=${Math.random()}`)
	} catch {
		// use backup url if blocked
		r = await fetch(`https://update.greasyfork.org/scripts/554578/TealMIDIPlayer%20%28JMP%20only%29.user.js?nocache=${Math.random()}`)
	}
	const code = (await r.text()).split('\n')
	for (let i = 0; i < code.length; i++) {
		const line = code[i]
		if (!line.startsWith('//')) continue
		if (line.includes('@version')) {
			let versionNum = line.split(' ')
			versionNum = versionNum[versionNum.length - 1]
			let splitNewNum = versionNum.split('.')
			let splitMyNum = version.split('.')

			if (silent) {
				let results = [];
				for (let i = 0; i < splitMyNum.length; i++) {
					const newNum = parseInt(splitNewNum[i])
					const myNum = parseInt(splitMyNum[i])
					if (myNum === newNum)
						results.push('=')
					else if (myNum > newNum)
						results.push('>')
					else if (myNum < newNum)
						results.push('<')
					else
						results.push('?')
				}
				let result = 'j';
				let isBeta = results.includes('>') && (results.includes('<') ? (results.indexOf('>') < results.indexOf('<')) : true)
				let isOutdated = results.includes('<') && (results.includes('>') ? (results.indexOf('<') < results.indexOf('>')) : true)
				let isStable = results.includes('=') && !results.includes('<') && !results.includes('>')

				if (isBeta)
					result = 'beta'
				else if (isOutdated)
					result = 'outdated'
				else if (isStable)
					result = 'stable'

				return result
			}

			for (let i = 0; i < splitMyNum.length; i++) {
				if (parseInt(splitNewNum[i]) !== parseInt(splitMyNum[i])) {
					const callback2 = () => {
						if (isInterval && parseInt(splitNewNum[i]) < parseInt(splitMyNum[i])) return
						notif(
							parseInt(splitNewNum[i]) > parseInt(splitMyNum[i]) ?
								`Your script is <b>outdated!</b>\nNewest version: ${versionNum} - Your version: ${version}\nPlease <a href='https://greasyfork.org/en/scripts/554578-tealmidiplayer-jmp-only' target='_blank'>update your script here</a>.\n<small><i>(This popup will automatically close in 15 seconds.)</i></small>`
								:
								`You are using an unreleased version of <b>${name}</b>.\nExpect instability and bugs.`,
							false,
							15e3
						)
					}

					if (loadNotif.isConnected) {
						let msgint = setInterval(() => {
							if (loadNotif.isConnected) return
							callback2()
							clearInterval(msgint)
						}, .5e3)
					} else {
						callback2()
					}
					break
				}
			}
		}
		if (line.startsWith('// ==/UserScript==')) break
	}
	return code
}
checkUpdate()
setInterval(() => checkUpdate(true), 120e3)