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

@@ -156,6 +156,8 @@ export function Chromecast({
user?.Id,
mediaStatus?.streamPosition,
mediaStatus?.mediaInfo?.contentId,
mediaStatus?.playerState,
mediaStatus?.mediaInfo?.contentUrl,
]);
// 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,
);
// 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
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);
}
// Update every second when playing
// Update based on elapsed real time when playing
const interval = setInterval(() => {
if (
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
mediaStatus?.streamPosition !== undefined
) {
setLiveProgress((prev) => prev + 1);
if (mediaStatus?.playerState === MediaPlayerState.PLAYING) {
const elapsed =
((Date.now() - baselineTimestampRef.current) *
(mediaStatus.playbackRate || 1)) /
1000;
setLiveProgress(baselinePositionRef.current + elapsed);
} else if (mediaStatus?.streamPosition !== undefined) {
// Sync with actual position when paused/buffering
baselinePositionRef.current = mediaStatus.streamPosition;
baselineTimestampRef.current = Date.now();
setLiveProgress(mediaStatus.streamPosition);
}
}, 1000);
return () => clearInterval(interval);
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
}, [
mediaStatus?.playerState,
mediaStatus?.streamPosition,
mediaStatus?.playbackRate,
]);
const progress = liveProgress * 1000; // Convert to ms
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
@@ -425,7 +439,13 @@ export const CastingMiniPlayer: React.FC = () => {
</View>
{/* Play/Pause button */}
<Pressable onPress={handleTogglePlayPause} style={{ padding: 8 }}>
<Pressable
onPress={(e) => {
e.stopPropagation();
handleTogglePlayPause();
}}
style={{ padding: 8 }}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={28}

View File

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

View File

@@ -278,13 +278,18 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
onSlidingStart={() => {
isSliding.current = true;
}}
onValueChange={(value) => {
onValueChange={async (value) => {
volumeValue.value = value;
handleVolumeChange(value);
// Unmute when adjusting volume
if (isMuted) {
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) => {

View File

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

View File

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

View File

@@ -78,6 +78,8 @@ export const useChromecastSegments = (
}, [segmentData]);
// 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(() => {
if (isWithinSegment(currentProgressMs, 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
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
useEffect(() => {
return () => {
@@ -346,15 +352,15 @@ export const Controls: FC<Props> = ({
seek(timeInSeconds * 1000);
// Brief delay ensures the seek operation completes before resuming playback
// Without this, playback may resume from the old position
// Only resume if currently playing to avoid overriding user pause
if (isPlaying) {
playTimeoutRef.current = setTimeout(() => {
// Read latest isPlaying from ref to avoid stale closure
playTimeoutRef.current = setTimeout(() => {
if (playingRef.current) {
play();
playTimeoutRef.current = null;
}, 200);
}
}
playTimeoutRef.current = null;
}, 200);
},
[seek, play, isPlaying],
[seek, play],
);
// Use unified segment skipper for all segment types