mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 03:58:36 +01:00
refactor(casting): route all cast loads through loadCastMedia
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user