mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-27 19:51:51 +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:
@@ -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