@@ -3,10 +3,17 @@ import { SkeletonPlaceholder } from '@carbon/react';
33import { useTheme } from '@shared/hooks/useTheme' ;
44import { useIsClient } from '@shared/hooks/useIsClient' ;
55import { useLocale } from '@shared/hooks' ;
6- import './styles/PhantomStreamPlayer.scss' ;
6+ import type { Chapter } from '@shared/types/chapters' ;
7+ import ChapterStrip from './ChapterStrip' ;
8+ import './PhantomStreamPlayer.scss' ;
79
810// ── Types ────────────────────────────────────────────────────────
911
12+ interface ChapterStripConfig {
13+ /** Ordered list of chapters covering the timeline. */
14+ chapters : Chapter [ ] ;
15+ }
16+
1017interface PhantomStreamPlayerProps {
1118 /** Cloudflare Stream video UUID (phantom-stream-tool upload output). */
1219 uuid : string ;
@@ -39,6 +46,14 @@ interface PhantomStreamPlayerProps {
3946 title ?: string ;
4047 /** Additional class name for the outer wrapper. */
4148 className ?: string ;
49+ /**
50+ * If present, a chapter strip is rendered under the player; clicking a
51+ * segment seeks the embed (auto-playing on the first cue so the poster
52+ * lifts). Each player owns its own strip — multiple players on a page
53+ * stay independent. Total duration is pulled from the player itself
54+ * once metadata loads.
55+ */
56+ chapterStrip ?: ChapterStripConfig ;
4257}
4358
4459// ── Theme-aware defaults ─────────────────────────────────────────
@@ -84,6 +99,7 @@ const PhantomStreamPlayerCore: React.FC<PhantomStreamPlayerProps> = ({
8499 aspectRatio = '16 / 9' ,
85100 title,
86101 className = '' ,
102+ chapterStrip,
87103} ) => {
88104 const { theme } = useTheme ( ) ;
89105 const { locale } = useLocale ( ) ;
@@ -99,19 +115,14 @@ const PhantomStreamPlayerCore: React.FC<PhantomStreamPlayerProps> = ({
99115 // play — otherwise the user stares at a black letterbox.
100116 const [ videoReady , setVideoReady ] = useState ( false ) ;
101117
102- useEffect ( ( ) => {
103- let cancelled = false ;
104- import ( '@cloudflare/stream-react' ) . then ( ( mod ) => {
105- if ( ! cancelled ) setStreamComp ( ( ) => mod . Stream ) ;
106- } ) ;
107- return ( ) => {
108- cancelled = true ;
109- } ;
110- } , [ ] ) ;
118+ // Drives the chapter strip's "currently playing" highlight.
119+ const [ currentTime , setCurrentTime ] = useState ( 0 ) ;
120+
121+ // Pulled from the player once metadata loads; the chapter strip
122+ // waits for this before rendering segment widths.
123+ const [ duration , setDuration ] = useState ( 0 ) ;
111124
112125 // Imperative handle from @cloudflare /stream-react (HTMLVideoElement-shaped).
113- // MDX modules can't share refs across pages, so chapter cues travel via
114- // window events — any CueLink / ChapterStrip on the page can drive us.
115126 // eslint-disable-next-line @typescript-eslint/no-explicit-any
116127 const streamRef = useRef < any > ( null ) ;
117128
@@ -122,40 +133,48 @@ const PhantomStreamPlayerCore: React.FC<PhantomStreamPlayerProps> = ({
122133 const hasStartedRef = useRef ( false ) ;
123134
124135 useEffect ( ( ) => {
125- const handler = ( e : Event ) => {
126- const seconds = ( e as CustomEvent ) . detail ?. seconds ;
127- if ( typeof seconds === 'number' && streamRef . current ) {
128- try {
129- streamRef . current . currentTime = seconds ;
130- if ( ! hasStartedRef . current ) {
131- const result = streamRef . current . play ?.( ) ;
132- if ( result ?. catch ) {
133- result . catch ( ( ) => {
134- // Sound autoplay refused — retry muted so the poster
135- // at least clears and frames appear.
136- try {
137- streamRef . current . muted = true ;
138- streamRef . current . play ?.( ) ;
139- } catch {
140- /* give up silently */
141- }
142- } ) ;
136+ let cancelled = false ;
137+ import ( '@cloudflare/stream-react' ) . then ( ( mod ) => {
138+ if ( ! cancelled ) setStreamComp ( ( ) => mod . Stream ) ;
139+ } ) ;
140+ return ( ) => {
141+ cancelled = true ;
142+ } ;
143+ } , [ ] ) ;
144+
145+ const cueTo = ( seconds : number ) => {
146+ if ( ! Number . isFinite ( seconds ) || ! streamRef . current ) return ;
147+ try {
148+ streamRef . current . currentTime = seconds ;
149+ if ( ! hasStartedRef . current ) {
150+ const result = streamRef . current . play ?.( ) ;
151+ if ( result ?. catch ) {
152+ result . catch ( ( ) => {
153+ // Sound autoplay refused — retry muted so the poster
154+ // at least clears and frames appear.
155+ try {
156+ streamRef . current . muted = true ;
157+ streamRef . current . play ?.( ) ;
158+ } catch {
159+ /* give up silently */
143160 }
144- }
145- } catch {
146- /* player not ready yet */
161+ } ) ;
147162 }
148163 }
149- } ;
150- window . addEventListener ( 'phantom-stream:cue' , handler ) ;
151- return ( ) => window . removeEventListener ( 'phantom-stream:cue' , handler ) ;
152- } , [ ] ) ;
164+ } catch {
165+ /* player not ready yet */
166+ }
167+ } ;
153168
154169 const handleTimeUpdate = ( ) => {
155- const t = streamRef . current ?. currentTime ?? 0 ;
156- window . dispatchEvent (
157- new CustomEvent ( 'phantom-stream:time' , { detail : { currentTime : t } } ) ,
158- ) ;
170+ setCurrentTime ( streamRef . current ?. currentTime ?? 0 ) ;
171+ } ;
172+
173+ const handleCanPlay = ( ) => {
174+ setVideoReady ( true ) ;
175+ // canPlay guarantees metadata is loaded, so the imperative handle's
176+ // duration is now valid.
177+ setDuration ( streamRef . current ?. duration ?? 0 ) ;
159178 } ;
160179
161180 const themeColors = THEME_COLORS [ theme ] ?? THEME_COLORS . white ;
@@ -190,7 +209,7 @@ const PhantomStreamPlayerCore: React.FC<PhantomStreamPlayerProps> = ({
190209 width = "100%"
191210 title = { title }
192211 streamRef = { streamRef }
193- onCanPlay = { ( ) => setVideoReady ( true ) }
212+ onCanPlay = { handleCanPlay }
194213 onPlay = { ( ) => { hasStartedRef . current = true ; } }
195214 onTimeUpdate = { handleTimeUpdate }
196215 />
@@ -200,6 +219,14 @@ const PhantomStreamPlayerCore: React.FC<PhantomStreamPlayerProps> = ({
200219 < SkeletonPlaceholder className = "phantom-stream-player__skeleton" />
201220 ) }
202221 </ div >
222+ { chapterStrip && duration > 0 && (
223+ < ChapterStrip
224+ chapters = { chapterStrip . chapters }
225+ duration = { duration }
226+ currentTime = { currentTime }
227+ onCue = { cueTo }
228+ />
229+ ) }
203230 </ div >
204231 ) ;
205232} ;
0 commit comments