refactor(casting): route all cast loads through loadCastMedia

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Uruk
2026-05-21 02:27:20 +02:00
parent fb8c649f6f
commit bcf6b705e1
5 changed files with 85 additions and 321 deletions

View File

@@ -44,6 +44,7 @@ import { useCasting } from "@/hooks/useCasting";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { loadCastMedia } from "@/utils/casting/castLoad";
import {
calculateEndingTime,
formatTime,
@@ -51,10 +52,6 @@ import {
getPosterUrl,
truncateTitle,
} from "@/utils/casting/helpers";
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function CastingPlayerScreen() {
@@ -292,15 +289,8 @@ export default function CastingPlayerScreen() {
}
try {
// Save current playback position
const currentPosition = mediaStatus?.streamPosition ?? 0;
// Get new stream URL with updated settings
const enableH265 = settings.enableH265ForChromecast;
// Resolve subtitle index:
// - options.subtitleIndex is explicitly provided: null = disable (-1), number = specific track
// - options.subtitleIndex is undefined: preserve current selection
let resolvedSubtitleIndex: number | undefined;
if (options.subtitleIndex === undefined) {
resolvedSubtitleIndex = selectedSubtitleTrackIndex ?? undefined;
@@ -310,32 +300,29 @@ export default function CastingPlayerScreen() {
resolvedSubtitleIndex = options.subtitleIndex;
}
const data = await getStreamUrl({
const result = await loadCastMedia({
client: remoteMediaClient,
device: castDevice,
api,
item: currentItem,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: Math.floor(currentPosition * 10000000), // Convert seconds to ticks
userId: user.Id,
audioStreamIndex:
options.audioIndex ?? selectedAudioTrackIndex ?? undefined,
subtitleStreamIndex: resolvedSubtitleIndex,
maxStreamingBitrate: options.bitrateValue,
profileMode: settings.chromecastProfile,
maxBitrateSetting: settings.chromecastMaxBitrate,
options: {
audioStreamIndex:
options.audioIndex ?? selectedAudioTrackIndex ?? undefined,
subtitleStreamIndex: resolvedSubtitleIndex,
maxBitrate: options.bitrateValue,
startPositionMs: currentPosition * 1000,
},
});
if (!data?.url) {
console.error("[Casting Player] Failed to get stream URL");
return;
if (!result.ok) {
console.error(
"[Casting Player] Failed to reload stream:",
result.error,
);
}
// Reload media with new URL
await remoteMediaClient.loadMedia({
mediaInfo: buildCastMediaInfo({
item: currentItem,
streamUrl: data.url,
api,
}),
startTime: currentPosition, // Resume at same position
});
} catch (error) {
console.error("[Casting Player] Failed to reload stream:", error);
}
@@ -345,8 +332,10 @@ export default function CastingPlayerScreen() {
user?.Id,
currentItem,
remoteMediaClient,
castDevice,
mediaStatus?.streamPosition,
settings.enableH265ForChromecast,
settings.chromecastProfile,
settings.chromecastMaxBitrate,
selectedAudioTrackIndex,
selectedSubtitleTrackIndex,
],
@@ -358,39 +347,42 @@ export default function CastingPlayerScreen() {
if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return;
try {
const enableH265 = settings.enableH265ForChromecast;
const data = await getStreamUrl({
const startPositionMs =
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
const result = await loadCastMedia({
client: remoteMediaClient,
device: castDevice,
api,
item: episode,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: episode.UserData?.PlaybackPositionTicks ?? 0,
userId: user.Id,
profileMode: settings.chromecastProfile,
maxBitrateSetting: settings.chromecastMaxBitrate,
options: { startPositionMs },
});
if (!data?.url) {
if (!result.ok) {
console.error(
"[Casting Player] Failed to get stream URL for episode",
"[Casting Player] Failed to load episode:",
result.error,
);
return;
}
await remoteMediaClient.loadMedia({
mediaInfo: buildCastMediaInfo({
item: episode,
streamUrl: data.url,
api,
}),
startTime: (episode.UserData?.PlaybackPositionTicks ?? 0) / 10000000,
});
// Reset track selections for new episode
setSelectedAudioTrackIndex(null);
setSelectedSubtitleTrackIndex(null);
} catch (error) {
console.error("[Casting Player] Failed to load episode:", error);
}
},
[api, user?.Id, remoteMediaClient, settings.enableH265ForChromecast],
[
api,
user?.Id,
remoteMediaClient,
castDevice,
settings.chromecastProfile,
settings.chromecastMaxBitrate,
],
);
// Fetch season data for season poster

View File

@@ -10,6 +10,7 @@ import CastContext, {
CastButton,
MediaPlayerState,
PlayServicesState,
useCastDevice,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
@@ -32,10 +33,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { loadCastMedia } from "@/utils/casting/castLoad";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Button } from "./Button";
import { Text } from "./common/Text";
@@ -58,6 +56,7 @@ export const PlayButton: React.FC<Props> = ({
const isOffline = useOfflineMode();
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const { showModal, hideModal } = useGlobalModal();
@@ -138,30 +137,8 @@ export const PlayButton: React.FC<Props> = ({
if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state);
} else {
// Check if user wants H265 for Chromecast
const enableH265 = settings.enableH265ForChromecast;
// Validate required parameters before calling getStreamUrl
if (!api) {
console.warn("API not available for Chromecast streaming");
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
if (!user?.Id) {
console.warn(
"User not authenticated for Chromecast streaming",
);
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
if (!item?.Id) {
console.warn("Item not available for Chromecast streaming");
if (!api || !user?.Id || !item?.Id) {
console.warn("Missing parameters for Chromecast streaming");
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
@@ -169,53 +146,37 @@ export const PlayButton: React.FC<Props> = ({
return;
}
// Get a new URL with the Chromecast device profile
try {
const data = await getStreamUrl({
api,
item,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
userId: user.Id,
const startPositionMs =
(item.UserData?.PlaybackPositionTicks ?? 0) / 10000;
const result = await loadCastMedia({
client,
device: castDevice,
api,
item,
userId: user.Id,
profileMode: settings.chromecastProfile,
maxBitrateSetting: settings.chromecastMaxBitrate,
options: {
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
maxBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
startPositionMs,
},
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
if (!result.ok) {
console.error("[PlayButton] cast load failed:", result.error);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
const startTimeSeconds =
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
client
.loadMedia({
mediaInfo: buildCastMediaInfo({
item,
streamUrl: data.url,
api,
}),
startTime: startTimeSeconds,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
router.push("/casting-player");
})
.catch((err) => {
console.error("[PlayButton] loadMedia failed:", err);
});
} catch (e) {
console.error("[PlayButton] Cast error:", e);
if (!isOpeningCurrentlyPlayingMedia) {
router.push("/casting-player");
}
}
});
@@ -231,6 +192,7 @@ export const PlayButton: React.FC<Props> = ({
}, [
item,
client,
castDevice,
settings,
api,
user,

View File

@@ -52,6 +52,14 @@ export const useCasting = (item: BaseItemDto | null) => {
[],
);
// Real Jellyfin PlaySessionId, embedded in customData by buildCastMediaInfo.
const playSessionId =
(
mediaStatus?.mediaInfo?.customData as
| { playSessionId?: string }
| undefined
)?.playSessionId ?? mediaStatus?.mediaInfo?.contentId;
// Detect which protocol is active - use CastState for reliable detection
const chromecastConnected = castState === CastState.CONNECTED;
// Future: Add detection for other protocols here
@@ -139,7 +147,7 @@ export const useCasting = (item: BaseItemDto | null) => {
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
VolumeLevel: Math.floor(currentState.volume * 100),
IsMuted: currentState.volume === 0,
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
PlaySessionId: playSessionId,
},
})
.catch((error) => {
@@ -179,7 +187,7 @@ export const useCasting = (item: BaseItemDto | null) => {
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
VolumeLevel: Math.floor(s.volume * 100),
IsMuted: s.volume === 0,
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
PlaySessionId: playSessionId,
},
})
.catch((error) => {
@@ -190,14 +198,7 @@ export const useCasting = (item: BaseItemDto | null) => {
// Report progress on a fixed interval, reading latest state from ref
const interval = setInterval(reportProgress, 10000);
return () => clearInterval(interval);
}, [
api,
item?.Id,
user?.Id,
isConnected,
activeProtocol,
mediaStatus?.mediaInfo?.contentId,
]);
}, [api, item?.Id, user?.Id, isConnected, activeProtocol, playSessionId]);
// Play/Pause controls
const play = useCallback(async () => {

View File

@@ -1,96 +0,0 @@
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
export const chromecast: DeviceProfile = {
Name: "Chromecast Video Profile",
MaxStreamingBitrate: 16000000, // 16 Mbps
MaxStaticBitrate: 16000000, // 16 Mbps
MusicStreamingTranscodingBitrate: 384000, // 384 kbps
CodecProfiles: [
{
Type: "Video",
Codec: "h264",
},
{
Type: "Audio",
Codec: "aac,mp3,flac,opus,vorbis",
// Force transcode if audio has more than 2 channels (5.1, 7.1, etc)
Conditions: [
{
Condition: "LessThanEqual",
Property: "AudioChannels",
Value: "2",
},
],
},
],
ContainerProfiles: [],
DirectPlayProfiles: [
{
Container: "mp4",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,opus,vorbis",
},
{
Container: "mp3",
Type: "Audio",
},
{
Container: "aac",
Type: "Audio",
},
{
Container: "flac",
Type: "Audio",
},
{
Container: "wav",
Type: "Audio",
},
],
TranscodingProfiles: [
{
Container: "ts",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3",
Protocol: "hls",
Context: "Streaming",
MaxAudioChannels: "2",
MinSegments: 2,
BreakOnNonKeyFrames: true,
},
{
Container: "mp4",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
MinSegments: 2,
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
],
SubtitleProfiles: [
{
Format: "vtt",
Method: "Encode",
},
],
};

View File

@@ -1,95 +0,0 @@
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
export const chromecasth265: DeviceProfile = {
Name: "Chromecast Video Profile",
MaxStreamingBitrate: 16000000, // 16Mbps
MaxStaticBitrate: 16000000, // 16 Mbps
MusicStreamingTranscodingBitrate: 384000, // 384 kbps
CodecProfiles: [
{
Type: "Video",
Codec: "hevc,h264",
},
{
Type: "Audio",
Codec: "aac,mp3,flac,opus,vorbis", // Force transcode if audio has more than 2 channels (5.1, 7.1, etc)
Conditions: [
{
Condition: "LessThanEqual",
Property: "AudioChannels",
Value: "2",
},
],
},
],
ContainerProfiles: [],
DirectPlayProfiles: [
{
Container: "mp4,mkv",
Type: "Video",
VideoCodec: "hevc,h264",
AudioCodec: "aac,mp3,opus,vorbis",
},
{
Container: "mp3",
Type: "Audio",
},
{
Container: "aac",
Type: "Audio",
},
{
Container: "flac",
Type: "Audio",
},
{
Container: "wav",
Type: "Audio",
},
],
TranscodingProfiles: [
{
Container: "ts",
Type: "Video",
VideoCodec: "hevc,h264",
AudioCodec: "aac,mp3",
Protocol: "hls",
Context: "Streaming",
MaxAudioChannels: "2",
MinSegments: 2,
BreakOnNonKeyFrames: true,
},
{
Container: "mp4,mkv",
Type: "Video",
VideoCodec: "hevc,h264",
AudioCodec: "aac",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
MinSegments: 2,
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
],
SubtitleProfiles: [
{
Format: "vtt",
Method: "Encode",
},
],
};