mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-10 06:12:23 +00:00
Fix: Improves Chromecast casting experience
Fixes several issues and enhances the Chromecast casting experience: - Prevents errors when loading media by slimming down the customData payload to avoid exceeding message size limits. - Improves logic for selecting custom data from media status. - Fixes an issue with subtitle track selection. - Recommends stereo audio tracks for better Chromecast compatibility. - Improves volume control and mute synchronization between the app and the Chromecast device. - Adds error handling for `loadMedia` in `PlayButton`. - Fixes image caching issue for season posters in mini player. - Implements cleanup for scroll retry timeout in episode list. - Ensures segment skipping functions are asynchronous. - Resets `hasReportedStartRef` after stopping casting. - Prevents seeking past the end of Outro segments. - Reports playback progress more accurately by also taking player state changes into account.
This commit is contained in:
@@ -156,8 +156,12 @@ export default function CastingPlayerScreen() {
|
||||
|
||||
// Priority 2: Try customData from mediaStatus
|
||||
const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null;
|
||||
if (customData?.Type && customData.Type !== "Movie") {
|
||||
// Only use customData if it has a real Type (not default fallback)
|
||||
if (
|
||||
customData?.Type &&
|
||||
(customData.ImageTags || customData.MediaSources || customData.Id)
|
||||
) {
|
||||
// Use customData if it has a real Type AND meaningful metadata
|
||||
// (rules out placeholder objects that lack image tags, media sources, or an ID)
|
||||
return customData;
|
||||
}
|
||||
|
||||
@@ -265,7 +269,9 @@ export default function CastingPlayerScreen() {
|
||||
userId: user.Id,
|
||||
audioStreamIndex:
|
||||
options.audioIndex ?? selectedAudioTrackIndex ?? undefined,
|
||||
subtitleStreamIndex: options.subtitleIndex ?? undefined,
|
||||
// null = subtitles off (omit from request), number = specific track
|
||||
subtitleStreamIndex:
|
||||
options.subtitleIndex === null ? undefined : options.subtitleIndex,
|
||||
maxStreamingBitrate: options.bitrateValue,
|
||||
});
|
||||
|
||||
@@ -447,26 +453,32 @@ export default function CastingPlayerScreen() {
|
||||
// Track whether user has manually selected an audio track
|
||||
const [userSelectedAudio, setUserSelectedAudio] = useState(false);
|
||||
|
||||
// Auto-select stereo audio track for better Chromecast compatibility
|
||||
// Note: This only updates the UI state. The actual audio track change requires
|
||||
// regenerating the stream URL, which would be disruptive on initial load.
|
||||
// The user can manually switch audio tracks if needed.
|
||||
// Detect recommended stereo track for Chromecast compatibility.
|
||||
// Does NOT mutate selectedAudioTrackIndex — UI can show a badge instead.
|
||||
// TODO: Use recommendedAudioTrackIndex in UI to show a "stereo recommended" badge
|
||||
const [_recommendedAudioTrackIndex, setRecommendedAudioTrackIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!remoteMediaClient || !mediaStatus?.mediaInfo || userSelectedAudio)
|
||||
if (!remoteMediaClient || !mediaStatus?.mediaInfo || userSelectedAudio) {
|
||||
setRecommendedAudioTrackIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTrack = availableAudioTracks.find(
|
||||
(t) => t.index === selectedAudioTrackIndex,
|
||||
);
|
||||
|
||||
// If current track is 5.1+ audio, suggest stereo in the UI
|
||||
// If current track is 5.1+ audio, recommend stereo alternative
|
||||
if (currentTrack && (currentTrack.channels || 0) > 2) {
|
||||
const stereoTrack = availableAudioTracks.find((t) => t.channels === 2);
|
||||
if (stereoTrack && stereoTrack.index !== selectedAudioTrackIndex) {
|
||||
// Auto-select stereo in UI (user can manually trigger reload)
|
||||
setSelectedAudioTrackIndex(stereoTrack.index);
|
||||
setRecommendedAudioTrackIndex(stereoTrack.index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setRecommendedAudioTrackIndex(null);
|
||||
}, [
|
||||
mediaStatus?.mediaInfo,
|
||||
availableAudioTracks,
|
||||
|
||||
@@ -41,6 +41,7 @@ export function Chromecast({
|
||||
const isConnected = castState === CastState.CONNECTED;
|
||||
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
const lastReportedPlayerStateRef = useRef<string | null>(null);
|
||||
const playSessionIdRef = useRef<string | null>(null);
|
||||
const lastContentIdRef = useRef<string | null>(null);
|
||||
const discoveryAttempts = useRef(0);
|
||||
@@ -116,9 +117,13 @@ export function Chromecast({
|
||||
}
|
||||
|
||||
const streamPosition = mediaStatus.streamPosition || 0;
|
||||
const playerState = mediaStatus.playerState || null;
|
||||
|
||||
// Report every 10 seconds
|
||||
if (Math.abs(streamPosition - lastReportedProgressRef.current) < 10) {
|
||||
// Report every 10 seconds OR immediately when playerState changes (pause/resume)
|
||||
const positionChanged =
|
||||
Math.abs(streamPosition - lastReportedProgressRef.current) >= 10;
|
||||
const stateChanged = playerState !== lastReportedPlayerStateRef.current;
|
||||
if (!positionChanged && !stateChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -147,6 +152,7 @@ export function Chromecast({
|
||||
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
||||
.then(() => {
|
||||
lastReportedProgressRef.current = streamPosition;
|
||||
lastReportedPlayerStateRef.current = playerState;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to report Chromecast progress:", error);
|
||||
|
||||
@@ -210,6 +210,9 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
router.push("/casting-player");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[PlayButton] loadMedia failed:", err);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[PlayButton] Cast error:", e);
|
||||
|
||||
@@ -127,8 +127,9 @@ export const CastingMiniPlayer: React.FC = () => {
|
||||
currentItem.ParentIndexNumber !== undefined &&
|
||||
currentItem.SeasonId
|
||||
) {
|
||||
// Build season poster URL using SeriesId and SeasonId as tag
|
||||
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96&tag=${currentItem.SeasonId}`;
|
||||
// Build season poster URL using SeriesId and image tag for cache validation
|
||||
const imageTag = currentItem.ImageTags?.Primary || "";
|
||||
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${imageTag ? `&tag=${imageTag}` : ""}`;
|
||||
}
|
||||
|
||||
// For non-episodes, use item's own poster
|
||||
|
||||
@@ -32,6 +32,7 @@ export const ChromecastConnectionMenu: React.FC<
|
||||
// Volume state - use refs to avoid triggering re-renders during sliding
|
||||
const [displayVolume, setDisplayVolume] = useState(50);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const isMutedRef = useRef(false);
|
||||
const volumeValue = useSharedValue(50);
|
||||
const minimumValue = useSharedValue(0);
|
||||
const maximumValue = useSharedValue(100);
|
||||
@@ -55,6 +56,7 @@ export const ChromecastConnectionMenu: React.FC<
|
||||
lastSetVolume.current = percent;
|
||||
}
|
||||
const muted = await castSession.isMute();
|
||||
isMutedRef.current = muted;
|
||||
setIsMuted(muted);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
@@ -78,7 +80,8 @@ export const ChromecastConnectionMenu: React.FC<
|
||||
}
|
||||
}
|
||||
const muted = await castSession.isMute();
|
||||
if (muted !== isMuted) {
|
||||
if (muted !== isMutedRef.current) {
|
||||
isMutedRef.current = muted;
|
||||
setIsMuted(muted);
|
||||
}
|
||||
} catch {
|
||||
@@ -87,7 +90,7 @@ export const ChromecastConnectionMenu: React.FC<
|
||||
}, 1000); // Poll less frequently
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, castSession, volumeValue, isMuted]);
|
||||
}, [visible, castSession, volumeValue]);
|
||||
|
||||
// Volume change during sliding - update display only, don't call API
|
||||
const handleVolumeChange = useCallback((value: number) => {
|
||||
@@ -120,6 +123,7 @@ export const ChromecastConnectionMenu: React.FC<
|
||||
try {
|
||||
const newMute = !isMuted;
|
||||
await castSession.setMute(newMute);
|
||||
isMutedRef.current = newMute;
|
||||
setIsMuted(newMute);
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Mute error:", error);
|
||||
@@ -259,6 +263,7 @@ export const ChromecastConnectionMenu: React.FC<
|
||||
volumeValue.value = value;
|
||||
handleVolumeChange(value);
|
||||
if (isMuted) {
|
||||
isMutedRef.current = false;
|
||||
setIsMuted(false);
|
||||
try {
|
||||
await castSession?.setMute(false);
|
||||
@@ -267,6 +272,7 @@ export const ChromecastConnectionMenu: React.FC<
|
||||
"[ChromecastConnectionMenu] Failed to unmute:",
|
||||
error,
|
||||
);
|
||||
isMutedRef.current = true;
|
||||
setIsMuted(true); // Rollback on failure
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,10 +282,10 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
||||
volumeValue.value = value;
|
||||
handleVolumeChange(value);
|
||||
// Unmute when adjusting volume
|
||||
if (isMuted) {
|
||||
if (isMuted && castSession) {
|
||||
setIsMuted(false);
|
||||
try {
|
||||
await castSession?.setMute(false);
|
||||
await castSession.setMute(false);
|
||||
} catch (error) {
|
||||
console.error("[Volume] Failed to unmute:", error);
|
||||
setIsMuted(true); // Rollback on failure
|
||||
|
||||
@@ -40,6 +40,17 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
||||
const scrollRetryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const MAX_SCROLL_RETRIES = 3;
|
||||
|
||||
// Cleanup pending retry timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
scrollRetryTimeoutRef.current = null;
|
||||
}
|
||||
scrollRetryCountRef.current = 0;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get unique seasons from episodes
|
||||
const seasons = useMemo(() => {
|
||||
const seasonSet = new Set<number>();
|
||||
|
||||
@@ -105,27 +105,27 @@ export const useChromecastSegments = (
|
||||
|
||||
// Skip functions
|
||||
const skipIntro = useCallback(
|
||||
(seekFn: (positionMs: number) => Promise<void>) => {
|
||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||
if (segments.intro) {
|
||||
return seekFn(segments.intro.end * 1000);
|
||||
await seekFn(segments.intro.end * 1000);
|
||||
}
|
||||
},
|
||||
[segments.intro],
|
||||
);
|
||||
|
||||
const skipCredits = useCallback(
|
||||
(seekFn: (positionMs: number) => Promise<void>) => {
|
||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||
if (segments.credits) {
|
||||
return seekFn(segments.credits.end * 1000);
|
||||
await seekFn(segments.credits.end * 1000);
|
||||
}
|
||||
},
|
||||
[segments.credits],
|
||||
);
|
||||
|
||||
const skipSegment = useCallback(
|
||||
(seekFn: (positionMs: number) => Promise<void>) => {
|
||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||
if (currentSegment?.segment) {
|
||||
return seekFn(currentSegment.segment.end * 1000);
|
||||
await seekFn(currentSegment.segment.end * 1000);
|
||||
}
|
||||
},
|
||||
[currentSegment],
|
||||
|
||||
@@ -307,6 +307,7 @@ export const useCasting = (item: BaseItemDto | null) => {
|
||||
} catch (error) {
|
||||
console.error("[useCasting] Error during stop:", error);
|
||||
} finally {
|
||||
hasReportedStartRef.current = null;
|
||||
setState(DEFAULT_CAST_STATE);
|
||||
stateRef.current = DEFAULT_CAST_STATE;
|
||||
|
||||
|
||||
@@ -66,7 +66,11 @@ export const useSegmentSkipper = ({
|
||||
if (!currentSegment || skipMode === "none") return;
|
||||
|
||||
// For Outro segments, prevent seeking past the end
|
||||
if (segmentType === "Outro" && totalDuration) {
|
||||
if (
|
||||
segmentType === "Outro" &&
|
||||
totalDuration != null &&
|
||||
Number.isFinite(totalDuration)
|
||||
) {
|
||||
const seekTime = Math.min(currentSegment.endTime, totalDuration);
|
||||
seek(seekTime);
|
||||
} else {
|
||||
|
||||
@@ -11,15 +11,26 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
/**
|
||||
* Build a MediaInfo object suitable for `remoteMediaClient.loadMedia()`.
|
||||
*
|
||||
* NOTE on contentType: Chromecast Default Media Receiver auto-detects HLS/DASH
|
||||
* from the URL. Setting contentType to "application/x-mpegurl" or "application/dash+xml"
|
||||
* actually BREAKS playback on many receivers. Always use "video/mp4" unless
|
||||
* you have a custom receiver that explicitly handles other MIME types.
|
||||
*/
|
||||
export const buildCastMediaInfo = ({
|
||||
item,
|
||||
streamUrl,
|
||||
api,
|
||||
contentType,
|
||||
isLive = false,
|
||||
}: {
|
||||
item: BaseItemDto;
|
||||
streamUrl: string;
|
||||
api: Api;
|
||||
/** Override MIME type. Defaults to "video/mp4" which works for all stream types on Default Media Receiver. */
|
||||
contentType?: string;
|
||||
/** Set true for live TV streams to use MediaStreamType.LIVE. */
|
||||
isLive?: boolean;
|
||||
}) => {
|
||||
if (!item.Id) {
|
||||
throw new Error("Missing item.Id for media load — cannot build contentId");
|
||||
@@ -33,58 +44,77 @@ export const buildCastMediaInfo = ({
|
||||
const buildImages = (urls: (string | null | undefined)[]) =>
|
||||
urls.filter(Boolean).map((url) => ({ url: url as string }));
|
||||
|
||||
const metadata =
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow" as const,
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: buildImages([
|
||||
getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
}),
|
||||
]),
|
||||
}
|
||||
: item.Type === "Movie"
|
||||
? {
|
||||
type: "movie" as const,
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: buildImages([
|
||||
getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
}),
|
||||
]),
|
||||
}
|
||||
: {
|
||||
type: "generic" as const,
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: buildImages([
|
||||
getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
}),
|
||||
]),
|
||||
};
|
||||
const buildItemMetadata = () => {
|
||||
if (item.Type === "Episode") {
|
||||
return {
|
||||
type: "tvShow" as const,
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: buildImages([
|
||||
getParentBackdropImageUrl({ api, item, quality: 90, width: 2000 }),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "Movie") {
|
||||
return {
|
||||
type: "movie" as const,
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: buildImages([
|
||||
getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "generic" as const,
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: buildImages([
|
||||
getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }),
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
const metadata = buildItemMetadata();
|
||||
|
||||
// Build a slim customData payload with only the fields the casting-player needs.
|
||||
// Sending the full BaseItemDto can exceed the Cast protocol's ~64KB message limit,
|
||||
// especially for movies with many chapters, media sources, and people.
|
||||
const slimCustomData: Partial<BaseItemDto> = {
|
||||
Id: item.Id,
|
||||
Name: item.Name,
|
||||
Type: item.Type,
|
||||
SeriesName: item.SeriesName,
|
||||
SeriesId: item.SeriesId,
|
||||
SeasonId: item.SeasonId,
|
||||
IndexNumber: item.IndexNumber,
|
||||
ParentIndexNumber: item.ParentIndexNumber,
|
||||
ImageTags: item.ImageTags,
|
||||
RunTimeTicks: item.RunTimeTicks,
|
||||
Overview: item.Overview,
|
||||
MediaStreams: item.MediaStreams,
|
||||
MediaSources: item.MediaSources?.map((src) => ({
|
||||
Id: src.Id,
|
||||
Bitrate: src.Bitrate,
|
||||
Container: src.Container,
|
||||
Name: src.Name,
|
||||
})),
|
||||
UserData: item.UserData
|
||||
? { PlaybackPositionTicks: item.UserData.PlaybackPositionTicks }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
contentId: itemId,
|
||||
contentUrl: streamUrl,
|
||||
contentType: "video/mp4",
|
||||
streamType: MediaStreamType.BUFFERED,
|
||||
contentType: contentType || "video/mp4",
|
||||
streamType: isLive ? MediaStreamType.LIVE : MediaStreamType.BUFFERED,
|
||||
streamDuration,
|
||||
customData: item,
|
||||
customData: slimCustomData,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user