Add click-to-jump feature for speakers and fix start offset logic

- Add findNextPhraseForSpeaker() to find next segment for a speaker
- Add findSpeakerAtPosition() for hit detection on speaker circles
- Click on speaker circle jumps to their next phrase (with wrap-around)
- Change cursor to pointer when hovering over speakers
- Simplify audio start offset logic in loadedmetadata handler
- Add SPEAKER_22 alias mapping to SPEAKER_21
This commit is contained in:
5shekel
2026-01-18 01:57:26 +02:00
parent b1d9c8eb86
commit 7e2789978e
2 changed files with 73 additions and 6 deletions

78
app.js
View File

@@ -286,6 +286,53 @@ function findActiveSpeaker(time) {
return null;
}
function findNextPhraseForSpeaker(speakerId, afterTime) {
// Find the next segment where this speaker talks after the given time
for (const segment of state.segments) {
if (resolveSpeaker(segment.speaker) === speakerId && segment.start > afterTime) {
return segment.start;
}
}
// Wrap around: if no phrase found after current time, find first phrase from beginning
for (const segment of state.segments) {
if (resolveSpeaker(segment.speaker) === speakerId) {
return segment.start;
}
}
return null;
}
function findSpeakerAtPosition(clientX, clientY) {
const rect = spaceCanvas.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
// Check each speaker, starting from the one with largest hit area (active speaker)
// Sort by whether they're active and by size to check larger ones first
const sortedSpeakers = [...state.speakers].sort((a, b) => {
const aActive = a.id === state.activeSpeaker;
const bActive = b.id === state.activeSpeaker;
if (aActive !== bActive) return bActive - aActive;
return b.baseSize - a.baseSize;
});
for (const speaker of sortedSpeakers) {
const isActive = speaker.id === state.activeSpeaker;
const size = speaker.baseSize * (isActive ? 1.4 : 1);
// Use a slightly larger hit area for better UX
const hitRadius = size * 1.5;
const dx = x - speaker.x;
const dy = y - speaker.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= hitRadius) {
return speaker;
}
}
return null;
}
function resizeCanvas(canvas, ctx) {
const { width, height } = canvas.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
@@ -671,6 +718,28 @@ async function init() {
attachScrubHandlers();
attachEmbeddedScrubHandlers();
// Click on speaker to jump to their next phrase
spaceCanvas.addEventListener("click", (event) => {
const speaker = findSpeakerAtPosition(event.clientX, event.clientY);
if (speaker) {
const nextTime = findNextPhraseForSpeaker(speaker.id, audio.currentTime);
if (nextTime !== null) {
audio.currentTime = nextTime;
// Start playing if not already
if (audio.paused) {
audio.play().catch(() => {});
playPause.textContent = "Pause";
}
}
}
});
// Change cursor when hovering over a speaker
spaceCanvas.addEventListener("mousemove", (event) => {
const speaker = findSpeakerAtPosition(event.clientX, event.clientY);
spaceCanvas.style.cursor = speaker ? "pointer" : "default";
});
// Load waveform data immediately on page load
loadWaveformData();
@@ -717,12 +786,9 @@ async function init() {
playPause.textContent = "Play";
});
audio.addEventListener("loadedmetadata", () => {
// Start at minute 10 by default
if (audio.currentTime < START_OFFSET_SECONDS && audio.duration >= START_OFFSET_SECONDS) {
audio.currentTime = START_OFFSET_SECONDS;
}
if (Number.isFinite(audio.duration) && audio.duration > 0) {
audio.currentTime = Math.min(START_OFFSET_SECONDS, Math.max(0, audio.duration - 0.01));
// Start at configured offset, but don't exceed audio duration
if (Number.isFinite(audio.duration) && audio.duration > 0 && START_OFFSET_SECONDS > 0) {
audio.currentTime = Math.min(START_OFFSET_SECONDS, audio.duration - 1);
}
});

View File

@@ -49,6 +49,7 @@ const CONFIG = {
"SPEAKER_09": ["SPEAKER_07", "SPEAKER_08"],
"SPEAKER_18": ["SPEAKER_16"],
"SPEAKER_00": ["SPEAKER_03"],
"SPEAKER_22": ["SPEAKER_21"],
},
// ===================