mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 03:58:36 +01:00
fix(casting): guard against stale currentItem during episode load
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user