6 changed files with 900 additions and 70 deletions
@ -0,0 +1,269 @@
@@ -0,0 +1,269 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
Dialog, |
||||
DialogContent, |
||||
DialogHeader, |
||||
DialogTitle |
||||
} from '@/components/ui/dialog' |
||||
import { |
||||
closeReadAloudPlayer, |
||||
getReadAloudServerSnapshot, |
||||
getReadAloudSnapshot, |
||||
subscribeReadAloud, |
||||
type ReadAloudSnapshot |
||||
} from '@/lib/read-aloud' |
||||
import { cn } from '@/lib/utils' |
||||
import type { TFunction } from 'i18next' |
||||
import { useCallback, useSyncExternalStore } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
function formatClock(ts: number | null): string { |
||||
if (ts == null) return '—' |
||||
try { |
||||
return new Date(ts).toLocaleTimeString(undefined, { |
||||
hour: '2-digit', |
||||
minute: '2-digit', |
||||
second: '2-digit' |
||||
}) |
||||
} catch { |
||||
return '—' |
||||
} |
||||
} |
||||
|
||||
/** Lighter scrim than default bg-black/80; content stays above overlay (z-230 vs z-220). */ |
||||
const READ_ALOUD_OVERLAY_CLASS = |
||||
'z-[220] bg-black/35 backdrop-blur-sm dark:bg-black/40' |
||||
|
||||
function sectionAriaLabel(i: number, snap: ReadAloudSnapshot, t: TFunction): string { |
||||
if (i < snap.chunksPlayed) { |
||||
return t('Read-aloud section done', { index: i + 1 }) |
||||
} |
||||
if (i > snap.currentChunkIndex) { |
||||
return t('Read-aloud section pending', { index: i + 1 }) |
||||
} |
||||
switch (snap.phase) { |
||||
case 'requesting': |
||||
return t('Read-aloud section fetching', { index: i + 1 }) |
||||
case 'buffering': |
||||
case 'preparing': |
||||
return t('Read-aloud section preparing audio', { index: i + 1 }) |
||||
case 'playing': |
||||
return t('Read-aloud section playing', { index: i + 1 }) |
||||
case 'paused': |
||||
return t('Read-aloud section paused', { index: i + 1 }) |
||||
default: |
||||
return t('Read-aloud section pending', { index: i + 1 }) |
||||
} |
||||
} |
||||
|
||||
function phaseLabel(s: ReadAloudSnapshot, t: (k: string) => string): string { |
||||
switch (s.phase) { |
||||
case 'idle': |
||||
return t('Read-aloud idle') |
||||
case 'preparing': |
||||
return t('Preparing read-aloud…') |
||||
case 'requesting': |
||||
return t('Requesting audio…') |
||||
case 'buffering': |
||||
return t('Loading audio…') |
||||
case 'playing': |
||||
return t('Playing') |
||||
case 'paused': |
||||
return t('Paused') |
||||
case 'done': |
||||
return t('Read-aloud finished') |
||||
case 'error': |
||||
return t('Read-aloud error') |
||||
default: |
||||
return s.phase |
||||
} |
||||
} |
||||
|
||||
export default function ReadAloudPlayerModal(): JSX.Element { |
||||
const { t } = useTranslation() |
||||
const snap = useSyncExternalStore( |
||||
subscribeReadAloud, |
||||
getReadAloudSnapshot, |
||||
getReadAloudServerSnapshot |
||||
) |
||||
|
||||
const onOpenChange = useCallback((open: boolean) => { |
||||
if (!open) { |
||||
closeReadAloudPlayer() |
||||
} |
||||
}, []) |
||||
|
||||
const showChunks = snap.engine === 'piper' && snap.totalChunks > 0 |
||||
|
||||
const nChunks = snap.totalChunks |
||||
const overallPct = |
||||
nChunks > 0 |
||||
? Math.min(100, ((snap.chunksPlayed + snap.chunkPlaybackRatio) / nChunks) * 100) |
||||
: 0 |
||||
|
||||
return ( |
||||
<Dialog open={snap.open} onOpenChange={onOpenChange}> |
||||
<DialogContent |
||||
className="z-[230] max-w-md border-2 border-border bg-card shadow-2xl" |
||||
overlayClassName={READ_ALOUD_OVERLAY_CLASS} |
||||
> |
||||
<DialogHeader> |
||||
<DialogTitle className="pr-8 text-foreground">{t('Read aloud')}</DialogTitle> |
||||
</DialogHeader> |
||||
<div className="space-y-3 text-sm"> |
||||
{snap.title ? ( |
||||
<p className="font-medium text-foreground line-clamp-2">{snap.title}</p> |
||||
) : null} |
||||
<p className="text-muted-foreground">{phaseLabel(snap, t)}</p> |
||||
{snap.engine === 'piper' ? ( |
||||
<p className="text-xs text-muted-foreground break-all"> |
||||
{t('TTS endpoint')}: {snap.backend || '—'} |
||||
</p> |
||||
) : snap.engine === 'webspeech' ? ( |
||||
<p className="text-xs text-muted-foreground">{t('Using browser speech synthesis')}</p> |
||||
) : null} |
||||
{snap.readAloudPiperSkipped || |
||||
snap.readAloudPiperTryStartedAt != null || |
||||
snap.usedPiperFallback ? ( |
||||
<div |
||||
className={cn( |
||||
'space-y-1.5 rounded-md border px-3 py-2 text-xs', |
||||
snap.readAloudPiperSkipped |
||||
? 'border-sky-500/35 bg-sky-500/10' |
||||
: 'border-border bg-muted/40' |
||||
)} |
||||
role="region" |
||||
aria-label={t('Read-aloud Piper status region')} |
||||
> |
||||
<p className="font-semibold text-foreground">{t('Read-aloud Piper status heading')}</p> |
||||
{snap.readAloudPiperSkipped ? ( |
||||
<p className="text-muted-foreground">{t('Read-aloud Piper skipped notice')}</p> |
||||
) : null} |
||||
{snap.readAloudPiperTryStartedAt != null ? ( |
||||
<p className="text-muted-foreground"> |
||||
{t('Read-aloud Piper attempt started', { |
||||
time: formatClock(snap.readAloudPiperTryStartedAt) |
||||
})} |
||||
</p> |
||||
) : null} |
||||
{!snap.readAloudPiperSkipped && snap.backend ? ( |
||||
<p className="break-all text-muted-foreground"> |
||||
{t('Read-aloud Piper endpoint tried', { url: snap.backend })} |
||||
</p> |
||||
) : null} |
||||
</div> |
||||
) : null} |
||||
{snap.engine === 'webspeech' && snap.usedPiperFallback ? ( |
||||
<div |
||||
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-foreground" |
||||
role="status" |
||||
> |
||||
<p className="font-medium text-amber-950 dark:text-amber-100"> |
||||
{t('Read-aloud Piper fallback notice')} |
||||
</p> |
||||
{snap.piperFallbackDetail ? ( |
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-muted-foreground"> |
||||
<span className="font-medium text-foreground/90"> |
||||
{t('Read-aloud Piper fallback detail label')}:{' '} |
||||
</span> |
||||
{snap.piperFallbackDetail} |
||||
</p> |
||||
) : null} |
||||
</div> |
||||
) : null} |
||||
{showChunks ? ( |
||||
<div className="space-y-2" role="region" aria-label={t('Read-aloud sections')}> |
||||
<p className="text-xs text-muted-foreground"> |
||||
{t('Read-aloud section progress', { |
||||
current: snap.currentChunkIndex + 1, |
||||
total: snap.totalChunks |
||||
})} |
||||
</p> |
||||
<div |
||||
className="h-2 w-full overflow-hidden rounded-full bg-muted" |
||||
role="progressbar" |
||||
aria-valuemin={0} |
||||
aria-valuemax={100} |
||||
aria-valuenow={Math.round(overallPct)} |
||||
aria-label={t('Read-aloud overall progress')} |
||||
> |
||||
<div |
||||
className="h-full rounded-full bg-primary transition-[width] duration-200 ease-out" |
||||
style={{ width: `${overallPct}%` }} |
||||
/> |
||||
</div> |
||||
<div className="flex gap-1" role="list"> |
||||
{Array.from({ length: snap.totalChunks }, (_, i) => { |
||||
const done = i < snap.chunksPlayed |
||||
const active = i === snap.currentChunkIndex |
||||
const fetching = active && snap.phase === 'requesting' |
||||
const decoding = active && (snap.phase === 'buffering' || snap.phase === 'preparing') |
||||
const playing = active && snap.phase === 'playing' |
||||
const paused = active && snap.phase === 'paused' |
||||
return ( |
||||
<div |
||||
key={i} |
||||
role="listitem" |
||||
className={cn( |
||||
'relative h-8 min-w-0 flex-1 overflow-hidden rounded-sm border border-border', |
||||
done && 'bg-primary', |
||||
!done && !active && 'bg-muted', |
||||
fetching && 'animate-pulse bg-amber-500/40', |
||||
decoding && !fetching && 'animate-pulse bg-amber-500/25', |
||||
(playing || paused) && !done && 'bg-muted' |
||||
)} |
||||
title={sectionAriaLabel(i, snap, t)} |
||||
> |
||||
{(playing || paused) && !done ? ( |
||||
<div |
||||
className={cn( |
||||
'absolute inset-y-0 left-0 bg-primary/90', |
||||
paused && 'opacity-80' |
||||
)} |
||||
style={{ |
||||
width: `${Math.round(Math.min(1, snap.chunkPlaybackRatio) * 100)}%` |
||||
}} |
||||
/> |
||||
) : null} |
||||
</div> |
||||
) |
||||
})} |
||||
</div> |
||||
<p className="text-[10px] leading-tight text-muted-foreground"> |
||||
{snap.phase === 'requesting' |
||||
? t('Read-aloud legend fetching') |
||||
: snap.phase === 'buffering' || snap.phase === 'preparing' |
||||
? t('Read-aloud legend buffering') |
||||
: snap.phase === 'playing' |
||||
? t('Read-aloud legend playing') |
||||
: snap.phase === 'paused' |
||||
? t('Read-aloud legend paused') |
||||
: null} |
||||
</p> |
||||
</div> |
||||
) : null} |
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs border border-border rounded-md p-2 bg-muted/30"> |
||||
<dt className="text-muted-foreground">{t('Request sent')}</dt> |
||||
<dd>{formatClock(snap.requestSentAt)}</dd> |
||||
<dt className="text-muted-foreground">{t('Response received')}</dt> |
||||
<dd>{formatClock(snap.responseReceivedAt)}</dd> |
||||
<dt className="text-muted-foreground">{t('Playback started')}</dt> |
||||
<dd>{formatClock(snap.playbackStartedAt)}</dd> |
||||
<dt className="text-muted-foreground">{t('Characters')}</dt> |
||||
<dd>{snap.charCount > 0 ? snap.charCount.toLocaleString() : '—'}</dd> |
||||
</dl> |
||||
{snap.error ? ( |
||||
<p className="text-xs text-destructive whitespace-pre-wrap break-words border border-destructive/30 rounded-md p-2 bg-destructive/5"> |
||||
{snap.error} |
||||
</p> |
||||
) : null} |
||||
<div className="flex justify-end pt-2"> |
||||
<Button type="button" variant="secondary" size="sm" onClick={() => closeReadAloudPlayer()}> |
||||
{t('Close')} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</DialogContent> |
||||
</Dialog> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue