Compare commits

...

32 Commits

Author SHA1 Message Date
Fredrik Burmester
d4252682be wip: use general poster component 2024-09-03 08:54:05 +03:00
Fredrik Burmester
7b9bad630f Merge branch 'master' into wip/general-posters 2024-09-01 20:11:48 +02:00
Fredrik Burmester
10e0a45cd4 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-09-01 17:37:33 +02:00
Fredrik Burmester
fb0b9c83ae fix: meta data (including image) when casting 2024-09-01 17:36:27 +02:00
Fredrik Burmester
58b72b8b75 fix: open expanded controls in header if casting 2024-09-01 17:36:15 +02:00
Fredrik Burmester
b771c90dfc Merge branch 'master' of https://github.com/jakequade/streamyfin into pr/106 2024-09-01 17:13:33 +02:00
Fredrik Burmester
7fa729f89f Merge branch 'master' into pr/106 2024-09-01 17:11:52 +02:00
Fredrik Burmester
682ab4dd31 Merge pull request #114 from lostb1t/feature/collectiondefault
feat: Add Default option and use collection sorting as default
2024-09-01 17:10:48 +02:00
Fredrik Burmester
3d73f604ac wip 2024-09-01 17:10:33 +02:00
jakequade
318940f7c4 remove additional play call 2024-09-01 18:21:40 +10:00
jakequade
2ee6573a90 iOS support 2024-09-01 16:26:53 +10:00
jakequade
3bd1177c45 chromecast controls 2024-09-01 16:26:51 +10:00
jakequade
080de162ec extended cast controls on android 2024-09-01 16:26:27 +10:00
Fredrik Burmester
cca28d7e21 fix: change to enums and only store key in filter state 2024-08-30 12:55:28 +02:00
Fredrik Burmester
e29b3787b9 chore 2024-08-30 12:54:53 +02:00
Fredrik Burmester
ef8bb3e717 chore 2024-08-30 12:54:38 +02:00
Fredrik Burmester
61cb205f93 fix: refactor to use enums 2024-08-30 12:54:31 +02:00
Fredrik Burmester
ffea51ccb0 chore: version 2024-08-30 10:07:39 +02:00
Fredrik Burmester
0a53cf6b17 fix: animated progress 2024-08-30 10:07:35 +02:00
sarendsen
32ac4ec62f fix: use PremiereDate as default if missing from collection 2024-08-30 10:04:02 +02:00
sarendsen
30678813b4 feat: Add Default option and use collection sorting as default 2024-08-30 09:58:50 +02:00
Fredrik Burmester
68cfe99421 fix: #95 2024-08-30 00:28:07 +02:00
Fredrik Burmester
55b1c3ae45 Reapply "fix: #104 #103 #102"
This reverts commit 6c1db4bbb9.

fix #104 fix #102 fix #103
2024-08-30 00:14:33 +02:00
Fredrik Burmester
6c1db4bbb9 Revert "fix: #104 #103 #102"
This reverts commit bbaab1994a.
2024-08-30 00:13:45 +02:00
Fredrik Burmester
bbaab1994a fix: #104 #103 #102 2024-08-30 00:13:15 +02:00
Fredrik Burmester
8c0e7f7db8 fix: item page for item not associated with movie/tv-show not loading 2024-08-29 23:03:51 +02:00
Fredrik Burmester
8b3b492f5e fix: small design fixes 2024-08-29 13:10:54 +02:00
Fredrik Burmester
78189c8246 fix: download url not correct for direct streams 2024-08-29 12:58:51 +02:00
Fredrik Burmester
b22ffee707 Merge branch 'master' into pr/106 2024-08-25 12:13:35 +02:00
jakequade
688c343a35 iOS support 2024-08-25 00:08:13 +10:00
jakequade
fb6e3dc690 chromecast controls 2024-08-24 15:14:14 +10:00
jakequade
e9783d293d extended cast controls on android 2024-08-24 14:37:49 +10:00
28 changed files with 789 additions and 346 deletions

1
.gitignore vendored
View File

