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/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 9aecff51..364cf52c 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -25,11 +25,10 @@ import { import NetInfo from "@react-native-community/netinfo"; import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigation, useRouter } from "expo-router"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, - Platform, RefreshControl, ScrollView, TouchableOpacity, diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 1f345a0a..563759c1 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -1,12 +1,8 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { - useFocusEffect, - useLocalSearchParams, - useNavigation, -} from "expo-router"; +import { useLocalSearchParams, useNavigation } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import { FlatList, useWindowDimensions, View } from "react-native"; import { Text } from "@/components/common/Text"; @@ -16,6 +12,7 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; import { ItemPoster } from "@/components/posters/ItemPoster"; +import { useOrientation } from "@/hooks/useOrientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { genreFilterAtom, @@ -43,7 +40,6 @@ import { } from "@jellyfin/sdk/lib/utils/api"; import { FlashList } from "@shopify/flash-list"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useOrientation } from "@/hooks/useOrientation"; const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter); diff --git a/app/(auth)/play-offline-video.tsx b/app/(auth)/play-offline-video.tsx index 63d9f092..b4310d31 100644 --- a/app/(auth)/play-offline-video.tsx +++ b/app/(auth)/play-offline-video.tsx @@ -2,30 +2,19 @@ import { Controls } from "@/components/video-player/Controls"; import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; +import useScreenDimensions from "@/hooks/useScreenDimensions"; import { apiAtom } from "@/providers/JellyfinProvider"; import { PlaybackType, usePlaySettings, } from "@/providers/PlaySettingsProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; -import orientationToOrientationLock from "@/utils/OrientationLockConverter"; import { secondsToTicks } from "@/utils/secondsToTicks"; import { Api } from "@jellyfin/sdk"; import * as Haptics from "expo-haptics"; -import * as NavigationBar from "expo-navigation-bar"; import { useFocusEffect } from "expo-router"; -import * as ScreenOrientation from "expo-screen-orientation"; import { useAtomValue } from "jotai"; -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { Pressable, StatusBar, View } from "react-native"; import { useSharedValue } from "react-native-reanimated"; import Video, { OnProgressData, VideoRef } from "react-native-video"; @@ -37,7 +26,10 @@ export default function page() { const videoSource = useVideoSource(playSettings, api, playUrl); const firstTime = useRef(true); - const screenDimensions = Dimensions.get("screen"); + const screenDimensions = useScreenDimensions(); + useOrientation(); + useOrientationSettings(); + useAndroidNavigationBar(); const [showControls, setShowControls] = useState(true); const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); @@ -77,10 +69,6 @@ export default function page() { }, [play, stop]) ); - const { orientation } = useOrientation(); - useOrientationSettings(); - useAndroidNavigationBar(); - const onProgress = useCallback(async (data: OnProgressData) => { if (isSeeking.value === true) return; progress.value = secondsToTicks(data.currentTime); diff --git a/app/(auth)/play-video.tsx b/app/(auth)/play-video.tsx index 59ef2733..2d4960bd 100644 --- a/app/(auth)/play-video.tsx +++ b/app/(auth)/play-video.tsx @@ -2,6 +2,7 @@ import { Controls } from "@/components/video-player/Controls"; import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; +import useScreenDimensions from "@/hooks/useScreenDimensions"; import { useWebSocket } from "@/hooks/useWebsockets"; import { apiAtom } from "@/providers/JellyfinProvider"; import { @@ -18,7 +19,7 @@ import * as Haptics from "expo-haptics"; import { useFocusEffect } from "expo-router"; import { useAtomValue } from "jotai"; import React, { useCallback, useMemo, useRef, useState } from "react"; -import { Dimensions, Pressable, StatusBar, View } from "react-native"; +import { Pressable, StatusBar, View } from "react-native"; import { useSharedValue } from "react-native-reanimated"; import Video, { OnProgressData, @@ -34,8 +35,7 @@ export default function page() { const poster = usePoster(playSettings, api); const videoSource = useVideoSource(playSettings, api, poster, playUrl); const firstTime = useRef(true); - - const screenDimensions = Dimensions.get("screen"); + const screenDimensions = useScreenDimensions(); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [showControls, setShowControls] = useState(true); 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/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx index 58aa9ad6..0a19d1a9 100644 --- a/components/library/LibraryItemCard.tsx +++ b/components/library/LibraryItemCard.tsx @@ -11,12 +11,9 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useAtom } from "jotai"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import { TouchableOpacityProps, View } from "react-native"; -import { getColors } from "react-native-image-colors"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; -import { useImageColors } from "@/hooks/useImageColors"; -import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; interface Props extends TouchableOpacityProps { library: BaseItemDto; @@ -53,10 +50,6 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => { [library] ); - // If we want to use image colors for library cards - // const [color] = useAtom(itemThemeColorAtom) - // useImageColors({ url }); - const { data: itemsCount } = useQuery({ queryKey: ["library-count", library.Id], queryFn: async () => { @@ -68,6 +61,7 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => { }); return response.data.TotalRecordCount; }, + staleTime: 1000 * 60 * 60, }); if (!url) return null; diff --git a/hooks/useScreenDimensions.ts b/hooks/useScreenDimensions.ts new file mode 100644 index 00000000..22fa33ff --- /dev/null +++ b/hooks/useScreenDimensions.ts @@ -0,0 +1,27 @@ +import { useState, useEffect } from "react"; +import { Dimensions, ScaledSize } from "react-native"; + +const useScreenDimensions = (): ScaledSize => { + const [screenDimensions, setScreenDimensions] = useState( + Dimensions.get("screen") + ); + + useEffect(() => { + const updateDimensions = () => { + setScreenDimensions(Dimensions.get("screen")); + }; + + const dimensionsListener = Dimensions.addEventListener( + "change", + updateDimensions + ); + + return () => { + dimensionsListener.remove(); + }; + }, []); + + return screenDimensions; +}; + +export default useScreenDimensions; 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;