Last active
February 10, 2026 02:06
-
-
Save mrdoob/ac671582e5a9bc71248ba69e2cfa49ca to your computer and use it in GitHub Desktop.
XMP
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>XMP</title> | |
| <script src="jsxm/xmeffects.js"></script> | |
| <script src="jsxm/xm.js"></script> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| background: #000; | |
| color: #fff; | |
| font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; | |
| font-size: 12px; | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| /* Header */ | |
| #header { | |
| padding: 16px 20px 12px; | |
| border-bottom: 1px solid #222; | |
| display: flex; | |
| align-items: center; | |
| gap: 20px; | |
| flex-shrink: 0; | |
| } | |
| #header h1 { | |
| font-size: 14px; | |
| font-weight: normal; | |
| color: #666; | |
| letter-spacing: 4px; | |
| text-transform: uppercase; | |
| } | |
| #now-playing { | |
| color: #0f0; | |
| flex: 1; | |
| } | |
| #song-title { | |
| color: #555; | |
| font-size: 11px; | |
| } | |
| /* Controls */ | |
| #controls { | |
| padding: 10px 20px; | |
| border-bottom: 1px solid #222; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| flex-shrink: 0; | |
| } | |
| #controls button { | |
| background: none; | |
| border: 1px solid #333; | |
| color: #aaa; | |
| font-family: inherit; | |
| font-size: 11px; | |
| padding: 3px 14px; | |
| cursor: pointer; | |
| } | |
| #controls button:hover { | |
| border-color: #666; | |
| color: #fff; | |
| } | |
| #btn-repeat.active { | |
| border-color: #0a0; | |
| color: #0f0; | |
| } | |
| #controls button.flash { | |
| border-color: #666; | |
| color: #fff; | |
| } | |
| #position { | |
| color: #555; | |
| font-size: 11px; | |
| margin-left: 8px; | |
| } | |
| #volume-wrap { | |
| margin-left: auto; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| color: #555; | |
| font-size: 11px; | |
| } | |
| #volume-slider { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 80px; | |
| height: 4px; | |
| background: #333; | |
| border-radius: 2px; | |
| outline: none; | |
| cursor: pointer; | |
| } | |
| #volume-slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 10px; | |
| height: 10px; | |
| background: #888; | |
| border-radius: 50%; | |
| } | |
| #volume-slider::-moz-range-thumb { | |
| width: 10px; | |
| height: 10px; | |
| background: #888; | |
| border-radius: 50%; | |
| border: none; | |
| } | |
| /* Main grid: 2x2 */ | |
| #main { | |
| flex: 1; | |
| display: grid; | |
| grid-template-columns: 1fr 360px; | |
| grid-template-rows: auto 1fr; | |
| overflow: hidden; | |
| } | |
| /* Top-left: Scopes */ | |
| #scopes { | |
| border-bottom: 1px solid #222; | |
| border-right: 1px solid #222; | |
| display: flex; | |
| flex-wrap: wrap; | |
| overflow: hidden; | |
| } | |
| .scope-box { | |
| position: relative; | |
| cursor: pointer; | |
| } | |
| .scope-box.muted { | |
| opacity: 0.2; | |
| } | |
| .scope-box canvas { | |
| display: block; | |
| } | |
| .scope-label { | |
| position: absolute; | |
| top: 2px; | |
| left: 4px; | |
| font-size: 9px; | |
| color: #333; | |
| pointer-events: none; | |
| } | |
| .scope-box.muted .scope-label { | |
| color: #600; | |
| } | |
| /* Top-right: File list */ | |
| #list { | |
| border-bottom: 1px solid #222; | |
| overflow-y: auto; | |
| max-height: 40vh; | |
| } | |
| .track { | |
| padding: 4px 16px; | |
| cursor: pointer; | |
| display: flex; | |
| color: #666; | |
| font-size: 11px; | |
| } | |
| .track:hover { | |
| background: #0a0a0a; | |
| color: #aaa; | |
| } | |
| .track.active { | |
| color: #0f0; | |
| background: #001800; | |
| } | |
| .track-number { | |
| width: 28px; | |
| text-align: right; | |
| margin-right: 12px; | |
| color: #333; | |
| } | |
| .track.active .track-number { | |
| color: #060; | |
| } | |
| .track-name { | |
| flex: 1; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .folder-header { | |
| padding: 6px 16px; | |
| color: #444; | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| cursor: pointer; | |
| user-select: none; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .folder-header:hover { | |
| color: #666; | |
| } | |
| .folder-toggle { | |
| font-size: 8px; | |
| transition: transform 0.15s; | |
| display: inline-block; | |
| } | |
| .folder-header.collapsed .folder-toggle { | |
| transform: rotate(-90deg); | |
| } | |
| /* Bottom-left: Pattern */ | |
| #pattern-view { | |
| border-right: 1px solid #222; | |
| overflow-x: auto; | |
| overflow-y: hidden; | |
| position: relative; | |
| } | |
| #pattern-canvas { | |
| display: block; | |
| } | |
| /* Bottom-right: Instruments */ | |
| #instruments-panel { | |
| overflow-y: auto; | |
| padding: 8px 0; | |
| } | |
| #instruments-panel .section-title { | |
| padding: 0 12px 6px; | |
| color: #444; | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| } | |
| .instrument { | |
| padding: 1px 12px; | |
| color: #555; | |
| font-size: 11px; | |
| display: flex; | |
| } | |
| .instrument-num { | |
| width: 24px; | |
| text-align: right; | |
| margin-right: 10px; | |
| color: #333; | |
| } | |
| .instrument-name { | |
| color: #777; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="header"> | |
| <h1>XMP</h1> | |
| <span id="now-playing"></span> | |
| <span id="song-title"></span> | |
| </div> | |
| <div id="controls"> | |
| <button id="btn-prev" onclick="prevTrack()">Prev</button> | |
| <button id="btn-playpause" onclick="togglePlay()">Play</button> | |
| <button id="btn-next" onclick="nextTrack()">Next</button> | |
| <button onclick="stopTrack()">Stop</button> | |
| <span id="position"></span> | |
| <button id="btn-prevpat" onclick="prevPattern()">|<</button> | |
| <button id="btn-nextpat" onclick="nextPattern()">>|</button> | |
| <button id="btn-repeat" onclick="toggleRepeat()">Repeat</button> | |
| <div id="volume-wrap"> | |
| <span>vol</span> | |
| <input type="range" id="volume-slider" min="0" max="100" value="100"> | |
| </div> | |
| </div> | |
| <div id="main"> | |
| <div id="scopes"></div> | |
| <div id="list"></div> | |
| <div id="pattern-view"> | |
| <canvas id="pattern-canvas"></canvas> | |
| </div> | |
| <div id="instruments-panel"> | |
| <div class="section-title">Instruments</div> | |
| <div id="instruments-list"></div> | |
| </div> | |
| </div> | |
| <script> | |
| // Override buffer size for lower latency (16384 → 2048 = ~46ms instead of ~370ms) | |
| var _origCreateScriptProcessor = AudioContext.prototype.createScriptProcessor; | |
| AudioContext.prototype.createScriptProcessor = function(size, inp, out) { | |
| return _origCreateScriptProcessor.call(this, 2048, inp, out); | |
| }; | |
| // files[] holds { name, folder, handle } objects | |
| var files = []; | |
| var currentIndex = -1; | |
| var isPlaying = false; | |
| var animFrame; | |
| var scopeCanvases = []; | |
| var scopeContexts = []; | |
| var mutedChannels = {}; | |
| var initTempo = 6; | |
| var initBpm = 125; | |
| // -- Note names for pattern view -- | |
| var noteNames = ["C-","C#","D-","D#","E-","F-","F#","G-","G#","A-","A#","B-"]; | |
| function noteName(n) { | |
| if (n < 0) return "..."; | |
| if (n == 96) return "^^^"; | |
| return noteNames[n % 12] + ~~(n / 12); | |
| } | |
| function hex2(v) { | |
| if (v < 0) return ".."; | |
| var s = v.toString(16).toUpperCase(); | |
| return s.length < 2 ? "0" + s : s; | |
| } | |
| // -- Scan directory for .xm files -- | |
| async function scanDirectory(dirHandle, path) { | |
| var results = []; | |
| for await (var entry of dirHandle.values()) { | |
| if (entry.kind === 'file' && entry.name.match(/\.xm$/i)) { | |
| results.push({ name: entry.name.replace(/\.xm$/i, ''), folder: path, handle: entry }); | |
| } else if (entry.kind === 'directory') { | |
| var subPath = path ? path + '/' + entry.name : entry.name; | |
| var subResults = await scanDirectory(entry, subPath); | |
| results = results.concat(subResults); | |
| } | |
| } | |
| results.sort(function(a, b) { return a.name.localeCompare(b.name); }); | |
| return results; | |
| } | |
| async function openFolder() { | |
| var dirHandle = await window.showDirectoryPicker(); | |
| files = await scanDirectory(dirHandle, ''); | |
| buildTrackList(dirHandle.name); | |
| } | |
| function buildTrackList(rootName) { | |
| var listEl = document.getElementById('list'); | |
| listEl.innerHTML = ''; | |
| // Add select folder button at top | |
| var btn = document.createElement('div'); | |
| btn.className = 'folder-header'; | |
| btn.textContent = 'Select Folder...'; | |
| btn.onclick = openFolder; | |
| listEl.appendChild(btn); | |
| // Group by folder | |
| var folders = {}; | |
| var folderOrder = []; | |
| files.forEach(function(f) { | |
| var folder = f.folder || rootName; | |
| if (!folders[folder]) { | |
| folders[folder] = []; | |
| folderOrder.push(folder); | |
| } | |
| folders[folder].push(f); | |
| }); | |
| // Reorder files[] to match the grouped display order | |
| var reordered = []; | |
| folderOrder.forEach(function(folderName) { | |
| reordered = reordered.concat(folders[folderName]); | |
| }); | |
| files = reordered; | |
| var globalIndex = 0; | |
| folderOrder.forEach(function(folderName) { | |
| var folderFiles = folders[folderName]; | |
| var header = document.createElement('div'); | |
| header.className = 'folder-header'; | |
| header.innerHTML = '<span class="folder-toggle">▼</span> ' + folderName + ' <span style="color:#333">(' + folderFiles.length + ')</span>'; | |
| var tracksContainer = document.createElement('div'); | |
| tracksContainer.className = 'folder-tracks'; | |
| header.onclick = function() { | |
| header.classList.toggle('collapsed'); | |
| tracksContainer.style.display = header.classList.contains('collapsed') ? 'none' : ''; | |
| }; | |
| listEl.appendChild(header); | |
| folderFiles.forEach(function(file) { | |
| var div = document.createElement('div'); | |
| div.className = 'track'; | |
| div.dataset.index = globalIndex; | |
| var idx = globalIndex; | |
| div.innerHTML = | |
| '<span class="track-number">' + (globalIndex + 1) + '</span>' + | |
| '<span class="track-name">' + file.name + '</span>'; | |
| div.onclick = function() { loadAndPlay(idx); }; | |
| tracksContainer.appendChild(div); | |
| globalIndex++; | |
| }); | |
| listEl.appendChild(tracksContainer); | |
| }); | |
| } | |
| // -- Scope / VU data -- | |
| var latestScopes = null; | |
| var vuLevels = new Float32Array(32); | |
| var latestRow = -1; | |
| var latestPat = -1; | |
| var latestSongpos = -1; | |
| var songLooped = false; | |
| XMView.scope_width = 128; | |
| XMView.pushEvent = function(e) { | |
| if (e.vu) { | |
| for (var i = 0; i < e.vu.length; i++) { | |
| vuLevels[i] = Math.max(vuLevels[i] * 0.85, e.vu[i] || 0); | |
| } | |
| } | |
| if (e.scopes) latestScopes = e.scopes; | |
| latestRow = e.row; | |
| latestPat = e.pat; | |
| if (latestSongpos > 0 && e.songpos < latestSongpos) songLooped = true; | |
| latestSongpos = e.songpos; | |
| }; | |
| // -- Create scope boxes -- | |
| function createScopes(nchan) { | |
| var container = document.getElementById('scopes'); | |
| container.innerHTML = ''; | |
| scopeCanvases = []; | |
| scopeContexts = []; | |
| var totalWidth = container.offsetWidth || 800; | |
| var cols = Math.min(nchan, 8); | |
| var w = Math.floor(totalWidth / cols); | |
| var h = 48; | |
| for (var i = 0; i < nchan; i++) { | |
| var box = document.createElement('div'); | |
| box.className = 'scope-box'; | |
| box.dataset.ch = i; | |
| if (mutedChannels[i]) box.classList.add('muted'); | |
| var canvas = document.createElement('canvas'); | |
| canvas.width = w; | |
| canvas.height = h; | |
| box.appendChild(canvas); | |
| var label = document.createElement('div'); | |
| label.className = 'scope-label'; | |
| label.textContent = (i + 1); | |
| box.appendChild(label); | |
| box.onclick = function(idx) { | |
| return function() { | |
| mutedChannels[idx] = !mutedChannels[idx]; | |
| var mute = mutedChannels[idx] ? 1 : 0; | |
| XMPlayer.xm.channelinfo[idx].mute = mute; | |
| XMPlayer.setMute(idx, mute); | |
| this.classList.toggle('muted'); | |
| }; | |
| }(i); | |
| container.appendChild(box); | |
| scopeCanvases.push(canvas); | |
| scopeContexts.push(canvas.getContext('2d')); | |
| } | |
| } | |
| // -- Draw scopes -- | |
| function drawScopes() { | |
| for (var i = 0; i < scopeCanvases.length; i++) { | |
| var canvas = scopeCanvases[i]; | |
| var ctx = scopeContexts[i]; | |
| var w = canvas.width; | |
| var h = canvas.height; | |
| ctx.fillStyle = '#0a0a0a'; | |
| ctx.fillRect(0, 0, w, h); | |
| // center line | |
| ctx.strokeStyle = '#1a1a1a'; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, h / 2); | |
| ctx.lineTo(w, h / 2); | |
| ctx.stroke(); | |
| if (latestScopes && latestScopes[i]) { | |
| var scope = latestScopes[i]; | |
| var level = Math.min(1, Math.sqrt(vuLevels[i] || 0) * 6); | |
| var g = Math.floor(80 + level * 175); | |
| ctx.strokeStyle = 'rgb(0,' + g + ',0)'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| var step = scope.length / w; | |
| for (var x = 0; x < w; x++) { | |
| var si = (x * step) | 0; | |
| var y = (h / 2) - scope[si] * h * 2; | |
| if (x === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| } | |
| ctx.stroke(); | |
| ctx.lineWidth = 1; | |
| } | |
| } | |
| } | |
| // -- Pattern view -- | |
| var patCanvas = document.getElementById('pattern-canvas'); | |
| var patCtx = patCanvas.getContext('2d'); | |
| function resizePatternCanvas() { | |
| var container = document.getElementById('pattern-view'); | |
| var nchan = (XMPlayer.xm && XMPlayer.xm.nchan) || 0; | |
| var rowNumWidth = 30; | |
| var channelWidth = 100; | |
| var neededWidth = rowNumWidth + nchan * channelWidth; | |
| patCanvas.width = Math.max(container.offsetWidth, neededWidth); | |
| patCanvas.height = container.offsetHeight; | |
| } | |
| function drawPattern() { | |
| var w = patCanvas.width; | |
| var h = patCanvas.height; | |
| if (w === 0 || h === 0) return; | |
| patCtx.fillStyle = '#000'; | |
| patCtx.fillRect(0, 0, w, h); | |
| if (!XMPlayer.xm || !XMPlayer.xm.patterns || latestPat < 0) return; | |
| if (latestPat >= XMPlayer.xm.patterns.length) return; | |
| var pat = XMPlayer.xm.patterns[latestPat]; | |
| if (!pat) return; | |
| var nchan = XMPlayer.xm.nchan; | |
| var lineHeight = 14; | |
| var visibleRows = Math.floor(h / lineHeight); | |
| var centerY = Math.floor(visibleRows / 2); | |
| var curRow = latestRow; | |
| patCtx.font = '11px "SF Mono", Monaco, Menlo, Consolas, monospace'; | |
| // Calculate column layout | |
| var rowNumWidth = 30; | |
| var channelWidth = 100; | |
| for (var vi = 0; vi < visibleRows; vi++) { | |
| var row = curRow - centerY + vi; | |
| var y = vi * lineHeight + 12; | |
| if (row < 0 || row >= pat.length) continue; | |
| var isCurrent = (row === curRow); | |
| // Highlight current row | |
| if (isCurrent) { | |
| patCtx.fillStyle = '#001800'; | |
| patCtx.fillRect(0, vi * lineHeight, w, lineHeight); | |
| } | |
| // Row number | |
| patCtx.fillStyle = isCurrent ? '#0a0' : '#333'; | |
| var rowStr = row < 10 ? '0' + row : '' + row; | |
| patCtx.fillText(rowStr, 6, y); | |
| // Channel data | |
| for (var ch = 0; ch < nchan; ch++) { | |
| var d = pat[row][ch]; | |
| var x = rowNumWidth + ch * channelWidth; | |
| if (mutedChannels[ch]) { | |
| patCtx.fillStyle = '#1a1a1a'; | |
| patCtx.fillText('--- -- --', x, y); | |
| continue; | |
| } | |
| // Note | |
| var note = noteName(d[0]); | |
| patCtx.fillStyle = isCurrent ? (d[0] >= 0 ? '#0f0' : '#060') : (d[0] >= 0 ? '#aaa' : '#2a2a2a'); | |
| patCtx.fillText(note, x, y); | |
| // Instrument | |
| var inst = d[1] >= 0 ? hex2(d[1]) : '..'; | |
| patCtx.fillStyle = isCurrent ? '#080' : (d[1] >= 0 ? '#666' : '#1a1a1a'); | |
| patCtx.fillText(inst, x + 30, y); | |
| // Effect | |
| if (d[3] > 0 || d[4] > 0) { | |
| var eff = XMPlayer.prettify_effect(d[3], d[4]); | |
| patCtx.fillStyle = isCurrent ? '#0a0' : '#555'; | |
| patCtx.fillText(eff, x + 52, y); | |
| } | |
| } | |
| } | |
| } | |
| // -- Instruments panel -- | |
| function showInstruments() { | |
| var container = document.getElementById('instruments-list'); | |
| container.innerHTML = ''; | |
| if (!XMPlayer.xm || !XMPlayer.xm.instruments) return; | |
| XMPlayer.xm.instruments.forEach(function(inst, i) { | |
| var name = inst.name ? inst.name.trim() : ''; | |
| if (!name && !inst.samples) return; | |
| var div = document.createElement('div'); | |
| div.className = 'instrument'; | |
| div.innerHTML = | |
| '<span class="instrument-num">' + hex2(i + 1) + '</span>' + | |
| '<span class="instrument-name">' + escapeHtml(name || '---') + '</span>'; | |
| container.appendChild(div); | |
| if (inst.samples) { | |
| inst.samples.forEach(function(samp, j) { | |
| if (samp.name && samp.name.trim() && samp.name.trim() !== name) { | |
| var sdiv = document.createElement('div'); | |
| sdiv.className = 'instrument'; | |
| sdiv.innerHTML = | |
| '<span class="instrument-num" style="color:#222"> </span>' + | |
| '<span class="instrument-name" style="color:#444">' + escapeHtml(samp.name.trim()) + '</span>'; | |
| container.appendChild(sdiv); | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| function escapeHtml(s) { | |
| var div = document.createElement('div'); | |
| div.textContent = s; | |
| return div.innerHTML; | |
| } | |
| // -- Animation loop -- | |
| function animate() { | |
| drawScopes(); | |
| drawPattern(); | |
| if (songLooped && isPlaying && !XMPlayer.repeat && currentIndex < files.length - 1) { | |
| songLooped = false; | |
| nextTrack(); | |
| return; | |
| } | |
| songLooped = false; | |
| if (isPlaying) { | |
| document.getElementById('position').innerHTML = | |
| 'BPM ' + (XMPlayer.xm ? XMPlayer.xm.bpm : 0) + | |
| ' SPD ' + (XMPlayer.xm ? XMPlayer.xm.tempo : 0) + | |
| ' POS ' + (latestSongpos + 1) + '/' + (XMPlayer.xm.songpats ? XMPlayer.xm.songpats.length : 0) + | |
| ' PAT ' + latestPat + | |
| ' ROW ' + latestRow + ' '; | |
| } | |
| animFrame = requestAnimationFrame(animate); | |
| } | |
| // -- Player controls -- | |
| async function loadAndPlay(index) { | |
| currentIndex = index; | |
| songLooped = false; | |
| latestSongpos = -1; | |
| var entry = files[index]; | |
| document.querySelectorAll('.track').forEach(function(el) { | |
| el.classList.remove('active'); | |
| }); | |
| var activeEl = document.querySelector('.track[data-index="' + index + '"]'); | |
| activeEl.classList.add('active'); | |
| activeEl.scrollIntoView({ block: 'nearest' }); | |
| document.getElementById('now-playing').textContent = entry.name; | |
| document.getElementById('song-title').textContent = 'Loading...'; | |
| document.getElementById('btn-playpause').textContent = 'Pause'; | |
| if (XMPlayer.playing) { | |
| XMPlayer.stop(); | |
| } | |
| var file = await entry.handle.getFile(); | |
| var buffer = await file.arrayBuffer(); | |
| XMPlayer.init(); | |
| updateVolume(); | |
| if (XMPlayer.load(buffer)) { | |
| initTempo = XMPlayer.xm.tempo; | |
| initBpm = XMPlayer.xm.bpm; | |
| document.getElementById('song-title').textContent = XMPlayer.xm.songname || ''; | |
| mutedChannels = {}; | |
| createScopes(XMPlayer.xm.nchan); | |
| vuLevels = new Float32Array(XMPlayer.xm.nchan); | |
| latestScopes = null; | |
| showInstruments(); | |
| resizePatternCanvas(); | |
| XMPlayer.play(); | |
| isPlaying = true; | |
| cancelAnimationFrame(animFrame); | |
| animate(); | |
| } else { | |
| document.getElementById('song-title').textContent = 'Error loading file'; | |
| } | |
| } | |
| function togglePlay() { | |
| if (currentIndex === -1) { loadAndPlay(0); return; } | |
| if (isPlaying) { | |
| XMPlayer.pause(); | |
| isPlaying = false; | |
| document.getElementById('btn-playpause').textContent = 'Play'; | |
| } else { | |
| XMPlayer.play(); | |
| isPlaying = true; | |
| document.getElementById('btn-playpause').textContent = 'Pause'; | |
| } | |
| } | |
| function stopTrack() { | |
| if (currentIndex === -1) return; | |
| XMPlayer.stop(); | |
| isPlaying = false; | |
| document.getElementById('btn-playpause').textContent = 'Play'; | |
| document.getElementById('position').textContent = ''; | |
| } | |
| function nextTrack() { | |
| if (currentIndex < files.length - 1) loadAndPlay(currentIndex + 1); | |
| } | |
| function prevTrack() { | |
| if (currentIndex > 0) loadAndPlay(currentIndex - 1); | |
| } | |
| function jumpToSongPos(pos) { | |
| if (!XMPlayer.xm || !isPlaying) return; | |
| var len = XMPlayer.xm.songpats.length; | |
| pos = Math.max(0, Math.min(pos, len - 1)); | |
| // Scan all patterns from start to target to reconstruct correct BPM/tempo | |
| var tempo = initTempo; | |
| var bpm = initBpm; | |
| for (var s = 0; s < pos; s++) { | |
| var patIdx = XMPlayer.xm.songpats[s]; | |
| if (patIdx >= XMPlayer.xm.patterns.length) continue; | |
| var pat = XMPlayer.xm.patterns[patIdx]; | |
| for (var row = 0; row < pat.length; row++) { | |
| for (var ch = 0; ch < pat[row].length; ch++) { | |
| if (pat[row][ch][3] === 0xf && pat[row][ch][4] > 0) { | |
| if (pat[row][ch][4] < 0x20) { | |
| tempo = pat[row][ch][4]; | |
| } else { | |
| bpm = pat[row][ch][4]; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| XMPlayer.xm.tempo = tempo; | |
| XMPlayer.xm.bpm = bpm; | |
| XMPlayer.cur_songpos = pos; | |
| XMPlayer.cur_pat = XMPlayer.xm.songpats[pos]; | |
| XMPlayer.cur_row = 0; | |
| XMPlayer.next_row = 0; | |
| XMPlayer.cur_tick = 0; | |
| } | |
| function prevPattern() { jumpToSongPos(XMPlayer.cur_songpos - 1); } | |
| function nextPattern() { jumpToSongPos(XMPlayer.cur_songpos + 1); } | |
| function toggleRepeat() { | |
| XMPlayer.repeat = !XMPlayer.repeat; | |
| document.getElementById('btn-repeat').classList.toggle('active', XMPlayer.repeat); | |
| } | |
| // -- Keyboard -- | |
| function flashButton(id) { | |
| var btn = document.getElementById(id); | |
| btn.classList.add('flash'); | |
| setTimeout(function() { btn.classList.remove('flash'); }, 100); | |
| } | |
| document.addEventListener('keydown', function(e) { | |
| if (e.code === 'Space') { | |
| e.preventDefault(); | |
| flashButton('btn-playpause'); | |
| togglePlay(); | |
| } else if (e.code === 'ArrowDown' || e.code === 'KeyN') { | |
| e.preventDefault(); | |
| flashButton('btn-next'); | |
| nextTrack(); | |
| } else if (e.code === 'ArrowUp' || e.code === 'KeyP') { | |
| e.preventDefault(); | |
| flashButton('btn-prev'); | |
| prevTrack(); | |
| } else if (e.code === 'ArrowLeft') { | |
| e.preventDefault(); | |
| flashButton('btn-prevpat'); | |
| prevPattern(); | |
| } else if (e.code === 'ArrowRight') { | |
| e.preventDefault(); | |
| flashButton('btn-nextpat'); | |
| nextPattern(); | |
| } | |
| }); | |
| // -- Resize -- | |
| window.addEventListener('resize', function() { | |
| resizePatternCanvas(); | |
| if (XMPlayer.xm && XMPlayer.xm.nchan) { | |
| createScopes(XMPlayer.xm.nchan); | |
| } | |
| }); | |
| resizePatternCanvas(); | |
| // -- Volume slider -- | |
| var volumeSlider = document.getElementById('volume-slider'); | |
| function updateVolume() { | |
| if (!XMPlayer.gainNode) return; | |
| var v = volumeSlider.value / 100; | |
| XMPlayer.gainNode.gain.value = v * v * 0.5; | |
| } | |
| volumeSlider.addEventListener('input', updateVolume); | |
| buildTrackList(''); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment