feat: enhance AudiobookCharacterResponse and AudioPlayer for compact mode support
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -70,3 +70,11 @@
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.compact {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.compact .downloadButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user