noiz/index.html

1198 lines
49 KiB
HTML
Raw Normal View History

2026-07-05 15:10:49 +02:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>noiz://</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #111; color: #e0e0e0; height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
.btn { background: linear-gradient(to bottom, #3a3a3a, #282828); border: 1px solid #484848; color: #e0e0e0; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px; transition: background .1s, transform .1s, box-shadow .1s; white-space: nowrap; box-shadow: 0 3px 0 #181818, 0 4px 6px rgba(0,0,0,0.4); transform: translateY(0); }
.btn:hover { background: linear-gradient(to bottom, #404040, #2e2e2e); transform: translateY(-1px); box-shadow: 0 4px 0 #181818, 0 5px 8px rgba(0,0,0,0.4); }
.btn:active { background: linear-gradient(to bottom, #282828, #3a3a3a); transform: translateY(2px); box-shadow: 0 1px 0 #181818, 0 2px 3px rgba(0,0,0,0.4); }
.btn-primary { background: linear-gradient(to bottom, #2a7dff, #185dd5); border-color: #1a6df5; color: #fff; box-shadow: 0 3px 0 #0f4ab0, 0 4px 6px rgba(0,0,0,0.4); }
.btn-primary:hover { background: linear-gradient(to bottom, #3a8dff, #1a6de5); box-shadow: 0 4px 0 #0f4ab0, 0 5px 8px rgba(0,0,0,0.4); }
.btn-primary:active { background: linear-gradient(to bottom, #185dd5, #2a7dff); box-shadow: 0 1px 0 #0f4ab0, 0 2px 3px rgba(0,0,0,0.4); }
.btn-sm { padding: 4px 10px; font-size: 11px; }
@keyframes spin { to { transform: rotate(360deg); } }
.brand { font-size: 15px; font-weight: 700; color: #999; letter-spacing: 1px; user-select: none; }
.brand span { color: #666; }
footer { display: flex !important; align-items: center; gap: 16px; padding: 10px 24px; background: linear-gradient(to bottom, #222, #181818); border-top: 1px solid #333; font-size: 14px; color: #aaa; flex-shrink: 0; }
footer .brand { margin-left: auto; }
.content { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.pinned { flex-shrink: 0; background: linear-gradient(to bottom, #191919, #0f0f0f); padding: 16px 24px; display: flex; flex-direction: row; gap: 20px; align-items: center; }
.player { display: flex; flex-direction: row; width: 100%; align-items: center; gap: 24px; }
canvas { width: 100%; height: 100px; border-radius: 6px; display: block; }
.player-right { display: flex; flex-direction: column; width: 320px; flex-shrink: 0; }
.audio-info { font-size: 11px; color: #555; font-variant-numeric: tabular-nums; align-self: flex-end; }
.player .track-info { margin-bottom: 4px; }
.player .track-info .title { font-size: 16px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.player .track-info .artist-name { font-size: 12px; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.big-time { font-size: 28px; font-weight: 200; color: #fff; letter-spacing: 1px; font-variant-numeric: tabular-nums; margin-bottom: 6px; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
.big-time .sep { color: #444; margin: 0 4px; }
.canvas-area { display: flex; flex-direction: column; flex: 1; min-width: 200px; gap: 6px; }
.progress-row { display: flex; align-items: center; gap: 8px; font-size: 11px; color: #666; font-variant-numeric: tabular-nums; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
.progress-row .progress-bar { flex: 1; height: 6px; background: #333; border-radius: 3px; cursor: pointer; }
.progress-row .progress-bar .fill { height: 100%; background: #1a6df5; border-radius: 3px; width: 0%; }
.progress-row .progress-bar:hover .fill { background: #3a8df5; }
#timeRemaining { color: #555; }
#vuMeter { width: 100%; height: 20px; border-radius: 2px; display: block; }
.controls { display: flex; align-items: center; gap: 16px; align-self: flex-end; }
.controls button { background: linear-gradient(to bottom, #3a3a3a, #222); border: none; color: #ccc; cursor: pointer; padding: 6px; font-size: 14px; border-radius: 50%; transition: all .1s; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 0 #1a1a1a, 0 3px 4px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.06); transform: translateY(0); }
.controls button:hover { color: #fff; background: linear-gradient(to bottom, #444, #2a2a2a); transform: translateY(-1px); box-shadow: 0 3px 0 #1a1a1a, 0 4px 6px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.08); }
.controls button:active { background: linear-gradient(to bottom, #222, #3a3a3a); transform: translateY(1px); box-shadow: 0 1px 0 #1a1a1a, 0 1px 2px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.03); }
.controls .play-btn { width: 44px; height: 44px; background: linear-gradient(to bottom, #2a7dff, #185dd5); color: #fff; font-size: 16px; border: 1px solid #1a6df5; box-shadow: 0 3px 0 #0f4ab0, 0 4px 6px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.15); }
.controls .play-btn:hover { background: linear-gradient(to bottom, #3a8dff, #2a6de5); transform: translateY(-1px); box-shadow: 0 4px 0 #0f4ab0, 0 5px 8px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.2); }
.controls .play-btn:active { background: linear-gradient(to bottom, #185dd5, #2a7dff); transform: translateY(2px); box-shadow: 0 1px 0 #0f4ab0, 0 2px 3px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.08); }
.volume-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; width: 100%; max-width: 200px; align-self: flex-end; }
.volume-row input[type="range"] { flex: 1; height: 3px; -webkit-appearance: none; appearance: none; background: #333; border-radius: 2px; outline: none; }
.volume-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #1a6df5; cursor: pointer; }
.volume-row .vol-icon { font-size: 14px; color: #666; }
.add-bar { display: flex; gap: 8px; padding: 10px 24px; background: linear-gradient(to bottom, #1e1e1e, #141414); border-top: 1px solid #222; flex-shrink: 0; }
.empty-state { display: none; text-align: center; width: 100%; height: 100px; border-radius: 6px; background: #161616; justify-content: center; align-items: center; flex-direction: column; gap: 4px; flex-shrink: 0; }
.empty-state .icon { font-size: 20px; opacity: .2; }
.empty-state h2 { font-size: 14px; font-weight: 600; color: #444; }
.empty-state p { color: #333; font-size: 11px; }
.playlist { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.playlist-toolbar { display: flex; align-items: center; gap: 8px; padding: 8px 24px; border-bottom: 1px solid #222; flex-shrink: 0; flex-wrap: wrap; }
.playlist-toolbar .spacer { flex: 1; }
.playlist-toolbar select { background: linear-gradient(to bottom, #3a3a3a, #282828); border: 1px solid #484848; color: #e0e0e0; padding: 4px 8px; border-radius: 4px; font-size: 11px; cursor: pointer; outline: none; box-shadow: 0 2px 0 #181818, 0 3px 4px rgba(0,0,0,0.3); transition: transform .1s, box-shadow .1s; }
.playlist-toolbar select:active { background: linear-gradient(to bottom, #282828, #3a3a3a); transform: translateY(1px); box-shadow: 0 1px 0 #181818, 0 1px 2px rgba(0,0,0,0.3); }
.playlist-header { display: grid; grid-template-columns: 36px minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) 60px 60px; gap: 8px; padding: 8px 24px; font-size: 10px; text-transform: uppercase; letter-spacing: .6px; color: #555; border-bottom: 1px solid #222; flex-shrink: 0; background: linear-gradient(to bottom, #1e1e1e, #141414); }
.playlist-body { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 2px 0; position: relative; }
#scanning { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; background: rgba(0,0,0,0.75); z-index: 10; font-size: 14px; color: #ccc; flex-direction: column; gap: 10px; }
#scanning .spinner { width: 20px; height: 20px; border: 2px solid #444; border-top-color: #1a6df5; border-radius: 50%; animation: spin .8s linear infinite; flex-shrink: 0; }
.playlist-body::-webkit-scrollbar { width: 6px; }
.playlist-body::-webkit-scrollbar-track { background: transparent; }
.playlist-body::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
.track-item { display: grid; grid-template-columns: 36px minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) 60px 60px; gap: 8px; padding: 6px 24px; cursor: pointer; font-size: 12px; border-left: 3px solid transparent; transition: background .08s; align-items: center; }
.track-item:hover { background: #1a1a1a; }
.track-item.active { background: linear-gradient(to bottom, #0f3a0f, #082208); border-left-color: #33ff55; }
.track-item.active .col-title { color: #55ff77; }
.track-item.active .col-artist { color: #33dd55; }
.track-item.active .col-idx { color: #55ff77; }
.track-item.active .col-time { color: #33dd55; }
.track-item.active .col-genre { color: #33dd55; }
.track-item.active .col-file { color: #33cc55; }
.track-item .col-idx { color: #444; text-align: right; font-size: 10px; }
.track-item .col-title { color: #ccc; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-item .col-artist { color: #999; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-item .col-file { color: #666; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-item .col-time { color: #666; font-size: 11px; font-variant-numeric: tabular-nums; }
.track-item .col-genre { color: #666; font-size: 10px; }
footer .footer-track { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#error { display: none; background: #3a1a1a; color: #e88; padding: 8px 16px; font-size: 13px; text-align: center; border-bottom: 1px solid #522; }
@media (max-width: 700px) {
.player { flex-direction: column; align-items: stretch; }
canvas { width: 100%; height: 80px; }
.player-right { width: 100%; }
.empty-state { width: 100%; height: 80px; }
#vuMeter { width: 100%; height: 20px; }
.pinned { padding: 12px; }
.playlist-toolbar { padding: 6px 12px; }
.playlist-header, .track-item { padding-left: 12px; padding-right: 12px; grid-template-columns: 28px minmax(0,1fr) minmax(0,1fr) 50px 40px; }
.track-item .col-file, .playlist-header .hdr-file { display: none; }
.track-item .col-genre, .playlist-header .hdr-genre { display: none; }
}
</style>
</head>
<body>
<div id="error"></div>
<input type="file" id="folderPicker" webkitdirectory multiple style="display:none">
<div class="content">
<div class="pinned" id="pinned">
<div class="player" id="player">
<div class="canvas-area" id="canvasArea">
<div class="empty-state" id="emptyState">
<div class="icon">&#9835;</div>
<h2>No music loaded</h2>
<p>Drag a folder of audio files onto this window.</p>
</div>
<canvas id="spectrum" width="480" height="200"></canvas>
<div class="progress-row" id="progressRow">
<span id="timeCurrent">0:00</span>
<div class="progress-bar" id="progressBar">
<div class="fill" id="progressFill"></div>
</div>
<span id="timeRemaining">0:00</span>
</div>
<div style="position:relative">
<canvas id="vuMeter" width="480" height="20"></canvas>
<span style="position:absolute;left:2px;top:0;font:7px monospace;color:#8cf;line-height:6px">L</span>
<span style="position:absolute;left:2px;top:10px;font:7px monospace;color:#8cf;line-height:6px">R</span>
</div>
</div>
<div class="player-right">
<div class="track-info">
<div class="title" id="trackTitle">Unknown</div>
<div class="artist-name" id="trackArtist">Unknown Artist</div>
</div>
<div class="big-time" id="bigTime">0:00 <span class="sep">/</span> 0:00</div>
<div class="controls">
<button id="prevBtn" title="Previous">&#9664;&#9664;</button>
<button class="play-btn" id="playBtn" title="Play">&#9654;</button>
<button id="nextBtn" title="Next">&#9654;&#9654;</button>
</div>
<div class="volume-row">
<span class="vol-icon">Vol: <span id="volDisplay">--</span>%</span>
<input type="range" id="volumeSlider" min="0" max="1" step="0.01" value="0.75">
</div>
<div class="audio-info" id="audioInfo"></div>
</div>
</div>
</div>
<div class="playlist" id="playlist">
<div class="playlist-toolbar">
<div class="spacer"></div>
<span style="font-size:11px;color:#555">Sort:</span>
<select id="sortSelect">
<option value="idx">#</option>
<option value="title">Title</option>
<option value="artist">Artist</option>
<option value="filename">Filename</option>
<option value="playtime">Length</option>
<option value="genre">Genre</option>
</select>
<span style="font-size:11px;color:#555;margin-left:4px" id="trackCount">0</span>
<span style="font-size:11px;color:#555;margin-left:8px">Window:</span>
<select id="windowSelect">
<option value="hanning">Hanning</option>
<option value="blackman">Blackman-Harris</option>
</select>
<span style="font-size:11px;color:#555">Decay:</span>
<select id="decaySelect">
<option value="0.995">Slow</option>
<option value="0.985" selected>Medium</option>
<option value="0.97">Fast</option>
<option value="0.94">Very Fast</option>
</select>
<span style="font-size:11px;color:#555;margin-left:8px">Crossfade:</span>
<select id="crossfadeSelect">
<option value="0">Off</option>
<option value="1">1s</option>
<option value="2">2s</option>
<option value="3" selected>3s</option>
<option value="5">5s</option>
</select>
<span style="font-size:11px;color:#555;margin-left:8px">Repeat:</span>
<select id="repeatSelect">
<option value="off" selected>Off</option>
<option value="all">All</option>
<option value="one">One</option>
</select>
<span style="font-size:11px;color:#555">Shuffle:</span>
<select id="shuffleSelect">
<option value="off" selected>Off</option>
<option value="on">On</option>
</select>
</div>
<div class="playlist-header">
<span class="hdr-idx">#</span>
<span class="hdr-title">Title</span>
<span class="hdr-artist">Artist</span>
<span class="hdr-file">Filename</span>
<span class="hdr-time">Length</span>
<span class="hdr-genre">Genre</span>
</div>
<div class="playlist-body">
<div id="trackList"></div>
<div id="scanning"><div class="spinner"></div><span>Scanning...</span></div>
</div>
</div>
<div class="add-bar">
<button class="btn btn-primary" id="addFolderBtn">+ Folder</button>
<button class="btn" id="addFilesBtn">+ Files</button>
<button class="btn btn-sm" id="clearBtn">Isolate</button>
</div>
</div>
<footer id="footer">
<span class="footer-track" id="footerTrack">No track</span>
<span id="footerTime">0:00 / 0:00</span>
<span class="brand">noiz<span>://</span></span>
</footer>
<script>
const audioElA = new Audio();
const audioElB = new Audio();
const initVol = 0.75;
audioElA.volume = initVol;
audioElB.volume = initVol;
let audio = audioElA;
let audioNext = audioElB;
let gainA = null;
let gainB = null;
let sourceNodeA = null;
let sourceNodeB = null;
let crossfadeDuration = 0;
let crossfadeTimer = null;
let isCrossfading = false;
let repeatMode = 'off';
let shuffleOn = false;
let audioCtx = null;
let analyser = null;
let analyserL = null;
let analyserR = null;
let animId = null;
let sortField = 'idx';
let sortAsc = true;
let windowType = 'hanning';
let peakDecay = 0.985;
function initAudioCtx() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0;
analyserL = audioCtx.createAnalyser();
analyserL.fftSize = 1024;
analyserL.smoothingTimeConstant = 0;
analyserR = audioCtx.createAnalyser();
analyserR.fftSize = 1024;
analyserR.smoothingTimeConstant = 0;
const splitter = audioCtx.createChannelSplitter(2);
try {
sourceNodeA = audioCtx.createMediaElementSource(audioElA);
gainA = audioCtx.createGain();
gainA.gain.value = 1;
sourceNodeA.connect(gainA);
gainA.connect(analyser);
gainA.connect(splitter);
sourceNodeB = audioCtx.createMediaElementSource(audioElB);
gainB = audioCtx.createGain();
gainB.gain.value = 0;
sourceNodeB.connect(gainB);
gainB.connect(analyser);
gainB.connect(splitter);
splitter.connect(analyserL, 0);
splitter.connect(analyserR, 1);
analyser.connect(audioCtx.destination);
} catch (e) {}
}
const canvas = document.getElementById('spectrum');
const ctx = canvas.getContext('2d');
const bigTime = document.getElementById('bigTime');
let peakData = [];
let smoothedData = [];
let vuPeakL = 0, vuPeakR = 0;
let vuSmoothL = 0, vuSmoothR = 0;
function applyWindow(data, type) {
const n = data.length;
for (let i = 0; i < n; i++) {
let w;
if (type === 'hanning') {
w = 0.5 * (1 - Math.cos(2 * Math.PI * i / (n - 1)));
} else {
w = 0.35875 - 0.48829 * Math.cos(2 * Math.PI * i / n) + 0.14128 * Math.cos(4 * Math.PI * i / n) - 0.01168 * Math.cos(6 * Math.PI * i / n);
}
data[i] *= w;
}
}
function fft(re, im) {
const n = re.length;
for (let i = 1, j = 0; i < n; i++) {
let bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) {
[re[i], re[j]] = [re[j], re[i]];
[im[i], im[j]] = [im[j], im[i]];
}
}
for (let len = 2; len <= n; len <<= 1) {
const ang = -2 * Math.PI / len;
const wlenR = Math.cos(ang);
const wlenI = Math.sin(ang);
for (let i = 0; i < n; i += len) {
let wR = 1, wI = 0;
for (let j = 0; j < len / 2; j++) {
const uR = re[i + j], uI = im[i + j];
const vR = re[i + j + len / 2] * wR - im[i + j + len / 2] * wI;
const vI = re[i + j + len / 2] * wI + im[i + j + len / 2] * wR;
re[i + j] = uR + vR;
im[i + j] = uI + vI;
re[i + j + len / 2] = uR - vR;
im[i + j + len / 2] = uI - vI;
const nw = wR * wlenR - wI * wlenI;
wI = wR * wlenI + wI * wlenR;
wR = nw;
}
}
}
}
const SCALE_FACTOR = { hanning: 0.2, blackman: 0.3 };
function drawGrid() {
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.lineWidth = 1;
for (let g = 1; g < 4; g++) {
const gy = h * g / 4;
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(w, gy);
ctx.stroke();
}
}
function drawSpectrum() {
if (!analyser) { drawGrid(); animId = requestAnimationFrame(drawSpectrum); return; }
const fftSize = analyser.fftSize;
const timeData = new Uint8Array(fftSize);
analyser.getByteTimeDomainData(timeData);
const re = new Float64Array(fftSize);
const im = new Float64Array(fftSize);
for (let i = 0; i < fftSize; i++) {
re[i] = (timeData[i] - 128) / 128;
im[i] = 0;
}
applyWindow(re, windowType);
fft(re, im);
const binCount = fftSize / 2;
const mags = new Float32Array(binCount);
const sf = SCALE_FACTOR[windowType] || 4;
for (let i = 1; i < binCount; i++) {
mags[i] = Math.min(1, Math.sqrt(re[i] * re[i] + im[i] * im[i]) * sf);
const freqWeight = 0.72 + 0.35 * (i / binCount);
mags[i] = Math.min(1, mags[i] * freqWeight);
}
if (peakData.length !== binCount) {
peakData = new Float32Array(binCount);
smoothedData = new Float32Array(binCount);
}
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.lineWidth = 1;
for (let g = 1; g < 4; g++) {
const gy = h * g / 4;
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(w, gy);
ctx.stroke();
}
const barCount = binCount;
const barW = w / barCount * 0.75;
const gap = w / barCount * 0.25;
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, '#4a9dff');
grad.addColorStop(0.4, '#1a6df5');
grad.addColorStop(1, '#0a1a3e');
const smoothFactor = 0.96;
for (let i = 1; i < barCount; i++) {
const raw = mags[i];
if (raw > smoothedData[i]) {
smoothedData[i] = raw;
} else {
smoothedData[i] = smoothedData[i] * smoothFactor + raw * (1 - smoothFactor);
}
const val = smoothedData[i];
const barH = val * h;
const x = i * (barW + gap) + gap / 2;
ctx.fillStyle = grad;
ctx.fillRect(x, h - barH, barW, barH);
if (val * h > peakData[i]) {
peakData[i] = val * h;
} else {
peakData[i] *= peakDecay;
}
if (peakData[i] > 1) {
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fillRect(x, h - peakData[i], barW, 2);
}
}
const vuCanvas = document.getElementById('vuMeter');
if (vuCanvas && tracks.length && analyserL && analyserR) {
const vuctx = vuCanvas.getContext('2d');
const vw = vuCanvas.width, vh = vuCanvas.height;
vuctx.clearRect(0, 0, vw, vh);
const rowH = 6;
const gap = 4;
const labelW = 12;
const segCount = 22;
const segArea = vw - labelW - segCount;
const segW = segArea / segCount;
const segG = 1;
function getLevel(analyserCh) {
const td = new Uint8Array(analyserCh.fftSize);
analyserCh.getByteTimeDomainData(td);
let peak = 0;
for (let j = 0; j < td.length; j++) {
const s = Math.abs((td[j] - 128) / 128);
if (s > peak) peak = s;
}
return Math.min(1, peak * 1.3);
}
let levelL = getLevel(analyserL);
let levelR = getLevel(analyserR);
if (levelR < 0.001 && levelL > 0.001) levelR = levelL;
const vuSmooth = 0.92;
if (levelL > vuSmoothL) vuSmoothL = levelL; else vuSmoothL = vuSmoothL * vuSmooth + levelL * (1 - vuSmooth);
if (levelR > vuSmoothR) vuSmoothR = levelR; else vuSmoothR = vuSmoothR * vuSmooth + levelR * (1 - vuSmooth);
levelL = vuSmoothL;
levelR = vuSmoothR;
function drawVuRow(y, level, peak) {
const lit = Math.round(level * segCount);
if (level > peak) peak = level;
else peak *= 0.990;
for (let i = 0; i < segCount; i++) {
const sx = labelW + i * (segW + segG);
const on = i < lit;
const pi = Math.round(peak * segCount);
vuctx.fillStyle = on ? '#1a6df5' : '#151528';
vuctx.fillRect(sx, y, segW, rowH);
if (on) {
const grd = vuctx.createLinearGradient(0, y, 0, y + rowH);
grd.addColorStop(0, 'rgba(100,180,255,0.5)');
grd.addColorStop(1, 'rgba(26,109,245,0.8)');
vuctx.fillStyle = grd;
vuctx.fillRect(sx, y, segW, 2);
}
if (i === pi && peak > 0.05) {
vuctx.fillStyle = '#ff6a33';
vuctx.fillRect(sx, y, segW, rowH);
}
}
return peak;
}
vuPeakL = drawVuRow(0, levelL, vuPeakL);
vuPeakR = drawVuRow(gap + rowH, levelR, vuPeakR);
}
animId = requestAnimationFrame(drawSpectrum);
}
drawSpectrum();
let tracks = [];
let currentIndex = -1;
let dirHandle = null;
const addFolderBtn = document.getElementById('addFolderBtn');
const addFilesBtn = document.getElementById('addFilesBtn');
const folderPicker = document.getElementById('folderPicker');
const clearBtn = document.getElementById('clearBtn');
const sortSelect = document.getElementById('sortSelect');
const scanning = document.getElementById('scanning');
const scanningText = scanning.querySelector('span');
const trackList = document.getElementById('trackList');
const trackCount = document.getElementById('trackCount');
const emptyState = document.getElementById('emptyState');
const player = document.getElementById('player');
const playlist = document.getElementById('playlist');
const footer = document.getElementById('footer');
const trackTitle = document.getElementById('trackTitle');
const trackArtist = document.getElementById('trackArtist');
const playBtn = document.getElementById('playBtn');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const progressFill = document.getElementById('progressFill');
const progressBar = document.getElementById('progressBar');
const timeCurrent = document.getElementById('timeCurrent');
const timeRemaining = document.getElementById('timeRemaining');
const progressRow = document.getElementById('progressRow');
const footerTrack = document.getElementById('footerTrack');
const footerTime = document.getElementById('footerTime');
const volumeSlider = document.getElementById('volumeSlider');
const volDisplay = document.getElementById('volDisplay');
const audioInfo = document.getElementById('audioInfo');
const errorEl = document.getElementById('error');
const windowSelect = document.getElementById('windowSelect');
const decaySelect = document.getElementById('decaySelect');
const crossfadeSelect = document.getElementById('crossfadeSelect');
const AUDIO_EXTS = new Set(['.mp3','.wav','.ogg','.flac','.m4a','.aac','.wma','.aiff','.alac','.opus']);
const GENRES = [
'Blues','Classic Rock','Country','Dance','Disco','Funk','Grunge','Hip-Hop','Jazz','Metal',
'New Age','Oldies','Other','Pop','R&B','Rap','Reggae','Rock','Techno','Industrial',
'Alternative','Ska','Death Metal','Pranks','Soundtrack','Euro-Techno','Ambient','Trip-Hop',
'Vocal','Jazz+Funk','Fusion','Trance','Classical','Instrumental','Acid','House','Game',
'Sound Clip','Gospel','Noise','AlternRock','Bass','Soul','Punk','Space','Meditative',
'Instrumental Pop','Instrumental Rock','Ethnic','Gothic','Darkwave','Techno-Industrial',
'Electronic','Pop-Folk','Eurodance','Dream','Southern Rock','Comedy','Cult','Gangsta',
'Top 40','Christian Rap','Pop/Funk','Jungle','Native American','Cabaret','New Wave',
'Psychadelic','Rave','Showtunes','Trailer','Lo-Fi','Tribal','Acid Punk','Acid Jazz',
'Polka','Retro','Musical','Rock & Roll','Hard Rock','Folk','Folk-Rock','National Folk',
'Swing','Fast Fusion','Bebob','Latin','Revival','Celtic','Bluegrass','Avantgarde',
'Gothic Rock','Progressive Rock','Psychedelic Rock','Symphonic Rock','Slow Rock','Big Band',
'Chorus','Easy Listening','Acoustic','Humour','Speech','Chanson','Opera','Chamber Music',
'Sonata','Symphony','Booty Bass','Primus','Porn Groove','Satire','Slow Jam','Club',
'Tango','Samba','Folklore','Ballad','Power Ballad','Rhythmic Soul','Freestyle','Duet',
'Punk Rock','Drum Solo','A Cappella','Euro-House','Dance Hall','Goa','Drum & Bass',
'Club-House','Hardcore','Terror','Indie','BritPop','Negerpunk','Polsk Punk','Beat',
'Christian Gangsta Rap','Heavy Metal','Black Metal','Crossover','Contemporary Christian',
'Christian Rock','Merengue','Salsa','Thrash Metal','Anime','JPop','Synthpop'
];
function fmtTime(s) {
if (!isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
setTimeout(() => { errorEl.style.display = 'none'; }, 5000);
}
async function* walkDir(dirHandle, path = '') {
for await (const entry of dirHandle.values()) {
const fullPath = path ? `${path}/${entry.name}` : entry.name;
if (entry.kind === 'directory') {
yield* walkDir(entry, fullPath);
} else {
yield { entry, path: fullPath };
}
}
}
function isAudioFile(name) {
const dot = name.lastIndexOf('.');
if (dot === -1) return false;
return AUDIO_EXTS.has(name.slice(dot).toLowerCase());
}
function readBytes(file, start, length) {
const blob = file.slice(start, start + length);
return new Promise((resolve) => {
const r = new FileReader();
r.onload = () => resolve(new Uint8Array(r.result));
r.readAsArrayBuffer(blob);
});
}
function decodeText(arr, offset, len, enc) {
const end = Math.min(offset + len, arr.length);
if (end <= offset) return '';
const slice = arr.slice(offset, end);
try {
if (enc === 0) {
return new TextDecoder('iso-8859-1').decode(slice).replace(/\0/g, '').trim();
}
if (enc === 1) {
if (slice[0] === 0xFE && slice[1] === 0xFF) {
return new TextDecoder('utf-16be').decode(slice).replace(/\0/g, '').trim();
}
return new TextDecoder('utf-16le').decode(slice).replace(/\0/g, '').trim();
}
if (enc === 2) {
return new TextDecoder('utf-16be').decode(slice).replace(/\0/g, '').trim();
}
return new TextDecoder('utf-8').decode(slice).replace(/\0/g, '').trim();
} catch { return ''; }
}
function parseSyncsafeInt(bytes, off, len) {
let val = 0;
for (let i = 0; i < len; i++) {
val = (val << 7) | (bytes[off + i] & 0x7f);
}
return val;
}
function parseGenre(text) {
if (!text) return '';
const m = text.match(/\((\d+)\)/);
if (m) return GENRES[parseInt(m[1])] || text;
if (!isNaN(text) && text !== '') return GENRES[parseInt(text)] || text;
return text;
}
async function readId3v2(file) {
try {
const hdr = await readBytes(file, 0, 10);
if (hdr[0] !== 0x49 || hdr[1] !== 0x44 || hdr[2] !== 0x33) return null;
const major = hdr[3];
const size = parseSyncsafeInt(hdr, 6, 4);
if (size < 10 || size > 10000000) return null;
const tagData = await readBytes(file, 10, size);
const tags = {};
let pos = 0;
while (pos + 10 <= tagData.length) {
let frameId = '';
for (let j = 0; j < 4; j++) {
if (tagData[pos + j] === 0) break;
frameId += String.fromCharCode(tagData[pos + j]);
}
if (!frameId || frameId.length < 4) break;
let frameSize;
if (major >= 4) {
frameSize = parseSyncsafeInt(tagData, pos + 4, 4);
} else {
frameSize = (tagData[pos+4] << 24) | (tagData[pos+5] << 16) | (tagData[pos+6] << 8) | tagData[pos+7];
}
if (frameSize < 1 || frameSize > 10000000) { pos += 10; continue; }
const fData = tagData.slice(pos + 10, pos + 10 + frameSize);
if (fData.length < 2) { pos += frameSize + 10; continue; }
const enc = fData[0];
const text = decodeText(fData, 1, fData.length - 1, enc);
if (!text) { pos += frameSize + 10; continue; }
if (frameId === 'TIT2') tags.title = text;
else if (frameId === 'TPE1') tags.artist = text;
else if (frameId === 'TALB') tags.album = text;
else if (frameId === 'TCON') tags.genre = parseGenre(text);
else if (frameId === 'TYER' || frameId === 'TDRC') tags.year = text;
else if (frameId === 'TRCK') tags.track = text;
pos += frameSize + 10;
}
return tags;
} catch { return null; }
}
async function readId3v1(file) {
try {
if (file.size < 128) return null;
const data = await readBytes(file, file.size - 128, 128);
if (data[0] !== 0x54 || data[1] !== 0x41 || data[2] !== 0x47) return null;
const title = decodeText(data, 3, 30, 0);
const artist = decodeText(data, 33, 30, 0);
const album = decodeText(data, 63, 30, 0);
const year = decodeText(data, 93, 4, 0);
const genreIdx = data[127];
const genre = genreIdx < GENRES.length ? GENRES[genreIdx] : '';
const tags = {};
if (title) tags.title = title;
if (artist) tags.artist = artist;
if (album) tags.album = album;
if (year) tags.year = year;
if (genre) tags.genre = genre;
return tags;
} catch { return null; }
}
async function readTags(file) {
let tags = await readId3v2(file);
if (tags && (tags.title || tags.artist)) return tags;
const v1 = await readId3v1(file);
if (v1) {
if (!tags) tags = {};
if (v1.title && !tags.title) tags.title = v1.title;
if (v1.artist && !tags.artist) tags.artist = v1.artist;
if (v1.album && !tags.album) tags.album = v1.album;
if (v1.year && !tags.year) tags.year = v1.year;
if (v1.genre && !tags.genre) tags.genre = v1.genre;
return tags;
}
return tags || {};
}
const MP3_BITRATES = [
[0,32,64,96,128,160,192,224,256,288,320,352,384,416,448,0],
[0,32,48,56,64,80,96,112,128,160,192,224,256,320,384,0],
[0,32,40,48,56,64,80,96,112,128,160,192,224,256,320,0],
[0,32,48,56,64,80,96,112,128,144,160,176,192,224,256,0],
[0,8,16,24,32,40,48,56,64,80,96,112,128,144,160,0]
];
const MP3_SAMPLERATES = [44100, 48000, 32000, 0, 22050, 24000, 16000, 0, 11025, 12000, 8000, 0];
const MP3_CHANNELS = ['Stereo', 'Joint Stereo', 'Dual Channel', 'Mono'];
function isMp3Sync(b) {
return b[0] === 0xFF && (b[1] & 0xE0) === 0xE0 && ((b[1] >> 3) & 3) !== 1 && ((b[1] >> 1) & 3) !== 0;
}
function findMp3Header(data, off) {
for (let i = off; i < data.length - 4; i++) {
if (isMp3Sync(data.slice(i, i + 4))) return i;
}
return -1;
}
async function readAudioInfo(file) {
const info = {};
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
try {
if (ext === '.flac') {
const hdr = await readBytes(file, 0, 42);
if (hdr[0] === 0x66 && hdr[1] === 0x4C && hdr[2] === 0x61 && hdr[3] === 0x43) {
info.sampleRate = (hdr[18] << 12) | (hdr[19] << 4) | (hdr[20] >> 4);
info.channels = ((hdr[20] >> 1) & 7) + 1;
info.bitDepth = ((hdr[20] & 1) << 4) | (hdr[21] >> 4);
info.codec = 'FLAC';
}
} else if (ext === '.mp3') {
let off = 0;
const first4 = await readBytes(file, 0, 4);
if (first4[0] === 0x49 && first4[1] === 0x44 && first4[2] === 0x33) {
const hdr10 = await readBytes(file, 0, 10);
off = parseSyncsafeInt(hdr10, 6, 4) + 10;
}
if (off < 0 || off > 1000000) off = 0;
const buf = await readBytes(file, off, 200);
const hdrOff = findMp3Header(buf, 0);
if (hdrOff >= 0) {
const b = buf.slice(hdrOff, hdrOff + 4);
const ver = (b[1] >> 3) & 3;
const layer = (b[1] >> 1) & 3;
const brIdx = (b[2] >> 4) & 0x0F;
const srIdx = (b[2] >> 2) & 3;
const chMode = (b[3] >> 6) & 3;
const isMpeg1 = ver === 3;
const isMpeg2 = ver === 2;
const srTable = srIdx + (isMpeg1 ? 0 : (isMpeg2 ? 4 : 8));
info.sampleRate = MP3_SAMPLERATES[srTable] || 0;
info.channels = chMode === 3 ? 1 : 2;
info.channelMode = MP3_CHANNELS[chMode] || '';
info.codec = 'MP3';
if (brIdx > 0 && brIdx < 15) {
if (layer === 3) {
info.bitrate = isMpeg1 ? MP3_BITRATES[0][brIdx] : MP3_BITRATES[3][brIdx];
} else if (layer === 2) {
info.bitrate = MP3_BITRATES[1][brIdx];
} else if (layer === 1) {
info.bitrate = isMpeg1 ? MP3_BITRATES[2][brIdx] : MP3_BITRATES[4][brIdx];
}
}
const xingOff = isMpeg1 ? 36 : 21;
if (hdrOff + xingOff + 4 <= buf.length) {
const tag = String.fromCharCode(buf[hdrOff+xingOff], buf[hdrOff+xingOff+1], buf[hdrOff+xingOff+2], buf[hdrOff+xingOff+3]);
if (tag === 'Xing' || tag === 'Info') info.vbr = tag === 'Xing' ? 'VBR' : 'CBR';
}
}
}
} catch {}
return info;
}
async function processFiles(files, paths) {
scanning.style.display = 'flex';
const total = files.length;
const found = [];
let prog = 0;
for (let i = 0; i < total; i++) {
const file = files[i];
if (!isAudioFile(file.name)) continue;
prog++;
const url = URL.createObjectURL(file);
const path = paths ? paths[i] : file.name;
const [tags, ainfo] = await Promise.all([readTags(file), readAudioInfo(file)]);
const name = tags.title || file.name.replace(/\.[^.]+$/, '');
found.push({
name,
artist: tags.artist || '',
album: tags.album || '',
genre: tags.genre || '',
year: tags.year || '',
track: tags.track || '',
filename: file.name,
url,
file,
path,
size: file.size,
playtime: 0,
ainfo,
});
scanningText.textContent = `Processing ${prog}: ${name}`;
}
scanningText.textContent = `Loaded ${found.length} track${found.length === 1 ? '' : 's'}`;
setTimeout(() => { scanning.style.display = 'none'; }, 800);
return found;
}
async function addFolder() {
if ('showDirectoryPicker' in window) {
try {
dirHandle = await window.showDirectoryPicker({ mode: 'read' });
scanning.style.display = 'flex';
scanningText.textContent = 'Scanning directory...';
const files = [];
const paths = [];
for await (const { entry, path } of walkDir(dirHandle)) {
if (isAudioFile(entry.name)) {
const file = await entry.getFile();
files.push(file);
paths.push(path);
scanningText.textContent = `Found ${files.length} audio file${files.length === 1 ? '' : 's'}...`;
}
}
const processed = await processFiles(files, paths);
addTracks(processed);
} catch (err) {
scanning.style.display = 'none';
if (err.name !== 'AbortError' && err.name !== 'SecurityError') {
showError('Error reading directory: ' + err.message);
}
}
return;
}
folderPicker.click();
}
folderPicker.addEventListener('change', async (e) => {
const fileList = e.target.files;
if (!fileList || fileList.length === 0) return;
const files = Array.from(fileList);
const paths = files.map(f => f.webkitRelativePath || f.name);
const processed = await processFiles(files, paths);
addTracks(processed);
folderPicker.value = '';
});
async function addFiles() {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = '.mp3,.wav,.ogg,.flac,.m4a,.aac,.wma,.aiff,.alac,.opus';
input.addEventListener('change', async (e) => {
const fileList = e.target.files;
if (!fileList || fileList.length === 0) return;
const files = Array.from(fileList);
const processed = await processFiles(files, null);
addTracks(processed);
input.remove();
});
input.click();
}
function addTracks(newTracks) {
if (newTracks.length === 0) {
showError('No audio files found.');
return;
}
const wasEmpty = tracks.length === 0;
tracks = tracks.concat(newTracks);
renderTracks();
if (wasEmpty) {
playTrack(0);
}
}
function clearTracks() {
tracks.forEach((t, i) => {
if (i !== currentIndex) URL.revokeObjectURL(t.url);
});
if (currentIndex >= 0) {
const current = tracks[currentIndex];
tracks = [current];
currentIndex = 0;
} else {
tracks = [];
}
renderTracks();
}
function getSortFn(field) {
switch (field) {
case 'idx': return (a, b) => tracks.indexOf(a) - tracks.indexOf(b);
case 'title': return (a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase());
case 'artist': return (a, b) => (a.artist || '').toLowerCase().localeCompare((b.artist || '').toLowerCase()) || a.name.toLowerCase().localeCompare(b.name.toLowerCase());
case 'filename': return (a, b) => a.filename.toLowerCase().localeCompare(b.filename.toLowerCase());
case 'playtime': return (a, b) => (a.playtime || 0) - (b.playtime || 0);
case 'genre': return (a, b) => (a.genre || '').toLowerCase().localeCompare((b.genre || '').toLowerCase());
default: return (a, b) => tracks.indexOf(a) - tracks.indexOf(b);
}
}
function renderTracks() {
const sorted = [...tracks].sort(getSortFn(sortField));
if (!sortAsc) sorted.reverse();
trackList.innerHTML = '';
trackCount.textContent = tracks.length;
sorted.forEach((t, i) => {
const div = document.createElement('div');
div.className = 'track-item' + (t === tracks[currentIndex] ? ' active' : '');
const realIdx = tracks.indexOf(t);
div.innerHTML = `<span class="col-idx">${realIdx + 1}</span><span class="col-title">${escHtml(t.name)}</span><span class="col-artist">${escHtml(t.artist || '-')}</span><span class="col-file">${escHtml(t.filename)}</span><span class="col-time">${t.playtime ? fmtTime(t.playtime) : '-'}</span><span class="col-genre">${escHtml(t.genre || '-')}</span>`;
div.addEventListener('click', () => playTrack(realIdx));
trackList.appendChild(div);
});
const hasT = tracks.length > 0;
emptyState.style.display = hasT ? 'none' : 'flex';
canvas.style.display = hasT ? 'block' : 'none';
progressRow.style.display = hasT ? 'flex' : 'none';
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function playTrack(i) {
if (i < 0 || i >= tracks.length) return;
const prevIdx = currentIndex;
currentIndex = i;
const t = tracks[i];
const fading = crossfadeDuration > 0 && prevIdx >= 0 && prevIdx !== i && !audio.paused && !isCrossfading;
if (fading) {
isCrossfading = true;
audioNext.src = t.url;
audioNext.load();
initAudioCtx();
const onReady = () => {
audioNext.removeEventListener('canplaythrough', onReady);
audioNext.play().catch(() => {});
const now = audioCtx.currentTime;
const cd = crossfadeDuration;
const fg = audio === audioElA ? gainA : gainB;
const ng = audio === audioElA ? gainB : gainA;
fg.gain.setValueAtTime(1, now);
fg.gain.linearRampToValueAtTime(0, now + cd);
ng.gain.setValueAtTime(0, now);
ng.gain.linearRampToValueAtTime(1, now + cd);
clearTimeout(crossfadeTimer);
crossfadeTimer = setTimeout(() => {
crossfadeTimer = null;
if (audio === audioElA) { audioElA.pause(); audioElA.removeAttribute('src'); audioElA.load(); }
else { audioElB.pause(); audioElB.removeAttribute('src'); audioElB.load(); }
audio = audioNext;
audioNext = audio === audioElA ? audioElB : audioElA;
isCrossfading = false;
}, cd * 1000 + 100);
updateUI();
renderTracks();
};
audioNext.addEventListener('canplaythrough', onReady);
} else {
if (audio.src !== t.url) {
audio.src = t.url;
audio.load();
}
audio.play().catch(() => {});
initAudioCtx();
updateUI();
renderTracks();
}
}
function updateUI() {
const t = tracks[currentIndex];
if (!t) return;
trackTitle.textContent = t.name;
trackArtist.textContent = t.artist || 'Unknown Artist';
footerTrack.textContent = `${t.name}${t.artist ? ' - ' + t.artist : ''}`;
const ai = t.ainfo || {};
const parts = [];
if (ai.codec) parts.push(ai.codec);
if (ai.vbr) parts.push(ai.vbr);
if (ai.bitrate) parts.push(ai.bitrate + ' kbps');
if (ai.sampleRate) parts.push((ai.sampleRate / 1000).toFixed(1) + ' kHz');
if (ai.bitDepth) parts.push(ai.bitDepth + ' bit');
if (ai.channelMode) parts.push(ai.channelMode);
else if (ai.channels) parts.push(ai.channels === 1 ? 'Mono' : 'Stereo');
audioInfo.textContent = parts.join(' \u00B7 ');
}
function onLoadedmetadata(e) {
if (e.target !== audio) return;
const t = tracks[currentIndex];
if (t && audio.duration) {
t.playtime = audio.duration;
renderTracks();
}
}
function onTimeupdate(e) {
if (e.target !== audio) return;
if (!audio.duration) return;
const pct = (audio.currentTime / audio.duration) * 100;
const remaining = audio.duration - audio.currentTime;
progressFill.style.width = pct + '%';
timeCurrent.textContent = fmtTime(audio.currentTime);
timeRemaining.textContent = '-' + fmtTime(remaining);
bigTime.innerHTML = `${fmtTime(audio.currentTime)} <span class="sep">/</span> ${fmtTime(audio.duration)}`;
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
footerTime.textContent = `${fmtTime(audio.currentTime)} / ${fmtTime(audio.duration)}`;
if (crossfadeDuration > 0 && !isCrossfading && audio.duration && !audio.paused && audio.currentTime > 0 && repeatMode !== 'one') {
const remaining2 = audio.duration - audio.currentTime;
if (remaining2 <= crossfadeDuration) {
nextTrack();
}
}
}
function onEnded(e) {
if (e.target !== audio) return;
if (isCrossfading) return;
if (repeatMode === 'one') {
playTrack(currentIndex);
} else {
nextTrack();
}
}
function onPlay(e) {
if (e.target !== audio) return;
playBtn.innerHTML = '&#9646;&#9646;'; playBtn.title = 'Pause';
}
function onPause(e) {
if (e.target !== audio) return;
playBtn.innerHTML = '&#9654;'; playBtn.title = 'Play';
}
audioElA.addEventListener('loadedmetadata', onLoadedmetadata);
audioElB.addEventListener('loadedmetadata', onLoadedmetadata);
audioElA.addEventListener('timeupdate', onTimeupdate);
audioElB.addEventListener('timeupdate', onTimeupdate);
audioElA.addEventListener('ended', onEnded);
audioElB.addEventListener('ended', onEnded);
audioElA.addEventListener('play', onPlay);
audioElB.addEventListener('play', onPlay);
audioElA.addEventListener('pause', onPause);
audioElB.addEventListener('pause', onPause);
playBtn.addEventListener('click', () => {
if (tracks.length === 0) return;
if (audio.paused) audio.play().catch(() => {}); else audio.pause();
});
prevBtn.addEventListener('click', prevTrack);
nextBtn.addEventListener('click', nextTrack);
function prevTrack() {
if (tracks.length === 0) return;
if (audio.currentTime > 3) { audio.currentTime = 0; return; }
if (shuffleOn) {
let ri;
do { ri = Math.floor(Math.random() * tracks.length); } while (ri === currentIndex && tracks.length > 1);
playTrack(ri);
} else {
playTrack(currentIndex <= 0 ? tracks.length - 1 : currentIndex - 1);
}
}
function nextTrack() {
if (tracks.length === 0) return;
if (shuffleOn) {
let ri;
do { ri = Math.floor(Math.random() * tracks.length); } while (ri === currentIndex && tracks.length > 1);
playTrack(ri);
} else if (repeatMode === 'off' && currentIndex >= tracks.length - 1) {
return;
} else {
playTrack(currentIndex >= tracks.length - 1 ? 0 : currentIndex + 1);
}
}
progressBar.addEventListener('click', (e) => {
if (!audio.duration) return;
const rect = progressBar.getBoundingClientRect();
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
});
volumeSlider.addEventListener('input', () => {
const v = parseFloat(volumeSlider.value);
audioElA.volume = v;
audioElB.volume = v;
volDisplay.textContent = Math.round(v * 100);
});
addFolderBtn.addEventListener('click', addFolder);
addFilesBtn.addEventListener('click', addFiles);
clearBtn.addEventListener('click', clearTracks);
sortSelect.addEventListener('change', () => {
if (sortSelect.value === sortField) { sortAsc = !sortAsc; }
else { sortField = sortSelect.value; sortAsc = true; }
renderTracks();
});
windowSelect.addEventListener('change', () => { windowType = windowSelect.value; });
decaySelect.addEventListener('change', () => { peakDecay = parseFloat(decaySelect.value); });
crossfadeSelect.addEventListener('change', () => { crossfadeDuration = parseFloat(crossfadeSelect.value); });
crossfadeDuration = parseFloat(crossfadeSelect.value);
repeatSelect.addEventListener('change', () => { repeatMode = repeatSelect.value; });
shuffleSelect.addEventListener('change', () => { shuffleOn = shuffleSelect.value === 'on'; });
volDisplay.textContent = Math.round(parseFloat(volumeSlider.value) * 100);
let dragCounter = 0;
const content = document.querySelector('.content');
content.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
content.style.outline = '2px dashed #1a6df5';
content.style.outlineOffset = '-12px';
});
content.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) { dragCounter = 0; content.style.outline = 'none'; }
});
content.addEventListener('dragover', (e) => e.preventDefault());
content.addEventListener('drop', async (e) => {
e.preventDefault();
dragCounter = 0;
content.style.outline = 'none';
const items = e.dataTransfer.items;
if (!items) {
const files = e.dataTransfer.files;
if (files && files.length) {
const processed = await processFiles(Array.from(files), null);
addTracks(processed);
}
return;
}
const entries = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
if (entry) entries.push(entry);
}
if (entries.length === 0) {
if (e.dataTransfer.files && e.dataTransfer.files.length) {
const processed = await processFiles(Array.from(e.dataTransfer.files), null);
addTracks(processed);
}
return;
}
scanning.style.display = 'flex';
scanningText.textContent = 'Scanning dropped items...';
const files = [];
const paths = [];
for (const entry of entries) {
await collectFiles(entry, files, paths, '', () => {
scanningText.textContent = `Found ${files.length} audio file${files.length === 1 ? '' : 's'}...`;
});
}
const processed = await processFiles(files, paths);
addTracks(processed);
});
async function collectFiles(entry, files, paths, path, onFile) {
if (entry.isFile) {
const file = await new Promise((resolve) => entry.file(resolve));
const fullPath = path ? `${path}/${file.name}` : file.name;
files.push(file);
paths.push(fullPath);
if (onFile) onFile(file, fullPath);
} else if (entry.isDirectory) {
const reader = entry.createReader();
const readAll = () => new Promise((resolve) => {
reader.readEntries(async (entries) => {
if (entries.length === 0) { resolve(); return; }
for (const e of entries) {
await collectFiles(e, files, paths, path ? `${path}/${entry.name}` : entry.name, onFile);
}
await readAll();
resolve();
});
});
await readAll();
}
}
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.code === 'Space') { e.preventDefault(); playBtn.click(); }
if (e.code === 'ArrowRight') nextBtn.click();
if (e.code === 'ArrowLeft') prevBtn.click();
});
window.addEventListener('focus', () => {
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
});
</script>
</body>
</html>