noiz/index.html

1699 lines
72 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; position: relative; }
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; align-items: center; }
.audio-info { font-size: 11px; color: #6b7089; font-variant-numeric: tabular-nums; min-height: 1.4em; }
.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; }
.waveform-wrap { flex: 1; min-width: 0; cursor: pointer; position: relative; height: 42px; }
.waveform-wrap canvas { display: block; width: 100%; height: 42px; }
#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; } }
#tagEditorOverlay { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; background: rgba(22,24,33,0.85); z-index: 10000; }
#tagEditorModal { background: #1e2132; border: 1px solid #6b7089; border-radius: 8px; padding: 24px; min-width: 640px; max-width: 700px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
#tagEditorModal h3 { margin-bottom: 16px; color: #c6c8d1; font-size: 14px; font-weight: 600; }
.tag-editor-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.tag-editor-fields label { display: flex; flex-direction: column; gap: 2px; margin-bottom: 0; font-size: 11px; color: #6b7089; text-transform: uppercase; letter-spacing: .4px; }
#tagEditorModal input, #tagEditorModal select { background: #161821; border: 1px solid #272c42; color: #c6c8d1; padding: 6px 8px; border-radius: 4px; font-size: 12px; outline: none; }
#tagEditorModal input:focus, #tagEditorModal select:focus { border-color: #84a0c6; }
.tag-editor-buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
#timeRemaining { color: #6b7089; }
#vuMeter { width: 100%; height: 20px; border-radius: 2px; display: block; }
.controls { display: flex; align-items: center; gap: 16px; }
.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; }
.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: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath d='M2 3l3 3 3-3' stroke='%23c6c8d1' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") right 6px center / 8px no-repeat, linear-gradient(to bottom, #272c42, #1e2132); border: 1px solid #6b7089; color: #c6c8d1; padding: 4px 20px 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; -webkit-appearance: none; appearance: none; }
.playlist-toolbar select:hover { background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath d='M2 3l3 3 3-3' stroke='%23c6c8d1' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") right 6px center / 8px no-repeat, linear-gradient(to bottom, #2e3348, #272c42); }
select option { background: #1e2132; color: #c6c8d1; }
.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: default; 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; }
.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: #8a8fa8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-item .col-file { color: #787d95; 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; overflow: hidden; white-space: nowrap; }
.track-item .col-genre { color: #6b7089; font-size: 10px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
footer .footer-track { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 700; }
#footerTime { font-weight: 700; }
#footerStatus { font-size: 12px; font-weight: 600; white-space: nowrap; position: absolute; left: 50%; transform: translateX(-50%); pointer-events: none; }
#footerStatus.success { color: #87be82; }
#footerStatus.error { color: #e27878; }
#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">&#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="waveform-wrap" id="waveformWrap">
<canvas id="waveform"></canvas>
</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">&#9664;&#9664;</button>
<button class="play-btn" id="playBtn" title="Play">&#9654;</button>
<button id="pauseBtn" title="Pause">&#9646;&#9646;</button>
<button id="stopBtn" title="Stop">&#9632;</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:#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="infoBtn">Info</button>
<button class="btn" id="isolateBtn">Isolate</button>
<button class="btn" id="deleteBtn">Delete</button>
</div>
</div>
<div id="tagEditorOverlay">
<div id="tagEditorModal">
<h3>Edit Tags</h3>
<div class="tag-editor-fields">
<label>Name <input type="text" id="tagName"></label>
<label>Artist <input type="text" id="tagArtist"></label>
<label>Album <input type="text" id="tagAlbum"></label>
<label>Genre <input type="text" id="tagGenre" list="genreList"></label>
<label>Year <input type="text" id="tagYear"></label>
<label>Track No. <input type="text" id="tagTrack"></label>
<label>Comment <input type="text" id="tagComment"></label>
<label>Composer <input type="text" id="tagComposer"></label>
<label>URL <input type="text" id="tagUrl"></label>
<label>Encoded By <input type="text" id="tagEncodedBy"></label>
</div>
<datalist id="genreList"></datalist>
<div class="tag-editor-buttons">
<button class="btn btn-primary" id="tagSaveBtn">Save</button>
<button class="btn" id="tagCancelBtn">Cancel</button>
</div>
</div>
</div>
<footer id="footer">
<span class="footer-track" id="footerTrack">No track</span>
<span id="footerStatus"></span>
<span id="footerTime">0:00 / 0:00</span>
<a href="https://codeberg.org/armin/noiz" target="_blank"><span class="brand">noiz<span>://</span></span></a>
</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 = 32;
const barMags = new Float32Array(NUM_BARS);
const minFreq = 20;
const maxFreq = 19000;
const blend = 0.6;
for (let b = 0; b < NUM_BARS; b++) {
const lowFreq = minFreq * Math.pow(maxFreq / minFreq, b / NUM_BARS);
const highFreq = minFreq * Math.pow(maxFreq / minFreq, (b + 1) / NUM_BARS);
const logStart = 1 + Math.floor(lowFreq * fftSize / sampleRate);
const logEnd = 1 + Math.min(maxBin, Math.ceil(highFreq * fftSize / sampleRate));
const linStart = 1 + Math.floor(b * (maxBin - 1) / NUM_BARS);
const linEnd = 1 + Math.floor((b + 1) * (maxBin - 1) / NUM_BARS);
const start = Math.round(linStart + (logStart - linStart) * blend);
const end = Math.round(linEnd + (logEnd - linEnd) * blend);
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 = 32;
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();
const WF_PPS = 6;
const WF_HEIGHT = 42;
function computeWaveform(t) {
if (t.waveform || !t.file) return;
fetch(t.url)
.then(r => r.arrayBuffer())
.then(buf => {
if (!audioCtx) initAudioCtx();
return audioCtx.decodeAudioData(buf);
})
.then(ab => {
const ch = ab.getChannelData(0);
const len = Math.ceil(t.playtime * WF_PPS);
const step = Math.max(1, Math.floor(ch.length / len));
const data = new Float32Array(len * 2);
for (let i = 0; i < len; i++) {
let mn = 0, mx = 0;
const start = i * step;
const end = Math.min(start + step, ch.length);
for (let j = start; j < end; j++) {
if (ch[j] < mn) mn = ch[j];
if (ch[j] > mx) mx = ch[j];
}
data[i * 2] = mn;
data[i * 2 + 1] = mx;
}
t.waveform = { len, data };
drawWaveform();
})
.catch(() => {});
}
function drawWaveform() {
const t = tracks[currentIndex];
const dpr = window.devicePixelRatio || 1;
const wrap = waveformWrap;
const visWidth = wrap.clientWidth;
const canvas = waveformCanvas;
const h = WF_HEIGHT;
canvas.style.height = h + 'px';
canvas.width = Math.round(visWidth * dpr);
canvas.height = Math.round(h * dpr);
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, visWidth, h);
if (!t || !t.waveform) return;
const wf = t.waveform;
const data = wf.data;
const halfH = h / 2;
const ampScale = halfH * 0.85;
let playheadX = -1;
if (audio && audio.duration && t.playtime) {
playheadX = (audio.currentTime / audio.duration) * visWidth;
}
for (let x = 0; x < visWidth; x++) {
const startIdx = Math.floor((x / visWidth) * wf.len);
const endIdx = Math.floor(((x + 1) / visWidth) * wf.len);
let mn = 0, mx = 0;
for (let i = startIdx; i < endIdx; i++) {
if (data[i * 2] < mn) mn = data[i * 2];
if (data[i * 2 + 1] > mx) mx = data[i * 2 + 1];
}
const y1 = halfH + mn * ampScale;
const y2 = halfH + mx * ampScale;
ctx.fillStyle = playheadX >= 0 && x < playheadX ? '#6b88b0' : '#84a0c6';
ctx.fillRect(x, y1, 1, Math.max(1, y2 - y1));
}
if (playheadX >= 0) {
ctx.strokeStyle = '#e27878';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(playheadX, 2);
ctx.lineTo(playheadX, h - 2);
ctx.stroke();
}
}
let tracks = [];
let currentIndex = -1;
let selectedIndices = new Set();
let anchorTrack = null;
let pendingNextIndex = -1;
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 pauseBtn = document.getElementById('pauseBtn');
const stopBtn = document.getElementById('stopBtn');
const waveformCanvas = document.getElementById('waveform');
const waveformWrap = document.getElementById('waveformWrap');
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 footerStatus = document.getElementById('footerStatus');
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 infoBtn = document.getElementById('infoBtn');
const tagEditorOverlay = document.getElementById('tagEditorOverlay');
const tagName = document.getElementById('tagName');
const tagArtist = document.getElementById('tagArtist');
const tagAlbum = document.getElementById('tagAlbum');
const tagGenre = document.getElementById('tagGenre');
const tagYear = document.getElementById('tagYear');
const tagTrack = document.getElementById('tagTrack');
const tagComment = document.getElementById('tagComment');
const tagComposer = document.getElementById('tagComposer');
const tagUrl = document.getElementById('tagUrl');
const tagEncodedBy = document.getElementById('tagEncodedBy');
const tagSaveBtn = document.getElementById('tagSaveBtn');
const tagCancelBtn = document.getElementById('tagCancelBtn');
const genreList = document.getElementById('genreList');
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;
else if (frameId === 'COMM') tags.comment = text;
else if (frameId === 'TCOM') tags.composer = text;
else if (frameId === 'TENC') tags.encodedBy = 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 readVorbisComments(file) {
try {
const maxRead = Math.min(file.size, 256 * 1024);
const data = await readBytes(file, 0, maxRead);
if (data[0] !== 0x66 || data[1] !== 0x4C || data[2] !== 0x61 || data[3] !== 0x43) return null;
let pos = 4;
let commentData = null;
while (pos + 4 <= data.length) {
const isLast = data[pos] & 0x80;
const blockType = data[pos] & 0x7f;
const blockLen = (data[pos + 1] << 16) | (data[pos + 2] << 8) | data[pos + 3];
pos += 4;
if (pos + blockLen > data.length) break;
if (blockType === 4) { commentData = data.slice(pos, pos + blockLen); break; }
pos += blockLen;
if (isLast) break;
}
if (!commentData) return null;
let off = 0;
if (off + 4 > commentData.length) return null;
const vendorLen = commentData[off] | (commentData[off + 1] << 8) | (commentData[off + 2] << 16) | (commentData[off + 3] << 24);
off += 4 + vendorLen;
if (off + 4 > commentData.length) return null;
const numTags = commentData[off] | (commentData[off + 1] << 8) | (commentData[off + 2] << 16) | (commentData[off + 3] << 24);
off += 4;
const tags = {};
for (let i = 0; i < numTags; i++) {
if (off + 4 > commentData.length) break;
const tagLen = commentData[off] | (commentData[off + 1] << 8) | (commentData[off + 2] << 16) | (commentData[off + 3] << 24);
off += 4;
if (off + tagLen > commentData.length) break;
const tagStr = new TextDecoder('utf-8').decode(commentData.slice(off, off + tagLen));
off += tagLen;
const eqIdx = tagStr.indexOf('=');
if (eqIdx === -1) continue;
const key = tagStr.substring(0, eqIdx).toUpperCase();
const value = tagStr.substring(eqIdx + 1);
switch (key) {
case 'TITLE': tags.title = value; break;
case 'ARTIST': tags.artist = value; break;
case 'ALBUM': tags.album = value; break;
case 'GENRE': tags.genre = value; break;
case 'DATE':
case 'YEAR': tags.year = value; break;
case 'TRACKNUMBER': tags.track = value; break;
case 'COMMENT': tags.comment = value; break;
case 'COMPOSER': tags.composer = value; break;
case 'URL': tags.urlTag = value; break;
case 'ENCODEDBY':
case 'ENCODER': tags.encodedBy = value; break;
}
}
return Object.keys(tags).length > 0 ? tags : null;
} catch { return null; }
}
async function readTags(file) {
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
if (ext === '.flac') {
const v = await readVorbisComments(file);
if (v) return v;
}
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 readDuration(file) {
try {
const url = URL.createObjectURL(file);
const dur = await new Promise((resolve) => {
const a = new Audio();
a.preload = 'metadata';
a.onloadedmetadata = () => { resolve(a.duration); };
a.onerror = () => { resolve(0); };
a.src = url;
});
URL.revokeObjectURL(url);
return isFinite(dur) && dur > 0 ? dur : 0;
} catch { return 0; }
}
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, duration] = await Promise.all([readTags(file), readAudioInfo(file), readDuration(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 || '',
comment: tags.comment || '',
composer: tags.composer || '',
urlTag: tags.urlTag || '',
encodedBy: tags.encodedBy || '',
filename: file.name,
url,
file,
path,
size: file.size,
playtime: duration,
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;
const sortedSel = [...selectedIndices].sort((a, b) => a - b);
const willDeleteCurrent = selectedIndices.has(currentIndex);
let playNextOrigIdx = -1;
if (willDeleteCurrent && selectedIndices.size < tracks.length) {
for (let i = currentIndex + 1; i < tracks.length; i++) {
if (!selectedIndices.has(i)) { playNextOrigIdx = i; break; }
}
if (playNextOrigIdx === -1) {
for (let i = currentIndex - 1; i >= 0; i--) {
if (!selectedIndices.has(i)) { playNextOrigIdx = i; break; }
}
}
}
if (willDeleteCurrent && playNextOrigIdx === -1) {
audio.pause();
audio.currentTime = 0;
if (isCrossfading) {
audioNext.pause();
audioNext.currentTime = 0;
}
}
if (selectedIndices.size === tracks.length) {
for (const idx of sortedSel) {
if (idx !== currentIndex) URL.revokeObjectURL(tracks[idx].url);
}
tracks = [];
currentIndex = -1;
selectedIndices.clear();
renderTracks();
return;
}
for (const idx of sortedSel) {
if (idx !== currentIndex) URL.revokeObjectURL(tracks[idx].url);
}
for (let j = sortedSel.length - 1; j >= 0; j--) tracks.splice(sortedSel[j], 1);
selectedIndices.clear();
if (willDeleteCurrent) {
if (playNextOrigIdx >= 0) {
const removedBefore = sortedSel.filter(idx => idx < playNextOrigIdx).length;
pendingNextIndex = playNextOrigIdx - removedBefore;
}
currentIndex = -1;
} else {
const removedBefore = sortedSel.filter(idx => idx < currentIndex).length;
currentIndex -= removedBefore;
}
renderTracks();
}
function openTagEditor() {
let idx = -1;
if (selectedIndices.size > 0) {
idx = [...selectedIndices][0];
} else if (currentIndex >= 0) {
idx = currentIndex;
}
if (idx < 0) { showStatus('No track selected.', 'error'); return; }
const t = tracks[idx];
tagEditorOverlay.dataset.idx = idx;
tagName.value = t.name || '';
tagArtist.value = t.artist || '';
tagAlbum.value = t.album || '';
tagGenre.value = t.genre || '';
tagYear.value = t.year || '';
tagTrack.value = t.track || '';
tagComment.value = t.comment || '';
tagComposer.value = t.composer || '';
tagUrl.value = t.urlTag || '';
tagEncodedBy.value = t.encodedBy || '';
tagEditorOverlay.style.display = 'flex';
}
function showStatus(msg, type) {
footerStatus.textContent = msg;
footerStatus.className = type;
clearTimeout(footerStatus._timer);
footerStatus._timer = setTimeout(() => { footerStatus.textContent = ''; footerStatus.className = ''; }, 2000);
}
function saveTagEditor() {
const idx = parseInt(tagEditorOverlay.dataset.idx, 10);
if (isNaN(idx) || idx < 0 || idx >= tracks.length) {
showStatus('Error saving tags', 'error');
return;
}
const t = tracks[idx];
t.name = tagName.value.trim();
t.artist = tagArtist.value.trim();
t.album = tagAlbum.value.trim();
t.genre = tagGenre.value.trim();
t.year = tagYear.value.trim();
t.track = tagTrack.value.trim();
t.comment = tagComment.value.trim();
t.composer = tagComposer.value.trim();
t.urlTag = tagUrl.value.trim();
t.encodedBy = tagEncodedBy.value.trim();
if (idx === currentIndex) updateUI();
renderTracks();
tagEditorOverlay.style.display = 'none';
showStatus('Tag information saved.', 'success');
}
function cancelTagEditor() {
tagEditorOverlay.style.display = 'none';
}
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;
}
selectedIndices.clear();
selectedIndices.add(realIdx);
anchorTrack = t;
renderTracks();
});
div.addEventListener('dblclick', () => {
if (realIdx === currentIndex) {
audio.currentTime = 0;
audio.play().catch(() => {});
} else {
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.replace(/[\r\n]+/g, ' ');
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);
audio = audioNext;
audioNext = audio === audioElA ? audioElB : audioElA;
clearTimeout(crossfadeTimer);
crossfadeTimer = setTimeout(() => {
crossfadeTimer = null;
audioNext.pause();
audioNext.removeAttribute('src');
audioNext.load();
isCrossfading = false;
}, cd * 1000 + 100);
updateUI();
renderTracks();
drawWaveform();
};
audioNext.addEventListener('canplaythrough', onReady);
} else {
if (audio.src !== t.url) {
audio.src = t.url;
audio.load();
}
audio.play().catch(() => {});
initAudioCtx();
updateUI();
renderTracks();
drawWaveform();
}
}
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();
computeWaveform(t);
}
}
function onTimeupdate(e) {
if (e.target !== audio) return;
if (!audio.duration) return;
const remaining = audio.duration - audio.currentTime;
drawWaveform();
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 (pendingNextIndex >= 0) {
const idx = pendingNextIndex;
pendingNextIndex = -1;
playTrack(idx);
return;
}
if (repeatMode === 'one') {
playTrack(currentIndex);
} else {
nextTrack();
}
}
function onPlay(e) {
}
function onPause(e) {
}
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 && currentIndex >= 0) {
audio.play().catch(() => {});
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 && pendingNextIndex >= 0) {
const idx = pendingNextIndex;
pendingNextIndex = -1;
playTrack(idx);
} else if (currentIndex < 0) {
playTrack(0);
} else {
audio.play().catch(() => {});
}
});
pauseBtn.addEventListener('click', () => {
if (currentIndex < 0) return;
if (audio.paused) {
audio.play().catch(() => {});
} else {
audio.pause();
if (isCrossfading) audioNext.pause();
}
});
stopBtn.addEventListener('click', () => {
if (currentIndex < 0) return;
audio.pause();
if (isCrossfading) audioNext.pause();
audio.currentTime = 0;
});
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);
}
}
waveformWrap.addEventListener('click', (e) => {
if (!audio.duration) return;
if (e.target !== waveformCanvas) return;
const rect = waveformCanvas.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * 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);
infoBtn.addEventListener('click', openTagEditor);
tagSaveBtn.addEventListener('click', saveTagEditor);
tagCancelBtn.addEventListener('click', cancelTagEditor);
tagEditorOverlay.addEventListener('click', (e) => {
if (e.target === tagEditorOverlay) cancelTagEditor();
});
tagName.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveTagEditor(); });
tagArtist.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveTagEditor(); });
tagAlbum.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveTagEditor(); });
tagGenre.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveTagEditor(); });
tagYear.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveTagEditor(); });
tagTrack.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveTagEditor(); });
tagComment.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveTagEditor(); });
tagComposer.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveTagEditor(); });
tagUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveTagEditor(); });
tagEncodedBy.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveTagEditor(); });
GENRES.forEach(g => { const opt = document.createElement('option'); opt.value = g; genreList.appendChild(opt); });
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 === 'KeyA' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (tracks.length) {
selectedIndices = new Set(tracks.keys());
anchorTrack = null;
renderTracks();
}
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>