mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-02 16:38:08 +00:00
Enriches the casting player screen by fetching item details from the Jellyfin API for a more reliable and complete user experience. The casting player now prioritizes item data fetched directly from the API, providing richer metadata and ensuring accurate information display. - Fetches full item data based on content ID. - Uses fetched data as the primary source of item information, falling back to customData or minimal info if unavailable. - Improves UI by showing connection quality and bitrate. - Enhances episode list display and scrolling. - Adds a stop casting button. - Minor UI adjustments for better readability and aesthetics. This change enhances the accuracy and reliability of displayed information, improving the overall user experience of the casting player.
571 lines
19 KiB
TypeScript
571 lines
19 KiB
TypeScript
import { useActionSheet } from "@expo/react-native-action-sheet";
|
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
|
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
import { useAtom, useAtomValue } from "jotai";
|
|
import { useCallback, useEffect } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
|
import CastContext, {
|
|
CastButton,
|
|
MediaStreamType,
|
|
PlayServicesState,
|
|
useMediaStatus,
|
|
useRemoteMediaClient,
|
|
} from "react-native-google-cast";
|
|
import Animated, {
|
|
Easing,
|
|
interpolate,
|
|
interpolateColor,
|
|
useAnimatedReaction,
|
|
useAnimatedStyle,
|
|
useDerivedValue,
|
|
useSharedValue,
|
|
withTiming,
|
|
} from "react-native-reanimated";
|
|
import useRouter from "@/hooks/useAppRouter";
|
|
import { useHaptic } from "@/hooks/useHaptic";
|
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
|
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
|
import { useSettings } from "@/utils/atoms/settings";
|
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
import { chromecast } from "@/utils/profiles/chromecast";
|
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
import { Button } from "./Button";
|
|
import { Text } from "./common/Text";
|
|
import type { SelectedOptions } from "./ItemContent";
|
|
|
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
|
item: BaseItemDto;
|
|
selectedOptions: SelectedOptions;
|
|
colors?: ThemeColors;
|
|
}
|
|
|
|
const ANIMATION_DURATION = 500;
|
|
const MIN_PLAYBACK_WIDTH = 15;
|
|
|
|
export const PlayButton: React.FC<Props> = ({
|
|
item,
|
|
selectedOptions,
|
|
colors,
|
|
}: Props) => {
|
|
const isOffline = useOfflineMode();
|
|
const { showActionSheetWithOptions } = useActionSheet();
|
|
const client = useRemoteMediaClient();
|
|
const mediaStatus = useMediaStatus();
|
|
const { t } = useTranslation();
|
|
const { showModal, hideModal } = useGlobalModal();
|
|
|
|
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
|
const api = useAtomValue(apiAtom);
|
|
const user = useAtomValue(userAtom);
|
|
|
|
// Use colors prop if provided, otherwise fallback to global atom
|
|
const effectiveColors = colors || globalColorAtom;
|
|
|
|
const router = useRouter();
|
|
|
|
const startWidth = useSharedValue(0);
|
|
const targetWidth = useSharedValue(0);
|
|
const endColor = useSharedValue(effectiveColors);
|
|
const startColor = useSharedValue(effectiveColors);
|
|
const widthProgress = useSharedValue(0);
|
|
const colorChangeProgress = useSharedValue(0);
|
|
const { settings, updateSettings } = useSettings();
|
|
const lightHapticFeedback = useHaptic("light");
|
|
|
|
const goToPlayer = useCallback(
|
|
(q: string) => {
|
|
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
|
|
updateSettings({ autoPlayEpisodeCount: 0 });
|
|
}
|
|
router.push(`/player/direct-player?${q}`);
|
|
},
|
|
[router, isOffline],
|
|
);
|
|
|
|
const handleNormalPlayFlow = useCallback(async () => {
|
|
if (!item) return;
|
|
|
|
const queryParams = new URLSearchParams({
|
|
itemId: item.Id!,
|
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
|
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
offline: isOffline ? "true" : "false",
|
|
});
|
|
|
|
const queryString = queryParams.toString();
|
|
|
|
if (!client) {
|
|
goToPlayer(queryString);
|
|
return;
|
|
}
|
|
|
|
const options = ["Chromecast", "Device", "Cancel"];
|
|
const cancelButtonIndex = 2;
|
|
showActionSheetWithOptions(
|
|
{
|
|
options,
|
|
cancelButtonIndex,
|
|
},
|
|
async (selectedIndex: number | undefined) => {
|
|
if (!api) return;
|
|
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
|
const isOpeningCurrentlyPlayingMedia =
|
|
currentTitle && currentTitle === item?.Name;
|
|
|
|
switch (selectedIndex) {
|
|
case 0:
|
|
await CastContext.getPlayServicesState().then(async (state) => {
|
|
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");
|
|
Alert.alert(
|
|
t("player.client_error"),
|
|
t("player.missing_parameters"),
|
|
);
|
|
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,
|
|
audioStreamIndex: selectedOptions.audioIndex,
|
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
|
mediaSourceId: selectedOptions.mediaSource?.Id,
|
|
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
|
});
|
|
|
|
console.log("URL: ", data?.url, enableH265);
|
|
console.log("[PlayButton] Item before casting:", {
|
|
Type: item.Type,
|
|
Id: item.Id,
|
|
Name: item.Name,
|
|
ParentIndexNumber: item.ParentIndexNumber,
|
|
IndexNumber: item.IndexNumber,
|
|
SeasonId: item.SeasonId,
|
|
SeriesId: item.SeriesId,
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
// Calculate start time in seconds from playback position
|
|
const startTimeSeconds =
|
|
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
|
|
|
// Calculate stream duration in seconds from runtime
|
|
const streamDurationSeconds = item.RunTimeTicks
|
|
? item.RunTimeTicks / 10000000
|
|
: undefined;
|
|
|
|
console.log("[PlayButton] Loading media with customData:", {
|
|
hasCustomData: !!item,
|
|
customDataType: item.Type,
|
|
});
|
|
|
|
client
|
|
.loadMedia({
|
|
mediaInfo: {
|
|
contentId: item.Id,
|
|
contentUrl: data?.url,
|
|
contentType: "video/mp4",
|
|
streamType: MediaStreamType.BUFFERED,
|
|
streamDuration: streamDurationSeconds,
|
|
customData: item,
|
|
metadata:
|
|
item.Type === "Episode"
|
|
? {
|
|
type: "tvShow",
|
|
title: item.Name || "",
|
|
episodeNumber: item.IndexNumber || 0,
|
|
seasonNumber: item.ParentIndexNumber || 0,
|
|
seriesTitle: item.SeriesName || "",
|
|
images: [
|
|
{
|
|
url: getParentBackdropImageUrl({
|
|
api,
|
|
item,
|
|
quality: 90,
|
|
width: 2000,
|
|
})!,
|
|
},
|
|
],
|
|
}
|
|
: item.Type === "Movie"
|
|
? {
|
|
type: "movie",
|
|
title: item.Name || "",
|
|
subtitle: item.Overview || "",
|
|
images: [
|
|
{
|
|
url: getPrimaryImageUrl({
|
|
api,
|
|
item,
|
|
quality: 90,
|
|
width: 2000,
|
|
})!,
|
|
},
|
|
],
|
|
}
|
|
: {
|
|
type: "generic",
|
|
title: item.Name || "",
|
|
subtitle: item.Overview || "",
|
|
images: [
|
|
{
|
|
url: getPrimaryImageUrl({
|
|
api,
|
|
item,
|
|
quality: 90,
|
|
width: 2000,
|
|
})!,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
startTime: startTimeSeconds,
|
|
})
|
|
.then(() => {
|
|
// state is already set when reopening current media, so skip it here.
|
|
if (isOpeningCurrentlyPlayingMedia) {
|
|
return;
|
|
}
|
|
router.push("/casting-player");
|
|
});
|
|
} catch (e) {
|
|
console.log(e);
|
|
}
|
|
}
|
|
});
|
|
break;
|
|
case 1:
|
|
goToPlayer(queryString);
|
|
break;
|
|
case cancelButtonIndex:
|
|
break;
|
|
}
|
|
},
|
|
);
|
|
}, [
|
|
item,
|
|
client,
|
|
settings,
|
|
api,
|
|
user,
|
|
router,
|
|
showActionSheetWithOptions,
|
|
mediaStatus,
|
|
selectedOptions,
|
|
goToPlayer,
|
|
isOffline,
|
|
t,
|
|
]);
|
|
|
|
const onPress = useCallback(async () => {
|
|
if (!item) return;
|
|
|
|
lightHapticFeedback();
|
|
|
|
// Check if item is downloaded
|
|
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
|
|
|
// If already in offline mode, play downloaded file directly
|
|
if (isOffline && downloadedItem) {
|
|
const queryParams = new URLSearchParams({
|
|
itemId: item.Id!,
|
|
offline: "true",
|
|
playbackPosition:
|
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
});
|
|
goToPlayer(queryParams.toString());
|
|
return;
|
|
}
|
|
|
|
// If online but file is downloaded, ask user which version to play
|
|
if (downloadedItem) {
|
|
if (Platform.OS === "android") {
|
|
// Show bottom sheet for Android
|
|
showModal(
|
|
<BottomSheetView>
|
|
<View className='px-4 mt-4 mb-12'>
|
|
<View className='pb-6'>
|
|
<Text className='text-2xl font-bold mb-2'>
|
|
{t("player.downloaded_file_title")}
|
|
</Text>
|
|
<Text className='opacity-70 text-base'>
|
|
{t("player.downloaded_file_message")}
|
|
</Text>
|
|
</View>
|
|
<View className='space-y-3'>
|
|
<Button
|
|
onPress={() => {
|
|
hideModal();
|
|
const queryParams = new URLSearchParams({
|
|
itemId: item.Id!,
|
|
offline: "true",
|
|
playbackPosition:
|
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
});
|
|
goToPlayer(queryParams.toString());
|
|
}}
|
|
color='purple'
|
|
>
|
|
{Platform.OS === "android"
|
|
? "Play downloaded file"
|
|
: t("player.downloaded_file_yes")}
|
|
</Button>
|
|
<Button
|
|
onPress={() => {
|
|
hideModal();
|
|
handleNormalPlayFlow();
|
|
}}
|
|
color='white'
|
|
variant='border'
|
|
>
|
|
{Platform.OS === "android"
|
|
? "Stream file"
|
|
: t("player.downloaded_file_no")}
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</BottomSheetView>,
|
|
{
|
|
snapPoints: ["35%"],
|
|
enablePanDownToClose: true,
|
|
},
|
|
);
|
|
} else {
|
|
// Show alert for iOS
|
|
Alert.alert(
|
|
t("player.downloaded_file_title"),
|
|
t("player.downloaded_file_message"),
|
|
[
|
|
{
|
|
text: t("player.downloaded_file_yes"),
|
|
onPress: () => {
|
|
const queryParams = new URLSearchParams({
|
|
itemId: item.Id!,
|
|
offline: "true",
|
|
playbackPosition:
|
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
});
|
|
goToPlayer(queryParams.toString());
|
|
},
|
|
isPreferred: true,
|
|
},
|
|
{
|
|
text: t("player.downloaded_file_no"),
|
|
onPress: () => {
|
|
handleNormalPlayFlow();
|
|
},
|
|
},
|
|
{
|
|
text: t("player.downloaded_file_cancel"),
|
|
style: "cancel",
|
|
},
|
|
],
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If not downloaded, proceed with normal flow
|
|
handleNormalPlayFlow();
|
|
}, [
|
|
item,
|
|
lightHapticFeedback,
|
|
handleNormalPlayFlow,
|
|
goToPlayer,
|
|
t,
|
|
showModal,
|
|
hideModal,
|
|
effectiveColors,
|
|
]);
|
|
|
|
const derivedTargetWidth = useDerivedValue(() => {
|
|
if (!item || !item.RunTimeTicks) return 0;
|
|
const userData = item.UserData;
|
|
if (userData?.PlaybackPositionTicks) {
|
|
return userData.PlaybackPositionTicks > 0
|
|
? Math.max(
|
|
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
|
MIN_PLAYBACK_WIDTH,
|
|
)
|
|
: 0;
|
|
}
|
|
return 0;
|
|
}, [item]);
|
|
|
|
useAnimatedReaction(
|
|
() => derivedTargetWidth.value,
|
|
(newWidth) => {
|
|
targetWidth.value = newWidth;
|
|
widthProgress.value = 0;
|
|
widthProgress.value = withTiming(1, {
|
|
duration: ANIMATION_DURATION,
|
|
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
|
});
|
|
},
|
|
[item],
|
|
);
|
|
|
|
useAnimatedReaction(
|
|
() => effectiveColors,
|
|
(newColor) => {
|
|
endColor.value = newColor;
|
|
colorChangeProgress.value = 0;
|
|
colorChangeProgress.value = withTiming(1, {
|
|
duration: ANIMATION_DURATION,
|
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
|
});
|
|
},
|
|
[effectiveColors],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const timeout_2 = setTimeout(() => {
|
|
startColor.value = effectiveColors;
|
|
startWidth.value = targetWidth.value;
|
|
}, ANIMATION_DURATION);
|
|
|
|
return () => {
|
|
clearTimeout(timeout_2);
|
|
};
|
|
}, [effectiveColors, item]);
|
|
|
|
/**
|
|
* ANIMATED STYLES
|
|
*/
|
|
const animatedAverageStyle = useAnimatedStyle(() => ({
|
|
backgroundColor: interpolateColor(
|
|
colorChangeProgress.value,
|
|
[0, 1],
|
|
[startColor.value.primary, endColor.value.primary],
|
|
),
|
|
}));
|
|
|
|
const animatedPrimaryStyle = useAnimatedStyle(() => ({
|
|
backgroundColor: interpolateColor(
|
|
colorChangeProgress.value,
|
|
[0, 1],
|
|
[startColor.value.primary, endColor.value.primary],
|
|
),
|
|
}));
|
|
|
|
const animatedWidthStyle = useAnimatedStyle(() => ({
|
|
width: `${interpolate(
|
|
widthProgress.value,
|
|
[0, 1],
|
|
[startWidth.value, targetWidth.value],
|
|
)}%`,
|
|
}));
|
|
|
|
const animatedTextStyle = useAnimatedStyle(() => ({
|
|
color: interpolateColor(
|
|
colorChangeProgress.value,
|
|
[0, 1],
|
|
[startColor.value.text, endColor.value.text],
|
|
),
|
|
}));
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
disabled={!item}
|
|
accessibilityLabel='Play button'
|
|
accessibilityHint='Tap to play the media'
|
|
onPress={onPress}
|
|
className={"relative flex-1"}
|
|
>
|
|
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
|
<Animated.View
|
|
style={[
|
|
animatedPrimaryStyle,
|
|
animatedWidthStyle,
|
|
{
|
|
height: "100%",
|
|
},
|
|
]}
|
|
/>
|
|
</View>
|
|
|
|
<Animated.View
|
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
|
className='absolute w-full h-full top-0 left-0 rounded-full'
|
|
/>
|
|
<View
|
|
style={{
|
|
borderWidth: 1,
|
|
borderColor: effectiveColors.primary,
|
|
borderStyle: "solid",
|
|
}}
|
|
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
|
|
>
|
|
<View className='flex flex-row items-center space-x-2'>
|
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
|
{runtimeTicksToMinutes(
|
|
(item?.RunTimeTicks || 0) -
|
|
(item?.UserData?.PlaybackPositionTicks || 0),
|
|
)}
|
|
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
|
</Animated.Text>
|
|
<Animated.Text style={animatedTextStyle}>
|
|
<Ionicons name='play-circle' size={24} />
|
|
</Animated.Text>
|
|
{client && (
|
|
<Animated.Text style={animatedTextStyle}>
|
|
<Feather name='cast' size={22} />
|
|
<CastButton tintColor='transparent' />
|
|
</Animated.Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
};
|