Skip to content

Commit 1ea9e8f

Browse files
Add a figure trailer audio player
Plays a figure's short page-intro clip as a standalone HTML5 audio element, served from R2 and decoupled from the conversation TTS pipeline. Tap-to-play only, so the play() call sits inside the user gesture iOS requires. Prefers webm and falls back to mp3 where the browser cannot confirm webm support.
1 parent ae9b0bc commit 1ea9e8f

3 files changed

Lines changed: 126 additions & 0 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// src/hooks/useFigureTrailer.ts
2+
import { useCallback, useEffect, useRef, useState } from 'react';
3+
import { getPublicTrailerUrl } from '../utils/public/publicMediaUrl';
4+
5+
/**
6+
* Plays a figure's ~50-second page trailer (rendered audio, served from R2).
7+
*
8+
* Deliberately a standalone HTML5 <audio> player, decoupled from the
9+
* conversation/TTS audio pipeline — a figure trailer is one short clip, and a
10+
* minimal surface is the most predictable thing on mobile. Tap-to-play only,
11+
* never autoplay: play() runs inside the tap gesture, which is what iOS needs.
12+
*/
13+
14+
// Each trailer is on R2 as both webm (~370 KB, Opus) and mp3 (~2.1 MB). webm is
15+
// ~6x smaller, so prefer it — but only when the browser is confident it can
16+
// play webm audio. iOS Safari reports no confidence and so gets mp3, which is
17+
// universally supported. Probed once and cached.
18+
let cachedExt: 'webm' | 'mp3' | null = null;
19+
function trailerExt(): 'webm' | 'mp3' {
20+
if (cachedExt) return cachedExt;
21+
let webm = false;
22+
try {
23+
const probe = document.createElement('audio');
24+
webm =
25+
probe.canPlayType('audio/webm; codecs="opus"') === 'probably' ||
26+
probe.canPlayType('audio/webm; codecs="vorbis"') === 'probably';
27+
} catch {
28+
webm = false;
29+
}
30+
cachedExt = webm ? 'webm' : 'mp3';
31+
return cachedExt;
32+
}
33+
34+
export type TrailerStatus = 'idle' | 'loading' | 'playing' | 'error';
35+
36+
export interface FigureTrailerControls {
37+
/** Figure id whose trailer is currently active (loading or playing), or null. */
38+
activeId: string | null;
39+
status: TrailerStatus;
40+
/** Play this figure's trailer — or pause it if it is already this figure's. */
41+
toggle: (figureId: string, language: string) => void;
42+
/** Stop and reset — call on figure change, modal close, or unmount. */
43+
stop: () => void;
44+
}
45+
46+
export function useFigureTrailer(): FigureTrailerControls {
47+
const audioRef = useRef<HTMLAudioElement | null>(null);
48+
const [activeId, setActiveId] = useState<string | null>(null);
49+
const [status, setStatus] = useState<TrailerStatus>('idle');
50+
51+
const stop = useCallback(() => {
52+
const audio = audioRef.current;
53+
if (audio) {
54+
audio.pause();
55+
try { audio.currentTime = 0; } catch { /* ignore — seek before load */ }
56+
}
57+
setActiveId(null);
58+
setStatus('idle');
59+
}, []);
60+
61+
// Never leave a trailer playing behind a closed / unmounted modal.
62+
useEffect(() => {
63+
return () => {
64+
const audio = audioRef.current;
65+
if (audio) {
66+
audio.pause();
67+
audio.removeAttribute('src');
68+
audio.load();
69+
}
70+
audioRef.current = null;
71+
};
72+
}, []);
73+
74+
const toggle = useCallback(
75+
(figureId: string, language: string) => {
76+
const audio = audioRef.current;
77+
78+
// Tap on the figure that is already active → pause it.
79+
if (audio && activeId === figureId && status !== 'idle' && status !== 'error') {
80+
audio.pause();
81+
setStatus('idle');
82+
setActiveId(null);
83+
return;
84+
}
85+
86+
const el = audio ?? new Audio();
87+
if (!audioRef.current) {
88+
el.preload = 'none';
89+
audioRef.current = el;
90+
}
91+
92+
el.pause();
93+
el.onplaying = () => setStatus('playing');
94+
el.onended = () => { setStatus('idle'); setActiveId(null); };
95+
el.onerror = () => { setStatus('error'); setActiveId(null); };
96+
el.src = getPublicTrailerUrl(figureId, language === 'de' ? 'de' : 'en', trailerExt());
97+
98+
setActiveId(figureId);
99+
setStatus('loading');
100+
101+
// play() must run inside the tap gesture (iOS) — toggle is always an onClick.
102+
const result = el.play();
103+
if (result && typeof result.catch === 'function') {
104+
result.catch(() => { setStatus('error'); setActiveId(null); });
105+
}
106+
},
107+
[activeId, status]
108+
);
109+
110+
return { activeId, status, toggle, stop };
111+
}

client/src/utils/public/publicMediaUrl.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ export function getPublicAudioUrl(
4141
return `${MEDIA_BASE}/stories/${figureId}/${lang}/${figureId}_${segment}_${lang}.webm`;
4242
}
4343

44+
export type TrailerFormat = 'webm' | 'mp3';
45+
46+
export function getPublicTrailerUrl(
47+
figureId: string,
48+
lang: string,
49+
format: TrailerFormat = 'webm'
50+
): string {
51+
return `${MEDIA_BASE}/trailers/figures/${figureId}/${lang}/${figureId}_trailer_${lang}.${format}`;
52+
}
53+
4454
export function getAvailableSizes(type: ImageType): number[] {
4555
return SIZES[type];
4656
}

client/vite.config.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,11 @@ export default defineConfig(({ command, mode }) => {
174174
target: env.VITE_MEDIA_BASE_URL || 'https://media.agoracosmica.org',
175175
changeOrigin: true,
176176
secure: true
177+
},
178+
'/trailers': {
179+
target: env.VITE_MEDIA_BASE_URL || 'https://media.agoracosmica.org',
180+
changeOrigin: true,
181+
secure: true
177182
}
178183
},
179184

0 commit comments

Comments
 (0)