From 643f9b39086ab3e9507b2af3bf2e5326682064bd Mon Sep 17 00:00:00 2001 From: lelo Date: Wed, 14 Jan 2026 16:38:34 +0000 Subject: [PATCH] clickable timecode in transcript --- static/app.css | 5 +++ static/app.js | 81 +++++++++++++++++++++++++++++++++++++++++++++++- static/search.js | 4 +-- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/static/app.css b/static/app.css index c93a145..6d52f3e 100644 --- a/static/app.css +++ b/static/app.css @@ -184,6 +184,11 @@ a.show-transcript:hover { .highlight { background-color: yellow; } +#transcriptContent code.timecode-link { + cursor: pointer; + font-size: small; +} + a.create-share { text-decoration: none; diff --git a/static/app.js b/static/app.js index 96f681f..9bcc4ab 100644 --- a/static/app.js +++ b/static/app.js @@ -2,6 +2,7 @@ let currentMusicFiles = []; // Array of objects with at least { path, index } let currentMusicIndex = -1; // Index of the current music file let currentTrackPath = ""; +let activeTranscriptAudio = ""; // Audio file that matches the currently open transcript. // Check thumb availability in parallel; sequentially generate missing ones to avoid concurrent creation. function ensureFirstFivePreviews() { @@ -70,6 +71,79 @@ function encodeSubpath(subpath) { .join('/'); } +// Convert a timecode string like "00:17" or "01:02:03" into total seconds. +function parseTimecode(text) { + const parts = text.trim().split(':').map(part => parseInt(part, 10)); + if (parts.some(Number.isNaN) || parts.length < 2 || parts.length > 3) return null; + + const hasHours = parts.length === 3; + const [hours, minutes, seconds] = hasHours ? parts : [0, parts[0], parts[1]]; + + if (seconds < 0 || seconds > 59) return null; + if (minutes < 0 || (hasHours && minutes > 59)) return null; + if (hours < 0) return null; + + return hours * 3600 + minutes * 60 + seconds; +} + +// Add click handlers to transcript timecodes so they seek the matching audio. +function attachTranscriptTimecodes(audioPath) { + const container = document.getElementById('transcriptContent'); + const targetAudio = audioPath || activeTranscriptAudio; + if (!container || !targetAudio) return; + + container.querySelectorAll('code').forEach(codeEl => { + if (codeEl.closest('pre')) return; + const label = codeEl.textContent.trim(); + const seconds = parseTimecode(label); + if (seconds === null) return; + + codeEl.classList.add('timecode-link'); + codeEl.title = `Zu ${label} springen`; + codeEl.addEventListener('click', () => jumpToTimecode(seconds, targetAudio)); + }); +} + +// Ensure we load the right track (if needed) and jump to the requested time. +function jumpToTimecode(seconds, relUrl) { + if (!relUrl || seconds === null) return; + + const updateTrackState = () => { + currentTrackPath = relUrl; + const matchIdx = currentMusicFiles.findIndex(file => file.path === relUrl); + currentMusicIndex = matchIdx !== -1 ? matchIdx : -1; + }; + + const seekWhenReady = () => { + player.audio.currentTime = Math.min( + seconds, + Number.isFinite(player.audio.duration) ? player.audio.duration : seconds + ); + player.audio.play(); + player.updateTimeline(); + }; + + updateTrackState(); + + if (player.currentRelUrl === relUrl) { + if (player.audio.readyState >= 1) { + seekWhenReady(); + } else { + player.audio.addEventListener('loadedmetadata', seekWhenReady, { once: true }); + } + return; + } + + player.audio.addEventListener('loadedmetadata', seekWhenReady, { once: true }); + + const trackLink = document.querySelector(`.play-file[data-url="${relUrl}"]`); + if (trackLink) { + trackLink.click(); + } else { + player.loadTrack(relUrl); + } +} + // Global variable for gallery images (updated from current folder) let currentGalleryImages = []; @@ -231,7 +305,7 @@ function renderContent(data) { contentHTML += `
  • ${symbol} ${file.name.replace('.mp3', '')}`; if (file.has_transcript) { - contentHTML += `📄`; + contentHTML += `📄`; } contentHTML += `
  • `; }); @@ -424,6 +498,7 @@ document.querySelectorAll('.play-file').forEach(link => { // Update the current music index. currentMusicIndex = index !== undefined ? parseInt(index) : -1; + currentTrackPath = relUrl; // Mark the clicked item as currently playing. this.closest('.file-item').classList.add('currently-playing'); @@ -452,7 +527,9 @@ document.querySelectorAll('.play-file').forEach(link => { link.addEventListener('click', function (event) { event.preventDefault(); const url = this.getAttribute('data-url'); + const audioUrl = this.getAttribute('data-audio-url') || ''; const highlight = this.getAttribute('highlight'); + activeTranscriptAudio = audioUrl; fetch(url) .then(response => { if (!response.ok) { @@ -479,9 +556,11 @@ document.querySelectorAll('.play-file').forEach(link => { } } document.getElementById('transcriptContent').innerHTML = marked.parse(data); + attachTranscriptTimecodes(audioUrl); document.getElementById('transcriptModal').style.display = 'block'; }) .catch(error => { + activeTranscriptAudio = ''; document.getElementById('transcriptContent').innerHTML = '

    Error loading transcription.

    '; document.getElementById('transcriptModal').style.display = 'block'; }); diff --git a/static/search.js b/static/search.js index f70839d..1c593c7 100644 --- a/static/search.js +++ b/static/search.js @@ -18,7 +18,7 @@ document.addEventListener('DOMContentLoaded', function() {

    Anzahl Downloads: ${file.hitcount}

    ${ file.performance_date !== undefined ? `

    Datum: ${file.performance_date}

    ` : ``} - ${ file.transcript_hits !== undefined ? `

    Treffer im Transkript: ${file.transcript_hits} 📄

    ` : ``} + ${ file.transcript_hits !== undefined ? `

    Treffer im Transkript: ${file.transcript_hits} 📄

    ` : ``} `; resultsDiv.appendChild(card); @@ -162,4 +162,4 @@ function syncThemeColor() { } // sync once on load - document.addEventListener('DOMContentLoaded', syncThemeColor); \ No newline at end of file + document.addEventListener('DOMContentLoaded', syncThemeColor);