diff --git a/app.json b/app.json index e2f796d0..5a2fbbf6 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.17.0", + "version": "0.18.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -33,7 +33,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 44, + "versionCode": 46, "adaptiveIcon": { "foregroundImage": "./assets/images/adaptive_icon.png" }, @@ -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 f2439409..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, @@ -32,7 +29,6 @@ import { tagsFilterAtom, yearFilterAtom, } from "@/utils/atoms/filters"; -import { orientationAtom } from "@/utils/atoms/orientation"; import { BaseItemDto, BaseItemDtoQueryResult, @@ -60,12 +56,13 @@ const Page = () => { const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); const [sortBy, _setSortBy] = useAtom(sortByAtom); const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom); - const [orientation] = useAtom(orientationAtom); const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom); const [sortOrderPreference, setOderByPreference] = useAtom( sortOrderPreferenceAtom ); + const { orientation } = useOrientation(); + useEffect(() => { const sop = getSortOrderPreference(libraryId, sortOrderPreference); if (sop) { @@ -106,11 +103,12 @@ const Page = () => { [libraryId, sortOrderPreference] ); - const getNumberOfColumns = useCallback(() => { - if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3; - if (screenWidth < 600) return 5; - if (screenWidth < 960) return 6; - if (screenWidth < 1280) return 7; + const nrOfCols = useMemo(() => { + if (screenWidth < 300) return 2; + if (screenWidth < 500) return 3; + if (screenWidth < 800) return 5; + if (screenWidth < 1000) return 6; + if (screenWidth < 1500) return 7; return 6; }, [screenWidth, orientation]); @@ -219,7 +217,7 @@ const Page = () => { const renderItem = useCallback( ({ item, index }: { item: BaseItemDto; index: number }) => ( - { { - + ), [orientation] ); @@ -429,6 +427,7 @@ const Page = () => { return ( No results @@ -437,10 +436,10 @@ const Page = () => { contentInsetAdjustmentBehavior="automatic" data={flatData} renderItem={renderItem} - extraData={orientation} + extraData={[orientation, nrOfCols]} keyExtractor={keyExtractor} estimatedItemSize={244} - numColumns={getNumberOfColumns()} + numColumns={nrOfCols} onEndReached={() => { if (hasNextPage) { fetchNextPage(); diff --git a/app/(auth)/play-offline-video.tsx b/app/(auth)/play-offline-video.tsx index 872bd91c..ea6029f4 100644 --- a/app/(auth)/play-offline-video.tsx +++ b/app/(auth)/play-offline-video.tsx @@ -9,14 +9,8 @@ import { VlcPlayerViewRef, } from "@/modules/vlc-player/src/VlcPlayer.types"; import { apiAtom } from "@/providers/JellyfinProvider"; -import { - PlaybackType, - usePlaySettings, -} from "@/providers/PlaySettingsProvider"; +import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; -import { ticksToSeconds } from "@/utils/time"; -import { Api } from "@jellyfin/sdk"; import * as Haptics from "expo-haptics"; import { useFocusEffect } from "expo-router"; import { useAtomValue } from "jotai"; @@ -27,7 +21,7 @@ import React, { useRef, useState, } from "react"; -import { Dimensions, Pressable, StatusBar, View } from "react-native"; +import { Pressable, StatusBar, useWindowDimensions, View } from "react-native"; import { useSharedValue } from "react-native-reanimated"; import { SelectedTrackType } from "react-native-video"; @@ -37,7 +31,10 @@ export default function page() { const [settings] = useSettings(); const videoRef = useRef(null); - const screenDimensions = Dimensions.get("screen"); + const dimensions = useWindowDimensions(); + useOrientation(); + useOrientationSettings(); + useAndroidNavigationBar(); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [showControls, setShowControls] = useState(true); @@ -176,8 +173,8 @@ export default function page() { return ( )} - + {item.Type === "Episode" && ( diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index ef120e83..c2db6636 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/components/video-player/Controls.tsx b/components/video-player/Controls.tsx index 7f40ae47..079162c5 100644 --- a/components/video-player/Controls.tsx +++ b/components/video-player/Controls.tsx @@ -85,47 +85,7 @@ export const Controls: React.FC = ({ const windowDimensions = Dimensions.get("window"); - const op = useSharedValue(1); - const tr = useSharedValue(10); - const animatedStyles = useAnimatedStyle(() => { - return { - opacity: op.value, - }; - }); - const animatedTopStyles = useAnimatedStyle(() => { - return { - opacity: op.value, - transform: [ - { - translateY: -tr.value, - }, - ], - }; - }); - const animatedBottomStyles = useAnimatedStyle(() => { - return { - opacity: op.value, - transform: [ - { - translateY: tr.value, - }, - ], - }; - }); - - useEffect(() => { - if (showControls || isBuffering) { - op.value = withTiming(1, { duration: 200 }); - tr.value = withTiming(0, { duration: 200 }); - } else { - op.value = withTiming(0, { duration: 200 }); - tr.value = withTiming(10, { duration: 200 }); - } - }, [showControls, isBuffering]); - - const { previousItem, nextItem } = useAdjacentItems({ - item: offline ? undefined : item, - }); + const { previousItem, nextItem } = useAdjacentItems({ item }); const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( item, !offline @@ -398,7 +358,7 @@ export const Controls: React.FC = ({ toggleControls(); }} > - = ({ left: 0, width: windowDimensions.width + 100, height: windowDimensions.height + 100, + opacity: showControls ? 1 : 0, }, - animatedStyles, ]} className={`bg-black/50 z-0`} - > + > = ({ - = ({ > - + - = ({ maxHeight: windowDimensions.height, left: insets.left, bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom, + opacity: showControls ? 1 : 0, }, - animatedBottomStyles, ]} pointerEvents={showControls ? "auto" : "none"} className={`flex flex-col p-4 `} @@ -606,7 +566,7 @@ export const Controls: React.FC = ({ - + ); }; diff --git a/eas.json b/eas.json index 5583d89f..6c42b511 100644 --- a/eas.json +++ b/eas.json @@ -22,13 +22,13 @@ } }, "production": { - "channel": "0.17.0", + "channel": "0.18.0", "android": { "image": "latest" } }, "production-apk": { - "channel": "0.17.0", + "channel": "0.18.0", "android": { "buildType": "apk", "image": "latest" 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; diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index b4c82b19..b1e5dda7 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -52,7 +52,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.17.0" }, + clientInfo: { name: "Streamyfin", version: "0.18.0" }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, }) ); @@ -86,7 +86,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.17.0"`, + }, DeviceId="${deviceId}", Version="0.18.0"`, }; }, [deviceId]); diff --git a/utils/time.ts b/utils/time.ts index ce3656b5..df4cd22d 100644 --- a/utils/time.ts +++ b/utils/time.ts @@ -16,7 +16,8 @@ export const runtimeTicksToMinutes = ( const hours = Math.floor(ticks / ticksPerHour); const minutes = Math.floor((ticks % ticksPerHour) / ticksPerMinute); - return `${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m`; + else return `${minutes}m`; }; export const runtimeTicksToSeconds = (