Studio lot 8d : portrait du narrateur à côté du lecteur audio
Article et parcours (liste et carte). Corrige au passage l'audio rangé par identifiant de ressource, qui n'avait jamais de lecteur : useAudioResource le résout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
This commit is contained in:
parent
82cff83bb6
commit
d21d794420
@ -8,6 +8,8 @@ import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import type { ContentDTO, SectionDTO } from '@/lib/api/types'
|
||||
import AppBar from '@/components/ui/AppBar'
|
||||
import NarratorAvatar from '@/components/ui/NarratorAvatar'
|
||||
import { useAudioResource } from '@/hooks/useAudioResource'
|
||||
import { trackEvent } from '@/lib/stats'
|
||||
|
||||
interface Props {
|
||||
@ -15,9 +17,10 @@ interface Props {
|
||||
slug: string
|
||||
configId: string
|
||||
languages: string[]
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export default function ArticleSection({ section, configId, languages }: Props) {
|
||||
export default function ArticleSection({ section, configId, languages, apiKey }: Props) {
|
||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||
const back = useBack()
|
||||
const article = section.article
|
||||
@ -60,8 +63,11 @@ export default function ArticleSection({ section, configId, languages }: Props)
|
||||
return () => el.removeEventListener('scroll', onScroll)
|
||||
}, [instanceId, configId, section.id, language, isLandscape])
|
||||
|
||||
const audioUrl = article?.audioIds?.find((a) => a.language === language)?.value
|
||||
?? article?.audioIds?.[0]?.value
|
||||
const audio = useAudioResource(
|
||||
article?.audioIds?.find((a) => a.language === language)?.value ?? article?.audioIds?.[0]?.value,
|
||||
apiKey,
|
||||
)
|
||||
const audioUrl = audio.url
|
||||
|
||||
useEffect(() => {
|
||||
if (article?.isReadAudioAuto && audioRef.current) {
|
||||
@ -131,7 +137,9 @@ export default function ArticleSection({ section, configId, languages }: Props)
|
||||
)}
|
||||
</button>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ color: 'var(--color-ink-muted)', fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>GUIDE AUDIO</span>
|
||||
{audio.narratorName
|
||||
? <NarratorAvatar name={audio.narratorName} portraitUrl={audio.narratorPortraitUrl} color="var(--color-ink)" />
|
||||
: <span style={{ color: 'var(--color-ink-muted)', fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>GUIDE AUDIO</span>}
|
||||
{!isAudioCompact && (
|
||||
<div style={{ marginTop: 5 }}>
|
||||
<Waveform currentTime={currentTime} duration={duration} onSeek={seekAudio} />
|
||||
|
||||
@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import ChevronLeft from '@/components/ui/ChevronLeftIcon'
|
||||
import ResourceViewer, { isImageResource } from '@/components/ui/ResourceViewer'
|
||||
import NarratorAvatar from '@/components/ui/NarratorAvatar'
|
||||
import { useAudioResource } from '@/hooks/useAudioResource'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { useIsLandscape, useMediaQuery } from '@/hooks/useOrientation'
|
||||
import FloatingPanel from '@/components/ui/FloatingPanel'
|
||||
@ -37,7 +39,7 @@ interface Props {
|
||||
|
||||
type View = 'list' | 'start' | 'progress' | 'end'
|
||||
|
||||
export default function ParcoursSection({ section, languages }: Props) {
|
||||
export default function ParcoursSection({ section, languages, apiKey }: Props) {
|
||||
const { language, setAvailableLanguages } = useVisitor()
|
||||
const back = useBack()
|
||||
|
||||
@ -130,6 +132,7 @@ export default function ParcoursSection({ section, languages }: Props) {
|
||||
if (useMapView) {
|
||||
return (
|
||||
<ParcoursMapProgression
|
||||
apiKey={apiKey}
|
||||
path={activePath}
|
||||
steps={steps}
|
||||
stepIndex={stepIndex}
|
||||
@ -149,6 +152,7 @@ export default function ParcoursSection({ section, languages }: Props) {
|
||||
}
|
||||
return (
|
||||
<ProgressView
|
||||
apiKey={apiKey}
|
||||
path={activePath}
|
||||
steps={steps}
|
||||
stepIndex={stepIndex}
|
||||
@ -291,7 +295,8 @@ export default function ParcoursSection({ section, languages }: Props) {
|
||||
|
||||
// ── Map-based progression ─────────────────────────────────────────────────────
|
||||
|
||||
function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primaryColor, isGame, language, sectionLat, sectionLng, onNext, onPrev, onBack, canJumpTo, onJump }: {
|
||||
function ParcoursMapProgression({ apiKey, path, steps, stepIndex, completedSteps, primaryColor, isGame, language, sectionLat, sectionLng, onNext, onPrev, onBack, canJumpTo, onJump }: {
|
||||
apiKey: string
|
||||
path: GuidedPathDTO
|
||||
steps: GuidedStepDTO[]
|
||||
stepIndex: number
|
||||
@ -425,9 +430,11 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
|
||||
const zoneOk = !geoTriggered || inZone || !requireSuccess || geoUnavailable
|
||||
const canAdvance = quizOk && zoneOk
|
||||
|
||||
const audioUrlRaw = currentStep?.audioIds?.find((a) => a.language === language)?.value
|
||||
?? currentStep?.audioIds?.[0]?.value
|
||||
const audioUrl = audioUrlRaw?.startsWith('http') ? audioUrlRaw : undefined
|
||||
const audio = useAudioResource(
|
||||
currentStep?.audioIds?.find((a) => a.language === language)?.value ?? currentStep?.audioIds?.[0]?.value,
|
||||
apiKey,
|
||||
)
|
||||
const audioUrl = audio.url
|
||||
const stepContents = [...(currentStep?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
const hasCarousel = stepContents.length > 0
|
||||
|
||||
@ -875,6 +882,11 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
|
||||
border: `1px solid ${isGame ? 'rgba(212,175,55,0.2)' : '#E8E3DA'}`,
|
||||
}}
|
||||
>
|
||||
{audio.narratorName && (
|
||||
<div style={{ padding: '12px 14px 0' }}>
|
||||
<NarratorAvatar name={audio.narratorName} portraitUrl={audio.narratorPortraitUrl} color={isGame ? '#fff' : '#1E2A33'} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px' }}>
|
||||
<button
|
||||
onClick={toggleAudio}
|
||||
@ -1379,7 +1391,8 @@ function StartSheet({ path, isGame, primaryColor, language, gameIntro, onStart,
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, isGame, language, onNext, onPrev, onBack, maxReached, canJumpTo, onJump }: {
|
||||
function ProgressView({ apiKey, path, steps, stepIndex, completedSteps, primaryColor, isGame, language, onNext, onPrev, onBack, maxReached, canJumpTo, onJump }: {
|
||||
apiKey: string
|
||||
path: GuidedPathDTO
|
||||
steps: GuidedStepDTO[]
|
||||
stepIndex: number
|
||||
@ -1444,9 +1457,11 @@ function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, is
|
||||
|
||||
const stepTitle = tPlain(step?.title, language)
|
||||
const stepDesc = t(step?.description, language)
|
||||
const audioUrlRaw = step?.audioIds?.find((a) => a.language === language)?.value
|
||||
?? step?.audioIds?.[0]?.value
|
||||
const audioUrl = audioUrlRaw?.startsWith('http') ? audioUrlRaw : undefined
|
||||
const audio = useAudioResource(
|
||||
step?.audioIds?.find((a) => a.language === language)?.value ?? step?.audioIds?.[0]?.value,
|
||||
apiKey,
|
||||
)
|
||||
const audioUrl = audio.url
|
||||
const stepContents = [...(step?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
const hasCarousel = stepContents.length > 0
|
||||
|
||||
@ -1660,6 +1675,11 @@ function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, is
|
||||
border: `1px solid ${isGame ? 'rgba(212,175,55,0.2)' : '#E8E3DA'}`,
|
||||
}}
|
||||
>
|
||||
{audio.narratorName && (
|
||||
<div style={{ padding: '12px 14px 0' }}>
|
||||
<NarratorAvatar name={audio.narratorName} portraitUrl={audio.narratorPortraitUrl} color={isGame ? '#fff' : '#1E2A33'} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px' }}>
|
||||
<button
|
||||
onClick={toggleAudio}
|
||||
|
||||
26
src/components/ui/NarratorAvatar.tsx
Normal file
26
src/components/ui/NarratorAvatar.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import Image from 'next/image'
|
||||
|
||||
/**
|
||||
* Le personnage qui parle, à côté du lecteur audio (plan Studio lot 8d) : portrait canon et
|
||||
* nom. Sans portrait, un disque neutre garde la place.
|
||||
*/
|
||||
export default function NarratorAvatar({ name, portraitUrl, color }: { name: string; portraitUrl?: string; color?: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
{portraitUrl ? (
|
||||
<Image
|
||||
src={portraitUrl}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
style={{ width: 32, height: 32, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#E8E3DA', flexShrink: 0 }} />
|
||||
)}
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
src/hooks/useAudioResource.ts
Normal file
42
src/hooks/useAudioResource.ts
Normal file
@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getResource } from '@/lib/api/client'
|
||||
|
||||
export interface AudioResource {
|
||||
url?: string
|
||||
narratorName?: string
|
||||
narratorPortraitUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* `audioIds` porte un identifiant de ressource — ce que range le manager, et la narration
|
||||
* générée — ou, sur d'anciens contenus, une URL. Sans cette résolution, un audio rangé par
|
||||
* identifiant ne jouait jamais. Le narrateur vient avec la ressource (plan Studio lot 8d).
|
||||
*/
|
||||
export function useAudioResource(value: string | undefined, apiKey: string): AudioResource {
|
||||
const [resource, setResource] = useState<AudioResource>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setResource({})
|
||||
return
|
||||
}
|
||||
if (value.startsWith('http')) {
|
||||
setResource({ url: value })
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
getResource(value, apiKey)
|
||||
.then((r) => {
|
||||
if (!cancelled) setResource({ url: r.url, narratorName: r.narratorName, narratorPortraitUrl: r.narratorPortraitUrl })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResource({})
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [value, apiKey])
|
||||
|
||||
return resource
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ResourceDTO } from './types'
|
||||
import type { ApplicationInstanceDTO, AppConfigurationLinkDTO, ConfigurationDTO, SectionDTO, GuidedPathDTO, ParcoursDTO, AiChatMessageDTO, AiChatResponseDTO } from './types'
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL
|
||||
@ -188,6 +189,11 @@ export async function getConfiguration(configId: string, apiKey: string): Promis
|
||||
return apiFetch(`/api/configuration/${configId}`, apiKey)
|
||||
}
|
||||
|
||||
/** URL et narrateur d'une ressource rangee par identifiant, typiquement un audio. */
|
||||
export async function getResource(resourceId: string, apiKey: string): Promise<ResourceDTO> {
|
||||
return apiFetch(`/api/Resource/${resourceId}/detail`, apiKey, 'default')
|
||||
}
|
||||
|
||||
export async function getSections(configId: string, apiKey: string): Promise<SectionDTO[]> {
|
||||
const raw: any[] = await apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey, 'no-store')
|
||||
return raw.map(normalizeSectionDTO)
|
||||
|
||||
@ -9,6 +9,9 @@ export interface ResourceDTO {
|
||||
type?: string
|
||||
/** ResourceOrigin cote backend : 0 televersee, 1 generee par le Studio, 2 derivee. */
|
||||
origin?: number
|
||||
/** Audio narre : le personnage qui parle et son portrait canon (lot 8d). */
|
||||
narratorName?: string
|
||||
narratorPortraitUrl?: string
|
||||
}
|
||||
|
||||
/** Une image generee porte la mention IA ; une image televersee, jamais. */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user