Text effects, pad crossfade, playback progress + per-track durations
- Scrolling text: font, size, weight, colour and movement effects (up/down, wave, zoom, flash, rotate) rendered per-letter (and captured in recordings). - Larger multiline text input. - Pad bank: crossfade between pads (per-source gain ramps in the audio engine). - Duration + remaining-time and progress bars for the playlist and the active pad, via metadata probing and live progress reports from the output window. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0a04df32de
commit
9653306a68
6 files changed
+226
-28
No files matched your search
+32
-6
@@ -29,6 +29,7 @@ class AudioEngine {
|
|||||||
} catch (e) { /* ConstantSource unsupported: live/file audio still flows */ }
|
} catch (e) { /* ConstantSource unsupported: live/file audio still flows */ }
|
||||||
|
|
||||||
this.sourceNode = null; // current MediaElement / MediaStream source
|
this.sourceNode = null; // current MediaElement / MediaStream source
|
||||||
|
this.sourceGain = null; // per-source gain (for crossfades)
|
||||||
this.mediaEl = null; // <audio> element when playing a file
|
this.mediaEl = null; // <audio> element when playing a file
|
||||||
this.stream = null; // MediaStream when using live input
|
this.stream = null; // MediaStream when using live input
|
||||||
this.mode = 'none';
|
this.mode = 'none';
|
||||||
@@ -53,28 +54,53 @@ class AudioEngine {
|
|||||||
|
|
||||||
_disconnectSource() {
|
_disconnectSource() {
|
||||||
if (this.sourceNode) { try { this.sourceNode.disconnect(); } catch (e) {} this.sourceNode = null; }
|
if (this.sourceNode) { try { this.sourceNode.disconnect(); } catch (e) {} this.sourceNode = null; }
|
||||||
|
if (this.sourceGain) { try { this.sourceGain.disconnect(); } catch (e) {} this.sourceGain = null; }
|
||||||
if (this.mediaEl) { this.mediaEl.pause(); this.mediaEl.src = ''; this.mediaEl = null; }
|
if (this.mediaEl) { this.mediaEl.pause(); this.mediaEl.src = ''; this.mediaEl = null; }
|
||||||
if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = null; }
|
if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play a File/Blob, or a src URL (e.g. file://…), through the analyser and
|
// Play a File/Blob, or a src URL (e.g. file://…), through the analyser and
|
||||||
// the speakers. opts.loop controls looping (default true for a single file).
|
// the speakers. opts.loop controls looping; opts.crossfade (ms) fades the
|
||||||
|
// previous file out while the new one fades in.
|
||||||
async loadFile(fileOrSrc, opts = {}) {
|
async loadFile(fileOrSrc, opts = {}) {
|
||||||
this.resume();
|
this.resume();
|
||||||
this._disconnectSource();
|
const fadeMs = Math.max(0, opts.crossfade || 0);
|
||||||
|
|
||||||
const el = new Audio();
|
const el = new Audio();
|
||||||
el.src = typeof fileOrSrc === 'string' ? fileOrSrc : URL.createObjectURL(fileOrSrc);
|
el.src = typeof fileOrSrc === 'string' ? fileOrSrc : URL.createObjectURL(fileOrSrc);
|
||||||
el.loop = opts.loop !== undefined ? opts.loop : true;
|
el.loop = opts.loop !== undefined ? opts.loop : true;
|
||||||
el.crossOrigin = 'anonymous';
|
el.crossOrigin = 'anonymous';
|
||||||
el.onended = () => { if (this.onEnded) this.onEnded(); };
|
el.onended = () => { if (this.onEnded) this.onEnded(); };
|
||||||
this.mediaEl = el;
|
|
||||||
|
|
||||||
const src = this.ctx.createMediaElementSource(el);
|
const src = this.ctx.createMediaElementSource(el);
|
||||||
src.connect(this.analyser);
|
const g = this.ctx.createGain();
|
||||||
src.connect(this.outGain); // file audio is audible
|
src.connect(g);
|
||||||
src.connect(this.recordDest); // and captured by the recorder
|
g.connect(this.analyser);
|
||||||
|
g.connect(this.outGain); // audible
|
||||||
|
g.connect(this.recordDest); // captured by the recorder
|
||||||
|
|
||||||
|
if (fadeMs > 0 && this.mediaEl && this.mode === 'file' && this.sourceGain) {
|
||||||
|
const now = this.ctx.currentTime, dur = fadeMs / 1000;
|
||||||
|
g.gain.setValueAtTime(0.0001, now);
|
||||||
|
g.gain.linearRampToValueAtTime(1, now + dur);
|
||||||
|
const oldEl = this.mediaEl, oldSrc = this.sourceNode, oldGain = this.sourceGain;
|
||||||
|
oldGain.gain.cancelScheduledValues(now);
|
||||||
|
oldGain.gain.setValueAtTime(oldGain.gain.value, now);
|
||||||
|
oldGain.gain.linearRampToValueAtTime(0.0001, now + dur);
|
||||||
|
setTimeout(() => {
|
||||||
|
try { oldEl.pause(); oldEl.src = ''; } catch (e) {}
|
||||||
|
try { oldSrc.disconnect(); } catch (e) {}
|
||||||
|
try { oldGain.disconnect(); } catch (e) {}
|
||||||
|
}, fadeMs + 80);
|
||||||
|
} else {
|
||||||
|
this._disconnectSource();
|
||||||
|
g.gain.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mediaEl = el;
|
||||||
this.sourceNode = src;
|
this.sourceNode = src;
|
||||||
|
this.sourceGain = g;
|
||||||
|
this.stream = null;
|
||||||
this.mode = 'file';
|
this.mode = 'file';
|
||||||
|
|
||||||
await el.play();
|
await el.play();
|
||||||
|
|||||||
+29
-1
@@ -119,6 +119,8 @@
|
|||||||
<button id="btn-pad-stop">⏹ Stop</button>
|
<button id="btn-pad-stop">⏹ Stop</button>
|
||||||
<button id="btn-pad-clear">🗑 Svuota tutti</button>
|
<button id="btn-pad-clear">🗑 Svuota tutti</button>
|
||||||
</div>
|
</div>
|
||||||
|
<label>Crossfade tra pad <span id="pad-xfade-val">0.0s</span></label>
|
||||||
|
<input type="range" id="pad-xfade" min="0" max="8" step="0.5" value="0" />
|
||||||
<div id="pad-grid" class="pad-grid"></div>
|
<div id="pad-grid" class="pad-grid"></div>
|
||||||
<input type="file" id="pad-input" accept="audio/*" hidden />
|
<input type="file" id="pad-input" accept="audio/*" hidden />
|
||||||
<div class="hint-mini">Clic su un pad vuoto (o trascina un brano) per assegnarlo · clic su un pad pieno per avviarlo · premendone un altro ferma quello in corso · <b>tasto destro</b> per ricaricare · ✕ per svuotare.</div>
|
<div class="hint-mini">Clic su un pad vuoto (o trascina un brano) per assegnarlo · clic su un pad pieno per avviarlo · premendone un altro ferma quello in corso · <b>tasto destro</b> per ricaricare · ✕ per svuotare.</div>
|
||||||
@@ -191,7 +193,7 @@
|
|||||||
<div class="tab-panel" data-tab="testo">
|
<div class="tab-panel" data-tab="testo">
|
||||||
<section class="span2">
|
<section class="span2">
|
||||||
<label>Testo scorrevole</label>
|
<label>Testo scorrevole</label>
|
||||||
<input type="text" id="ticker-text" class="textfield" placeholder="Es. NOME DJ · EVENTO" />
|
<textarea id="ticker-text" class="textfield textarea-lg" rows="4" placeholder="Scrivi qui il testo da far scorrere… Es. NOME DJ · TITOLO EVENTO · DATA"></textarea>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="chk"><input type="checkbox" id="ticker-on" /> Mostra</label>
|
<label class="chk"><input type="checkbox" id="ticker-on" /> Mostra</label>
|
||||||
<select id="ticker-pos">
|
<select id="ticker-pos">
|
||||||
@@ -202,6 +204,32 @@
|
|||||||
</div>
|
</div>
|
||||||
<label>Velocità testo <span id="ticker-speed-val">1.0×</span></label>
|
<label>Velocità testo <span id="ticker-speed-val">1.0×</span></label>
|
||||||
<input type="range" id="ticker-speed" min="0.2" max="4" step="0.1" value="1" />
|
<input type="range" id="ticker-speed" min="0.2" max="4" step="0.1" value="1" />
|
||||||
|
<label>Carattere</label>
|
||||||
|
<select id="ticker-font">
|
||||||
|
<option value="-apple-system, BlinkMacSystemFont, sans-serif">Sistema</option>
|
||||||
|
<option value="'Arial Black', Impact, sans-serif">Arial Black</option>
|
||||||
|
<option value="Impact, sans-serif">Impact</option>
|
||||||
|
<option value="Georgia, 'Times New Roman', serif">Georgia</option>
|
||||||
|
<option value="'Courier New', monospace">Monospace</option>
|
||||||
|
<option value="'Brush Script MT', 'Snell Roundhand', cursive">Corsivo</option>
|
||||||
|
<option value="Verdana, Geneva, sans-serif">Verdana</option>
|
||||||
|
<option value="'Trebuchet MS', sans-serif">Trebuchet</option>
|
||||||
|
</select>
|
||||||
|
<label>Grandezza <span id="ticker-size-val">6.0</span></label>
|
||||||
|
<input type="range" id="ticker-size" min="2" max="18" step="0.5" value="6" />
|
||||||
|
<div class="row">
|
||||||
|
<label class="chk"><input type="checkbox" id="ticker-bold" checked /> Grassetto</label>
|
||||||
|
<label class="chk">Colore <input type="color" id="ticker-color" value="#ffffff" /></label>
|
||||||
|
</div>
|
||||||
|
<label>Effetto movimento</label>
|
||||||
|
<select id="ticker-fx">
|
||||||
|
<option value="none">Nessuno</option>
|
||||||
|
<option value="updown">Su e giù</option>
|
||||||
|
<option value="wave">Onda</option>
|
||||||
|
<option value="zoom">Zoom / pulse</option>
|
||||||
|
<option value="flash">Flash</option>
|
||||||
|
<option value="rotate">Rotazione</option>
|
||||||
|
</select>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+51
-6
@@ -195,15 +195,24 @@ let repeat = false;
|
|||||||
let isPlaying = false; // whether the current track is playing
|
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)
|
||||||
|
let playCur = 0, playDur = 0; // live progress of the currently playing track
|
||||||
|
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
|
||||||
|
|
||||||
const baseName = (p) => p.split('/').pop();
|
const baseName = (p) => p.split('/').pop();
|
||||||
|
|
||||||
|
function probePaths(paths) {
|
||||||
|
const todo = paths.filter(p => p && !(p in durations));
|
||||||
|
if (todo.length) send({ type: 'probeDurations', paths: todo });
|
||||||
|
}
|
||||||
|
|
||||||
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 }); });
|
items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null }); });
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
|
probePaths(items.map(it => it.path));
|
||||||
// Auto-start the first added track if nothing is playing yet.
|
// Auto-start the first added track if nothing is playing yet.
|
||||||
if (currentIndex < 0 && playlist.length > start) playIndex(start);
|
if (currentIndex < 0 && playlist.length > start) playIndex(start);
|
||||||
}
|
}
|
||||||
@@ -259,13 +268,19 @@ function renderPlaylist() {
|
|||||||
|
|
||||||
const isCur = i === currentIndex;
|
const isCur = i === currentIndex;
|
||||||
const playIcon = (isCur && isPlaying) ? '⏸' : '▶';
|
const playIcon = (isCur && isPlaying) ? '⏸' : '▶';
|
||||||
|
let timeLabel = '';
|
||||||
|
if (isCur && playDur > 0) timeLabel = '-' + fmtTime(Math.floor(Math.max(0, playDur - playCur)));
|
||||||
|
else if (durations[tr.path] > 0) timeLabel = fmtTime(Math.floor(durations[tr.path]));
|
||||||
|
const prog = (isCur && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0;
|
||||||
li.innerHTML =
|
li.innerHTML =
|
||||||
'<span class="grip">⠿</span>' +
|
'<span class="grip">⠿</span>' +
|
||||||
'<span class="tname" title="' + tr.name.replace(/"/g, '"') + '">' + tr.name + '</span>' +
|
'<span class="tname" title="' + tr.name.replace(/"/g, '"') + '">' + tr.name + '</span>' +
|
||||||
|
'<span class="ttime">' + timeLabel + '</span>' +
|
||||||
'<button class="key-btn" title="Assegna tasto rapido">' +
|
'<button class="key-btn" title="Assegna tasto rapido">' +
|
||||||
(capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' +
|
(capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' +
|
||||||
'<button class="play-btn" title="' + (isCur && isPlaying ? 'Pausa' : 'Avvia') + '">' + playIcon + '</button>' +
|
'<button class="play-btn" title="' + (isCur && isPlaying ? 'Pausa' : 'Avvia') + '">' + playIcon + '</button>' +
|
||||||
'<button class="del-btn" title="Rimuovi">✕</button>';
|
'<button class="del-btn" title="Rimuovi">✕</button>' +
|
||||||
|
(isCur ? '<i class="tprog" style="width:' + prog.toFixed(1) + '%"></i>' : '');
|
||||||
|
|
||||||
// On the current track the button pauses/resumes/replays; on others it starts that track.
|
// On the current track the button pauses/resumes/replays; on others it starts that track.
|
||||||
li.querySelector('.play-btn').addEventListener('click', () => {
|
li.querySelector('.play-btn').addEventListener('click', () => {
|
||||||
@@ -348,7 +363,7 @@ $('#img-interval').addEventListener('input', (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------- ticker
|
// ---------------------------------------------------------------- ticker
|
||||||
$('#ticker-text').addEventListener('input', (e) => send({ type: 'tickerText', text: e.target.value }));
|
$('#ticker-text').addEventListener('input', (e) => send({ type: 'tickerText', text: e.target.value.replace(/\s*\n\s*/g, ' · ') }));
|
||||||
$('#ticker-on').addEventListener('change', (e) => send({ type: 'tickerOn', on: e.target.checked }));
|
$('#ticker-on').addEventListener('change', (e) => send({ type: 'tickerOn', on: e.target.checked }));
|
||||||
$('#ticker-pos').addEventListener('change', (e) => send({ type: 'tickerPos', pos: e.target.value }));
|
$('#ticker-pos').addEventListener('change', (e) => send({ type: 'tickerPos', pos: e.target.value }));
|
||||||
$('#ticker-speed').addEventListener('input', (e) => {
|
$('#ticker-speed').addEventListener('input', (e) => {
|
||||||
@@ -356,6 +371,14 @@ $('#ticker-speed').addEventListener('input', (e) => {
|
|||||||
$('#ticker-speed-val').textContent = m.toFixed(1) + '×';
|
$('#ticker-speed-val').textContent = m.toFixed(1) + '×';
|
||||||
send({ type: 'tickerSpeed', mult: m });
|
send({ type: 'tickerSpeed', mult: m });
|
||||||
});
|
});
|
||||||
|
$('#ticker-font').addEventListener('change', (e) => send({ type: 'tickerFont', value: e.target.value }));
|
||||||
|
$('#ticker-size').addEventListener('input', (e) => {
|
||||||
|
$('#ticker-size-val').textContent = parseFloat(e.target.value).toFixed(1);
|
||||||
|
send({ type: 'tickerSize', value: parseFloat(e.target.value) });
|
||||||
|
});
|
||||||
|
$('#ticker-bold').addEventListener('change', (e) => send({ type: 'tickerWeight', on: e.target.checked }));
|
||||||
|
$('#ticker-color').addEventListener('input', (e) => send({ type: 'tickerColor', value: e.target.value }));
|
||||||
|
$('#ticker-fx').addEventListener('change', (e) => send({ type: 'tickerFx', value: e.target.value }));
|
||||||
|
|
||||||
// ---------------------------------------------------------------- displays
|
// ---------------------------------------------------------------- displays
|
||||||
async function refreshDisplays() {
|
async function refreshDisplays() {
|
||||||
@@ -414,17 +437,23 @@ function renderPads() {
|
|||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.className = 'pad' + (p ? ' filled' : '') + (i === activePad && padPlaying ? ' active' : '');
|
d.className = 'pad' + (p ? ' filled' : '') + (i === activePad && padPlaying ? ' active' : '');
|
||||||
d.dataset.i = i;
|
d.dataset.i = i;
|
||||||
|
const isActive = i === activePad && padPlaying;
|
||||||
|
const prog = (isActive && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0;
|
||||||
|
const remain = (isActive && playDur > 0) ? '-' + fmtTime(Math.floor(Math.max(0, playDur - playCur)))
|
||||||
|
: (p && durations[p.path] > 0 ? fmtTime(Math.floor(durations[p.path])) : '');
|
||||||
d.innerHTML = '<span class="pad-num">' + (i + 1) + '</span>' +
|
d.innerHTML = '<span class="pad-num">' + (i + 1) + '</span>' +
|
||||||
(p ? '<span class="pad-name">' + p.name.replace(/&/g, '&').replace(/</g, '<') + '</span>' +
|
(p ? '<span class="pad-name">' + p.name.replace(/&/g, '&').replace(/</g, '<') + '</span>' +
|
||||||
|
(remain ? '<span class="pad-time">' + remain + '</span>' : '') +
|
||||||
'<button class="pad-x" title="Svuota">✕</button>' +
|
'<button class="pad-x" title="Svuota">✕</button>' +
|
||||||
'<button class="pad-key" title="Assegna tasto rapido">' +
|
'<button class="pad-key" title="Assegna tasto rapido">' +
|
||||||
(padCapturing === i ? '…' : (p.key ? p.key.toUpperCase() : '⌨')) + '</button>'
|
(padCapturing === i ? '…' : (p.key ? p.key.toUpperCase() : '⌨')) + '</button>' +
|
||||||
|
(isActive ? '<i class="pad-prog" style="width:' + prog.toFixed(1) + '%"></i>' : '')
|
||||||
: '<span class="pad-plus">+</span>');
|
: '<span class="pad-plus">+</span>');
|
||||||
padGrid.appendChild(d);
|
padGrid.appendChild(d);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignPad(i, item) { pads[i] = item; renderPads(); savePadState(); }
|
function assignPad(i, item) { pads[i] = item; renderPads(); savePadState(); if (item) probePaths([item.path]); }
|
||||||
function loadPadFile(i) { padLoadTarget = i; $('#pad-input').click(); }
|
function loadPadFile(i) { padLoadTarget = i; $('#pad-input').click(); }
|
||||||
|
|
||||||
function playPad(i) {
|
function playPad(i) {
|
||||||
@@ -437,9 +466,10 @@ function playPad(i) {
|
|||||||
}
|
}
|
||||||
playbackOwner = 'pad';
|
playbackOwner = 'pad';
|
||||||
activePad = i; padPlaying = true;
|
activePad = i; padPlaying = true;
|
||||||
|
playCur = 0; playDur = durations[p.path] || 0;
|
||||||
// The playlist is no longer the active source.
|
// The playlist is no longer the active source.
|
||||||
currentIndex = -1; isPlaying = false; renderPlaylist();
|
currentIndex = -1; isPlaying = false; renderPlaylist();
|
||||||
send({ type: 'playTrack', path: p.path });
|
send({ type: 'playTrack', path: p.path, crossfade: padCrossfadeMs });
|
||||||
renderPads();
|
renderPads();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +516,12 @@ $('#btn-pad-stop').addEventListener('click', () => {
|
|||||||
$('#btn-pad-clear').addEventListener('click', () => {
|
$('#btn-pad-clear').addEventListener('click', () => {
|
||||||
pads = new Array(PAD_COUNT).fill(null); activePad = -1; padPlaying = false; renderPads(); savePadState();
|
pads = new Array(PAD_COUNT).fill(null); activePad = -1; padPlaying = false; renderPads(); savePadState();
|
||||||
});
|
});
|
||||||
|
const padXfade = $('#pad-xfade');
|
||||||
|
if (padXfade) padXfade.addEventListener('input', (e) => {
|
||||||
|
const s = parseFloat(e.target.value);
|
||||||
|
$('#pad-xfade-val').textContent = s.toFixed(1) + 's';
|
||||||
|
padCrossfadeMs = s * 1000;
|
||||||
|
});
|
||||||
|
|
||||||
// Load saved pad assignments on startup.
|
// Load saved pad assignments on startup.
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -494,6 +530,7 @@ $('#btn-pad-clear').addEventListener('click', () => {
|
|||||||
if (Array.isArray(saved)) for (let i = 0; i < PAD_COUNT; i++) pads[i] = saved[i] || null;
|
if (Array.isArray(saved)) for (let i = 0; i < PAD_COUNT; i++) pads[i] = saved[i] || null;
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
renderPads();
|
renderPads();
|
||||||
|
probePaths(pads.filter(Boolean).map(p => p.path));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ---------------------------------------------------------------- tabs
|
// ---------------------------------------------------------------- tabs
|
||||||
@@ -586,7 +623,7 @@ window.addEventListener('keydown', (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.target.tagName === 'INPUT' && e.target.type === 'text') return;
|
if ((e.target.tagName === 'INPUT' && e.target.type === 'text') || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
const lk = e.key.toLowerCase();
|
const lk = e.key.toLowerCase();
|
||||||
// Track hotkeys take priority, then effect hotkeys.
|
// Track hotkeys take priority, then effect hotkeys.
|
||||||
@@ -676,6 +713,14 @@ djv.onReport((m) => {
|
|||||||
if (now - lastBeatAdvance > 250) { lastBeatAdvance = now; nextInSequence(); }
|
if (now - lastBeatAdvance > 250) { lastBeatAdvance = now; nextInSequence(); }
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'durations':
|
||||||
|
m.list.forEach(d => { durations[d.path] = d.duration; });
|
||||||
|
renderPlaylist(); renderPads();
|
||||||
|
break;
|
||||||
|
case 'progress':
|
||||||
|
playCur = m.currentTime; playDur = m.duration;
|
||||||
|
if (playbackOwner === 'pad') renderPads(); else renderPlaylist();
|
||||||
|
break;
|
||||||
case 'recState':
|
case 'recState':
|
||||||
recOn = m.recording;
|
recOn = m.recording;
|
||||||
$('#btn-rec').disabled = false;
|
$('#btn-rec').disabled = false;
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
<img id="logo-1" class="logo" />
|
<img id="logo-1" class="logo" />
|
||||||
|
|
||||||
<!-- Scrolling text ticker -->
|
<!-- Scrolling text ticker -->
|
||||||
<div id="ticker"><div id="ticker-track"><span></span><span></span></div></div>
|
<div id="ticker"><div id="ticker-track"><span class="tick-copy"></span><span class="tick-copy"></span></div></div>
|
||||||
|
|
||||||
<div id="drop-hint">
|
<div id="drop-hint">
|
||||||
<div class="big">🎛 In attesa dell'audio…</div>
|
<div class="big">🎛 In attesa dell'audio…</div>
|
||||||
|
|||||||
+53
-8
@@ -97,20 +97,22 @@ function composeFrame() {
|
|||||||
recCtx.globalAlpha = 1;
|
recCtx.globalAlpha = 1;
|
||||||
|
|
||||||
if (ticker.classList.contains('show')) {
|
if (ticker.classList.contains('show')) {
|
||||||
for (const span of tickerSpans) {
|
recCtx.textBaseline = 'middle';
|
||||||
|
for (const span of tickerLetters()) {
|
||||||
const txt = span.textContent;
|
const txt = span.textContent;
|
||||||
if (!txt) continue;
|
if (!txt || txt === ' ') continue;
|
||||||
const r = span.getBoundingClientRect();
|
const r = span.getBoundingClientRect();
|
||||||
if (r.right < 0 || r.left > window.innerWidth) continue;
|
if (r.right < 0 || r.left > window.innerWidth) continue;
|
||||||
const cs = getComputedStyle(span);
|
const cs = getComputedStyle(span);
|
||||||
|
recCtx.globalAlpha = parseFloat(cs.opacity) || 1;
|
||||||
recCtx.font = cs.fontWeight + ' ' + (parseFloat(cs.fontSize) * sy) + 'px ' + cs.fontFamily;
|
recCtx.font = cs.fontWeight + ' ' + (parseFloat(cs.fontSize) * sy) + 'px ' + cs.fontFamily;
|
||||||
recCtx.fillStyle = cs.color;
|
recCtx.fillStyle = cs.color;
|
||||||
recCtx.textBaseline = 'middle';
|
|
||||||
recCtx.shadowColor = 'rgba(140,180,255,0.9)';
|
recCtx.shadowColor = 'rgba(140,180,255,0.9)';
|
||||||
recCtx.shadowBlur = 16 * sy;
|
recCtx.shadowBlur = 14 * sy;
|
||||||
recCtx.fillText(txt, r.x * sx, (r.y + r.height / 2) * sy);
|
recCtx.fillText(txt, r.x * sx, (r.y + r.height / 2) * sy);
|
||||||
recCtx.shadowBlur = 0;
|
recCtx.shadowBlur = 0;
|
||||||
}
|
}
|
||||||
|
recCtx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
composeRAF = requestAnimationFrame(composeFrame);
|
composeRAF = requestAnimationFrame(composeFrame);
|
||||||
}
|
}
|
||||||
@@ -195,15 +197,41 @@ function setLogo(i, url) {
|
|||||||
|
|
||||||
const ticker = $('#ticker');
|
const ticker = $('#ticker');
|
||||||
const tickerTrack = $('#ticker-track');
|
const tickerTrack = $('#ticker-track');
|
||||||
const tickerSpans = ticker.querySelectorAll('span');
|
const tickCopies = ticker.querySelectorAll('.tick-copy');
|
||||||
function setTickerText(t) { tickerSpans.forEach(s => s.textContent = t + ' • '); }
|
function buildLetters(txt) {
|
||||||
|
const full = (txt || '') + ' • ';
|
||||||
|
let html = '';
|
||||||
|
for (let i = 0; i < full.length; i++) {
|
||||||
|
const c = full[i];
|
||||||
|
const ch = c === ' ' ? ' ' : c.replace(/&/g, '&').replace(/</g, '<');
|
||||||
|
html += '<span class="tl" style="--i:' + i + '">' + ch + '</span>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
function setTickerText(t) { const h = buildLetters(t); tickCopies.forEach(c => c.innerHTML = h); }
|
||||||
function setTickerSpeed(m) { tickerTrack.style.setProperty('--ticker-dur', (18 / m) + 's'); }
|
function setTickerSpeed(m) { tickerTrack.style.setProperty('--ticker-dur', (18 / m) + 's'); }
|
||||||
|
function tickerLetters() { return tickerTrack.querySelectorAll('.tl'); }
|
||||||
ticker.classList.add('pos-bottom');
|
ticker.classList.add('pos-bottom');
|
||||||
setTickerSpeed(1);
|
setTickerSpeed(1);
|
||||||
|
|
||||||
function hideHint() { $('#drop-hint').classList.add('hidden'); }
|
function hideHint() { $('#drop-hint').classList.add('hidden'); }
|
||||||
|
|
||||||
// ---------------------------------------------------------------- audio devices
|
// ---------------------------------------------------------------- audio devices
|
||||||
|
// Probe duration (metadata only) for a list of file paths.
|
||||||
|
function probeDurations(paths) {
|
||||||
|
if (!paths.length) return;
|
||||||
|
const results = [];
|
||||||
|
let pending = paths.length;
|
||||||
|
const finish = () => { if (--pending === 0) djv.report({ type: 'durations', list: results }); };
|
||||||
|
paths.forEach(p => {
|
||||||
|
const a = new Audio();
|
||||||
|
a.preload = 'metadata';
|
||||||
|
a.onloadedmetadata = () => { results.push({ path: p, duration: isFinite(a.duration) ? a.duration : 0 }); finish(); };
|
||||||
|
a.onerror = () => { results.push({ path: p, duration: 0 }); finish(); };
|
||||||
|
a.src = toFileURL(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function reportDevices() {
|
async function reportDevices() {
|
||||||
try {
|
try {
|
||||||
const inputs = await audio.listInputDevices();
|
const inputs = await audio.listInputDevices();
|
||||||
@@ -239,11 +267,12 @@ djv.onControl(async (m) => {
|
|||||||
case 'playTrack':
|
case 'playTrack':
|
||||||
try {
|
try {
|
||||||
audio.onEnded = () => djv.report({ type: 'trackEnded' });
|
audio.onEnded = () => djv.report({ type: 'trackEnded' });
|
||||||
await audio.loadFile(toFileURL(m.path), { loop: false });
|
await audio.loadFile(toFileURL(m.path), { loop: false, crossfade: m.crossfade || 0 });
|
||||||
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 }); }
|
||||||
break;
|
break;
|
||||||
|
case 'probeDurations': probeDurations(m.paths || []); break;
|
||||||
case 'togglePlay': {
|
case 'togglePlay': {
|
||||||
const playing = audio.togglePlay();
|
const playing = audio.togglePlay();
|
||||||
if (playing !== null) djv.report({ type: 'playState', playing });
|
if (playing !== null) djv.report({ type: 'playState', playing });
|
||||||
@@ -278,12 +307,20 @@ djv.onControl(async (m) => {
|
|||||||
ticker.classList.add('pos-' + m.pos);
|
ticker.classList.add('pos-' + m.pos);
|
||||||
break;
|
break;
|
||||||
case 'tickerSpeed': setTickerSpeed(m.mult); break;
|
case 'tickerSpeed': setTickerSpeed(m.mult); break;
|
||||||
|
case 'tickerFont': tickerTrack.style.fontFamily = m.value; break;
|
||||||
|
case 'tickerSize': tickerTrack.style.fontSize = m.value + 'vh'; break;
|
||||||
|
case 'tickerWeight': tickerTrack.style.fontWeight = m.on ? '800' : '400'; break;
|
||||||
|
case 'tickerColor': tickerTrack.style.color = m.value; break;
|
||||||
|
case 'tickerFx':
|
||||||
|
tickerTrack.classList.remove('fx-updown', 'fx-wave', 'fx-zoom', 'fx-flash', 'fx-rotate');
|
||||||
|
if (m.value && m.value !== 'none') tickerTrack.classList.add('fx-' + m.value);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------- render loop
|
// ---------------------------------------------------------------- render loop
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0;
|
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0, lastProgress = 0;
|
||||||
|
|
||||||
function frame() {
|
function frame() {
|
||||||
const t = (performance.now() - startTime) / 1000;
|
const t = (performance.now() - startTime) / 1000;
|
||||||
@@ -308,6 +345,14 @@ 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 });
|
||||||
}
|
}
|
||||||
|
// Playback progress for the currently playing file.
|
||||||
|
if (now - lastProgress > 400) {
|
||||||
|
lastProgress = now;
|
||||||
|
const el = audio.mediaEl;
|
||||||
|
if (el && !el.paused && isFinite(el.duration) && el.duration > 0) {
|
||||||
|
djv.report({ type: 'progress', currentTime: el.currentTime, duration: el.duration });
|
||||||
|
}
|
||||||
|
}
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
|
|||||||
+60
-6
@@ -225,6 +225,29 @@ body.control select { width: 100%; max-width: 100%; }
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
.pad-key:hover { background: rgba(120,150,255,0.45); }
|
.pad-key:hover { background: rgba(120,150,255,0.45); }
|
||||||
|
.pad-time {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 3px;
|
||||||
|
left: 4px;
|
||||||
|
font-size: 8.5px;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.pad-prog {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; bottom: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #5ad18a, #aef0c0);
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger ticker text area */
|
||||||
|
textarea.textarea-lg {
|
||||||
|
min-height: 78px;
|
||||||
|
resize: vertical;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Recording status ---- */
|
/* ---- Recording status ---- */
|
||||||
.rec-status {
|
.rec-status {
|
||||||
@@ -269,6 +292,7 @@ body.control select { width: 100%; max-width: 100%; }
|
|||||||
.hint-mini { font-size: 10px; opacity: 0.45; margin-top: 6px; }
|
.hint-mini { font-size: 10px; opacity: 0.45; margin-top: 6px; }
|
||||||
|
|
||||||
.track {
|
.track {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -278,6 +302,22 @@ body.control select { width: 100%; max-width: 100%; }
|
|||||||
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;
|
||||||
|
}
|
||||||
|
.track .ttime {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.65;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 34px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.track .tprog {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, #5ad1ff, #7aa2ff, #ff7ad1);
|
||||||
|
width: 0;
|
||||||
}
|
}
|
||||||
.track.playing {
|
.track.playing {
|
||||||
background: rgba(110,150,255,0.22);
|
background: rgba(110,150,255,0.22);
|
||||||
@@ -368,15 +408,17 @@ body.output { cursor: none; background: #000; }
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
animation: ticker-scroll var(--ticker-dur, 18s) linear infinite;
|
|
||||||
}
|
|
||||||
#ticker-track span {
|
|
||||||
font-size: 6vh;
|
font-size: 6vh;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 2px;
|
|
||||||
padding: 0 4vw;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
animation: ticker-scroll var(--ticker-dur, 18s) linear infinite;
|
||||||
|
}
|
||||||
|
.tick-copy { display: inline-flex; padding: 0 2vw; }
|
||||||
|
.tl {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: pre;
|
||||||
|
letter-spacing: 2px;
|
||||||
text-shadow: 0 0 calc(8px + var(--ticker-glow, 0px)) rgba(140,180,255,0.9),
|
text-shadow: 0 0 calc(8px + var(--ticker-glow, 0px)) rgba(140,180,255,0.9),
|
||||||
0 2px 30px rgba(0,0,0,0.8);
|
0 2px 30px rgba(0,0,0,0.8);
|
||||||
}
|
}
|
||||||
@@ -385,6 +427,18 @@ body.output { cursor: none; background: #000; }
|
|||||||
to { transform: translateX(-50%); }
|
to { transform: translateX(-50%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Text movement effects (applied per letter) */
|
||||||
|
.fx-updown .tl { animation: tl-updown 1.1s ease-in-out infinite; }
|
||||||
|
.fx-wave .tl { animation: tl-wave 1.2s ease-in-out infinite; animation-delay: calc(var(--i) * 0.05s); }
|
||||||
|
.fx-zoom .tl { animation: tl-zoom 0.9s ease-in-out infinite; animation-delay: calc(var(--i) * 0.04s); }
|
||||||
|
.fx-flash .tl { animation: tl-flash 0.6s steps(1, end) infinite; }
|
||||||
|
.fx-rotate .tl { animation: tl-rotate 2s ease-in-out infinite; animation-delay: calc(var(--i) * 0.03s); }
|
||||||
|
@keyframes tl-updown { 0%, 100% { transform: translateY(-0.18em); } 50% { transform: translateY(0.18em); } }
|
||||||
|
@keyframes tl-wave { 0%, 100% { transform: translateY(-0.24em); } 50% { transform: translateY(0.24em); } }
|
||||||
|
@keyframes tl-zoom { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.35); } }
|
||||||
|
@keyframes tl-flash { 0%, 100% { opacity: 1; } 50% { opacity: 0.12; } }
|
||||||
|
@keyframes tl-rotate { 0%, 100% { transform: rotate(-12deg); } 50% { transform: rotate(12deg); } }
|
||||||
|
|
||||||
/* ---- Drop hint ---- */
|
/* ---- Drop hint ---- */
|
||||||
#drop-hint {
|
#drop-hint {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in new issue
Block a user