MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m10s
- /audio (only in an empty bullet) shows a recording bubble: green round button starts, it turns red with a live wave and timer on its left, a tap saves the recording as an inline audio player. An audio file can be uploaded instead; Esc/✕ restores the empty bullet. - Chrome's WebM recordings get their length measured, so the player shows the total time and seeking works. - With the own Whisper server "Transkribieren" turns the recording into the (searchable) caption. - Loaded files keep their Blob, as the CSP rightly blocks fetch() on blob: URLs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
137 lines
4.8 KiB
TypeScript
137 lines
4.8 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { FileAudio, Loader2, Wand2 } from 'lucide-react';
|
|
import { api } from '../../lib/api';
|
|
import { mediaBlob, mediaObjectURL, parseMediaContent } from '../../lib/media';
|
|
import { loadVoiceConfig } from '../../lib/voice';
|
|
|
|
interface AudioBlockProps {
|
|
content: string;
|
|
canEdit: boolean;
|
|
onCaptionChange: (caption: string) => void;
|
|
}
|
|
|
|
// Audio block: player plus caption. With the own Whisper server the recording can be turned into text,
|
|
// which becomes the caption (and so searchable).
|
|
export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCaptionChange }) => {
|
|
const ref = parseMediaContent(content);
|
|
const mediaId = ref?.id;
|
|
const [src, setSrc] = useState<string | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
const [caption, setCaption] = useState(ref?.caption || '');
|
|
const [canTranscribe, setCanTranscribe] = useState(false);
|
|
const [transcribing, setTranscribing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
|
|
// Recordings from Chrome (WebM) carry no length: jump to the end once so the browser measures it,
|
|
// otherwise the player shows no total time and seeking does not work
|
|
const fixDuration = () => {
|
|
const a = audioRef.current;
|
|
if (!a || Number.isFinite(a.duration)) return;
|
|
const reset = () => {
|
|
a.removeEventListener('timeupdate', reset);
|
|
a.currentTime = 0;
|
|
};
|
|
a.addEventListener('timeupdate', reset);
|
|
a.currentTime = 1e101;
|
|
};
|
|
|
|
useEffect(() => {
|
|
setCaption(ref?.caption || '');
|
|
}, [ref?.caption]);
|
|
|
|
useEffect(() => {
|
|
if (!mediaId) return;
|
|
let alive = true;
|
|
setFailed(false);
|
|
mediaObjectURL(mediaId)
|
|
.then((u) => alive && setSrc(u))
|
|
.catch(() => alive && setFailed(true));
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [mediaId]);
|
|
|
|
useEffect(() => {
|
|
void loadVoiceConfig().then((cfg) => setCanTranscribe(cfg.mode === 'server'));
|
|
}, []);
|
|
|
|
if (!ref) {
|
|
return <div className="text-xs text-status-err py-1">Audio-Verweis ist beschädigt: {content}</div>;
|
|
}
|
|
|
|
const commitCaption = (value: string) => {
|
|
if (value.trim() !== ref.caption) onCaptionChange(value.trim());
|
|
};
|
|
|
|
const transcribe = async () => {
|
|
if (!src) return;
|
|
setTranscribing(true);
|
|
setError(null);
|
|
try {
|
|
const blob = await mediaBlob(ref.id);
|
|
const text = await api.transcribe(blob);
|
|
if (text) {
|
|
setCaption(text);
|
|
commitCaption(text);
|
|
} else {
|
|
setError('Nichts erkannt');
|
|
}
|
|
} catch (err: any) {
|
|
setError(err?.message || 'Spracherkennung fehlgeschlagen');
|
|
} finally {
|
|
setTranscribing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="my-1 space-y-1">
|
|
{failed ? (
|
|
<div className="flex items-center gap-2 h-12 px-3 rounded-card border border-border bg-panel text-xs text-muted">
|
|
<FileAudio className="w-4 h-4" /> Aufnahme nicht verfügbar (offline oder gelöscht)
|
|
</div>
|
|
) : src ? (
|
|
<div className="flex items-center gap-2">
|
|
<audio ref={audioRef} controls preload="metadata" src={src} onLoadedMetadata={fixDuration} className="w-full max-w-md h-11" />
|
|
{canEdit && canTranscribe && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void transcribe()}
|
|
disabled={transcribing}
|
|
title="In Text umwandeln (eigene Spracherkennung)"
|
|
className="btn-secondary h-11 sm:h-8 px-2.5 text-xs flex items-center gap-1.5 shrink-0 disabled:opacity-40"
|
|
>
|
|
{transcribing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Wand2 className="w-3.5 h-3.5" />}
|
|
<span className="hidden sm:inline">{transcribing ? 'Wird erkannt…' : 'Transkribieren'}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center h-12 px-3 rounded-card border border-border bg-panel w-64">
|
|
<Loader2 className="w-4 h-4 animate-spin text-muted" />
|
|
</div>
|
|
)}
|
|
|
|
{canEdit ? (
|
|
<input
|
|
type="text"
|
|
value={caption}
|
|
onChange={(e) => setCaption(e.target.value)}
|
|
onBlur={() => commitCaption(caption)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
(e.target as HTMLInputElement).blur();
|
|
}
|
|
}}
|
|
placeholder="Beschriftung hinzufügen…"
|
|
className="w-full bg-transparent border-0 p-0 text-xs text-muted placeholder-faint focus:outline-none focus:ring-0"
|
|
/>
|
|
) : (
|
|
ref.caption && <p className="text-xs text-muted">{ref.caption}</p>
|
|
)}
|
|
{error && <p className="text-xs text-status-err">{error}</p>}
|
|
</div>
|
|
);
|
|
};
|