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 (
{
setShowControls(!showControls);
}}
- className="absolute z-0 h-full w-full"
+ style={{
+ position: "absolute",
+ top: 0,
+ left: 0,
+ width: dimensions.width,
+ height: dimensions.height,
+ zIndex: 0,
+ }}
>
)}
-
+
{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 = (