Skip to content

Commit cf26437

Browse files
committed
Add Chromecast support
- Add Google Cast SDK to index.html - Create castService for managing Cast sessions and media playback - Update Player component with Cast controls and state management - Add Cast button with visual indicator when connected - Support seamless transition between local and Cast playback - Display casting device name in player UI
1 parent 3e9db99 commit cf26437

File tree

3 files changed

+414
-12
lines changed

3 files changed

+414
-12
lines changed

components/Player.tsx

Lines changed: 132 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import React, { useRef, useEffect, useState, useCallback } from 'react';
33
import { Episode, Podcast } from '../types';
44
import { storageService } from '../services/storageService';
5+
import { castService } from '../services/castService';
56
import { APP_CONFIG } from '../config';
67

78
interface 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>

index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818

1919
<script src="https://cdn.tailwindcss.com"></script>
2020
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
21+
22+
<!-- Google Cast SDK -->
23+
<script type="text/javascript" src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
24+
2125
<script>
2226
tailwind.config = {
2327
darkMode: 'class',

0 commit comments

Comments
 (0)