22import React , { useRef , useEffect , useState , useCallback } from 'react' ;
33import { Episode , Podcast } from '../types' ;
44import { storageService } from '../services/storageService' ;
5+ import { castService } from '../services/castService' ;
56import { APP_CONFIG } from '../config' ;
67
78interface PlayerProps {
@@ -40,9 +41,18 @@ const Player: React.FC<PlayerProps> = ({
4041 const [ duration , setDuration ] = useState ( 0 ) ;
4142 const [ playbackRate , setPlaybackRate ] = useState ( 1 ) ;
4243 const [ error , setError ] = useState < string | null > ( null ) ;
44+ const [ isCasting , setIsCasting ] = useState ( false ) ;
45+ const [ castDeviceName , setCastDeviceName ] = useState < string | undefined > ( ) ;
4346
4447 const saveProgress = useCallback ( ( ) => {
45- if ( audioRef . current && ! error && audioRef . current . currentTime > 0.1 ) {
48+ if ( isCasting ) {
49+ const castTime = castService . getCurrentTime ( ) ;
50+ const castDuration = castService . getDuration ( ) ;
51+ if ( castTime > 0.1 ) {
52+ storageService . updatePlayback ( episode , podcast , castTime , castDuration ) ;
53+ if ( onProgress ) onProgress ( ) ;
54+ }
55+ } else if ( audioRef . current && ! error && audioRef . current . currentTime > 0.1 ) {
4656 storageService . updatePlayback (
4757 episode ,
4858 podcast ,
@@ -51,10 +61,19 @@ const Player: React.FC<PlayerProps> = ({
5161 ) ;
5262 if ( onProgress ) onProgress ( ) ;
5363 }
54- } , [ episode , podcast , error , onProgress ] ) ;
64+ } , [ episode , podcast , error , onProgress , isCasting ] ) ;
5565
5666 const togglePlay = useCallback ( ( ) => {
57- if ( audioRef . current ) {
67+ if ( isCasting ) {
68+ if ( castService . isPlaying ( ) ) {
69+ castService . pause ( ) ;
70+ setIsPlaying ( false ) ;
71+ } else {
72+ castService . play ( ) ;
73+ setIsPlaying ( true ) ;
74+ }
75+ saveProgress ( ) ;
76+ } else if ( audioRef . current ) {
5877 if ( isPlaying ) {
5978 audioRef . current . pause ( ) ;
6079 setIsPlaying ( false ) ;
@@ -63,19 +82,80 @@ const Player: React.FC<PlayerProps> = ({
6382 audioRef . current . play ( ) . then ( ( ) => setIsPlaying ( true ) ) . catch ( ( ) => setIsPlaying ( false ) ) ;
6483 }
6584 }
66- } , [ isPlaying , saveProgress ] ) ;
85+ } , [ isPlaying , saveProgress , isCasting ] ) ;
6786
6887 const skipSeconds = useCallback ( ( seconds : number ) => {
69- if ( audioRef . current ) {
88+ if ( isCasting ) {
89+ const newTime = Math . max ( 0 , Math . min ( castService . getDuration ( ) , castService . getCurrentTime ( ) + seconds ) ) ;
90+ castService . seek ( newTime ) ;
91+ } else if ( audioRef . current ) {
7092 audioRef . current . currentTime = Math . max ( 0 , Math . min ( audioRef . current . duration , audioRef . current . currentTime + seconds ) ) ;
7193 }
72- } , [ ] ) ;
94+ } , [ isCasting ] ) ;
7395
7496 const seekToPercentage = useCallback ( ( percent : number ) => {
75- if ( audioRef . current && isFinite ( audioRef . current . duration ) ) {
97+ if ( isCasting ) {
98+ const duration = castService . getDuration ( ) ;
99+ if ( isFinite ( duration ) ) {
100+ castService . seek ( ( percent / 100 ) * duration ) ;
101+ }
102+ } else if ( audioRef . current && isFinite ( audioRef . current . duration ) ) {
76103 audioRef . current . currentTime = ( percent / 100 ) * audioRef . current . duration ;
77104 }
78- } , [ ] ) ;
105+ } , [ isCasting ] ) ;
106+
107+ // Initialize Cast Service
108+ useEffect ( ( ) => {
109+ castService . initialize ( ) . catch ( err => console . warn ( 'Cast init failed:' , err ) ) ;
110+
111+ const unsubscribeState = castService . onStateChange ( ( isConnected , deviceName ) => {
112+ setIsCasting ( isConnected ) ;
113+ setCastDeviceName ( deviceName ) ;
114+
115+ if ( isConnected && audioRef . current ) {
116+ // Transfer playback to Cast
117+ const currentTime = audioRef . current . currentTime ;
118+ audioRef . current . pause ( ) ;
119+ setIsPlaying ( false ) ;
120+
121+ castService . loadMedia ( episode , podcast , currentTime )
122+ . then ( ( ) => {
123+ setIsPlaying ( true ) ;
124+ } )
125+ . catch ( err => console . error ( 'Failed to load media on Cast:' , err ) ) ;
126+ } else if ( ! isConnected && audioRef . current ) {
127+ // Transfer back to local playback
128+ const currentTime = castService . getCurrentTime ( ) ;
129+ audioRef . current . currentTime = currentTime ;
130+ if ( isPlaying ) {
131+ audioRef . current . play ( ) . catch ( ( ) => setIsPlaying ( false ) ) ;
132+ }
133+ }
134+ } ) ;
135+
136+ const unsubscribeMedia = castService . onMediaStatus ( ( status ) => {
137+ setIsPlaying ( status . isPlaying ) ;
138+ setCurrentTime ( status . currentTime ) ;
139+ setDuration ( status . duration ) ;
140+ } ) ;
141+
142+ return ( ) => {
143+ unsubscribeState ( ) ;
144+ unsubscribeMedia ( ) ;
145+ } ;
146+ } , [ episode , podcast , isPlaying ] ) ;
147+
148+ const handleCastToggle = async ( ) => {
149+ if ( isCasting ) {
150+ castService . endSession ( ) ;
151+ } else {
152+ try {
153+ await castService . requestSession ( ) ;
154+ } catch ( error ) {
155+ console . warn ( 'Cast session request cancelled or failed:' , error ) ;
156+ }
157+ }
158+ } ;
79159
80160 // Keyboard Shortcuts via Config
81161 useEffect ( ( ) => {
@@ -119,7 +199,23 @@ const Player: React.FC<PlayerProps> = ({
119199 useEffect ( ( ) => {
120200 setError ( null ) ;
121201 if ( ! episode . audioUrl ) { setError ( "No source found." ) ; setIsPlaying ( false ) ; return ; }
122- if ( audioRef . current ) {
202+
203+ if ( isCasting ) {
204+ // Load media on Cast device
205+ const historyData = storageService . getHistory ( ) ;
206+ const state = historyData [ episode . id ] ;
207+ const startTime = state && ! state . completed ? state . currentTime : 0 ;
208+
209+ castService . loadMedia ( episode , podcast , startTime )
210+ . then ( ( ) => {
211+ setIsPlaying ( true ) ;
212+ setIsBuffering ( false ) ;
213+ } )
214+ . catch ( ( ) => {
215+ setIsPlaying ( false ) ;
216+ setIsBuffering ( false ) ;
217+ } ) ;
218+ } else if ( audioRef . current ) {
123219 const isNewEpisode = audioRef . current . src !== episode . audioUrl ;
124220 if ( isNewEpisode ) {
125221 audioRef . current . pause ( ) ;
@@ -141,14 +237,18 @@ const Player: React.FC<PlayerProps> = ({
141237 }
142238 }
143239 }
144- } , [ episode . id , episode . audioUrl , autoPlay , playbackRate ] ) ;
240+ } , [ episode . id , episode . audioUrl , autoPlay , playbackRate , isCasting , podcast ] ) ;
145241
146242 useEffect ( ( ) => {
147243 const interval = setInterval ( ( ) => { if ( isPlaying ) saveProgress ( ) ; } , 5000 ) ;
148244 return ( ) => clearInterval ( interval ) ;
149245 } , [ isPlaying , saveProgress ] ) ;
150246
151247 const handleTimeUpdate = ( ) => {
248+ if ( isCasting ) {
249+ // Time updates come through the Cast media status listener
250+ return ;
251+ }
152252 if ( audioRef . current ) {
153253 const cur = audioRef . current . currentTime ;
154254 const dur = audioRef . current . duration ;
@@ -191,7 +291,16 @@ const Player: React.FC<PlayerProps> = ({
191291 </ div >
192292 < div className = "min-w-0 flex-1" >
193293 < h4 className = "text-sm font-bold truncate text-zinc-900 dark:text-zinc-100 leading-tight mb-0.5" > { episode . title } </ h4 >
194- < p className = "text-xs text-zinc-500 dark:text-zinc-400 truncate font-medium" > { podcast . title } </ p >
294+ < p className = "text-xs text-zinc-500 dark:text-zinc-400 truncate font-medium" >
295+ { isCasting ? (
296+ < span className = "flex items-center gap-1.5" >
297+ < i className = "fa-solid fa-tv text-indigo-600" > </ i >
298+ < span > Casting to { castDeviceName } </ span >
299+ </ span >
300+ ) : (
301+ podcast . title
302+ ) }
303+ </ p >
195304 </ div >
196305 </ div >
197306
@@ -217,13 +326,24 @@ const Player: React.FC<PlayerProps> = ({
217326 < div className = "relative flex-1 h-1 flex items-center" >
218327 < div className = "absolute inset-0 bg-zinc-200 dark:bg-zinc-800 rounded-full" > </ div >
219328 < div className = "absolute inset-y-0 left-0 bg-indigo-600 rounded-full" style = { { width : `${ progressPercent } %` } } > </ div >
220- < input type = "range" min = "0" max = { duration || 0 } step = "0.1" value = { currentTime } onChange = { ( e ) => { const v = parseFloat ( e . target . value ) ; if ( audioRef . current ) audioRef . current . currentTime = v ; } } className = "absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" />
329+ < input type = "range" min = "0" max = { duration || 0 } step = "0.1" value = { currentTime } onChange = { ( e ) => { const v = parseFloat ( e . target . value ) ; if ( isCasting ) { castService . seek ( v ) ; } else if ( audioRef . current ) { audioRef . current . currentTime = v ; } } } className = "absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" />
221330 </ div >
222331 < span className = "text-[10px] text-zinc-400 w-10 font-mono" > { formatTime ( duration ) } </ span >
223332 </ div >
224333 </ div >
225334
226335 < div className = "hidden lg:flex items-center gap-4 flex-1 justify-end" >
336+ < button
337+ onClick = { handleCastToggle }
338+ className = { `w-10 h-10 flex items-center justify-center rounded-xl transition ${
339+ isCasting
340+ ? 'bg-indigo-600 text-white hover:bg-indigo-700'
341+ : 'hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-400'
342+ } `}
343+ title = { isCasting ? `Casting to ${ castDeviceName } ` : 'Cast' }
344+ >
345+ < i className = "fa-solid fa-tv" > </ i >
346+ </ button >
227347 < button onClick = { onShare } className = "w-10 h-10 flex items-center justify-center rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-400 transition" > < i className = "fa-solid fa-share-nodes" > </ i > </ button >
228348 < button onClick = { ( ) => setPlaybackRate ( prev => { const n = SPEEDS [ ( SPEEDS . indexOf ( prev ) + 1 ) % SPEEDS . length ] ; if ( audioRef . current ) audioRef . current . playbackRate = n ; return n ; } ) } className = "px-3 py-1.5 bg-zinc-100 dark:bg-zinc-800 rounded-lg text-[10px] font-bold text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition min-w-[50px]" > { playbackRate } x</ button >
229349 < button onClick = { onClose } className = "w-10 h-10 flex items-center justify-center rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-400 hover:text-red-500 transition-all" > < i className = "fa-solid fa-xmark text-lg" > </ i > </ button >
0 commit comments