Fix: Improves Chromecast casting experience

Fixes several issues and improves the overall Chromecast casting experience:

- Implements an AbortController for fetching item data to prevent race conditions.
- Syncs live progress in the mini player more accurately using elapsed real time.
- Prevents event propagation in the mini player's play/pause button.
- Ensures the disconnect callback in the connection menu is always called.
- Retries scrolling in the episode list on failure.
- Handles unmute failures gracefully in volume controls.
- Clamps seek positions to prevent exceeding duration.
- Fixes reporting playback start multiple times
- Improves segment calculation in `useChromecastSegments`
- Prevents race condition with `isPlaying` state in `Controls` component

Also includes minor UI and timing adjustments for a smoother user experience.
This commit is contained in:
Uruk
2026-02-08 15:23:01 +01:00
parent c243fbc0ba
commit 7c81c0ff33
14 changed files with 208 additions and 133 deletions

View File

@@ -96,22 +96,30 @@ export default function CastingPlayerScreen() {
const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null); const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null);
useEffect(() => { useEffect(() => {
const controller = new AbortController();
const fetchItemData = async () => { const fetchItemData = async () => {
const itemId = mediaStatus?.mediaInfo?.contentId; const itemId = mediaStatus?.mediaInfo?.contentId;
if (!itemId || !api || !user?.Id) return; if (!itemId || !api || !user?.Id) return;
try { try {
const res = await getUserLibraryApi(api).getItem({ const res = await getUserLibraryApi(api).getItem(
itemId, { itemId, userId: user.Id },
userId: user.Id, { signal: controller.signal },
}); );
setFetchedItem(res.data); if (!controller.signal.aborted) {
setFetchedItem(res.data);
}
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === "AbortError")
return;
console.error("[Casting Player] Failed to fetch item:", error); console.error("[Casting Player] Failed to fetch item:", error);
} }
}; };
fetchItemData(); fetchItemData();
return () => controller.abort();
}, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]); }, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]);
useEffect(() => { useEffect(() => {

View File

@@ -156,6 +156,8 @@ export function Chromecast({
user?.Id, user?.Id,
mediaStatus?.streamPosition, mediaStatus?.streamPosition,
mediaStatus?.mediaInfo?.contentId, mediaStatus?.mediaInfo?.contentId,
mediaStatus?.playerState,
mediaStatus?.mediaInfo?.contentUrl,
]); ]);
// Android requires the cast button to be present for startDiscovery to work // Android requires the cast button to be present for startDiscovery to work

View File

@@ -63,27 +63,41 @@ export const CastingMiniPlayer: React.FC = () => {
mediaStatus?.streamPosition || 0, mediaStatus?.streamPosition || 0,
); );
// Track baseline for elapsed-time computation
const baselinePositionRef = useRef(mediaStatus?.streamPosition || 0);
const baselineTimestampRef = useRef(Date.now());
// Sync live progress with mediaStatus and poll every second when playing // Sync live progress with mediaStatus and poll every second when playing
useEffect(() => { useEffect(() => {
if (mediaStatus?.streamPosition) { // Resync baseline whenever mediaStatus reports a new position
if (mediaStatus?.streamPosition !== undefined) {
baselinePositionRef.current = mediaStatus.streamPosition;
baselineTimestampRef.current = Date.now();
setLiveProgress(mediaStatus.streamPosition); setLiveProgress(mediaStatus.streamPosition);
} }
// Update every second when playing // Update based on elapsed real time when playing
const interval = setInterval(() => { const interval = setInterval(() => {
if ( if (mediaStatus?.playerState === MediaPlayerState.PLAYING) {
mediaStatus?.playerState === MediaPlayerState.PLAYING && const elapsed =
mediaStatus?.streamPosition !== undefined ((Date.now() - baselineTimestampRef.current) *
) { (mediaStatus.playbackRate || 1)) /
setLiveProgress((prev) => prev + 1); 1000;
setLiveProgress(baselinePositionRef.current + elapsed);
} else if (mediaStatus?.streamPosition !== undefined) { } else if (mediaStatus?.streamPosition !== undefined) {
// Sync with actual position when paused/buffering // Sync with actual position when paused/buffering
baselinePositionRef.current = mediaStatus.streamPosition;
baselineTimestampRef.current = Date.now();
setLiveProgress(mediaStatus.streamPosition); setLiveProgress(mediaStatus.streamPosition);
} }
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]); }, [
mediaStatus?.playerState,
mediaStatus?.streamPosition,
mediaStatus?.playbackRate,
]);
const progress = liveProgress * 1000; // Convert to ms const progress = liveProgress * 1000; // Convert to ms
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000; const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
@@ -425,7 +439,13 @@ export const CastingMiniPlayer: React.FC = () => {
</View> </View>
{/* Play/Pause button */} {/* Play/Pause button */}
<Pressable onPress={handleTogglePlayPause} style={{ padding: 8 }}> <Pressable
onPress={(e) => {
e.stopPropagation();
handleTogglePlayPause();
}}
style={{ padding: 8 }}
>
<Ionicons <Ionicons
name={isPlaying ? "pause" : "play"} name={isPlaying ? "pause" : "play"}
size={28} size={28}

View File

@@ -132,9 +132,10 @@ export const ChromecastConnectionMenu: React.FC<
if (onDisconnect) { if (onDisconnect) {
await onDisconnect(); await onDisconnect();
} }
onClose();
} catch (error) { } catch (error) {
console.error("[Connection Menu] Disconnect error:", error); console.error("[Connection Menu] Disconnect error:", error);
} finally {
onClose();
} }
}, [onDisconnect, onClose]); }, [onDisconnect, onClose]);
@@ -254,13 +255,13 @@ export const ChromecastConnectionMenu: React.FC<
onSlidingStart={() => { onSlidingStart={() => {
isSliding.current = true; isSliding.current = true;
}} }}
onValueChange={(value) => { onValueChange={async (value) => {
volumeValue.value = value; volumeValue.value = value;
handleVolumeChange(value); handleVolumeChange(value);
if (isMuted) { if (isMuted) {
setIsMuted(false); setIsMuted(false);
try { try {
castSession?.setMute(false); await castSession?.setMute(false);
} catch (error: unknown) { } catch (error: unknown) {
console.error( console.error(
"[ChromecastConnectionMenu] Failed to unmute:", "[ChromecastConnectionMenu] Failed to unmute:",

View File

@@ -278,13 +278,18 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
onSlidingStart={() => { onSlidingStart={() => {
isSliding.current = true; isSliding.current = true;
}} }}
onValueChange={(value) => { onValueChange={async (value) => {
volumeValue.value = value; volumeValue.value = value;
handleVolumeChange(value); handleVolumeChange(value);
// Unmute when adjusting volume // Unmute when adjusting volume
if (isMuted) { if (isMuted) {
setIsMuted(false); setIsMuted(false);
castSession?.setMute(false); try {
await castSession?.setMute(false);
} catch (error) {
console.error("[Volume] Failed to unmute:", error);
setIsMuted(true); // Rollback on failure
}
} }
}} }}
onSlidingComplete={(value) => { onSlidingComplete={(value) => {

View File

@@ -36,6 +36,9 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null); const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const scrollRetryCountRef = useRef(0);
const scrollRetryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const MAX_SCROLL_RETRIES = 3;
// Get unique seasons from episodes // Get unique seasons from episodes
const seasons = useMemo(() => { const seasons = useMemo(() => {
@@ -72,6 +75,12 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}, [currentItem]); }, [currentItem]);
useEffect(() => { useEffect(() => {
// Reset retry counter when visibility or data changes
scrollRetryCountRef.current = 0;
if (scrollRetryTimeoutRef.current) {
clearTimeout(scrollRetryTimeoutRef.current);
}
if (visible && currentItem && filteredEpisodes.length > 0) { if (visible && currentItem && filteredEpisodes.length > 0) {
const currentIndex = filteredEpisodes.findIndex( const currentIndex = filteredEpisodes.findIndex(
(ep) => ep.Id === currentItem.Id, (ep) => ep.Id === currentItem.Id,
@@ -85,7 +94,12 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
viewPosition: 0.5, // Center the item viewPosition: 0.5, // Center the item
}); });
}, 300); }, 300);
return () => clearTimeout(timeoutId); return () => {
clearTimeout(timeoutId);
if (scrollRetryTimeoutRef.current) {
clearTimeout(scrollRetryTimeoutRef.current);
}
};
} }
} }
}, [visible, currentItem, filteredEpisodes]); }, [visible, currentItem, filteredEpisodes]);
@@ -117,26 +131,30 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
}} }}
> >
{api && item.Id && ( {(() => {
<Image const imageUrl =
source={{ api && item.Id ? getPrimaryImageUrl({ api, item }) : null;
uri: getPrimaryImageUrl({ api, item }) || undefined, if (imageUrl) {
}} return (
style={{ width: "100%", height: "100%" }} <Image
contentFit='cover' source={{ uri: imageUrl }}
/> style={{ width: "100%", height: "100%" }}
)} contentFit='cover'
{(!api || !item.Id) && ( />
<View );
style={{ }
flex: 1, return (
justifyContent: "center", <View
alignItems: "center", style={{
}} flex: 1,
> justifyContent: "center",
<Ionicons name='film-outline' size={32} color='#333' /> alignItems: "center",
</View> }}
)} >
<Ionicons name='film-outline' size={32} color='#333' />
</View>
);
})()}
</View> </View>
{/* Episode info */} {/* Episode info */}
@@ -150,7 +168,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}} }}
numberOfLines={1} numberOfLines={1}
> >
{item.IndexNumber}.{" "} {item.IndexNumber != null ? `${item.IndexNumber}. ` : ""}
{truncateTitle(item.Name || t("casting_player.unknown"), 30)} {truncateTitle(item.Name || t("casting_player.unknown"), 30)}
</Text> </Text>
{item.Overview && ( {item.Overview && (
@@ -295,8 +313,18 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScrollToIndexFailed={(info) => { onScrollToIndexFailed={(info) => {
// Fallback if scroll fails // Bounded retry for scroll failures
setTimeout(() => { if (
scrollRetryCountRef.current >= MAX_SCROLL_RETRIES ||
info.index >= filteredEpisodes.length
) {
return;
}
scrollRetryCountRef.current += 1;
if (scrollRetryTimeoutRef.current) {
clearTimeout(scrollRetryTimeoutRef.current);
}
scrollRetryTimeoutRef.current = setTimeout(() => {
flatListRef.current?.scrollToIndex({ flatListRef.current?.scrollToIndex({
index: info.index, index: info.index,
animated: true, animated: true,

View File

@@ -286,11 +286,11 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
track.language || track.language ||
t("casting_player.unknown")} t("casting_player.unknown")}
</Text> </Text>
{track.codec && ( {(track.codec || track.isForced) && (
<Text <Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }} style={{ color: "#999", fontSize: 13, marginTop: 2 }}
> >
{track.codec.toUpperCase()} {track.codec ? track.codec.toUpperCase() : ""}
{track.isForced && `${t("casting_player.forced")}`} {track.isForced && `${t("casting_player.forced")}`}
</Text> </Text>
)} )}

View File

@@ -78,6 +78,8 @@ export const useChromecastSegments = (
}, [segmentData]); }, [segmentData]);
// Check which segment we're currently in // Check which segment we're currently in
// currentProgressMs is in milliseconds; isWithinSegment() converts ms→seconds internally
// before comparing with segment times (which are in seconds from the autoskip API)
const currentSegment = useMemo(() => { const currentSegment = useMemo(() => {
if (isWithinSegment(currentProgressMs, segments.intro)) { if (isWithinSegment(currentProgressMs, segments.intro)) {
return { type: "intro" as const, segment: segments.intro }; return { type: "intro" as const, segment: segments.intro };

View File

@@ -124,6 +124,12 @@ export const Controls: FC<Props> = ({
// Ref to track pending play timeout for cleanup and cancellation // Ref to track pending play timeout for cleanup and cancellation
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Mutable ref tracking isPlaying to avoid stale closures in seekMs timeout
const playingRef = useRef(isPlaying);
useEffect(() => {
playingRef.current = isPlaying;
}, [isPlaying]);
// Clean up timeout on unmount // Clean up timeout on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -346,15 +352,15 @@ export const Controls: FC<Props> = ({
seek(timeInSeconds * 1000); seek(timeInSeconds * 1000);
// Brief delay ensures the seek operation completes before resuming playback // Brief delay ensures the seek operation completes before resuming playback
// Without this, playback may resume from the old position // Without this, playback may resume from the old position
// Only resume if currently playing to avoid overriding user pause // Read latest isPlaying from ref to avoid stale closure
if (isPlaying) { playTimeoutRef.current = setTimeout(() => {
playTimeoutRef.current = setTimeout(() => { if (playingRef.current) {
play(); play();
playTimeoutRef.current = null; }
}, 200); playTimeoutRef.current = null;
} }, 200);
}, },
[seek, play, isPlaying], [seek, play],
); );
// Use unified segment skipper for all segment types // Use unified segment skipper for all segment types

View File

@@ -36,7 +36,6 @@ export const useCasting = (item: BaseItemDto | null) => {
// Local state // Local state
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE); const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null); const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastReportedProgressRef = useRef(0); const lastReportedProgressRef = useRef(0);
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null); const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
@@ -125,6 +124,9 @@ export const useCasting = (item: BaseItemDto | null) => {
// Report playback start when media begins (only once per item) // Report playback start when media begins (only once per item)
const currentState = stateRef.current; const currentState = stateRef.current;
if (hasReportedStartRef.current !== item.Id && currentState.progress > 0) { if (hasReportedStartRef.current !== item.Id && currentState.progress > 0) {
// Set synchronously before async call to prevent race condition duplicates
hasReportedStartRef.current = item.Id || null;
playStateApi playStateApi
.reportPlaybackStart({ .reportPlaybackStart({
playbackStartInfo: { playbackStartInfo: {
@@ -137,10 +139,9 @@ export const useCasting = (item: BaseItemDto | null) => {
PlaySessionId: mediaStatus?.mediaInfo?.contentId, PlaySessionId: mediaStatus?.mediaInfo?.contentId,
}, },
}) })
.then(() => {
hasReportedStartRef.current = item.Id || null;
})
.catch((error) => { .catch((error) => {
// Revert on failure so it can be retried
hasReportedStartRef.current = null;
console.error("[useCasting] Failed to report playback start:", error); console.error("[useCasting] Failed to report playback start:", error);
}); });
} }
@@ -217,7 +218,12 @@ export const useCasting = (item: BaseItemDto | null) => {
const pause = useCallback(async () => { const pause = useCallback(async () => {
if (activeProtocol === "chromecast") { if (activeProtocol === "chromecast") {
await client?.pause(); try {
await client?.pause();
} catch (error) {
console.error("[useCasting] Error pausing:", error);
throw error;
}
} }
// Future: Add pause control for other protocols // Future: Add pause control for other protocols
}, [client, activeProtocol]); }, [client, activeProtocol]);
@@ -244,8 +250,9 @@ export const useCasting = (item: BaseItemDto | null) => {
// Additional validation for Chromecast // Additional validation for Chromecast
if (activeProtocol === "chromecast") { if (activeProtocol === "chromecast") {
// state.duration is in ms, positionSeconds is in seconds - compare in same unit // state.duration is in ms, positionSeconds is in seconds - compare in same unit
// Only clamp when duration is known (> 0) to avoid forcing seeks to 0
const durationSeconds = state.duration / 1000; const durationSeconds = state.duration / 1000;
if (positionSeconds > durationSeconds) { if (durationSeconds > 0 && positionSeconds > durationSeconds) {
console.warn( console.warn(
"[useCasting] Seek position exceeds duration, clamping:", "[useCasting] Seek position exceeds duration, clamping:",
positionSeconds, positionSeconds,
@@ -281,31 +288,35 @@ export const useCasting = (item: BaseItemDto | null) => {
// Stop and disconnect // Stop and disconnect
const stop = useCallback( const stop = useCallback(
async (onStopComplete?: () => void) => { async (onStopComplete?: () => void) => {
if (activeProtocol === "chromecast") { try {
await client?.stop(); if (activeProtocol === "chromecast") {
} await client?.stop();
// Future: Add stop control for other protocols }
// Future: Add stop control for other protocols
// Report stop to Jellyfin // Report stop to Jellyfin
if (api && item?.Id && user?.Id) { if (api && item?.Id && user?.Id) {
const playStateApi = getPlaystateApi(api); const playStateApi = getPlaystateApi(api);
await playStateApi.reportPlaybackStopped({ await playStateApi.reportPlaybackStopped({
playbackStopInfo: { playbackStopInfo: {
ItemId: item.Id, ItemId: item.Id,
PositionTicks: state.progress * 10000, PositionTicks: stateRef.current.progress * 10000,
}, },
}); });
} }
} catch (error) {
console.error("[useCasting] Error during stop:", error);
} finally {
setState(DEFAULT_CAST_STATE);
stateRef.current = DEFAULT_CAST_STATE;
setState(DEFAULT_CAST_STATE); // Call callback after stop completes (e.g., to navigate away)
stateRef.current = DEFAULT_CAST_STATE; if (onStopComplete) {
onStopComplete();
// Call callback after stop completes (e.g., to navigate away) }
if (onStopComplete) {
onStopComplete();
} }
}, },
[client, api, item?.Id, user?.Id, state.progress, activeProtocol], [client, api, item?.Id, user?.Id, activeProtocol],
); );
// Volume control (debounced to reduce API calls) // Volume control (debounced to reduce API calls)
@@ -343,11 +354,12 @@ export const useCasting = (item: BaseItemDto | null) => {
clearTimeout(controlsTimeoutRef.current); clearTimeout(controlsTimeoutRef.current);
} }
controlsTimeoutRef.current = setTimeout(() => { controlsTimeoutRef.current = setTimeout(() => {
if (state.isPlaying) { // Read latest isPlaying from stateRef to avoid stale closure
if (stateRef.current.isPlaying) {
updateState((prev) => ({ ...prev, showControls: false })); updateState((prev) => ({ ...prev, showControls: false }));
} }
}, 5000); }, 5000);
}, [state.isPlaying, updateState]); }, [updateState]);
const hideControls = useCallback(() => { const hideControls = useCallback(() => {
updateState((prev) => ({ ...prev, showControls: false })); updateState((prev) => ({ ...prev, showControls: false }));
@@ -359,9 +371,6 @@ export const useCasting = (item: BaseItemDto | null) => {
// Cleanup // Cleanup
useEffect(() => { useEffect(() => {
return () => { return () => {
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
if (controlsTimeoutRef.current) { if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current); clearTimeout(controlsTimeoutRef.current);
} }

View File

@@ -50,7 +50,7 @@ export const calculateEndingTime = (
* Determine connection quality based on bitrate * Determine connection quality based on bitrate
*/ */
export const getConnectionQuality = (bitrate?: number): ConnectionQuality => { export const getConnectionQuality = (bitrate?: number): ConnectionQuality => {
if (!bitrate) return "good"; if (bitrate == null) return "good";
const mbps = bitrate / 1000000; const mbps = bitrate / 1000000;
if (mbps >= 15) return "excellent"; if (mbps >= 15) return "excellent";

View File

@@ -21,6 +21,11 @@ export const buildCastMediaInfo = ({
streamUrl: string; streamUrl: string;
api: Api; api: Api;
}) => { }) => {
if (!item.Id) {
throw new Error("Missing item.Id for media load — cannot build contentId");
}
const itemId: string = item.Id;
const streamDuration = item.RunTimeTicks const streamDuration = item.RunTimeTicks
? item.RunTimeTicks / 10000000 ? item.RunTimeTicks / 10000000
: undefined; : undefined;
@@ -74,7 +79,7 @@ export const buildCastMediaInfo = ({
}; };
return { return {
contentId: item.Id, contentId: itemId,
contentUrl: streamUrl, contentUrl: streamUrl,
contentType: "video/mp4", contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED, streamType: MediaStreamType.BUFFERED,

View File

@@ -3,22 +3,22 @@
*/ */
export const CHROMECAST_CONSTANTS = { export const CHROMECAST_CONSTANTS = {
// Timing // Timing (all milliseconds for consistency)
PROGRESS_REPORT_INTERVAL: 10, // seconds PROGRESS_REPORT_INTERVAL_MS: 10_000,
CONTROLS_TIMEOUT: 5000, // ms CONTROLS_TIMEOUT_MS: 5_000,
BUFFERING_THRESHOLD: 10, // seconds of buffer before hiding indicator BUFFERING_THRESHOLD_MS: 10_000,
NEXT_EPISODE_COUNTDOWN_START: 30, // seconds before end NEXT_EPISODE_COUNTDOWN_MS: 30_000,
CONNECTION_CHECK_INTERVAL: 5000, // ms CONNECTION_CHECK_INTERVAL_MS: 5_000,
// UI // UI
POSTER_WIDTH: 300, POSTER_WIDTH: 300,
POSTER_HEIGHT: 450, POSTER_HEIGHT: 450,
MINI_PLAYER_HEIGHT: 80, MINI_PLAYER_HEIGHT: 80,
SKIP_FORWARD_TIME: 15, // seconds (overridden by settings) SKIP_FORWARD_SECS: 15, // overridden by settings
SKIP_BACKWARD_TIME: 15, // seconds (overridden by settings) SKIP_BACKWARD_SECS: 15, // overridden by settings
// Animation // Animation
ANIMATION_DURATION: 300, // ms ANIMATION_DURATION_MS: 300,
BLUR_RADIUS: 10, BLUR_RADIUS: 10,
} as const; } as const;
@@ -31,13 +31,12 @@ export const CONNECTION_QUALITY = {
export type ConnectionQuality = keyof typeof CONNECTION_QUALITY; export type ConnectionQuality = keyof typeof CONNECTION_QUALITY;
export type PlaybackState = "playing" | "paused" | "stopped" | "buffering";
export interface ChromecastPlayerState { export interface ChromecastPlayerState {
isConnected: boolean; isConnected: boolean;
deviceName: string | null; deviceName: string | null;
isPlaying: boolean; playbackState: PlaybackState;
isPaused: boolean;
isStopped: boolean;
isBuffering: boolean;
progress: number; // milliseconds progress: number; // milliseconds
duration: number; // milliseconds duration: number; // milliseconds
volume: number; // 0-1 volume: number; // 0-1
@@ -57,10 +56,7 @@ export interface ChromecastSegmentData {
export const DEFAULT_CHROMECAST_STATE: ChromecastPlayerState = { export const DEFAULT_CHROMECAST_STATE: ChromecastPlayerState = {
isConnected: false, isConnected: false,
deviceName: null, deviceName: null,
isPlaying: false, playbackState: "stopped",
isPaused: false,
isStopped: true,
isBuffering: false,
progress: 0, progress: 0,
duration: 0, duration: 0,
volume: 1, volume: 1,

View File

@@ -185,38 +185,31 @@ const fetchLegacySegments = async (
const introSegments: MediaTimeSegment[] = []; const introSegments: MediaTimeSegment[] = [];
const creditSegments: MediaTimeSegment[] = []; const creditSegments: MediaTimeSegment[] = [];
try { const [introRes, creditRes] = await Promise.allSettled([
const [introRes, creditRes] = await Promise.allSettled([ api.axiosInstance.get<IntroTimestamps>(
api.axiosInstance.get<IntroTimestamps>( `${api.basePath}/Episode/${itemId}/IntroTimestamps`,
`${api.basePath}/Episode/${itemId}/IntroTimestamps`, { headers: getAuthHeaders(api) },
{ headers: getAuthHeaders(api) }, ),
), api.axiosInstance.get<CreditTimestamps>(
api.axiosInstance.get<CreditTimestamps>( `${api.basePath}/Episode/${itemId}/Timestamps`,
`${api.basePath}/Episode/${itemId}/Timestamps`, { headers: getAuthHeaders(api) },
{ headers: getAuthHeaders(api) }, ),
), ]);
]);
if (introRes.status === "fulfilled" && introRes.value.data.Valid) { if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
introSegments.push({ introSegments.push({
startTime: introRes.value.data.IntroStart, startTime: introRes.value.data.IntroStart,
endTime: introRes.value.data.IntroEnd, endTime: introRes.value.data.IntroEnd,
text: "Intro", text: "Intro",
}); });
} }
if ( if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) {
creditRes.status === "fulfilled" && creditSegments.push({
creditRes.value.data.Credits.Valid startTime: creditRes.value.data.Credits.Start,
) { endTime: creditRes.value.data.Credits.End,
creditSegments.push({ text: "Credits",
startTime: creditRes.value.data.Credits.Start, });
endTime: creditRes.value.data.Credits.End,
text: "Credits",
});
}
} catch (error) {
console.error("Failed to fetch legacy segments", error);
} }
return { return {