Playlist: per-track start/end trim, total/elapsed/remaining times, waveform cards

- Per-track start/end points (mm:ss inputs, 'set to current' buttons, draggable
  handles on the waveform). Trim applied via audio.setTrim (seek to start, stop
  at end) and pushed live to the playing track.
- Wider cards showing elapsed / total / remaining for the trimmed range.
- Waveform rendered per track: peaks computed in main via ffmpeg (mono 8kHz PCM
  -> 400 normalized buckets, cached), drawn on a canvas with trim region +
  playhead. New audio:peaks IPC + djv.peaks preload binding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.8 committed 2026-06-22 16:36:54 +02:00
1 parent a8973619e6
commit 882dd4643a
6 files changed
+249 -18

No files matched your search

+31
View File
@@ -134,6 +134,37 @@ function transcodeToMp4(input, output, opts) {
}); });
} }
// Decode a media file to mono 8 kHz PCM via ffmpeg and reduce it to `buckets`
// peak amplitudes (0..1) for drawing a waveform. Returns null on failure.
const peaksCache = new Map();
ipcMain.handle('audio:peaks', async (_e, filePath, buckets) => {
buckets = buckets || 400;
const key = filePath + '|' + buckets;
if (peaksCache.has(key)) return peaksCache.get(key);
const args = ['-v', 'quiet', '-i', filePath, '-ac', '1', '-ar', '8000', '-f', 's16le', '-'];
return new Promise((resolve) => {
execFile(ffmpegPath(), args, { encoding: 'buffer', maxBuffer: 1 << 28 }, (err, stdout) => {
if (err || !stdout || stdout.length < 4) { resolve(null); return; }
const u8 = Uint8Array.from(stdout);
const samples = new Int16Array(u8.buffer, 0, u8.length >> 1);
const n = samples.length;
const peaks = new Array(buckets).fill(0);
const per = Math.max(1, Math.floor(n / buckets));
for (let b = 0; b < buckets; b++) {
let max = 0;
const s = b * per, e = Math.min(n, s + per);
for (let i = s; i < e; i++) { const v = samples[i] < 0 ? -samples[i] : samples[i]; if (v > max) max = v; }
peaks[b] = max / 32768;
}
// Normalize so the loudest peak fills the height.
const top = Math.max(0.0001, ...peaks);
for (let b = 0; b < buckets; b++) peaks[b] = Math.min(1, peaks[b] / top);
peaksCache.set(key, peaks);
resolve(peaks);
});
});
});
function recordingsDir() { function recordingsDir() {
const dir = path.join(app.getPath('videos'), 'DJ Visualizer'); const dir = path.join(app.getPath('videos'), 'DJ Visualizer');
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
+2 -1
View File
@@ -34,5 +34,6 @@ contextBridge.exposeInMainWorld('djv', {
loadPlaylist: () => ipcRenderer.invoke('playlist:load'), loadPlaylist: () => ipcRenderer.invoke('playlist:load'),
savePlaylist: (data) => ipcRenderer.invoke('playlist:save', data), savePlaylist: (data) => ipcRenderer.invoke('playlist:save', data),
exportPlaylist: (data) => ipcRenderer.invoke('playlist:export', data), exportPlaylist: (data) => ipcRenderer.invoke('playlist:export', data),
importPlaylist: () => ipcRenderer.invoke('playlist:import') importPlaylist: () => ipcRenderer.invoke('playlist:import'),
peaks: (path, buckets) => ipcRenderer.invoke('audio:peaks', path, buckets)
}); });
+27
View File
@@ -34,6 +34,9 @@ class AudioEngine {
this.stream = null; // MediaStream when using live input this.stream = null; // MediaStream when using live input
this.mode = 'none'; this.mode = 'none';
this.onEnded = null; // callback fired when a (non-looping) track ends this.onEnded = null; // callback fired when a (non-looping) track ends
this.trimStart = 0; // playback start point (s); seek here on load
this.trimEnd = 0; // playback end point (s); 0 = play to the natural end
this._trimFired = false;
this._videoSrc = null; // persistent source for the playlist video element this._videoSrc = null; // persistent source for the playlist video element
this._videoGain = null; this._videoGain = null;
this._videoSrcEl = null; this._videoSrcEl = null;
@@ -68,6 +71,28 @@ class AudioEngine {
resume() { if (this.ctx.state === 'suspended') this.ctx.resume(); } resume() { if (this.ctx.state === 'suspended') this.ctx.resume(); }
// Trim the current/next track to [start, end] seconds (end 0 = natural end).
setTrim(start, end) {
this.trimStart = Math.max(0, start || 0);
this.trimEnd = Math.max(0, end || 0);
this._trimFired = false;
}
// Seek a freshly-loaded media element to the trim start (waits for metadata).
seekToTrimStart(el) {
const go = () => { if (this.trimStart > 0) { try { el.currentTime = this.trimStart; } catch (e) {} } };
if (el.readyState >= 1) go(); else el.addEventListener('loadedmetadata', go, { once: true });
}
// Called every frame from the output loop: stop at the trim end point.
checkTrim() {
const el = this.mediaEl;
if (!el || el.paused || this._trimFired) return;
if (this.trimEnd > 0 && el.currentTime >= this.trimEnd) {
this._trimFired = true;
try { el.pause(); } catch (e) {}
if (this.onEnded) this.onEnded();
}
}
// Stop any current audio/video source but keep the context & analyser alive // Stop any current audio/video source but keep the context & analyser alive
// (meters read 0, recording keep-alive continues). Used for visual-only // (meters read 0, recording keep-alive continues). Used for visual-only
// playlist interludes. // playlist interludes.
@@ -128,8 +153,10 @@ class AudioEngine {
this.sourceGain = g; this.sourceGain = g;
this.stream = null; this.stream = null;
this.mode = 'file'; this.mode = 'file';
this._trimFired = false;
await el.play(); await el.play();
this.seekToTrimStart(el);
return el; return el;
} }
+150 -13
View File
@@ -202,7 +202,106 @@ let isPlaying = false; // whether the current track is playing
let ended = false; // true when the current track reached its end let ended = false; // true when the current track reached its end
let playbackOwner = 'playlist'; // 'playlist' or 'pad' — who started playback let playbackOwner = 'playlist'; // 'playlist' or 'pad' — who started playback
const durations = {}; // path -> seconds (probed from the output window) const durations = {}; // path -> seconds (probed from the output window)
const peaksCache = {}; // path -> Array(N) of 0..1 peak amplitudes (or 'loading')
const WAVE_BUCKETS = 400;
let playCur = 0, playDur = 0; // live progress of the currently playing track let playCur = 0, playDur = 0; // live progress of the currently playing track
// Lazily fetch the waveform peaks for a file, then redraw its card.
function ensurePeaks(path) {
if (!path || peaksCache[path]) return;
peaksCache[path] = 'loading';
djv.peaks(path, WAVE_BUCKETS).then((p) => {
peaksCache[path] = p && p.length ? p : null;
renderPlaylist();
}).catch(() => { peaksCache[path] = null; });
}
// Draw a waveform on a canvas, shading the trimmed-out parts and the playhead.
function drawWave(canvas, tr, isCur) {
const peaks = peaksCache[tr.path];
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height, mid = h / 2;
ctx.clearRect(0, 0, w, h);
if (!Array.isArray(peaks)) { // still loading / unavailable
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(0, mid - 1, w, 2);
return;
}
const dur = durations[tr.path] || 0;
const s = tr.start || 0;
const e = tr.end > 0 ? tr.end : dur;
const sx = dur > 0 ? (s / dur) * w : 0;
const ex = dur > 0 ? (e / dur) * w : w;
const N = peaks.length;
for (let x = 0; x < w; x++) {
const pk = peaks[Math.min(N - 1, Math.floor(x / w * N))];
const bar = Math.max(1, pk * (h * 0.46));
const inTrim = x >= sx && x <= ex;
ctx.fillStyle = inTrim ? 'rgba(140,182,255,0.85)' : 'rgba(140,182,255,0.18)';
ctx.fillRect(x, mid - bar, 1, bar * 2);
}
// trim handles
if (dur > 0) {
ctx.fillStyle = '#6ee7a0'; ctx.fillRect(sx, 0, 2, h); // start (green)
ctx.fillStyle = '#ff9a9a'; ctx.fillRect(ex - 2, 0, 2, h); // end (red)
}
// playhead
if (isCur && playDur > 0) {
const px = Math.min(w, (playCur / playDur) * w);
ctx.fillStyle = '#ffffff'; ctx.fillRect(px, 0, 1.5, h);
}
}
// Clamp and persist a track's start/end; push it live to the output if playing.
function commitTrim(tr, i, rerender) {
const dur = durations[tr.path] || 0;
tr.start = Math.max(0, tr.start || 0);
if (dur > 0 && tr.start > dur) tr.start = dur;
if (tr.end > 0) {
if (dur > 0 && tr.end > dur) tr.end = dur;
if (tr.end <= tr.start + 0.2) tr.end = Math.min(dur || tr.start + 1, tr.start + 1);
}
savePlaylistState();
if (i === currentIndex) send({ type: 'setTrim', start: tr.start || 0, end: tr.end || 0 });
if (rerender) renderPlaylist();
}
// Wire the trim inputs, the "set to current" buttons and waveform drag handles.
function wireTrim(li, canvas, tr, i) {
const sIn = li.querySelector('.trim-start');
const eIn = li.querySelector('.trim-end');
sIn.addEventListener('change', () => { tr.start = parseTime(sIn.value); commitTrim(tr, i, true); });
eIn.addEventListener('change', () => { tr.end = eIn.value.trim() ? parseTime(eIn.value) : 0; commitTrim(tr, i, true); });
li.querySelector('.trim-here-s').addEventListener('click', () => { if (i === currentIndex) { tr.start = Math.max(0, playCur); commitTrim(tr, i, true); } });
li.querySelector('.trim-here-e').addEventListener('click', () => { if (i === currentIndex) { tr.end = Math.max(0.5, playCur); commitTrim(tr, i, true); } });
li.querySelector('.trim-reset').addEventListener('click', () => { tr.start = 0; tr.end = 0; commitTrim(tr, i, true); });
let dragH = null;
const dur = () => durations[tr.path] || (i === currentIndex ? playDur : 0) || 0;
const timeAt = (e) => {
const rect = canvas.getBoundingClientRect();
const f = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
return f * dur();
};
canvas.addEventListener('pointerdown', (e) => {
if (!dur()) return;
const t = timeAt(e), s = tr.start || 0, en = tr.end > 0 ? tr.end : dur();
dragH = Math.abs(t - s) <= Math.abs(t - en) ? 'start' : 'end';
canvas.setPointerCapture(e.pointerId); e.preventDefault();
});
canvas.addEventListener('pointermove', (e) => {
if (!dragH) return;
const t = timeAt(e);
if (dragH === 'start') tr.start = Math.max(0, Math.min(t, (tr.end > 0 ? tr.end : dur()) - 0.5));
else tr.end = Math.min(dur(), Math.max(t, (tr.start || 0) + 0.5));
drawWave(canvas, tr, i === currentIndex);
sIn.value = fmtTime(Math.floor(tr.start || 0));
eIn.value = tr.end > 0 ? fmtTime(Math.floor(tr.end)) : '';
});
const endDrag = () => { if (dragH) { dragH = null; commitTrim(tr, i, true); } };
canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', endDrag);
}
let padCrossfadeMs = 0; // crossfade duration for pad triggers let padCrossfadeMs = 0; // crossfade duration for pad triggers
let capturingFor = -1; // index of the track awaiting a hotkey, or -1 let capturingFor = -1; // index of the track awaiting a hotkey, or -1
let sceneEditing = -1; // playlist index whose scene editor is open let sceneEditing = -1; // playlist index whose scene editor is open
@@ -210,10 +309,10 @@ let sceneImgTarget = -1, sceneCueTarget = -1; // track/cue awaiting an image
let activeCues = [], firedCue = -1; // cue schedule for the playing track let activeCues = [], firedCue = -1; // cue schedule for the playing track
function serializePlaylist() { function serializePlaylist() {
return playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0 })); return playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0, start: t.start || 0, end: t.end || 0 }));
} }
function normalizeTrack(t) { function normalizeTrack(t) {
return { path: t.path || '', name: t.name || baseName(t.path || '') || 'Intermezzo', key: t.key || null, cues: migrateCues(Array.isArray(t.cues) ? t.cues : []), isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0 }; return { path: t.path || '', name: t.name || baseName(t.path || '') || 'Intermezzo', key: t.key || null, cues: migrateCues(Array.isArray(t.cues) ? t.cues : []), isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0, start: t.start || 0, end: t.end || 0 };
} }
function savePlaylistState() { function savePlaylistState() {
if (djv.savePlaylist) djv.savePlaylist(serializePlaylist()); if (djv.savePlaylist) djv.savePlaylist(serializePlaylist());
@@ -362,7 +461,7 @@ function probePaths(paths) {
function addTracks(items) { function addTracks(items) {
// items: array of { path, name } // items: array of { path, name }
const start = playlist.length; const start = playlist.length;
items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null, cues: [], isVideo: !!it.isVideo, gap: 0 }); }); items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null, cues: [], isVideo: !!it.isVideo, gap: 0, start: 0, end: 0 }); });
renderPlaylist(); renderPlaylist();
savePlaylistState(); savePlaylistState();
probePaths(items.map(it => it.path)); probePaths(items.map(it => it.path));
@@ -393,7 +492,7 @@ function playIndex(i) {
playInterlude(tr); playInterlude(tr);
return; return;
} }
send({ type: tr.isVideo ? 'playVideoTrack' : 'playTrack', path: tr.path }); send({ type: tr.isVideo ? 'playVideoTrack' : 'playTrack', path: tr.path, start: tr.start || 0, end: tr.end || 0 });
startCues(tr); startCues(tr);
renderPlaylist(); renderPlaylist();
} }
@@ -668,16 +767,46 @@ function renderPlaylist() {
const prog = (isCur && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0; const prog = (isCur && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0;
const gapBadge = (tr.gap > 0 && !tr.isInterlude) ? '<span class="gap-badge" title="Pausa di ' + tr.gap + 's dopo questo brano">⏸' + tr.gap + 's</span>' : ''; const gapBadge = (tr.gap > 0 && !tr.isInterlude) ? '<span class="gap-badge" title="Pausa di ' + tr.gap + 's dopo questo brano">⏸' + tr.gap + 's</span>' : '';
const icon = tr.isInterlude ? '✨ ' : (tr.isVideo ? '🎞 ' : ''); const icon = tr.isInterlude ? '✨ ' : (tr.isVideo ? '🎞 ' : '');
// Total / elapsed / remaining for the trimmed range.
const dur = durations[tr.path] || (isCur ? playDur : 0);
const tStart = tr.start || 0;
const tEnd = tr.end > 0 ? tr.end : dur;
const total = Math.max(0, tEnd - tStart);
const elapsed = isCur ? Math.max(0, Math.min(total, playCur - tStart)) : 0;
const remaining = Math.max(0, total - elapsed);
const body = (tr.isInterlude || !tr.path) ? '' :
'<div class="track-body">' +
'<div class="track-times">' +
'<span class="tt-el" title="Trascorso">▶ ' + fmtTime(Math.floor(elapsed)) + '</span>' +
'<span class="tt-tot" title="Durata totale">⏱ ' + fmtTime(Math.floor(total)) + '</span>' +
'<span class="tt-rem" title="Rimasto">⧗ -' + fmtTime(Math.ceil(remaining)) + '</span>' +
'</div>' +
'<canvas class="wave" width="' + WAVE_BUCKETS + '" height="46"></canvas>' +
'<div class="track-trim">' +
'<span class="trim-lbl trim-lbl-s">Inizio</span>' +
'<input class="trim-start" type="text" value="' + fmtTime(Math.floor(tStart)) + '" title="Punto di inizio (mm:ss)" />' +
'<button class="trim-here-s" title="Imposta inizio al punto attuale">📍</button>' +
'<span class="trim-lbl trim-lbl-e">Fine</span>' +
'<input class="trim-end" type="text" value="' + (tr.end > 0 ? fmtTime(Math.floor(tr.end)) : '') + '" placeholder="—" title="Punto di fine (mm:ss, vuoto = fine brano)" />' +
'<button class="trim-here-e" title="Imposta fine al punto attuale">📍</button>' +
'<button class="trim-reset" title="Azzera inizio/fine">↺</button>' +
'</div>' +
'</div>';
li.innerHTML = li.innerHTML =
'<span class="grip">⠿</span>' + '<div class="track-head">' +
'<span class="tname' + (tr.isInterlude ? ' is-interlude' : '') + '" title="Avvia / riavvia dall\'inizio — ' + tr.name.replace(/"/g, '&quot;') + '">' + icon + tr.name + gapBadge + '</span>' + '<span class="grip">⠿</span>' +
'<span class="ttime">' + timeLabel + '</span>' + '<span class="tname' + (tr.isInterlude ? ' is-interlude' : '') + '" title="Avvia / riavvia dall\'inizio — ' + tr.name.replace(/"/g, '&quot;') + '">' + icon + tr.name + gapBadge + '</span>' +
'<button class="key-btn" title="Assegna tasto rapido">' + '<span class="ttime">' + timeLabel + '</span>' +
(capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' + '<button class="key-btn" title="Assegna tasto rapido">' +
'<button class="play-btn" title="' + (isCur && isPlaying ? 'Pausa' : 'Avvia') + '">' + playIcon + '</button>' + (capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' +
'<button class="scene-btn' + (hasScene(tr) ? ' active' : '') + '" title="Scena del brano (effetto/testo/immagine)">🎬</button>' + '<button class="play-btn" title="' + (isCur && isPlaying ? 'Pausa' : 'Avvia') + '">' + playIcon + '</button>' +
'<button class="del-btn" title="Rimuovi">✕</button>' + '<button class="scene-btn' + (hasScene(tr) ? ' active' : '') + '" title="Scena del brano (effetto/testo/immagine)">🎬</button>' +
(isCur ? '<i class="tprog" style="width:' + prog.toFixed(1) + '%"></i>' : ''); '<button class="del-btn" title="Rimuovi">✕</button>' +
(isCur ? '<i class="tprog" style="width:' + prog.toFixed(1) + '%"></i>' : '') +
'</div>' + body;
// Clicking the name (re)starts the track from the beginning. // Clicking the name (re)starts the track from the beginning.
li.querySelector('.tname').addEventListener('click', () => playIndex(i)); li.querySelector('.tname').addEventListener('click', () => playIndex(i));
@@ -696,6 +825,14 @@ function renderPlaylist() {
renderPlaylist(); renderPlaylist();
}); });
// Waveform + trim controls.
const canvas = li.querySelector('.wave');
if (canvas) {
ensurePeaks(tr.path);
drawWave(canvas, tr, isCur);
wireTrim(li, canvas, tr, i);
}
// Drag to reorder. // Drag to reorder.
li.addEventListener('dragstart', () => { dragFrom = i; li.classList.add('dragging'); }); li.addEventListener('dragstart', () => { dragFrom = i; li.classList.add('dragging'); });
li.addEventListener('dragend', () => { dragFrom = -1; li.classList.remove('dragging'); }); li.addEventListener('dragend', () => { dragFrom = -1; li.classList.remove('dragging'); });
+6
View File
@@ -323,6 +323,7 @@ djv.onControl(async (m) => {
try { try {
// Switching to an audio track: stop/hide any playing video track. // Switching to an audio track: stop/hide any playing video track.
trackVideo.pause(); trackVideo.classList.remove('show'); trackVideo.pause(); trackVideo.classList.remove('show');
audio.setTrim(m.start || 0, m.end || 0);
audio.onEnded = () => djv.report({ type: 'trackEnded' }); audio.onEnded = () => djv.report({ type: 'trackEnded' });
await audio.loadFile(toFileURL(m.path), { loop: false, crossfade: m.crossfade || 0 }); await audio.loadFile(toFileURL(m.path), { loop: false, crossfade: m.crossfade || 0 });
hideHint(); hideHint();
@@ -331,6 +332,7 @@ djv.onControl(async (m) => {
break; break;
case 'playVideoTrack': case 'playVideoTrack':
try { try {
audio.setTrim(m.start || 0, m.end || 0);
audio.onEnded = () => djv.report({ type: 'trackEnded' }); audio.onEnded = () => djv.report({ type: 'trackEnded' });
audio.attachVideo(trackVideo); audio.attachVideo(trackVideo);
trackVideo.loop = false; trackVideo.muted = false; trackVideo.loop = false; trackVideo.muted = false;
@@ -341,6 +343,7 @@ djv.onControl(async (m) => {
trackVideo.load(); trackVideo.load();
trackVideo.classList.add('show'); trackVideo.classList.add('show');
await trackVideo.play(); await trackVideo.play();
audio.seekToTrimStart(trackVideo);
hideHint(); hideHint();
djv.report({ type: 'playState', playing: true }); djv.report({ type: 'playState', playing: true });
} catch (e) { djv.report({ type: 'error', message: e.message }); } } catch (e) { djv.report({ type: 'error', message: e.message }); }
@@ -355,6 +358,7 @@ djv.onControl(async (m) => {
djv.report({ type: 'playState', playing: true }); djv.report({ type: 'playState', playing: true });
} catch (e) { djv.report({ type: 'error', message: e.message }); } } catch (e) { djv.report({ type: 'error', message: e.message }); }
break; break;
case 'setTrim': audio.setTrim(m.start || 0, m.end || 0); break;
case 'probeDurations': probeDurations(m.paths || []); break; case 'probeDurations': probeDurations(m.paths || []); break;
case 'togglePlay': { case 'togglePlay': {
const playing = audio.togglePlay(); const playing = audio.togglePlay();
@@ -470,6 +474,8 @@ function frame() {
lastReport = now; lastReport = now;
djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps }); djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps });
} }
// Stop at the trim end point if one is set.
audio.checkTrim();
// Playback progress for the currently playing file. // Playback progress for the currently playing file.
if (now - lastProgress > 400) { if (now - lastProgress > 400) {
lastProgress = now; lastProgress = now;
+33 -4
View File
@@ -294,16 +294,23 @@ textarea.textarea-lg {
.track { .track {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 6px; align-items: stretch;
padding: 6px 8px; gap: 0;
margin-bottom: 5px; margin-bottom: 6px;
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.08);
border-radius: 9px; border-radius: 9px;
font-size: 12px; font-size: 12px;
overflow: hidden; overflow: hidden;
} }
.track .track-head {
position: relative;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
}
.track .ttime { .track .ttime {
flex: 0 0 auto; flex: 0 0 auto;
font-size: 10px; font-size: 10px;
@@ -319,6 +326,28 @@ textarea.textarea-lg {
background: linear-gradient(90deg, #5ad1ff, #7aa2ff, #ff7ad1); background: linear-gradient(90deg, #5ad1ff, #7aa2ff, #ff7ad1);
width: 0; width: 0;
} }
.track .track-body { padding: 0 8px 8px; }
.track .track-times {
display: flex; gap: 10px; align-items: center;
font-size: 10px; font-variant-numeric: tabular-nums; margin-bottom: 5px;
}
.track .track-times .tt-el { color: #9be6a0; }
.track .track-times .tt-tot { opacity: 0.7; }
.track .track-times .tt-rem { color: #ffb3b3; margin-left: auto; }
.track .wave {
display: block; width: 100%; height: 46px;
background: rgba(0,0,0,0.28); border-radius: 6px; cursor: ew-resize;
touch-action: none;
}
.track .track-trim {
display: flex; align-items: center; gap: 5px; margin-top: 6px; font-size: 10px; flex-wrap: wrap;
}
.track .track-trim .trim-lbl { opacity: 0.6; }
.track .track-trim .trim-lbl-s { color: #9be6a0; opacity: 0.9; }
.track .track-trim .trim-lbl-e { color: #ffb3b3; opacity: 0.9; }
.track .track-trim input { width: 52px; text-align: center; padding: 4px 3px; font-variant-numeric: tabular-nums; }
.track .track-trim button { padding: 3px 6px; }
.track .track-trim .trim-reset { margin-left: auto; }
.track.playing { .track.playing {
background: rgba(110,150,255,0.22); background: rgba(110,150,255,0.22);
border-color: rgba(120,170,255,0.6); border-color: rgba(120,170,255,0.6);