feat: enhance AudiobookCharacterResponse and AudioPlayer for compact mode support

This commit is contained in:
2026-03-13 17:14:33 +08:00
parent e024910411
commit dbfcff3476
6 changed files with 70 additions and 37 deletions

View File

@@ -60,9 +60,17 @@ def _project_to_response(project, segment_total: int = 0, segment_done: int = 0)
)
def _project_to_detail(project, db: Session) -> AudiobookProjectDetail:
characters = [
AudiobookCharacterResponse(
def _char_to_response(c, db: Session) -> AudiobookCharacterResponse:
vd_name = None
vd_speaker = None
if c.voice_design_id:
from db.models import VoiceDesign
vd = db.query(VoiceDesign).filter(VoiceDesign.id == c.voice_design_id).first()
if vd:
vd_name = vd.name
meta = vd.meta_data or {}
vd_speaker = meta.get('speaker') or vd.aliyun_voice_id or vd.instruct or None
return AudiobookCharacterResponse(
id=c.id,
project_id=c.project_id,
name=c.name,
@@ -70,10 +78,14 @@ def _project_to_detail(project, db: Session) -> AudiobookProjectDetail:
description=c.description,
instruct=c.instruct,
voice_design_id=c.voice_design_id,
voice_design_name=vd_name,
voice_design_speaker=vd_speaker,
use_indextts2=c.use_indextts2 or False,
)
for c in (project.characters or [])
]
def _project_to_detail(project, db: Session) -> AudiobookProjectDetail:
characters = [_char_to_response(c, db) for c in (project.characters or [])]
chapters = [
AudiobookChapterResponse(
id=ch.id,
@@ -730,16 +742,7 @@ async def update_character(
voice_design.instruct = data.instruct
db.commit()
return AudiobookCharacterResponse(
id=char.id,
project_id=char.project_id,
name=char.name,
gender=char.gender,
description=char.description,
instruct=char.instruct,
voice_design_id=char.voice_design_id,
use_indextts2=char.use_indextts2 or False,
)
return _char_to_response(char, db)
@router.post("/projects/{project_id}/generate")

View File

@@ -59,6 +59,8 @@ class AudiobookCharacterResponse(BaseModel):
description: Optional[str] = None
instruct: Optional[str] = None
voice_design_id: Optional[int] = None
voice_design_name: Optional[str] = None
voice_design_speaker: Optional[str] = None
use_indextts2: bool = False
model_config = ConfigDict(from_attributes=True)

View File

@@ -70,3 +70,11 @@
min-height: 40px;
min-width: 40px;
}
.compact {
padding: 0.25rem 0.5rem;
}
.compact .downloadButton {
display: none;
}

View File

@@ -12,9 +12,10 @@ interface AudioPlayerProps {
audioUrl: string
jobId: number
text?: string
compact?: boolean
}
const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
const AudioPlayer = memo(({ audioUrl, jobId, compact }: AudioPlayerProps) => {
const { t } = useTranslation('common')
const { theme } = useTheme()
const [blobUrl, setBlobUrl] = useState<string>('')
@@ -97,13 +98,13 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
const player = new WaveformPlayer(containerRef.current, {
url: blobUrl,
waveformStyle: 'mirror',
height: 60,
barWidth: 3,
height: compact ? 32 : 60,
barWidth: compact ? 2 : 3,
barSpacing: 1,
samples: 200,
samples: compact ? 80 : 200,
waveformColor,
progressColor,
showTime: true,
showTime: !compact,
showPlaybackSpeed: false,
autoplay: false,
enableMediaSession: true,
@@ -157,6 +158,17 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
return null
}
if (compact) {
return (
<audio
src={blobUrl}
controls
className="w-full h-8"
style={{ colorScheme: 'dark' }}
/>
)
}
return (
<div className={styles.audioPlayerWrapper}>
<div ref={containerRef} className={styles.waveformContainer} />

View File

@@ -48,6 +48,8 @@ export interface AudiobookCharacter {
description?: string
instruct?: string
voice_design_id?: number
voice_design_name?: string
voice_design_speaker?: string
use_indextts2?: boolean
}

View File

@@ -16,7 +16,7 @@ import { RotateCcw } from 'lucide-react'
import apiClient, { formatApiError, adminApi, authApi } from '@/lib/api'
import { useAuth } from '@/contexts/AuthContext'
function LazyAudioPlayer({ audioUrl, jobId }: { audioUrl: string; jobId: number }) {
function LazyAudioPlayer({ audioUrl, jobId, compact }: { audioUrl: string; jobId: number; compact?: boolean }) {
const [visible, setVisible] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -29,7 +29,7 @@ function LazyAudioPlayer({ audioUrl, jobId }: { audioUrl: string; jobId: number
observer.observe(el)
return () => observer.disconnect()
}, [])
return <div ref={ref}>{visible && <AudioPlayer audioUrl={audioUrl} jobId={jobId} />}</div>
return <div ref={ref}>{visible && <AudioPlayer audioUrl={audioUrl} jobId={jobId} compact={compact} />}</div>
}
const STATUS_COLORS: Record<string, string> = {
@@ -1333,22 +1333,23 @@ function CharactersPanel({
)}
</div>
</div>
{char.instruct && <span className="text-xs text-muted-foreground truncate">{char.instruct}</span>}
<div className="flex items-center gap-1">
{char.description && <span className="text-xs text-muted-foreground">{char.description}</span>}
{char.instruct && <span className="text-xs text-muted-foreground/70">{char.instruct}</span>}
<div className="text-xs text-muted-foreground/60">
{char.voice_design_id
? <Badge variant="outline" className="text-xs">{t('projectCard.characters.voiceDesign', { id: char.voice_design_id })}</Badge>
: <Badge variant="secondary" className="text-xs">{t('projectCard.characters.noVoice')}</Badge>
}
? (char.voice_design_name || `#${char.voice_design_id}`)
: t('projectCard.characters.noVoice')}
</div>
</div>
)}
{!editingCharId && char.voice_design_id && (
<div className="mt-2 flex items-center justify-between gap-2 bg-muted/30 rounded-md p-1.5 border border-muted/50">
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 min-w-0">
<LazyAudioPlayer
key={`audio-${char.id}-${voiceKeys[char.id] || 0}`}
audioUrl={`${audiobookApi.getCharacterAudioUrl(project.id, char.id)}?t=${voiceKeys[char.id] || 0}`}
jobId={char.id}
compact
/>
</div>
{status === 'characters_ready' && (
@@ -2321,7 +2322,12 @@ export default function Audiobook() {
<>
<div className="shrink-0 border-b px-4 py-2 flex items-start justify-between gap-2">
<div className="flex items-start gap-2 min-w-0 flex-1">
<Book className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
{(() => {
const isNsfw = selectedProject.source_type === 'ai_generated' && !!(selectedProject.script_config as any)?.nsfw_mode
const Icon = selectedProject.source_type === 'epub' ? BookOpen : isNsfw ? Flame : selectedProject.source_type === 'ai_generated' ? Wand2 : Book
const cls = isNsfw ? 'h-4 w-4 shrink-0 mt-0.5 text-orange-500' : selectedProject.source_type === 'ai_generated' ? 'h-4 w-4 shrink-0 mt-0.5 text-violet-500' : 'h-4 w-4 shrink-0 mt-0.5 text-muted-foreground'
return <Icon className={cls} />
})()}
<span className="font-medium break-words">{selectedProject.title}</span>
</div>
<div className="flex items-center gap-1 shrink-0 flex-wrap justify-end">