Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "section_map",
"name": "Section Map",
"version": "1.0.0",
"version": "1.1.0",
"private": false,
"script": "screen.js",
"category": "practice",
Expand Down
78 changes: 46 additions & 32 deletions screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,45 @@ function _smRemove() {
}
}

function _smOnClick(e) {
if (!_smDuration) return;
const rect = _smBar.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
const time = pct * _smDuration;
// The playback clock, across whichever backend is driving audio. getTime() is
// the audio-aligned clock the host exposes to plugins; the raw <audio> element
// is only a fallback (and is stale when a native/streaming backend is playing).
function _smNow() {
if (typeof highway !== 'undefined' && highway && typeof highway.getTime === 'function') {
const t = highway.getTime();
if (Number.isFinite(t)) return t;
}
const audio = document.getElementById('audio');
if (!audio) return;

// Update lastAudioTime to prevent the jump detector from resetting
if (typeof lastAudioTime !== 'undefined') lastAudioTime = time;
return audio && Number.isFinite(audio.currentTime) ? audio.currentTime : 0;
}

// Pause, seek, then resume — seeking during playback fails on unbuffered regions
// Reposition through the host's canonical seek funnel (window.feedBack.seek ->
// _audioSeek). It moves whichever backend is ACTUALLY playing — JUCE native
// output, or the bounded-memory stem-streaming worklet, which only reseeks in
// response to the song:seek event the funnel emits — and keeps the highway
// clock in sync. Poking audio.currentTime directly only relocates regions the
// <audio> element has already buffered near the playhead, so once playback
// moved off that element (native routing + stem streaming) far sections stopped
// seeking — they land in an unbuffered/unstreamed region and snap back. The raw
// path stays only as a fallback for a host old enough to lack the seek API.
function _smSeek(time, reason) {
// Clamp centrally so every caller (click passes a raw pct*duration; a pct
// just over 1 at the bar's right edge would otherwise seek past the end).
const max = (typeof _smDuration === 'number' && _smDuration > 0) ? _smDuration : Infinity;
const t = Math.max(0, Math.min(max, time));
const host = (typeof window !== 'undefined') && (window.feedBack || window.slopsmith);
if (host && typeof host.seek === 'function') {
host.seek(t, reason);
return;
}
const audio = document.getElementById('audio');
if (!audio) return;
// Legacy fallback: keep the jump detector from reverting the seek, and
// pause/seek/resume because seeking during playback fails on unbuffered regions.
if (typeof lastAudioTime !== 'undefined') lastAudioTime = t;
const wasPlaying = !audio.paused;
if (wasPlaying) audio.pause();
audio.currentTime = Math.max(0, time);
audio.currentTime = t;
Comment thread
byrongamatos marked this conversation as resolved.
if (wasPlaying) {
audio.addEventListener('seeked', function resume() {
audio.removeEventListener('seeked', resume);
Expand All @@ -73,31 +97,21 @@ function _smOnClick(e) {
}
}

function _smOnClick(e) {
if (!_smDuration) return;
const rect = _smBar.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
_smSeek(pct * _smDuration, 'sectionmap-click');
}

function _smOnWheel(e) {
if (!_smDuration) return;
e.preventDefault();

const audio = document.getElementById('audio');
if (!audio) return;

// Calculate time delta: up (negative deltaY) = forward, down (positive deltaY) = backward
// up (negative deltaY) = forward, down (positive deltaY) = backward
const increment = e.ctrlKey ? 0.1 : 1; // Fine control with Ctrl modifier
const deltaTime = -(e.deltaY > 0 ? 1 : -1) * increment; // Negate to match scroll direction to time direction
const newTime = Math.max(0, Math.min(_smDuration, audio.currentTime + deltaTime));

// Update lastAudioTime to prevent the jump detector from resetting
if (typeof lastAudioTime !== 'undefined') lastAudioTime = newTime;

// Pause, seek, then resume — seeking during playback fails on unbuffered regions
const wasPlaying = !audio.paused;
if (wasPlaying) audio.pause();
audio.currentTime = newTime;
if (wasPlaying) {
audio.addEventListener('seeked', function resume() {
audio.removeEventListener('seeked', resume);
audio.play();
}, { once: true });
}
const deltaTime = -(e.deltaY > 0 ? 1 : -1) * increment;
const newTime = Math.max(0, Math.min(_smDuration, _smNow() + deltaTime));
_smSeek(newTime, 'sectionmap-wheel');
}

function _smUpdate() {
Expand Down
81 changes: 60 additions & 21 deletions tests/screen.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,60 +81,99 @@ test('_smRender strips a trailing numeric suffix and capitalizes the label', ()
assert.ok(bar.innerHTML.includes('>Verse<'));
});

test('_smOnClick seeks to the clicked fraction of the bar', () => {
// The seek must go through the host's canonical funnel (window.feedBack.seek),
// NOT raw audio.currentTime — the funnel is what repositions a native/streaming
// backend and emits song:seek so the stem worklet reseeks. Poking the <audio>
// element only moved regions buffered near the playhead (the far-section bug).

test('_smOnClick routes the clicked fraction through the host seek funnel', () => {
const mod = freshPlugin();
const bar = new FakeBar();
bar.width = 500;
const seeks = [];
global.window.feedBack = { seek: (t, reason) => seeks.push([t, reason]) };
const audio = new FakeAudio();
audio.paused = true; // not playing -> seeks immediately, no resume dance
mod._setState({ bar, sections: [{ name: 'Intro', time: 0 }], duration: 100 });
global.document = { getElementById: (id) => (id === 'audio' ? audio : null) };

mod._smOnClick({ clientX: 250 }); // 50% across a 500px-wide bar
assert.equal(audio.currentTime, 50);
assert.deepEqual(seeks, [[50, 'sectionmap-click']]);
assert.equal(audio.currentTime, 0, 'must not poke the raw element when the funnel exists');
});

test('_smOnClick pauses, seeks, and resumes playback while playing', () => {
test('_smOnClick clamps a right-edge overshoot to the song duration', () => {
const mod = freshPlugin();
const bar = new FakeBar();
bar.width = 500;
const seeks = [];
global.window.feedBack = { seek: (t, reason) => seeks.push([t, reason]) };
global.document = { getElementById: () => null };
mod._setState({ bar, sections: [{ name: 'Intro', time: 0 }], duration: 100 });

mod._smOnClick({ clientX: 505 }); // pct = 1.01 -> would be 101s without the clamp
assert.deepEqual(seeks, [[100, 'sectionmap-click']]);
});

test('_smNow reads the host clock (getTime) so a wheel nudge starts from real position', () => {
const mod = freshPlugin();
global.highway = { getTime: () => 42 };
const seeks = [];
global.window.feedBack = { seek: (t, reason) => seeks.push([t, reason]) };
global.document = { getElementById: () => null };
mod._setState({ bar: new FakeBar(), sections: [{ name: 'Intro', time: 0 }], duration: 100 });
try {
mod._smOnWheel({ deltaY: -1, ctrlKey: true, preventDefault: () => {} });
assert.deepEqual(seeks, [[42.1, 'sectionmap-wheel']]); // 42 (host clock) + 0.1 fine step
} finally {
delete global.highway;
}
});

test('_smOnWheel routes the computed delta through the funnel and clamps to [0, duration]', () => {
const mod = freshPlugin();
const seeks = [];
global.window.feedBack = { seek: (t, reason) => seeks.push([t, reason]) };
const audio = new FakeAudio();
audio.currentTime = 0; // no host clock in test -> falls back to audio position
global.document = { getElementById: (id) => (id === 'audio' ? audio : null) };
mod._setState({ bar: new FakeBar(), sections: [{ name: 'Intro', time: 0 }], duration: 100 });

let prevented = false;
mod._smOnWheel({ deltaY: 1, ctrlKey: false, preventDefault: () => { prevented = true; } }); // backward from 0
assert.equal(prevented, true);
assert.deepEqual(seeks, [[0, 'sectionmap-wheel']]); // clamped at 0, can't go negative
});

// Fallback: a host too old to expose window.feedBack.seek still seeks the raw
// <audio> element, pausing/resuming around it as before.

test('_smOnClick falls back to the raw <audio>, pausing/seeking/resuming while playing', () => {
const mod = freshPlugin();
const bar = new FakeBar();
const audio = new FakeAudio();
audio.paused = false;
mod._setState({ bar, sections: [{ name: 'Intro', time: 0 }], duration: 100 });
global.document = { getElementById: (id) => (id === 'audio' ? audio : null) };

mod._smOnClick({ clientX: 0 });
mod._smOnClick({ clientX: 0 }); // no window.feedBack.seek -> fallback path
assert.equal(audio.currentTime, 0);
assert.equal(audio.paused, true); // paused before the seek
audio._listeners.seeked(); // simulate the browser firing 'seeked'
assert.equal(audio.paused, false); // resumed
});

test('_smOnWheel computes a fine-grained seek delta with ctrlKey', () => {
test('_smOnWheel fallback pokes the raw <audio> when there is no seek API', () => {
const mod = freshPlugin();
const audio = new FakeAudio();
audio.currentTime = 10;
audio.paused = true;
mod._setState({ bar: new FakeBar(), sections: [{ name: 'Intro', time: 0 }], duration: 100 });
global.document = { getElementById: (id) => (id === 'audio' ? audio : null) };

let prevented = false;
mod._smOnWheel({ deltaY: -1, ctrlKey: true, preventDefault: () => { prevented = true; } });
assert.equal(prevented, true);
mod._smOnWheel({ deltaY: -1, ctrlKey: true, preventDefault: () => {} });
assert.equal(audio.currentTime, 10.1); // scroll up -> forward by 0.1s (ctrl = fine)
});

test('_smOnWheel without ctrlKey moves a full second and clamps to [0, duration]', () => {
const mod = freshPlugin();
const audio = new FakeAudio();
audio.currentTime = 0;
audio.paused = true;
mod._setState({ bar: new FakeBar(), sections: [{ name: 'Intro', time: 0 }], duration: 100 });
global.document = { getElementById: (id) => (id === 'audio' ? audio : null) };

mod._smOnWheel({ deltaY: 1, ctrlKey: false, preventDefault: () => {} }); // scroll down -> backward
assert.equal(audio.currentTime, 0); // clamped at 0, can't go negative
});

test('_smOnWheel/_smOnClick are no-ops without a known duration', () => {
const mod = freshPlugin();
const audio = new FakeAudio();
Expand Down
Loading