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

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

View File

@@ -21,6 +21,11 @@ export const buildCastMediaInfo = ({
streamUrl: string;
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
? item.RunTimeTicks / 10000000
: undefined;
@@ -74,7 +79,7 @@ export const buildCastMediaInfo = ({
};
return {
contentId: item.Id,
contentId: itemId,
contentUrl: streamUrl,
contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED,

View File

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

View File

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