mirror of
https://codeberg.org/armin/noiz.git
synced 2026-09-01 14:00:54 +02:00
1424 lines
57 KiB
HTML
1424 lines
57 KiB
HTML
<!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: #161821; color: #c6c8d1; height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
|
|
.btn { background: linear-gradient(to bottom, #272c42, #1e2132); border: 1px solid #6b7089; color: #c6c8d1; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px; white-space: nowrap; box-shadow: 0 3px 0 #161821, 0 4px 6px rgba(0,0,0,0.4); }
|
|
.btn:hover { background: linear-gradient(to bottom, #2e3348, #272c42); }
|
|
.btn:active { background: linear-gradient(to bottom, #1e2132, #272c42); box-shadow: 0 1px 0 #161821, 0 2px 4px rgba(0,0,0,0.4); transform: translateY(2px); }
|
|
.btn-primary { background: linear-gradient(to bottom, #91acd1, #84a0c6); border-color: #84a0c6; color: #161821; box-shadow: 0 3px 0 #6b88b0, 0 4px 6px rgba(0,0,0,0.4); }
|
|
.btn-primary:hover { background: linear-gradient(to bottom, #a0b8db, #91acd1); }
|
|
.btn-primary:active { background: linear-gradient(to bottom, #84a0c6, #91acd1); box-shadow: 0 1px 0 #6b88b0, 0 2px 4px rgba(0,0,0,0.4); transform: translateY(2px); }
|
|
.btn-sm { padding: 4px 10px; font-size: 11px; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.brand { font-size: 15px; font-weight: 700; color: #6b7089; letter-spacing: 1px; user-select: none; }
|
|
.brand span { color: #6b7089; }
|
|
footer { display: flex !important; align-items: center; gap: 16px; padding: 10px 24px; background: linear-gradient(to bottom, #1e2132, #161821); border-top: 1px solid #272c42; font-size: 14px; color: #6b7089; 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, #1e2132, #161821); 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: #6b7089; 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: #c6c8d1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.player .track-info .artist-name { font-size: 12px; color: #6b7089; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.big-time { font-size: 28px; font-weight: 200; color: #d2d4de; letter-spacing: 1px; font-variant-numeric: tabular-nums; margin-bottom: 6px; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
|
|
.big-time .sep { color: #6b7089; 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: #6b7089; font-variant-numeric: tabular-nums; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
|
|
.progress-row .progress-bar { flex: 1; height: 6px; background: #272c42; border-radius: 3px; cursor: pointer; }
|
|
.progress-row .progress-bar .fill { height: 100%; background: #84a0c6; border-radius: 3px; width: 0%; }
|
|
.progress-row .progress-bar:hover .fill { background: #91acd1; }
|
|
#noiz-overlay { position: fixed; opacity: 0.75; inset: 0; display: flex; justify-content: center; align-items: center; background: transparent; pointer-events: none; z-index: 9999; animation: fadeout 750ms ease 750ms forwards; }
|
|
#noiz-overlay img { max-width: 50vw; max-height: 50vh; filter: drop-shadow(0 8px 16px rgba(0,0,0,0.5)); }
|
|
@keyframes fadeout { from { opacity: 0.75; transform: scale(1); } to { opacity: 0; transform: scale(1.75); visibility: hidden; } }
|
|
#timeRemaining { color: #6b7089; }
|
|
#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, #272c42, #1e2132); border: none; color: #c6c8d1; cursor: pointer; padding: 6px; font-size: 14px; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 0 #161821, 0 3px 4px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.06); }
|
|
.controls button:hover { color: #d2d4de; background: linear-gradient(to bottom, #2e3348, #272c42); }
|
|
.controls button:active { background: linear-gradient(to bottom, #1e2132, #272c42); box-shadow: 0 1px 0 #161821, 0 1px 2px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.06); transform: translateY(1px); }
|
|
.controls .play-btn { width: 44px; height: 44px; background: linear-gradient(to bottom, #91acd1, #84a0c6); color: #161821; font-size: 16px; border: 1px solid #84a0c6; box-shadow: 0 3px 0 #6b88b0, 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, #a0b8db, #91acd1); }
|
|
.controls .play-btn:active { background: linear-gradient(to bottom, #84a0c6, #91acd1); box-shadow: 0 1px 0 #6b88b0, 0 2px 4px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.15); transform: translateY(2px); }
|
|
.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: #272c42; border-radius: 2px; outline: none; }
|
|
.volume-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #84a0c6; cursor: pointer; }
|
|
.volume-row .vol-icon { font-size: 14px; color: #6b7089; }
|
|
.add-bar { display: flex; gap: 8px; padding: 10px 24px; background: linear-gradient(to bottom, #1e2132, #161821); border-top: 1px solid #272c42; flex-shrink: 0; }
|
|
.empty-state { display: none; text-align: center; width: 100%; height: 100px; border-radius: 6px; background: #1e2132; 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: #6b7089; }
|
|
.empty-state p { color: #6b7089; 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 #272c42; flex-shrink: 0; flex-wrap: wrap; }
|
|
.playlist-toolbar .spacer { flex: 1; }
|
|
.playlist-toolbar select { background: linear-gradient(to bottom, #272c42, #1e2132); border: 1px solid #6b7089; color: #c6c8d1; padding: 4px 8px; border-radius: 4px; font-size: 11px; cursor: pointer; outline: none; box-shadow: 0 2px 0 #161821, 0 3px 4px rgba(0,0,0,0.3); transition: transform .1s, box-shadow .1s; }
|
|
|
|
.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: #6b7089; border-bottom: 1px solid #272c42; flex-shrink: 0; background: linear-gradient(to bottom, #1e2132, #161821); }
|
|
.playlist-body { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 2px 0; position: relative; background: #101219; }
|
|
#scanning { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; background: rgba(22,24,33,0.85); z-index: 10; font-size: 14px; color: #c6c8d1; flex-direction: column; gap: 10px; }
|
|
#scanning .spinner { width: 20px; height: 20px; border: 2px solid #6b7089; border-top-color: #84a0c6; 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: #272c42; 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; user-select: none; }
|
|
.track-item:nth-child(odd) { background: rgba(0,0,0,0.12); }
|
|
.track-item:hover { background: #1e2132; }
|
|
.track-item.active { background: linear-gradient(to bottom, rgba(135,190,130,0.12), rgba(135,190,130,0.06)); border-left-color: #87be82; }
|
|
.track-item.active .col-title { color: #9bbe8e; font-weight: 700; }
|
|
.track-item.active .col-artist { color: #87be82; font-weight: 600; }
|
|
.track-item.active .col-idx { color: #9bbe8e; font-weight: 700; }
|
|
.track-item.active .col-time { color: #87be82; font-weight: 600; }
|
|
.track-item.active .col-genre { color: #87be82; }
|
|
.track-item.active .col-file { color: #87be82; font-weight: 600; }
|
|
.track-item.selected { background: linear-gradient(to bottom, rgba(132,160,198,0.15), rgba(132,160,198,0.08)); border-left-color: #84a0c6; }
|
|
.track-item.selected .col-title { color: #91acd1; font-weight: 600; }
|
|
.track-item.selected .col-artist { color: #84a0c6; }
|
|
.track-item.selected .col-idx { color: #84a0c6; }
|
|
.track-item.selected .col-time { color: #84a0c6; }
|
|
.track-item.selected .col-file { color: #84a0c6; }
|
|
.track-item.active.selected { background: linear-gradient(to bottom, rgba(135,190,130,0.18), rgba(132,160,198,0.12)); border-left-color: #87be82; }
|
|
.track-item.active.selected .col-title { color: #9bbe8e; }
|
|
.track-item.dragging { opacity: 0.4; }
|
|
.track-item.drag-over { border-top: 2px solid #87be82; }
|
|
.track-item .col-idx { color: #6b7089; text-align: right; font-size: 10px; }
|
|
.track-item .col-title { color: #c6c8d1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.track-item .col-artist { color: #6b7089; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.track-item .col-file { color: #6b7089; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.track-item .col-time { color: #6b7089; font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
.track-item .col-genre { color: #6b7089; font-size: 10px; }
|
|
footer .footer-track { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
#error { display: none; background: rgba(226,120,120,0.15); color: #e27878; padding: 8px 16px; font-size: 13px; text-align: center; border-bottom: 1px solid rgba(226,120,120,0.3); }
|
|
@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="noiz-overlay">
|
|
<img src="noizmainlogo.png" alt="noiz://">
|
|
</div>
|
|
|
|
<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">♫</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:#84a0c6;line-height:6px">L</span>
|
|
<span style="position:absolute;left:2px;top:10px;font:7px monospace;color:#84a0c6;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">◀◀</button>
|
|
<button class="play-btn" id="playBtn" title="Play">▶</button>
|
|
<button id="nextBtn" title="Next">▶▶</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:#6b7089">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:#6b7089;margin-left:4px" id="trackCount">0</span>
|
|
<span style="font-size:11px;color:#6b7089;margin-left:8px">Window:</span>
|
|
<select id="windowSelect">
|
|
<option value="none">None</option>
|
|
<option value="hanning" selected>Hanning</option>
|
|
<option value="hamming">Hamming</option>
|
|
<option value="blackman">Blackman-Harris</option>
|
|
<option value="bartlett">Bartlett</option>
|
|
<option value="gaussian">Gaussian</option>
|
|
<option value="kaiser">Kaiser</option>
|
|
<option value="flatTop">Flat Top</option>
|
|
</select>
|
|
<span style="font-size:11px;color:#6b7089">Peak 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:#6b7089;margin-left:8px">Bar Decay:</span>
|
|
<select id="barDecaySelect">
|
|
<option value="0.99">Slow</option>
|
|
<option value="0.96" selected>Medium</option>
|
|
<option value="0.9">Fast</option>
|
|
<option value="0.8">Very Fast</option>
|
|
</select>
|
|
<span style="font-size:11px;color:#6b7089;margin-left:8px">Crossfade:</span>
|
|
<select id="crossfadeSelect">
|
|
<option value="0" selected>Off</option>
|
|
<option value="1">1s</option>
|
|
<option value="2">2s</option>
|
|
<option value="3">3s</option>
|
|
<option value="5">5s</option>
|
|
</select>
|
|
<span style="font-size:11px;color:#6b7089;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:#6b7089">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" id="isolateBtn">Isolate</button>
|
|
<button class="btn" id="deleteBtn">Delete</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;
|
|
let barDecay = 0.96;
|
|
|
|
|
|
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 bessi0(x) {
|
|
let s = 1, t = 1;
|
|
for (let k = 1; k <= 20; k++) {
|
|
t *= (x / (2 * k)) ** 2;
|
|
s += t;
|
|
if (t < 1e-12) break;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function applyWindow(data, type) {
|
|
const n = data.length;
|
|
let kaiserI0;
|
|
if (type === 'kaiser') kaiserI0 = bessi0(3);
|
|
for (let i = 0; i < n; i++) {
|
|
let w;
|
|
switch (type) {
|
|
case 'none':
|
|
w = 1;
|
|
break;
|
|
case 'hanning':
|
|
w = 0.5 * (1 - Math.cos(2 * Math.PI * i / (n - 1)));
|
|
break;
|
|
case 'hamming':
|
|
w = 0.54 - 0.46 * Math.cos(2 * Math.PI * i / (n - 1));
|
|
break;
|
|
case 'blackman':
|
|
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);
|
|
break;
|
|
case 'bartlett':
|
|
w = 1 - Math.abs(2 * i / (n - 1) - 1);
|
|
break;
|
|
case 'gaussian': {
|
|
const z = 3 * (2 * i / (n - 1) - 1);
|
|
w = Math.exp(-0.5 * z * z);
|
|
break;
|
|
}
|
|
case 'kaiser': {
|
|
const iz = 2 * i / (n - 1) - 1;
|
|
const arg = 3 * Math.sqrt(Math.max(0, 1 - iz * iz));
|
|
w = bessi0(arg) / kaiserI0;
|
|
break;
|
|
}
|
|
case 'flatTop':
|
|
w = 0.21557895 - 0.41663158 * Math.cos(2 * Math.PI * i / n) + 0.277263158 * Math.cos(4 * Math.PI * i / n) - 0.083578947 * Math.cos(6 * Math.PI * i / n) + 0.006947368 * Math.cos(8 * Math.PI * i / n);
|
|
break;
|
|
default:
|
|
w = 1;
|
|
}
|
|
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_EXP = { none: 1, hanning: 0.5, hamming: 0.48, blackman: 0.4, bartlett: 0.55, gaussian: 0.43, kaiser: 0.33, flatTop: 0.3 };
|
|
|
|
function drawSpectrum() {
|
|
if (!analyser) { 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);
|
|
for (let i = 1; i < binCount; i++) {
|
|
let mag = Math.sqrt(re[i] * re[i] + im[i] * im[i]);
|
|
let dB = 20 * Math.log10(Math.max(mag / 64, 1e-3));
|
|
dB += 10 * (i / binCount);
|
|
let r = Math.min(1, Math.max(0, (dB + 50) / 50));
|
|
const exp = SCALE_EXP[windowType] || 0.5;
|
|
mags[i] = Math.min(1, Math.pow(r, exp) * 0.85);
|
|
}
|
|
const sampleRate = audioCtx.sampleRate;
|
|
const maxBin = Math.min(binCount, Math.floor(19000 * fftSize / sampleRate));
|
|
const NUM_BARS = 24;
|
|
const barMags = new Float32Array(NUM_BARS);
|
|
for (let b = 0; b < NUM_BARS; b++) {
|
|
const start = 1 + Math.floor(b * (maxBin - 1) / NUM_BARS);
|
|
const end = 1 + Math.floor((b + 1) * (maxBin - 1) / NUM_BARS);
|
|
let sum = 0;
|
|
for (let j = start; j < end; j++) sum += mags[j];
|
|
barMags[b] = sum / (end - start);
|
|
}
|
|
if (peakData.length !== NUM_BARS) {
|
|
peakData = new Float32Array(NUM_BARS);
|
|
smoothedData = new Float32Array(NUM_BARS);
|
|
}
|
|
const rect = canvas.getBoundingClientRect();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const w = rect.width;
|
|
const h = rect.height;
|
|
const bufW = Math.round(w * dpr);
|
|
const bufH = Math.round(h * dpr);
|
|
if (canvas.width !== bufW || canvas.height !== bufH) {
|
|
canvas.width = bufW;
|
|
canvas.height = bufH;
|
|
}
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.clearRect(0, 0, w, h);
|
|
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
|
grad.addColorStop(0, '#91acd1');
|
|
grad.addColorStop(0.4, '#84a0c6');
|
|
grad.addColorStop(1, '#272c42');
|
|
const smoothFactor = barDecay;
|
|
const barW = Math.max(1, Math.floor(w / NUM_BARS * 0.75));
|
|
const gap = Math.max(1, Math.floor(w / NUM_BARS * 0.25));
|
|
const totalBar = barW + gap;
|
|
const offset = Math.floor((w - NUM_BARS * totalBar + gap) / 2);
|
|
for (let i = 0; i < NUM_BARS; i++) {
|
|
const raw = barMags[i];
|
|
if (raw > smoothedData[i]) {
|
|
smoothedData[i] = raw;
|
|
} else {
|
|
smoothedData[i] = smoothedData[i] * smoothFactor + raw * (1 - smoothFactor);
|
|
}
|
|
const val = smoothedData[i];
|
|
const barH = Math.round(val * h);
|
|
const x = offset + i * totalBar;
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(x, h - barH, barW, barH);
|
|
if (barH > peakData[i]) {
|
|
peakData[i] = barH;
|
|
} else {
|
|
peakData[i] *= peakDecay;
|
|
}
|
|
}
|
|
ctx.fillStyle = '#e27878';
|
|
for (let i = 0; i < NUM_BARS; i++) {
|
|
const x = offset + i * totalBar;
|
|
if (peakData[i] > 1) {
|
|
ctx.fillRect(x, h - Math.round(peakData[i]), barW, 4);
|
|
}
|
|
}
|
|
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 ? '#84a0c6' : '#1e2132';
|
|
vuctx.fillRect(sx, y, segW, rowH);
|
|
if (on) {
|
|
const grd = vuctx.createLinearGradient(0, y, 0, y + rowH);
|
|
grd.addColorStop(0, 'rgba(145,172,209,0.5)');
|
|
grd.addColorStop(1, 'rgba(132,160,198,0.8)');
|
|
vuctx.fillStyle = grd;
|
|
vuctx.fillRect(sx, y, segW, 2);
|
|
}
|
|
if (i === pi && peak > 0.05) {
|
|
vuctx.fillStyle = '#e98989';
|
|
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 selectedIndices = new Set();
|
|
let anchorTrack = null;
|
|
let dragIndices = null;
|
|
let dirHandle = null;
|
|
|
|
const addFolderBtn = document.getElementById('addFolderBtn');
|
|
const addFilesBtn = document.getElementById('addFilesBtn');
|
|
const folderPicker = document.getElementById('folderPicker');
|
|
const isolateBtn = document.getElementById('isolateBtn');
|
|
const deleteBtn = document.getElementById('deleteBtn');
|
|
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 barDecaySelect = document.getElementById('barDecaySelect');
|
|
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;
|
|
}
|
|
tracks = tracks.concat(newTracks);
|
|
renderTracks();
|
|
}
|
|
|
|
function isolateTracks() {
|
|
tracks.forEach((t, i) => {
|
|
if (i !== currentIndex) URL.revokeObjectURL(t.url);
|
|
});
|
|
if (currentIndex >= 0) {
|
|
const current = tracks[currentIndex];
|
|
tracks = [current];
|
|
currentIndex = 0;
|
|
selectedIndices.clear();
|
|
} else {
|
|
tracks = [];
|
|
selectedIndices.clear();
|
|
}
|
|
renderTracks();
|
|
}
|
|
|
|
function deleteTrack() {
|
|
if (selectedIndices.size === 0) return;
|
|
if (selectedIndices.size === tracks.length) {
|
|
tracks.forEach(t => URL.revokeObjectURL(t.url));
|
|
tracks = [];
|
|
currentIndex = -1;
|
|
selectedIndices.clear();
|
|
renderTracks();
|
|
return;
|
|
}
|
|
const sortedSel = [...selectedIndices].sort((a, b) => a - b);
|
|
const willDeleteCurrent = selectedIndices.has(currentIndex);
|
|
const playingTrack = currentIndex >= 0 ? tracks[currentIndex] : null;
|
|
for (const idx of sortedSel) URL.revokeObjectURL(tracks[idx].url);
|
|
for (let j = sortedSel.length - 1; j >= 0; j--) tracks.splice(sortedSel[j], 1);
|
|
selectedIndices.clear();
|
|
if (willDeleteCurrent) {
|
|
if (tracks.length > 0) {
|
|
currentIndex = Math.min(sortedSel[0], tracks.length - 1);
|
|
playTrack(currentIndex);
|
|
} else {
|
|
currentIndex = -1;
|
|
renderTracks();
|
|
}
|
|
} else {
|
|
const removedBefore = sortedSel.filter(idx => idx < currentIndex).length;
|
|
currentIndex -= removedBefore;
|
|
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');
|
|
const realIdx = tracks.indexOf(t);
|
|
const classes = ['track-item'];
|
|
if (t === tracks[currentIndex]) classes.push('active');
|
|
if (selectedIndices.has(realIdx)) classes.push('selected');
|
|
div.className = classes.join(' ');
|
|
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('mousedown', (e) => {
|
|
if (e.detail > 1) return;
|
|
const ctrl = e.ctrlKey || e.metaKey;
|
|
const shift = e.shiftKey;
|
|
if (shift && anchorTrack) {
|
|
const sorted2 = [...tracks].sort(getSortFn(sortField));
|
|
if (!sortAsc) sorted2.reverse();
|
|
const anchorPos = sorted2.indexOf(anchorTrack);
|
|
const clickPos = i;
|
|
const lo = Math.min(anchorPos, clickPos);
|
|
const hi = Math.max(anchorPos, clickPos);
|
|
selectedIndices.clear();
|
|
for (let j = lo; j <= hi; j++) selectedIndices.add(tracks.indexOf(sorted2[j]));
|
|
renderTracks();
|
|
return;
|
|
}
|
|
if (ctrl) {
|
|
if (selectedIndices.has(realIdx)) selectedIndices.delete(realIdx);
|
|
else selectedIndices.add(realIdx);
|
|
anchorTrack = t;
|
|
renderTracks();
|
|
return;
|
|
}
|
|
if (!selectedIndices.has(realIdx)) {
|
|
selectedIndices.clear();
|
|
selectedIndices.add(realIdx);
|
|
anchorTrack = t;
|
|
renderTracks();
|
|
}
|
|
});
|
|
div.addEventListener('dblclick', () => {
|
|
selectedIndices.clear();
|
|
selectedIndices.add(realIdx);
|
|
anchorTrack = t;
|
|
playTrack(realIdx);
|
|
});
|
|
div.draggable = true;
|
|
div.addEventListener('dragstart', (e) => {
|
|
if (!selectedIndices.has(realIdx)) {
|
|
selectedIndices.clear();
|
|
selectedIndices.add(realIdx);
|
|
anchorTrack = t;
|
|
renderTracks();
|
|
}
|
|
dragIndices = new Set(selectedIndices);
|
|
isInternalDrag = true;
|
|
div.classList.add('dragging');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
div.addEventListener('dragend', () => {
|
|
div.classList.remove('dragging');
|
|
dragIndices = null;
|
|
isInternalDrag = false;
|
|
document.querySelectorAll('.track-item.drag-over').forEach(el => el.classList.remove('drag-over'));
|
|
});
|
|
div.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
document.querySelectorAll('.track-item.drag-over').forEach(el => el.classList.remove('drag-over'));
|
|
div.classList.add('drag-over');
|
|
});
|
|
div.addEventListener('dragleave', () => {
|
|
div.classList.remove('drag-over');
|
|
});
|
|
div.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
div.classList.remove('drag-over');
|
|
if (!dragIndices || dragIndices.size === 0 || dragIndices.has(realIdx)) return;
|
|
const indices = [...dragIndices].sort((a, b) => a - b);
|
|
const draggedTracks = indices.map(idx => tracks[idx]);
|
|
const wasPlaying = currentIndex >= 0 ? tracks[currentIndex] : null;
|
|
for (let j = indices.length - 1; j >= 0; j--) tracks.splice(indices[j], 1);
|
|
const targetIdx = tracks.indexOf(t);
|
|
tracks.splice(targetIdx, 0, ...draggedTracks);
|
|
if (wasPlaying) currentIndex = tracks.indexOf(wasPlaying);
|
|
selectedIndices.clear();
|
|
for (const tr of draggedTracks) selectedIndices.add(tracks.indexOf(tr));
|
|
anchorTrack = draggedTracks[0];
|
|
dragIndices = null;
|
|
renderTracks();
|
|
});
|
|
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 = '▮▮'; playBtn.title = 'Pause';
|
|
}
|
|
function onPause(e) {
|
|
if (e.target !== audio) return;
|
|
playBtn.innerHTML = '▶'; 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;
|
|
const selArr = [...selectedIndices];
|
|
const sel = selArr.length > 0 ? selArr[0] : -1;
|
|
if (sel >= 0 && sel !== currentIndex) {
|
|
playTrack(sel);
|
|
} else if (currentIndex < 0 && sel >= 0) {
|
|
playTrack(sel);
|
|
} else if (currentIndex < 0) {
|
|
playTrack(0);
|
|
} else 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);
|
|
isolateBtn.addEventListener('click', isolateTracks);
|
|
deleteBtn.addEventListener('click', deleteTrack);
|
|
|
|
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); });
|
|
barDecaySelect.addEventListener('change', () => { barDecay = parseFloat(barDecaySelect.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;
|
|
let isInternalDrag = false;
|
|
const content = document.querySelector('.content');
|
|
|
|
content.addEventListener('dragenter', (e) => {
|
|
if (isInternalDrag) return;
|
|
e.preventDefault();
|
|
dragCounter++;
|
|
content.style.outline = '2px dashed #84a0c6';
|
|
content.style.outlineOffset = '-12px';
|
|
});
|
|
|
|
content.addEventListener('dragleave', (e) => {
|
|
if (isInternalDrag) return;
|
|
e.preventDefault();
|
|
dragCounter--;
|
|
if (dragCounter <= 0) { dragCounter = 0; content.style.outline = 'none'; }
|
|
});
|
|
|
|
content.addEventListener('dragover', (e) => {
|
|
if (isInternalDrag) { e.preventDefault(); return; }
|
|
e.preventDefault();
|
|
});
|
|
|
|
content.addEventListener('drop', async (e) => {
|
|
if (isInternalDrag) return;
|
|
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>
|