mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-27 09:08:31 +01:00
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:
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user