You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

269 lines
10 KiB

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>
)
}