synops/frontend/src/lib/components/AudioPlayer.svelte
vegard b4ee80a97b Fullfør oppgave 7.7: Re-transkripsjonsflyt med side-om-side-sammenligning
Ny funksjonalitet for å kjøre re-transkripsjon på eksisterende media-noder
og sammenligne gammel vs ny versjon per segment. Manuelt redigerte segmenter
fra forrige versjon blir uthevet, og brukeren velger per segment hvilken
versjon som skal beholdes.

Backend (Rust):
- POST /intentions/retranscribe — trigger ny Whisper-jobb for media-node
- GET /query/transcription_versions — list alle versjoner for en node
- GET /query/segments_version — hent segmenter for spesifikk versjon
- POST /intentions/resolve_retranscription — anvend per-segment-valg

Frontend (Svelte):
- RetranscriptionCompare.svelte — side-om-side visning med per-segment-valg
- TranscriptionView: re-transkriber-knapp, auto-detect nye versjoner, polling
- API-klient: nye funksjoner for alle re-transkripsjonsendepunkter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:41:09 +01:00

171 lines
4.8 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import WaveSurfer from 'wavesurfer.js';
import TranscriptionView from './TranscriptionView.svelte';
interface Props {
/** CAS URL for the audio file */
src: string;
/** Duration in seconds (from transcription metadata, used as fallback) */
duration?: number;
/** Flat transcript text (fallback if no segments available) */
transcript?: string;
/** Compact mode for chat bubbles */
compact?: boolean;
/** Media node ID — enables segment-level transcription view */
nodeId?: string;
/** Access token for fetching segments */
accessToken?: string;
}
let { src, duration, transcript, compact = false, nodeId, accessToken }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let wavesurfer: WaveSurfer | undefined = $state();
let playing = $state(false);
let currentTime = $state(0);
let totalDuration = $state(0);
$effect(() => { if (duration && !ready) totalDuration = duration; });
let ready = $state(false);
let loadError = $state(false);
let showTranscript = $state(false);
/** Indikerer om noden har segment-data (satt via metadata) */
const hasSegments = $derived(!!nodeId && !!accessToken);
function formatTime(seconds: number): string {
if (!seconds || !isFinite(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function seekTo(timeSeconds: number) {
if (!wavesurfer || !ready) return;
wavesurfer.setTime(timeSeconds);
if (!playing) {
wavesurfer.play();
}
}
onMount(() => {
if (!container) return;
wavesurfer = WaveSurfer.create({
container,
height: compact ? 32 : 48,
waveColor: '#93c5fd',
progressColor: '#2563eb',
cursorColor: '#1d4ed8',
cursorWidth: 2,
barWidth: 2,
barGap: 1,
barRadius: 2,
normalize: true,
url: src,
});
wavesurfer.on('ready', () => {
ready = true;
totalDuration = wavesurfer!.getDuration();
});
wavesurfer.on('timeupdate', (time: number) => {
currentTime = time;
});
wavesurfer.on('finish', () => {
playing = false;
});
wavesurfer.on('error', () => {
loadError = true;
});
wavesurfer.on('play', () => {
playing = true;
});
wavesurfer.on('pause', () => {
playing = false;
});
});
onDestroy(() => {
wavesurfer?.destroy();
});
function togglePlayback() {
wavesurfer?.playPause();
}
</script>
<div class="audio-player flex flex-col gap-1 {compact ? 'min-w-[200px]' : 'min-w-[240px]'}">
<div class="flex items-center gap-2">
<!-- Play/pause button -->
<button
onclick={togglePlayback}
disabled={!ready && !loadError}
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors
{ready ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-200 text-gray-400'}
disabled:cursor-not-allowed"
aria-label={playing ? 'Pause' : 'Spill av'}
>
{#if !ready && !loadError}
<svg class="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else if playing}
<svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
{:else}
<svg class="h-3.5 w-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<!-- Waveform -->
<div class="flex-1 min-w-0">
<div bind:this={container} class="w-full"></div>
</div>
</div>
<!-- Time display -->
<div class="flex items-center justify-between px-1">
<span class="text-[10px] text-gray-400">{formatTime(currentTime)}</span>
<span class="text-[10px] text-gray-400">{formatTime(totalDuration)}</span>
</div>
{#if loadError}
<p class="text-[10px] text-red-400 px-1">Kunne ikke laste lydfilen</p>
{/if}
<!-- Transcript toggle -->
{#if hasSegments || transcript}
<button
onclick={() => { showTranscript = !showTranscript; }}
class="flex items-center gap-1 px-1 text-[10px] text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="h-3 w-3 transition-transform {showTranscript ? 'rotate-90' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
Transkripsjon
</button>
{#if showTranscript}
{#if hasSegments}
<TranscriptionView
nodeId={nodeId!}
accessToken={accessToken!}
{currentTime}
onseek={seekTo}
/>
{:else if transcript}
<p class="text-xs text-gray-500 px-1 py-1 bg-gray-50 rounded whitespace-pre-wrap">{transcript}</p>
{/if}
{/if}
{/if}
</div>