@@ -21,6 +21,7 @@ build-*
*.mp4
build-*
Streamyfin.app
package-lock.json
/ios
/android

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.10.1",
"version": "0.10.3",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -33,7 +33,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 30,
"versionCode": 32,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
},
@@ -71,6 +71,13 @@
}
}
],
[
"./plugins/withAndroidMainActivityAttributes",
{
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
}
],
["./plugins/withExpandedController.js"],
[
"expo-build-properties",
{

View File

@@ -65,15 +65,14 @@ const downloads: React.FC = () => {
}
return (
<ScrollView>
<View
className="px-4 py-4"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="px-4 py-4">
<View className="mb-4 flex flex-col space-y-4">
<View>
<Text className="text-2xl font-bold mb-2">Queue</Text>

View File

@@ -27,15 +27,14 @@ export default function settings() {
const insets = useSafeAreaInsets();
return (
<ScrollView>
<View
className="p-4 flex flex-col gap-y-4 pb-12"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="p-4 flex flex-col gap-y-4">
<Text className="font-bold text-2xl">Information</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">

View File

@@ -1,15 +1,19 @@
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
sortByAtom,
SortByOption,
sortOptions,
sortOrderAtom,
SortOrderOption,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
@@ -17,6 +21,7 @@ import {
import {
BaseItemDto,
BaseItemDtoQueryResult,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
@@ -56,21 +61,6 @@ const page: React.FC = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
useLayoutEffect(() => {
setSortBy([
{
key: "PremiereDate",
value: "Premiere Date",
},
]);
setSortOrder([
{
key: "Ascending",
value: "Ascending",
},
]);
}, []);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
@@ -88,6 +78,18 @@ const page: React.FC = () => {
useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" });
setSortOrder([SortOrderOption.Ascending]);
if (!collection) return;
// Convert the DisplayOrder to SortByOption
const displayOrder = collection.DisplayOrder as ItemSortBy;
const sortByOption = displayOrder
? SortByOption[displayOrder as keyof typeof SortByOption] ||
SortByOption.PremiereDate
: SortByOption.PremiereDate;
setSortBy([sortByOption]);
}, [navigation, collection]);
const fetchItems = useCallback(
@@ -103,8 +105,9 @@ const page: React.FC = () => {
parentId: collectionId,
limit: 18,
startIndex: pageParam,
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
sortOrder: [sortOrder[0].key],
// Set one ordering at a time. As collections do not work with correctly with multiple.
sortBy: [sortBy[0]],
sortOrder: [sortOrder[0]],
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
@@ -194,7 +197,8 @@ const page: React.FC = () => {
width: "89%",
}}
>
<MoviePoster item={item} />
<ItemPoster item={item} />
{/* <MoviePoster item={item} /> */}
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
@@ -216,6 +220,13 @@ const page: React.FC = () => {
paddingVertical: 16,
flexDirection: "row",
}}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
data={[
{
key: "reset",
@@ -307,13 +318,15 @@ const page: React.FC = () => {
className="mr-1"
collectionId={collectionId}
queryKey="sortBy"
queryFn={async () => sortOptions}
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title="Sort By"
renderItemLabel={(item) => item.value}
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
@@ -325,13 +338,15 @@ const page: React.FC = () => {
className="mr-1"
collectionId={collectionId}
queryKey="sortOrder"
queryFn={async () => sortOrderOptions}
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
title="Sort Order"
renderItemLabel={(item) => item.value}
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
@@ -369,6 +384,13 @@ const page: React.FC = () => {
<Text className="font-bold text-xl text-neutral-500">No results</Text>
</View>
}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}

View File

@@ -9,25 +9,23 @@ import React, {
useMemo,
useState,
} from "react";
import {
FlatList,
RefreshControl,
useWindowDimensions,
View,
} from "react-native";
import { FlatList, useWindowDimensions, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
sortByAtom,
SortByOption,
sortOptions,
sortOrderAtom,
SortOrderOption,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
@@ -35,7 +33,6 @@ import {
import {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
@@ -43,8 +40,9 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { Loader } from "@/components/Loader";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { orientationAtom } from "@/utils/atoms/orientation";
import { ItemPoster } from "@/components/posters/ItemPoster";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
@@ -54,7 +52,6 @@ const Page = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const { width: screenWidth } = useWindowDimensions();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
@@ -63,9 +60,7 @@ const Page = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
const [orientation, setOrientation] = useAtom(orientationAtom);
const getNumberOfColumns = useCallback(() => {
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
@@ -73,37 +68,11 @@ const Page = () => {
if (screenWidth < 960) return 6;
if (screenWidth < 1280) return 7;
return 6;
}, [screenWidth]);
}, [screenWidth, orientation]);
useLayoutEffect(() => {
setSortBy([
{
key: "SortName",
value: "Name",
},
]);
setSortOrder([
{
key: "Ascending",
value: "Ascending",
},
]);
}, []);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
setSortBy([SortByOption.SortName]);
setSortOrder([SortOrderOption.Ascending]);
}, []);
const { data: library, isLoading: isLibraryLoading } = useQuery({
@@ -133,8 +102,8 @@ const Page = () => {
parentId: libraryId,
limit: 36,
startIndex: pageParam,
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
sortOrder: [sortOrder[0].key],
sortBy: [sortBy[0], "SortName", "ProductionYear"],
sortOrder: [sortOrder[0]],
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
recursive: false,
imageTypeLimit: 1,
@@ -225,7 +194,8 @@ const Page = () => {
width: "89%",
}}
>
<MoviePoster item={item} />
{/* <MoviePoster item={item} /> */}
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
@@ -338,13 +308,15 @@ const Page = () => {
className="mr-1"
collectionId={libraryId}
queryKey="sortBy"
queryFn={async () => sortOptions}
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title="Sort By"
renderItemLabel={(item) => item.value}
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
@@ -356,13 +328,15 @@ const Page = () => {
className="mr-1"
collectionId={libraryId}
queryKey="sortOrder"
queryFn={async () => sortOrderOptions}
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
title="Sort Order"
renderItemLabel={(item) => item.value}
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
@@ -417,6 +391,7 @@ const Page = () => {
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}
extraData={orientation}
keyExtractor={keyExtractor}
estimatedItemSize={244}
numColumns={getNumberOfColumns()}

View File

@@ -13,11 +13,12 @@ import { Stack, useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { Provider as JotaiProvider } from "jotai";
import { Provider as JotaiProvider, useAtom } from "jotai";
import { useEffect, useRef } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import * as Linking from "expo-linking";
import { orientationAtom } from "@/utils/atoms/orientation";
SplashScreen.preventAutoHideAsync();
@@ -45,6 +46,7 @@ export default function RootLayout() {
function Layout() {
const [settings, updateSettings] = useSettings();
const [orientation, setOrientation] = useAtom(orientationAtom);
useKeepAwake();
@@ -71,8 +73,24 @@ function Layout() {
);
}, [settings]);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
console.log(event.orientationInfo.orientation);
setOrientation(event.orientationInfo.orientation);
}
);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
const url = Linking.useURL();
const router = useRouter();
if (url) {
const { hostname, path, queryParams } = Linking.parse(url);

View File

@@ -1,10 +1,12 @@
import { Feather } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useEffect } from "react";
import { View, ViewProps } from "react-native";
import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
useCastDevice,
useDevices,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
@@ -25,6 +27,7 @@ export const Chromecast: React.FC<Props> = ({
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus();
useEffect(() => {
(async () => {
@@ -38,21 +41,47 @@ export const Chromecast: React.FC<Props> = ({
if (background === "transparent")
return (
<View
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center b"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
);
if (Platform.OS === "android")
return (
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
{...props}
>
<CastButton style={{ tintColor: "white", height, width }} />
</View>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
);
return (
<BlurView
intensity={100}
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}
>
<CastButton style={{ tintColor: "white", height, width }} />
</BlurView>
<BlurView
intensity={100}
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</BlurView>
</TouchableOpacity>
);
};

View File

@@ -22,7 +22,7 @@ import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
@@ -54,11 +54,14 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
value: undefined,
});
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
/**
* Bottom sheet
*/
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["50%"], []);
const handlePresentModalPress = useCallback(() => {
bottomSheetModalRef.current?.present();
@@ -145,15 +148,13 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
item.Id
}/universal?${searchParams.toString()}`;
}
}
if (mediaSource.TranscodingUrl) {
} else if (mediaSource.TranscodingUrl) {
console.log("Using transcoded stream!");
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
} else {
throw new Error("No transcoding url");
}
if (!url) throw new Error("No url");
return await startRemuxing(url);
}, [
api,
@@ -288,14 +289,21 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
<Button
className="mt-auto"
onPress={() => {
closeModal();
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
await initiateDownload();
},
item,
});
if (userCanDownload === true) {
closeModal();
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
await initiateDownload();
},
item,
});
} else {
Alert.alert(
"Disabled",
"This user is not allowed to download files."
);
}
}}
color="purple"
>

View File

@@ -11,8 +11,10 @@ import { ItemImage } from "@/components/common/ItemImage";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getItemImage } from "@/utils/getItemImage";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
@@ -25,23 +27,22 @@ import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
runOnJS,
} from "react-native-reanimated";
import { Loader } from "./Loader";
import { set } from "lodash";
import * as ScreenOrientation from "expo-screen-orientation";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MediaSourceSelector } from "./MediaSourceSelector";
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
const [api] = useAtom(apiAtom);
@@ -61,7 +62,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
value: undefined,
});
const [loadingImage, setLoadingImage] = useState(true);
const [loadingLogo, setLoadingLogo] = useState(true);
const [orientation, setOrientation] = useState(
@@ -102,7 +102,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
});
};
const headerHeightRef = useRef(0);
const headerHeightRef = useRef(400);
const {
data: item,
@@ -166,6 +166,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
}
if (item?.Type === "Episode") headerHeightRef.current = 400;
else if (item?.Type === "Movie") headerHeightRef.current = 500;
else headerHeightRef.current = 400;
}, [item]);
const { data: sessionData } = useQuery({
@@ -232,12 +233,22 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
});
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
const themeImageColorSource = useMemo(() => {
if (!api || !item) return;
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
}, [api, item]);
useImageColors(themeImageColorSource?.uri);
const loading = useMemo(() => {
return Boolean(
isLoading || isFetching || loadingImage || (logoUrl && loadingLogo)
);
}, [isLoading, isFetching, loadingImage, loadingLogo, logoUrl]);
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
}, [isLoading, isFetching, loadingLogo, logoUrl]);
const insets = useSafeAreaInsets();
@@ -262,6 +273,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
<Animated.View style={[animatedStyle, { flex: 1 }]}>
{localItem && (
<ItemImage
useThemeColor
variant={
localItem.Type === "Movie" && logoUrl
? "Backdrop"
@@ -272,8 +284,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
width: "100%",
height: "100%",
}}
onLoad={() => setLoadingImage(false)}
onError={() => setLoadingImage(false)}
/>
)}
</Animated.View>

View File

@@ -36,6 +36,14 @@ export const MediaSourceSelector: React.FC<Props> = ({
if (mediaSources?.length) onChange(mediaSources[0]);
}, [mediaSources]);
const name = (name?: string | null) => {
if (name && name.length > 40)
return (
name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
);
return name;
};
return (
<View
className="flex shrink"
@@ -69,7 +77,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
onChange(source);
}}
>
<DropdownMenu.ItemTitle>{source.Name}</DropdownMenu.ItemTitle>
<DropdownMenu.ItemTitle>
{name(source.Name)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>

View File

@@ -1,115 +1,156 @@
import { usePlayback } from "@/providers/PlaybackProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import CastContext, {
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { useAtom } from "jotai";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
Easing,
interpolate,
interpolateColor,
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
interface Props extends React.ComponentProps<typeof Button> {
item?: BaseItemDto | null;
url?: string | null;
}
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const { setCurrentlyPlayingState } = usePlayback();
const mediaStatus = useMediaStatus();
const [color] = useAtom(itemThemeColorAtom);
const [colorAtom] = useAtom(itemThemeColorAtom);
const [api] = useAtom(apiAtom);
// Create a shared value for animation progress
const progress = useSharedValue(0);
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
// Create shared values for start and end colors
const startColor = useSharedValue(color);
const endColor = useSharedValue(color);
useEffect(() => {
// When color changes, update end color and animate progress
endColor.value = color;
progress.value = 0; // Reset progress
progress.value = withTiming(1, { duration: 300 }); // Animate to 1 over 500ms
}, [color]);
// Animated style for primary color
const animatedPrimaryStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.value,
[0, 1],
[startColor.value.average, endColor.value.average]
),
}));
// Animated style for text color
const animatedTextStyle = useAnimatedStyle(() => ({
color: interpolateColor(
progress.value,
[0, 1],
[startColor.value.text, endColor.value.text]
),
}));
// Update start color after animation completes
useEffect(() => {
const timeout = setTimeout(() => {
startColor.value = color;
}, 500); // Should match the duration in withTiming
return () => clearTimeout(timeout);
}, [color]);
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
const endColor = useSharedValue(memoizedColor);
const startColor = useSharedValue(memoizedColor);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const onPress = async () => {
if (!url || !item) return;
if (!client) {
setCurrentlyPlayingState({ item, url });
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((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: url,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
// If we're opening a currently playing item, don't restart the media.
// Instead just open controls.
if (isOpeningCurrentlyPlayingMedia) {
CastContext.showExpandedControls();
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: url,
contentType: "video/mp4",
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: 0,
});
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
}
});
break;
@@ -123,38 +164,123 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
);
};
const playbackPercent = useMemo(() => {
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (!userData) return 0;
const PlaybackPositionTicks = userData.PlaybackPositionTicks;
if (!PlaybackPositionTicks) return 0;
return (PlaybackPositionTicks / item.RunTimeTicks) * 100;
}, [item]);
const derivedTargetWidth = useDerivedValue(() => {
if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
const userData = memoizedItem.UserData;
if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH
)
: 0;
}
return 0;
}, [memoizedItem]);
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(
() => memoizedColor,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
colorChangeProgress.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[memoizedColor]
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = memoizedColor;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [memoizedColor, memoizedItem]);
/**
* ANIMATED STYLES
*/
const animatedAverageStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.average, endColor.value.average]
),
}));
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 onPress={onPress} className="relative" {...props}>
<TouchableOpacity
accessibilityLabel="Play button"
accessibilityHint="Tap to play the media"
onPress={onPress}
className="relative"
{...props}
>
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<Animated.View
style={[
animatedPrimaryStyle,
{
width:
playbackPercent === 0
? "100%"
: `${Math.max(playbackPercent, 15)}%`,
height: "100%",
},
]}
className="absolute w-full h-full top-0 left-0 rounded-xl z-10"
/>
<Animated.View
style={[animatedPrimaryStyle]}
className="absolute w-full h-full top-0 left-0 rounded-xl "
style={[animatedAverageStyle]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
/>
<View
style={{
borderWidth: 1,
borderColor: color.primary,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "

View File

@@ -1,95 +1,83 @@
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { getItemImage } from "@/utils/getItemImage";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image, ImageProps, ImageSource } from "expo-image";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { getColors } from "react-native-image-colors";
import { useMemo } from "react";
import { View } from "react-native";
interface Props extends ImageProps {
item: BaseItemDto;
variant?: "Backdrop" | "Primary" | "Thumb" | "Logo";
variant?:
| "Primary"
| "Backdrop"
| "ParentBackdrop"
| "ParentLogo"
| "Logo"
| "AlbumPrimary"
| "SeriesPrimary"
| "Screenshot"
| "Thumb";
quality?: number;
width?: number;
useThemeColor?: boolean;
onError?: () => void;
}
export const ItemImage: React.FC<Props> = ({
item,
variant,
variant = "Primary",
quality = 90,
width = 1000,
useThemeColor = false,
onError,
...props
}) => {
const [api] = useAtom(apiAtom);
const source = useMemo(() => {
if (!api) return null;
let tag: string | null | undefined;
let blurhash: string | null | undefined;
let src: ImageSource | null = null;
switch (variant) {
case "Backdrop":
if (item.Type === "Episode") {
tag = item.ParentBackdropImageTags?.[0];
if (!tag) break;
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
src = {
uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}`,
blurhash,
};
break;
}
tag = item.BackdropImageTags?.[0];
if (!tag) break;
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}`,
blurhash,
};
break;
case "Primary":
tag = item.ImageTags?.["Primary"];
if (!tag) break;
blurhash = item.ImageBlurHashes?.Primary?.[tag];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`,
blurhash,
};
break;
case "Thumb":
tag = item.ImageTags?.["Thumb"];
if (!tag) break;
blurhash = item.ImageBlurHashes?.Thumb?.[tag];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}`,
blurhash,
};
break;
default:
tag = item.ImageTags?.["Primary"];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`,
};
break;
if (!api) {
onError && onError();
return;
}
return getItemImage({
item,
api,
variant,
quality,
width,
});
}, [api, item, quality, variant, width]);
return src;
}, [item.ImageTags]);
useImageColors(source?.uri);
// return placeholder icon if no source
if (!source?.uri)
return (
<View
{...props}
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
>
<Ionicons
name="image-outline"
size={24}
color="white"
style={{ opacity: 0.4 }}
/>
</View>
);
return (
<Image
cachePolicy={"memory-disk"}
transition={300}
placeholder={{
blurhash: source?.blurhash,
}}
style={{
width: "100%",
height: "100%",
}}
source={{
uri: source?.uri,
}}

View File

@@ -55,7 +55,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
}
if (item.Type === "UserView") {
Alert.alert("Not implemented");
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
return;
}

View File

@@ -25,7 +25,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
return (
<View>
<View className="flex flex-row items-center justify-between">
<Text className="text-2xl font-bold">{items[0].SeriesName}</Text>
<Text className="text-2xl font-bold shrink">{items[0].SeriesName}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{items.length}</Text>
</View>

View File

@@ -23,7 +23,7 @@ export const FilterButton = <T,>({
queryFn,
queryKey,
set,
values,
values, // selected values
title,
renderItemLabel,
searchFilter,

View File

@@ -186,7 +186,7 @@ export const FilterSheet = <T,>({
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
>
<Text>{renderItemLabel(item)}</Text>
{values.includes(item) ? (
{values.some((i) => i === item) ? (
<Ionicons name="radio-button-on" size={24} color="white" />
) : (
<Ionicons name="radio-button-off" size={24} color="white" />

View File

@@ -0,0 +1,53 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { ItemImage } from "../common/ItemImage";
import { WatchedIndicator } from "../WatchedIndicator";
import { useState } from "react";
interface Props extends ViewProps {
item: BaseItemDto;
showProgress?: boolean;
}
export const ItemPoster: React.FC<Props> = ({
item,
showProgress,
...props
}) => {
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
);
if (item.Type === "Movie" || item.Type === "Series" || item.Type === "BoxSet")
return (
<View
className="relative rounded-lg overflow-hidden border border-neutral-900"
{...props}
>
<ItemImage
style={{
aspectRatio: "10/15",
width: "100%",
}}
item={item}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className="h-1 bg-red-600 w-full"></View>
)}
</View>
);
return (
<View
className="rounded-lg w-full aspect-square overflow-hidden border border-neutral-900"
{...props}
>
<ItemImage className="w-full aspect-square" item={item} />
</View>
);
};

View File

@@ -21,13 +21,13 @@
}
},
"production": {
"channel": "0.10.1",
"channel": "0.10.3",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.10.1",
"channel": "0.10.3",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -1,12 +1,16 @@
import { useState, useEffect } from "react";
import { getColors } from "react-native-image-colors";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useAtom } from "jotai";
import { useEffect } from "react";
import { getColors } from "react-native-image-colors";
export const useImageColors = (uri: string | undefined | null) => {
export const useImageColors = (
uri: string | undefined | null,
disabled = false
) => {
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
useEffect(() => {
if (disabled) return;
if (uri) {
getColors(uri, {
fallback: "#fff",
@@ -38,5 +42,5 @@ export const useImageColors = (uri: string | undefined | null) => {
console.error("Error getting colors", error);
});
}
}, [uri, setPrimaryColor]);
}, [uri, setPrimaryColor, disabled]);
};

View File

@@ -0,0 +1,42 @@
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;
});
};

View File

@@ -0,0 +1,20 @@
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;

View File

@@ -1,6 +1,7 @@
import { useInterval } from "@/hooks/useInterval";
import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query";
import axios, { AxiosError } from "axios";
@@ -62,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.10.1" },
clientInfo: { name: "Streamyfin", version: "0.10.3" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
})
);
@@ -75,12 +76,28 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [isPolling, setIsPolling] = useState<boolean>(false);
const [secret, setSecret] = useState<string | null>(null);
useQuery({
queryKey: ["user", api],
queryFn: async () => {
if (!api) return null;
const response = await getUserApi(api).getCurrentUser();
if (response.data) setUser(response.data);
return user;
},
enabled: !!api,
refetchOnWindowFocus: true,
refetchInterval: 1000 * 60,
refetchIntervalInBackground: true,
refetchOnMount: true,
refetchOnReconnect: true,
});
const headers = useMemo(() => {
if (!deviceId) return {};
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.10.1"`,
}, DeviceId="${deviceId}", Version="0.10.3"`,
};
}, [deviceId]);

View File

@@ -224,7 +224,9 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
if (!deviceId || !api?.accessToken) return;
const url = `wss://${api?.basePath
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken

View File

@@ -1,50 +1,67 @@
import {
ItemFilter,
ItemSortBy,
NameGuidPair,
SortOrder,
} from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai";
import { atom } from "jotai";
export enum SortByOption {
Default = "Default",
SortName = "SortName",
CommunityRating = "CommunityRating",
CriticRating = "CriticRating",
DateCreated = "DateCreated",
DatePlayed = "DatePlayed",
PlayCount = "PlayCount",
ProductionYear = "ProductionYear",
Runtime = "Runtime",
OfficialRating = "OfficialRating",
PremiereDate = "PremiereDate",
StartDate = "StartDate",
IsUnplayed = "IsUnplayed",
IsPlayed = "IsPlayed",
AirTime = "AirTime",
Studio = "Studio",
IsFavoriteOrLiked = "IsFavoriteOrLiked",
Random = "Random",
}
export enum SortOrderOption {
Ascending = "Ascending",
Descending = "Descending",
}
export const sortOptions: {
key: ItemSortBy;
key: SortByOption;
value: string;
}[] = [
{ key: "SortName", value: "Name" },
{ key: "CommunityRating", value: "Community Rating" },
{ key: "CriticRating", value: "Critics Rating" },
{ key: "DateCreated", value: "Date Added" },
// Only works for shows (last episode added) keeping for future ref.
// { key: "DateLastContentAdded", value: "Content Added" },
{ key: "DatePlayed", value: "Date Played" },
{ key: "PlayCount", value: "Play Count" },
{ key: "ProductionYear", value: "Production Year" },
{ key: "Runtime", value: "Runtime" },
{ key: "OfficialRating", value: "Official Rating" },
{ key: "PremiereDate", value: "Premiere Date" },
{ key: "StartDate", value: "Start Date" },
{ key: "IsUnplayed", value: "Is Unplayed" },
{ key: "IsPlayed", value: "Is Played" },
// Broken in JF
// { key: "VideoBitRate", value: "Video Bit Rate" },
{ key: "AirTime", value: "Air Time" },
{ key: "Studio", value: "Studio" },
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },
{ key: "Random", value: "Random" },
{ key: SortByOption.Default, value: "Default" },
{ key: SortByOption.SortName, value: "Name" },
{ key: SortByOption.CommunityRating, value: "Community Rating" },
{ key: SortByOption.CriticRating, value: "Critics Rating" },
{ key: SortByOption.DateCreated, value: "Date Added" },
{ key: SortByOption.DatePlayed, value: "Date Played" },
{ key: SortByOption.PlayCount, value: "Play Count" },
{ key: SortByOption.ProductionYear, value: "Production Year" },
{ key: SortByOption.Runtime, value: "Runtime" },
{ key: SortByOption.OfficialRating, value: "Official Rating" },
{ key: SortByOption.PremiereDate, value: "Premiere Date" },
{ key: SortByOption.StartDate, value: "Start Date" },
{ key: SortByOption.IsUnplayed, value: "Is Unplayed" },
{ key: SortByOption.IsPlayed, value: "Is Played" },
{ key: SortByOption.AirTime, value: "Air Time" },
{ key: SortByOption.Studio, value: "Studio" },
{ key: SortByOption.IsFavoriteOrLiked, value: "Is Favorite Or Liked" },
{ key: SortByOption.Random, value: "Random" },
];
export const sortOrderOptions: {
key: SortOrder;
key: SortOrderOption;
value: string;
}[] = [
{ key: "Ascending", value: "Ascending" },
{ key: "Descending", value: "Descending" },
{ key: SortOrderOption.Ascending, value: "Ascending" },
{ key: SortOrderOption.Descending, value: "Descending" },
];
export const genreFilterAtom = atom<string[]>([]);
export const tagsFilterAtom = atom<string[]>([]);
export const yearFilterAtom = atom<string[]>([]);
export const sortByAtom = atom<[typeof sortOptions][number]>([sortOptions[0]]);
export const sortOrderAtom = atom<[typeof sortOrderOptions][number]>([
sortOrderOptions[0],
export const sortByAtom = atom<SortByOption[]>([SortByOption.Default]);
export const sortOrderAtom = atom<SortOrderOption[]>([
SortOrderOption.Ascending,
]);

View File

@@ -0,0 +1,7 @@
import * as ScreenOrientation from "expo-screen-orientation";
import { Orientation } from "expo-screen-orientation";
import { atom } from "jotai";
export const orientationAtom = atom<number>(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);

87
utils/getItemImage.ts Normal file
View File

@@ -0,0 +1,87 @@
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { ImageSource } from "expo-image";
interface Props {
item: BaseItemDto;
api: Api;
quality?: number;
width?: number;
variant?:
| "Primary"
| "Backdrop"
| "ParentBackdrop"
| "ParentLogo"
| "Logo"
| "AlbumPrimary"
| "SeriesPrimary"
| "Screenshot"
| "Thumb";
}
export const getItemImage = ({
item,
api,
variant = "Primary",
quality = 90,
width = 1000,
}: Props) => {
if (!api) return null;
let tag: string | null | undefined;
let blurhash: string | null | undefined;
let src: ImageSource | null = null;
switch (variant) {
case "Backdrop":
if (item.Type === "Episode") {
tag = item.ParentBackdropImageTags?.[0];
if (!tag) break;
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
src = {
uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}&width=${width}`,
blurhash,
};
break;
}
tag = item.BackdropImageTags?.[0];
if (!tag) break;
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}&width=${width}`,
blurhash,
};
break;
case "Primary":
tag = item.ImageTags?.["Primary"];
if (!tag) break;
blurhash = item.ImageBlurHashes?.Primary?.[tag];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`,
blurhash,
};
break;
case "Thumb":
tag = item.ImageTags?.["Thumb"];
if (!tag) break;
blurhash = item.ImageBlurHashes?.Thumb?.[tag];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}&width=${width}`,
blurhash,
};
break;
default:
tag = item.ImageTags?.["Primary"];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`,
};
break;
}
if (!src?.uri) return null;
return src;
};

View File

@@ -76,10 +76,12 @@ export const getStreamUrl = async ({
throw new Error("no PlaySessionId");
}
let url: string | null | undefined;
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
if (item.MediaType === "Video") {
console.log("Using direct stream for video!");
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({
@@ -97,16 +99,16 @@ export const getStreamUrl = async ({
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
return `${
url = `${
api.basePath
}/Audio/${itemId}/universal?${searchParams.toString()}`;
}
} else if (mediaSource.TranscodingUrl) {
console.log("Using transcoded stream!");
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
}
if (mediaSource.TranscodingUrl) {
console.log("Using transcoded stream!");
return `${api.basePath}${mediaSource.TranscodingUrl}`;
} else {
throw new Error("No transcoding url");
}
if (!url) throw new Error("No url");
return url;
};