diff --git a/app.json b/app.json index 9e227a49..00b060b9 100644 --- a/app.json +++ b/app.json @@ -66,13 +66,6 @@ } } ], - [ - "./plugins/withAndroidMainActivityAttributes", - { - "com.reactnative.googlecast.RNGCExpandedControllerActivity": true - } - ], - ["./plugins/withExpandedController.js"], [ "expo-build-properties", { diff --git a/bun.lockb b/bun.lockb index a101897e..29413a9f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index ff88767c..6ba0e089 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -1,8 +1,9 @@ import { Feather } from "@expo/vector-icons"; import { BlurView } from "expo-blur"; -import React, { useEffect } from "react"; +import React, { useCallback, useEffect } from "react"; import { Platform, TouchableOpacity, ViewProps } from "react-native"; import GoogleCast, { + CastButton, CastContext, useCastDevice, useDevices, @@ -39,18 +40,32 @@ export const Chromecast: React.FC = ({ })(); }, [client, devices, castDevice, sessionManager, discoveryManager]); + // Android requires the cast button to be present for startDiscovery to work + const AndroidCastButton = useCallback( + () => + Platform.OS === "android" ? ( + + ) : ( + <> + ), + [Platform.OS] + ); + if (background === "transparent") return ( - { - if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); - else CastContext.showCastDialog(); - }} - className="rounded-full h-10 w-10 flex items-center justify-center b" - {...props} - > - - + <> + { + if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); + else CastContext.showCastDialog(); + }} + className="rounded-full h-10 w-10 flex items-center justify-center b" + {...props} + > + + + + ); if (Platform.OS === "android") @@ -82,6 +97,7 @@ export const Chromecast: React.FC = ({ > + ); }; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 6b752e75..eb5e82e8 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -246,7 +246,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( )} - + {item.Type === "Episode" && ( diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index f87eebf8..69d256c2 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -1,4 +1,4 @@ -import { apiAtom } from "@/providers/JellyfinProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; @@ -6,10 +6,11 @@ import { runtimeTicksToMinutes } from "@/utils/time"; import { useActionSheet } from "@expo/react-native-action-sheet"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useAtom } from "jotai"; -import { useEffect, useMemo } from "react"; -import { Linking, TouchableOpacity, View } from "react-native"; +import { useAtom, useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo } from "react"; +import { Alert, Linking, TouchableOpacity, View } from "react-native"; import CastContext, { + CastButton, PlayServicesState, useMediaStatus, useRemoteMediaClient, @@ -28,32 +29,31 @@ import { Button } from "./Button"; import { Text } from "./common/Text"; import { useRouter } from "expo-router"; import { useSettings } from "@/utils/atoms/settings"; +import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; +import { chromecastProfile } from "@/utils/profiles/chromecast"; +import { usePlaySettings } from "@/providers/PlaySettingsProvider"; -interface Props extends React.ComponentProps { - item?: BaseItemDto | null; - url?: string | null; -} +interface Props extends React.ComponentProps {} const ANIMATION_DURATION = 500; const MIN_PLAYBACK_WIDTH = 15; -export const PlayButton: React.FC = ({ item, url, ...props }) => { +export const PlayButton: React.FC = ({ ...props }) => { + const { playSettings, playUrl: url } = usePlaySettings(); const { showActionSheetWithOptions } = useActionSheet(); const client = useRemoteMediaClient(); const mediaStatus = useMediaStatus(); const [colorAtom] = useAtom(itemThemeColorAtom); - const [api] = useAtom(apiAtom); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); const router = useRouter(); - const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item - const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color - const startWidth = useSharedValue(0); const targetWidth = useSharedValue(0); - const endColor = useSharedValue(memoizedColor); - const startColor = useSharedValue(memoizedColor); + const endColor = useSharedValue(colorAtom); + const startColor = useSharedValue(colorAtom); const widthProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0); const [settings] = useSettings(); @@ -62,7 +62,11 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { return !url?.includes("m3u8"); }, [url]); - const onPress = async () => { + const item = useMemo(() => { + return playSettings?.item; + }, [playSettings?.item]); + + const onPress = useCallback(async () => { if (!url || !item) { console.warn( "No URL or item provided to PlayButton", @@ -98,7 +102,7 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { switch (selectedIndex) { case 0: - await CastContext.getPlayServicesState().then((state) => { + await CastContext.getPlayServicesState().then(async (state) => { if (state && state !== PlayServicesState.SUCCESS) CastContext.showPlayServicesErrorDialog(state); else { @@ -108,10 +112,34 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { CastContext.showExpandedControls(); return; } + + // Get a new URL with the Chromecast device profile: + const data = await getStreamUrl({ + api, + deviceProfile: chromecastProfile, + item, + mediaSourceId: playSettings?.mediaSource?.Id, + startTimeTicks: 0, + maxStreamingBitrate: playSettings?.bitrate?.value, + audioStreamIndex: playSettings?.audioIndex ?? 0, + subtitleStreamIndex: playSettings?.subtitleIndex ?? -1, + userId: user?.Id, + forceDirectPlay: settings?.forceDirectPlay, + }); + + if (!data?.url) { + console.warn("No URL returned from getStreamUrl", data); + Alert.alert( + "Client error", + "Could not create stream for Chromecast" + ); + return; + } + client .loadMedia({ mediaInfo: { - contentUrl: url, + contentUrl: data?.url, contentType: "video/mp4", metadata: item.Type === "Episode" @@ -184,21 +212,32 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { } } ); - }; + }, [ + url, + item, + client, + settings, + api, + user, + playSettings, + router, + showActionSheetWithOptions, + mediaStatus, + ]); const derivedTargetWidth = useDerivedValue(() => { - if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0; - const userData = memoizedItem.UserData; + if (!item || !item.RunTimeTicks) return 0; + const userData = item.UserData; if (userData && userData.PlaybackPositionTicks) { return userData.PlaybackPositionTicks > 0 ? Math.max( - (userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100, + (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, MIN_PLAYBACK_WIDTH ) : 0; } return 0; - }, [memoizedItem]); + }, [item]); useAnimatedReaction( () => derivedTargetWidth.value, @@ -214,7 +253,7 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { ); useAnimatedReaction( - () => memoizedColor, + () => colorAtom, (newColor) => { endColor.value = newColor; colorChangeProgress.value = 0; @@ -223,19 +262,19 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { easing: Easing.bezier(0.9, 0, 0.31, 0.99), }); }, - [memoizedColor] + [colorAtom] ); useEffect(() => { const timeout_2 = setTimeout(() => { - startColor.value = memoizedColor; + startColor.value = colorAtom; startWidth.value = targetWidth.value; }, ANIMATION_DURATION); return () => { clearTimeout(timeout_2); }; - }, [memoizedColor, memoizedItem]); + }, [colorAtom, item]); /** * ANIMATED STYLES @@ -318,6 +357,7 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { {client && ( + )} {!client && settings?.openInVLC && ( diff --git a/plugins/withAndroidMainActivityAttributes.js b/plugins/withAndroidMainActivityAttributes.js deleted file mode 100644 index c5764408..00000000 --- a/plugins/withAndroidMainActivityAttributes.js +++ /dev/null @@ -1,42 +0,0 @@ -const { withAndroidManifest } = require("@expo/config-plugins"); - -function addAttributesToMainActivity(androidManifest, attributes) { - const { manifest } = androidManifest; - - if (!Array.isArray(manifest["application"])) { - console.warn("withAndroidMainActivityAttributes: No application array in manifest?"); - return androidManifest; - } - - const application = manifest["application"].find( - (item) => item.$["android:name"] === ".MainApplication" - ); - if (!application) { - console.warn("withAndroidMainActivityAttributes: No .MainApplication?"); - return androidManifest; - } - - if (!Array.isArray(application["activity"])) { - console.warn("withAndroidMainActivityAttributes: No activity array in .MainApplication?"); - return androidManifest; - } - - const activity = application["activity"].find( - (item) => item.$["android:name"] === ".MainActivity" - ); - if (!activity) { - console.warn("withAndroidMainActivityAttributes: No .MainActivity?"); - return androidManifest; - } - - activity.$ = { ...activity.$, ...attributes }; - - return androidManifest; -} - -module.exports = function withAndroidMainActivityAttributes(config, attributes) { - return withAndroidManifest(config, (config) => { - config.modResults = addAttributesToMainActivity(config.modResults, attributes); - return config; - }); -}; diff --git a/plugins/withExpandedController.js b/plugins/withExpandedController.js deleted file mode 100644 index 9ea30dcd..00000000 --- a/plugins/withExpandedController.js +++ /dev/null @@ -1,20 +0,0 @@ -const { withAppDelegate } = require("@expo/config-plugins"); - -const withExpandedController = (config) => { - return withAppDelegate(config, async (config) => { - const contents = config.modResults.contents; - - // Looking for the initialProps string inside didFinishLaunchingWithOptions, - // and injecting expanded controller config. - // Should be updated once there is an expo config option - see https://github.com/react-native-google-cast/react-native-google-cast/discussions/537 - const injectionIndex = contents.indexOf("self.initialProps = @{};"); - config.modResults.contents = - contents.substring(0, injectionIndex) + - `\n [GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true; \n` + - contents.substring(injectionIndex); - - return config; - }); -}; - -module.exports = withExpandedController;