fix(casting): guard against stale currentItem during episode load

This commit is contained in:
Uruk
2026-05-22 02:24:13 +02:00
parent 6ca1f63877
commit e9f61a2f7c
2 changed files with 46 additions and 19 deletions

View File

@@ -172,14 +172,21 @@ export default function CastingPlayerScreen() {
});
// Episode/season cluster: episode list, next episode, season data, loader
const { episodes, nextEpisode, seasonData, loadEpisode } = useCastEpisodes({
api,
user,
currentItem,
remoteMediaClient,
castDevice,
settings,
});
const { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId } =
useCastEpisodes({
api,
user,
currentItem,
remoteMediaClient,
castDevice,
settings,
});
// True while a `loadEpisode` is in flight and `currentItem` (derived from the
// cast customData) still describes the previous episode. Used to suppress
// episode-dependent secondary UI that would otherwise flash stale data.
const isEpisodeTransitioning =
loadingEpisodeId != null && loadingEpisodeId !== currentItem?.Id;
// Expose this player to the app-wide remote-control surface while a cast
// session is connected. `castingControls` is the live useCasting result.
@@ -442,12 +449,15 @@ export default function CastingPlayerScreen() {
onPressSettings={() => setShowSettings(true)}
/>
{/* Title Area */}
<CastPlayerTitle
insetTop={insets.top}
currentItem={currentItem}
t={t}
/>
{/* Title Area — hidden during an episode change to avoid flashing
the previous episode's title/season-episode numbers. */}
{!isEpisodeTransitioning && (
<CastPlayerTitle
insetTop={insets.top}
currentItem={currentItem}
t={t}
/>
)}
{/* Scrollable content area */}
<ScrollView
@@ -458,10 +468,11 @@ export default function CastingPlayerScreen() {
}}
showsVerticalScrollIndicator={false}
>
{/* Poster with buffering overlay */}
{/* Poster with buffering overlay — force the overlay during an
episode change so the loading state covers the stale poster. */}
<CastPlayerPoster
posterUrl={posterUrl}
isBuffering={isBuffering}
isBuffering={isBuffering || isEpisodeTransitioning}
currentSegment={currentSegment}
skipIntro={skipIntro}
skipCredits={skipCredits}
@@ -596,12 +607,14 @@ export default function CastingPlayerScreen() {
qualities={availableQualities}
selectedMaxBitrate={currentSelection?.maxBitrate}
onQualityChange={(value) => applySelection({ maxBitrate: value })}
audioTracks={availableAudioTracks}
audioTracks={isEpisodeTransitioning ? [] : availableAudioTracks}
selectedAudioIndex={currentSelection?.audioStreamIndex ?? -1}
onAudioChange={(index) =>
applySelection({ audioStreamIndex: index })
}
subtitleTracks={availableSubtitleTracks}
subtitleTracks={
isEpisodeTransitioning ? [] : availableSubtitleTracks
}
selectedSubtitleIndex={currentSelection?.subtitleStreamIndex ?? -1}
onSubtitleChange={(index) =>
applySelection({ subtitleStreamIndex: index })

View File

@@ -20,6 +20,13 @@ interface UseCastEpisodesResult {
nextEpisode: BaseItemDto | null;
seasonData: BaseItemDto | null;
loadEpisode: (episode: BaseItemDto) => Promise<void>;
/**
* Id of the episode currently being loaded onto the cast device, or null
* when no load is pending. The cast `customData` (and thus `currentItem`)
* lags behind the load, so consumers use this to detect the stale window
* between a `loadEpisode` call and the cast reporting the new episode.
*/
loadingEpisodeId: string | null;
}
export function useCastEpisodes({
@@ -33,12 +40,15 @@ export function useCastEpisodes({
const [episodes, setEpisodes] = useState<BaseItemDto[]>([]);
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null);
// Target episode id while a load is in flight; cleared once it resolves.
const [loadingEpisodeId, setLoadingEpisodeId] = useState<string | null>(null);
// Load a different episode on the Chromecast
const loadEpisode = useCallback(
async (episode: BaseItemDto) => {
if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return;
setLoadingEpisodeId(episode.Id);
try {
const startPositionMs =
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
@@ -63,6 +73,10 @@ export function useCastEpisodes({
}
} catch (error) {
console.error("[Casting Player] Failed to load episode:", error);
} finally {
// Clear regardless of outcome: on success `currentItem` catches up via
// customData; on failure the stale guard must not stay stuck.
setLoadingEpisodeId(null);
}
},
[
@@ -143,5 +157,5 @@ export function useCastEpisodes({
api,
]);
return { episodes, nextEpisode, seasonData, loadEpisode };
return { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId };
}