697 lines
25 KiB
TypeScript
697 lines
25 KiB
TypeScript
/**
|
|
* ChatStream -- SSE-driven message display for the workspace.
|
|
*
|
|
* Renders messages with full Markdown (GFM tables, code blocks with syntax
|
|
* highlighting), agent progress indicators, and file edit proposals.
|
|
*
|
|
* Audio playback uses a playlist queue: when the agent sends multiple TTS
|
|
* chunks they are queued and played one after the other instead of overlapping.
|
|
*/
|
|
|
|
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import api from '../../../api';
|
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
|
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
|
import type { AgentProgress, FileEditProposal } from './useWorkspace';
|
|
import { useAudioQueue, type AudioQueueApi } from '../../../hooks/useAudioQueue';
|
|
|
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
|
|
|
interface ChatStreamProps {
|
|
messages: Message[];
|
|
agentProgress: AgentProgress | null;
|
|
isProcessing: boolean;
|
|
pendingEdits: FileEditProposal[];
|
|
onAcceptEdit: (editId: string) => void;
|
|
onRejectEdit: (editId: string) => void;
|
|
onOpenEditor?: () => void;
|
|
}
|
|
|
|
export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
|
|
agentProgress,
|
|
isProcessing,
|
|
pendingEdits,
|
|
onAcceptEdit,
|
|
onRejectEdit,
|
|
onOpenEditor,
|
|
}) => {
|
|
const { t } = useLanguage();
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
const audioQueue = useAudioQueue();
|
|
const enqueuedIdsRef = useRef<Set<string>>(new Set());
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages, agentProgress]);
|
|
|
|
useEffect(() => {
|
|
for (const msg of messages) {
|
|
const audioUrl = (msg as any)._audioUrl;
|
|
if (!audioUrl) continue;
|
|
if (enqueuedIdsRef.current.has(msg.id)) continue;
|
|
enqueuedIdsRef.current.add(msg.id);
|
|
audioQueue.enqueue({
|
|
id: msg.id,
|
|
url: audioUrl,
|
|
language: (msg as any)._audioLang,
|
|
charCount: (msg as any)._audioCharCount,
|
|
});
|
|
}
|
|
}, [messages, audioQueue]);
|
|
|
|
return (
|
|
<div style={{
|
|
flex: 1,
|
|
minHeight: 0,
|
|
overflowY: 'auto',
|
|
padding: '16px 24px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 12,
|
|
}}>
|
|
{messages.map((msg) => (
|
|
<div
|
|
key={msg.id}
|
|
style={{
|
|
flexShrink: 0,
|
|
padding: '10px 14px',
|
|
borderRadius: 8,
|
|
maxWidth: '85%',
|
|
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
|
background: _getBubbleBackground(msg.role || 'assistant'),
|
|
border: msg.role === 'user'
|
|
? 'none'
|
|
: '1px solid var(--border-color, #e0e0e0)',
|
|
fontSize: msg.role === 'status' ? 12 : 14,
|
|
color: msg.role === 'status' ? '#795548' : 'inherit',
|
|
fontStyle: msg.role === 'status' ? 'italic' : 'normal',
|
|
wordBreak: 'break-word',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{msg.role === 'assistant' && (
|
|
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>Assistant</div>
|
|
)}
|
|
{msg.role === 'status' ? (
|
|
<span>{msg.message}</span>
|
|
) : (
|
|
<div className="workspace-markdown">
|
|
{msg.documentsLabel && (
|
|
<div
|
|
style={{
|
|
fontSize: 12,
|
|
color: '#666',
|
|
marginBottom: msg.message ? 8 : 0,
|
|
fontStyle: 'italic',
|
|
}}
|
|
>
|
|
{msg.documentsLabel}
|
|
</div>
|
|
)}
|
|
{msg.message && (
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
components={{
|
|
code: _CodeBlock,
|
|
table: ({ children }) => (
|
|
<div style={{ overflowX: 'auto', margin: '8px 0' }}>
|
|
<table style={{
|
|
borderCollapse: 'collapse',
|
|
width: '100%',
|
|
fontSize: 13,
|
|
}}>
|
|
{children}
|
|
</table>
|
|
</div>
|
|
),
|
|
th: ({ children }) => (
|
|
<th style={{
|
|
borderBottom: '2px solid #ddd',
|
|
padding: '6px 10px',
|
|
textAlign: 'left',
|
|
fontWeight: 600,
|
|
background: '#f8f9fa',
|
|
fontSize: 12,
|
|
}}>
|
|
{children}
|
|
</th>
|
|
),
|
|
td: ({ children }) => (
|
|
<td style={{
|
|
borderBottom: '1px solid #eee',
|
|
padding: '5px 10px',
|
|
fontSize: 12,
|
|
}}>
|
|
{children}
|
|
</td>
|
|
),
|
|
a: ({ href, children }) => (
|
|
<a href={href} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--primary-color, #F25843)' }}>
|
|
{children}
|
|
</a>
|
|
),
|
|
img: ({ src, alt, ...rest }) =>
|
|
src ? <img src={src} alt={alt || ''} {...rest} style={{ maxWidth: '100%', borderRadius: 6 }} /> : null,
|
|
}}
|
|
>
|
|
{msg.message}
|
|
</ReactMarkdown>
|
|
)}
|
|
{msg.documents && msg.documents.length > 0 && (
|
|
<div style={{ marginTop: msg.message ? 8 : 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{msg.documents.map((doc) => (
|
|
<_FileCard key={doc.id || doc.fileId} doc={doc} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{(msg as any)._audioUrl && (
|
|
<_QueuedAudioPlayer
|
|
msgId={msg.id}
|
|
url={(msg as any)._audioUrl}
|
|
language={(msg as any)._audioLang}
|
|
audioQueue={audioQueue}
|
|
/>
|
|
)}
|
|
{msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && (
|
|
<details className="sentDataDetails" style={{ marginTop: 8, fontSize: '0.8rem', borderTop: '1px solid var(--border-color, #e5e7eb)', paddingTop: 6 }}>
|
|
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary, #6b7280)', fontWeight: 500, userSelect: 'none' }}>
|
|
Gesendete Daten ({msg.documents.length} {msg.documents.length === 1 ? 'Dokument' : 'Dokumente'})
|
|
</summary>
|
|
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
{msg.documents.map((doc, idx) => (
|
|
<div key={doc.id || idx} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-hover, rgba(0,0,0,0.02))', borderRadius: 4 }}>
|
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{doc.documentName || doc.fileName || `Dokument ${idx + 1}`}
|
|
</span>
|
|
{doc.validationMetadata?.neutralized && (
|
|
<span style={{ fontSize: '0.7rem', padding: '1px 6px', borderRadius: 10, background: '#dcfce7', color: '#166534' }}>
|
|
neutralisiert
|
|
</span>
|
|
)}
|
|
{doc.validationMetadata?.skipped && (
|
|
<span style={{ fontSize: '0.7rem', padding: '1px 6px', borderRadius: 10, background: '#fef2f2', color: '#991b1b' }}>
|
|
übersprungen
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{(msg as any).neutralizationExcluded?.length > 0 && (
|
|
<div style={{ marginTop: 6, padding: '6px 8px', background: '#fef2f2', borderRadius: 4, border: '1px solid #fecaca' }}>
|
|
<div style={{ fontWeight: 600, color: '#991b1b', marginBottom: 4 }}>
|
|
Nicht gesendet (Neutralisierung fehlgeschlagen):
|
|
</div>
|
|
{(msg as any).neutralizationExcluded.map((docName: string, i: number) => (
|
|
<div key={i} style={{ fontSize: '0.75rem', color: '#991b1b', paddingLeft: 4 }}>
|
|
{docName}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</details>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{/* File edit proposals -- compact notification cards */}
|
|
{pendingEdits.filter(e => e.status === 'pending').length > 0 && (
|
|
<div
|
|
style={{
|
|
flexShrink: 0,
|
|
padding: 12,
|
|
borderRadius: 8,
|
|
border: '1px solid var(--warning-color, #ff9800)',
|
|
background: 'var(--edit-bg, #fff8e1)',
|
|
alignSelf: 'flex-start',
|
|
maxWidth: '85%',
|
|
}}
|
|
>
|
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<span style={{ color: '#ff9800' }}>✎</span>
|
|
{pendingEdits.filter(e => e.status === 'pending').length} Aenderungsvorschlag(e)
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
|
|
{pendingEdits.filter(e => e.status === 'pending').map(edit => (
|
|
<div key={edit.id} style={{ fontSize: 12, color: '#555', display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ff9800', flexShrink: 0 }} />
|
|
{edit.fileName}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
{onOpenEditor && (
|
|
<button
|
|
onClick={onOpenEditor}
|
|
style={{
|
|
padding: '5px 14px', borderRadius: 4, border: 'none',
|
|
background: 'var(--primary-color, #F25843)', color: '#fff',
|
|
cursor: 'pointer', fontSize: 12, fontWeight: 600,
|
|
}}
|
|
>
|
|
Im Editor pruefen
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => pendingEdits.filter(e => e.status === 'pending').forEach(e => onAcceptEdit(e.id))}
|
|
style={{
|
|
padding: '5px 14px', borderRadius: 4, border: 'none',
|
|
background: 'var(--success-color, #4caf50)', color: '#fff',
|
|
cursor: 'pointer', fontSize: 12, fontWeight: 600,
|
|
}}
|
|
>
|
|
Alle annehmen
|
|
</button>
|
|
<button
|
|
onClick={() => pendingEdits.filter(e => e.status === 'pending').forEach(e => onRejectEdit(e.id))}
|
|
style={{
|
|
padding: '5px 14px', borderRadius: 4,
|
|
border: '1px solid var(--border-color, #ccc)',
|
|
background: '#fff', cursor: 'pointer', fontSize: 12,
|
|
}}
|
|
>
|
|
Alle ablehnen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Thinking / agent-progress indicator */}
|
|
{isProcessing && (
|
|
<div style={{
|
|
flexShrink: 0,
|
|
padding: '10px 16px',
|
|
borderRadius: 12,
|
|
alignSelf: 'flex-start',
|
|
maxWidth: '85%',
|
|
background: 'var(--assistant-bg, #ffffff)',
|
|
border: '1px solid var(--border-color, #e0e0e0)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 6,
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<div style={{ fontSize: 11, color: '#888' }}>Assistant</div>
|
|
<div className="workspace-thinking-dots" style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
<span className="workspace-dot workspace-dot-1" />
|
|
<span className="workspace-dot workspace-dot-2" />
|
|
<span className="workspace-dot workspace-dot-3" />
|
|
</div>
|
|
</div>
|
|
{agentProgress ? (
|
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', fontSize: 11, color: '#888' }}>
|
|
<span style={{ fontWeight: 600 }}>
|
|
Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''}
|
|
</span>
|
|
<span>{agentProgress.totalToolCalls} tools</span>
|
|
<span>{agentProgress.costCHF?.toFixed(4) || '0'} CHF</span>
|
|
</div>
|
|
) : (
|
|
<div style={{ fontSize: 12, color: '#999' }}>
|
|
{t('Denkt nach…')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div ref={bottomRef} />
|
|
|
|
<style>{`
|
|
@keyframes workspace-dot-bounce {
|
|
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
|
|
40% { transform: translateY(-5px); opacity: 1; }
|
|
}
|
|
.workspace-dot {
|
|
display: inline-block;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--primary-color, #F25843);
|
|
animation: workspace-dot-bounce 1.4s ease-in-out infinite;
|
|
}
|
|
.workspace-dot-1 { animation-delay: 0s; }
|
|
.workspace-dot-2 { animation-delay: 0.2s; }
|
|
.workspace-dot-3 { animation-delay: 0.4s; }
|
|
.workspace-markdown p { margin: 4px 0; }
|
|
.workspace-markdown ul, .workspace-markdown ol { margin: 4px 0; padding-left: 20px; }
|
|
.workspace-markdown blockquote {
|
|
margin: 8px 0; padding: 4px 12px;
|
|
border-left: 3px solid #ddd; color: #666;
|
|
}
|
|
.workspace-markdown h1, .workspace-markdown h2, .workspace-markdown h3 {
|
|
margin: 8px 0 4px; line-height: 1.3;
|
|
}
|
|
.workspace-markdown img { max-width: 100%; border-radius: 4px; }
|
|
.workspace-markdown hr { border: none; border-top: 1px solid #e0e0e0; margin: 8px 0; }
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function _getBubbleBackground(role: string): string {
|
|
switch (role) {
|
|
case 'user': return 'var(--primary-light, #e3f2fd)';
|
|
case 'status': return 'var(--status-bg, #fff3e0)';
|
|
case 'system': return 'var(--system-bg, #f5f5f5)';
|
|
default: return 'var(--assistant-bg, #ffffff)';
|
|
}
|
|
}
|
|
|
|
function _FileCard({ doc }: { doc: MessageDocument }) {
|
|
const { t } = useLanguage();
|
|
const _handleDownload = useCallback(async () => {
|
|
try {
|
|
const res = await api.get(`/api/files/${doc.fileId}/download`, {
|
|
responseType: 'blob',
|
|
});
|
|
const blob = new Blob([res.data], { type: doc.mimeType || 'application/octet-stream' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = doc.fileName || 'download';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
console.error('Download failed:', err);
|
|
}
|
|
}, [doc]);
|
|
|
|
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
|
|
const icon = _getFileIcon(ext);
|
|
const sizeLabel = doc.fileSize ? formatBinaryDataSizeBytes(doc.fileSize) : '';
|
|
|
|
return (
|
|
<div
|
|
onClick={_handleDownload}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
padding: '8px 12px',
|
|
borderRadius: 6,
|
|
border: '1px solid var(--border-color, #e0e0e0)',
|
|
background: 'var(--file-card-bg, #f8f9fa)',
|
|
cursor: 'pointer',
|
|
transition: 'background 0.15s',
|
|
maxWidth: 340,
|
|
}}
|
|
title={`Download ${doc.fileName}`}
|
|
onMouseEnter={e => (e.currentTarget.style.background = '#e8f0fe')}
|
|
onMouseLeave={e => (e.currentTarget.style.background = 'var(--file-card-bg, #f8f9fa)')}
|
|
>
|
|
<span style={{ fontSize: 22 }}>{icon}</span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{
|
|
fontSize: 13, fontWeight: 600, overflow: 'hidden',
|
|
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}>
|
|
{doc.fileName}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#888' }}>
|
|
{ext.toUpperCase()}{sizeLabel ? ` \u00b7 ${sizeLabel}` : ''}
|
|
</div>
|
|
</div>
|
|
<span style={{ fontSize: 14, color: 'var(--primary-color, #F25843)' }} title={t('Download')}>⬇</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function _getFileIcon(ext: string): string {
|
|
const map: Record<string, string> = {
|
|
pdf: '\uD83D\uDCC4', csv: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', xls: '\uD83D\uDCCA',
|
|
doc: '\uD83D\uDCC3', docx: '\uD83D\uDCC3', txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
|
|
md: '\uD83D\uDCC4', xml: '\uD83D\uDCCB', yaml: '\uD83D\uDCCB', yml: '\uD83D\uDCCB',
|
|
html: '\uD83C\uDF10', css: '\uD83C\uDFA8', js: '\uD83D\uDCDC', ts: '\uD83D\uDCDC',
|
|
py: '\uD83D\uDC0D', sql: '\uD83D\uDDC3\uFE0F', log: '\uD83D\uDCDD',
|
|
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
|
|
gif: '\uD83D\uDDBC\uFE0F', svg: '\uD83D\uDDBC\uFE0F', webp: '\uD83D\uDDBC\uFE0F',
|
|
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', '7z': '\uD83D\uDCE6', tar: '\uD83D\uDCE6',
|
|
pptx: '\uD83D\uDCCA', ppt: '\uD83D\uDCCA',
|
|
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', ogg: '\uD83C\uDFB5',
|
|
mp4: '\uD83C\uDFAC', avi: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', webm: '\uD83C\uDFAC',
|
|
eml: '\uD83D\uDCE7', msg: '\uD83D\uDCE7',
|
|
};
|
|
return map[ext] || '\uD83D\uDCC4';
|
|
}
|
|
|
|
/**
|
|
* Queue-aware audio player with replay support.
|
|
*
|
|
* During the initial queue pass the player shows queue/active/done state.
|
|
* Once an item is done, clicking Play starts an independent local replay
|
|
* so every message can be re-listened at any time.
|
|
*/
|
|
function _QueuedAudioPlayer({
|
|
msgId,
|
|
url,
|
|
language,
|
|
audioQueue,
|
|
}: {
|
|
msgId: string;
|
|
url: string;
|
|
language?: string;
|
|
audioQueue: AudioQueueApi;
|
|
}) {
|
|
const isActive = audioQueue.state.currentId === msgId;
|
|
const isQueued = audioQueue.isItemQueued(msgId);
|
|
const queueDone = !isActive && !isQueued;
|
|
|
|
const isQueuePlaying = isActive && audioQueue.state.isPlaying && !audioQueue.state.isPaused;
|
|
const isQueuePaused = isActive && audioQueue.state.isPaused;
|
|
|
|
const replayRef = useRef<HTMLAudioElement | null>(null);
|
|
const [replayPlaying, setReplayPlaying] = useState(false);
|
|
const [replayProgress, setReplayProgress] = useState(0);
|
|
const [replayDuration, setReplayDuration] = useState(0);
|
|
|
|
const [queueProgress, setQueueProgress] = useState(0);
|
|
const [queueDuration, setQueueDuration] = useState(0);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (replayRef.current) { replayRef.current.pause(); replayRef.current = null; }
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isActive) return;
|
|
const interval = setInterval(() => {
|
|
setQueueProgress(audioQueue.getProgress());
|
|
setQueueDuration(audioQueue.getDuration());
|
|
}, 200);
|
|
return () => clearInterval(interval);
|
|
}, [isActive, audioQueue]);
|
|
|
|
const _startReplay = useCallback(() => {
|
|
if (replayRef.current) { replayRef.current.pause(); replayRef.current = null; }
|
|
const audio = new Audio(url);
|
|
replayRef.current = audio;
|
|
audio.addEventListener('loadedmetadata', () => setReplayDuration(audio.duration));
|
|
audio.addEventListener('timeupdate', () => {
|
|
if (audio.duration) setReplayProgress(audio.currentTime / audio.duration);
|
|
});
|
|
audio.addEventListener('ended', () => {
|
|
setReplayPlaying(false);
|
|
setReplayProgress(0);
|
|
replayRef.current = null;
|
|
});
|
|
audio.play()
|
|
.then(() => setReplayPlaying(true))
|
|
.catch(() => setReplayPlaying(false));
|
|
}, [url]);
|
|
|
|
const _toggleReplay = useCallback(() => {
|
|
const audio = replayRef.current;
|
|
if (!audio) { _startReplay(); return; }
|
|
if (audio.paused) {
|
|
audio.play().then(() => setReplayPlaying(true)).catch(() => {});
|
|
} else {
|
|
audio.pause();
|
|
setReplayPlaying(false);
|
|
}
|
|
}, [_startReplay]);
|
|
|
|
const _stopReplay = useCallback(() => {
|
|
if (replayRef.current) {
|
|
replayRef.current.pause();
|
|
replayRef.current.currentTime = 0;
|
|
replayRef.current = null;
|
|
}
|
|
setReplayPlaying(false);
|
|
setReplayProgress(0);
|
|
}, []);
|
|
|
|
const _handleMainButton = useCallback(() => {
|
|
if (isActive) {
|
|
if (isQueuePaused) audioQueue.resume();
|
|
else audioQueue.pause();
|
|
} else if (queueDone) {
|
|
_toggleReplay();
|
|
}
|
|
}, [isActive, isQueuePaused, queueDone, audioQueue, _toggleReplay]);
|
|
|
|
const _handleSkip = useCallback(() => {
|
|
if (isActive) audioQueue.skip();
|
|
}, [isActive, audioQueue]);
|
|
|
|
const _formatTime = (s: number) => {
|
|
const m = Math.floor(s / 60);
|
|
const sec = Math.floor(s % 60);
|
|
return `${m}:${sec.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const isAnythingPlaying = isQueuePlaying || replayPlaying;
|
|
const showProgress = isActive ? queueProgress : replayProgress;
|
|
const showDuration = isActive ? queueDuration : replayDuration;
|
|
const isWaiting = isQueued && !isActive;
|
|
|
|
let statusLabel = '';
|
|
if (isWaiting) statusLabel = 'in Warteschlange';
|
|
|
|
let buttonIcon = '\u25B6';
|
|
let buttonTitle = 'Abspielen';
|
|
if (isAnythingPlaying) { buttonIcon = '\u275A\u275A'; buttonTitle = 'Pause'; }
|
|
else if (isQueuePaused) { buttonIcon = '\u25B6'; buttonTitle = 'Weiter'; }
|
|
else if (isWaiting) { buttonIcon = '\u25B6'; buttonTitle = 'Warten…'; }
|
|
|
|
const canInteract = isActive || queueDone;
|
|
|
|
return (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
padding: '8px 12px', borderRadius: 8,
|
|
background: isActive
|
|
? 'var(--audio-player-bg-active, #e8f0fe)'
|
|
: replayPlaying
|
|
? 'var(--audio-player-bg-active, #e8f0fe)'
|
|
: 'var(--audio-player-bg, #f0f4f8)',
|
|
border: (isActive || replayPlaying)
|
|
? '1px solid var(--primary-color, #F25843)'
|
|
: '1px solid var(--border-color, #e0e0e0)',
|
|
maxWidth: 360, marginTop: 6,
|
|
transition: 'border-color 0.3s, background 0.3s',
|
|
}}>
|
|
<button
|
|
onClick={_handleMainButton}
|
|
disabled={isWaiting}
|
|
style={{
|
|
width: 32, height: 32, borderRadius: '50%', border: 'none',
|
|
background: canInteract ? 'var(--primary-color, #F25843)' : '#bbb',
|
|
color: '#fff',
|
|
cursor: canInteract ? 'pointer' : 'default',
|
|
fontSize: 14,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
flexShrink: 0,
|
|
}}
|
|
title={buttonTitle}
|
|
>
|
|
{buttonIcon}
|
|
</button>
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{
|
|
height: 4, borderRadius: 2,
|
|
background: 'var(--border-color, #ddd)',
|
|
overflow: 'hidden',
|
|
}}>
|
|
<div style={{
|
|
height: '100%', borderRadius: 2,
|
|
background: 'var(--primary-color, #F25843)',
|
|
width: `${showProgress * 100}%`,
|
|
transition: 'width 0.2s',
|
|
}} />
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}>
|
|
<span style={{ fontSize: 10, color: '#888' }}>
|
|
{showDuration > 0 ? _formatTime(showProgress * showDuration) : '0:00'}
|
|
</span>
|
|
<span style={{ fontSize: 10, color: '#888' }}>
|
|
{statusLabel || (showDuration > 0 ? _formatTime(showDuration) : '--:--')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{(isActive || replayPlaying) && (
|
|
<button
|
|
onClick={isActive ? _handleSkip : _stopReplay}
|
|
style={{
|
|
width: 28, height: 28, borderRadius: '50%', border: '1px solid #ccc',
|
|
background: 'transparent', color: '#888',
|
|
cursor: 'pointer', fontSize: 12,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
flexShrink: 0,
|
|
}}
|
|
title={isActive ? 'Skip' : 'Stop'}
|
|
>
|
|
{isActive ? '\u23ED' : '\u25A0'}
|
|
</button>
|
|
)}
|
|
|
|
{language && (
|
|
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>
|
|
{language}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function _CodeBlock({
|
|
className,
|
|
children,
|
|
...props
|
|
}: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
|
|
const match = /language-(\w+)/.exec(className || '');
|
|
const isInline = !match && !String(children).includes('\n');
|
|
|
|
if (isInline) {
|
|
return (
|
|
<code
|
|
style={{
|
|
background: '#f0f0f0',
|
|
padding: '1px 5px',
|
|
borderRadius: 3,
|
|
fontSize: '0.9em',
|
|
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
|
}}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</code>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ position: 'relative', margin: '8px 0' }}>
|
|
{match && (
|
|
<div style={{
|
|
position: 'absolute', top: 0, right: 0,
|
|
padding: '2px 8px', fontSize: 10, color: '#888',
|
|
background: '#2d2d2d', borderBottomLeftRadius: 4,
|
|
}}>
|
|
{match[1]}
|
|
</div>
|
|
)}
|
|
<pre style={{
|
|
background: '#1e1e1e',
|
|
color: '#d4d4d4',
|
|
padding: '12px 14px',
|
|
borderRadius: 6,
|
|
overflow: 'auto',
|
|
fontSize: 13,
|
|
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
|
lineHeight: 1.5,
|
|
margin: 0,
|
|
}}>
|
|
<code className={className} {...props}>
|
|
{children}
|
|
</code>
|
|
</pre>
|
|
</div>
|
|
);
|
|
}
|