Compare commits

...

30 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
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
23 changed files with 740 additions and 313 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -13,11 +13,12 @@ import { Stack, useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar"; 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 { useEffect, useRef } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated"; import "react-native-reanimated";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import { orientationAtom } from "@/utils/atoms/orientation";
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
@@ -45,6 +46,7 @@ export default function RootLayout() {
function Layout() { function Layout() {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings] = useSettings();
const [orientation, setOrientation] = useAtom(orientationAtom);
useKeepAwake(); useKeepAwake();
@@ -71,8 +73,24 @@ function Layout() {
); );
}, [settings]); }, [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 url = Linking.useURL();
const router = useRouter();
if (url) { if (url) {
const { hostname, path, queryParams } = Linking.parse(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 { BlurView } from "expo-blur";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { Platform, View, ViewProps } from "react-native"; import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, { import GoogleCast, {
CastButton, CastContext,
useCastDevice, useCastDevice,
useDevices, useDevices,
useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
@@ -25,6 +27,7 @@ export const Chromecast: React.FC<Props> = ({
const devices = useDevices(); const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager(); const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager(); const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus();
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -38,31 +41,47 @@ export const Chromecast: React.FC<Props> = ({
if (background === "transparent") if (background === "transparent")
return ( 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" className="rounded-full h-10 w-10 flex items-center justify-center b"
{...props} {...props}
> >
<CastButton style={{ tintColor: "white", height, width }} /> <Feather name="cast" size={22} color={"white"} />
</View> </TouchableOpacity>
); );
if (Platform.OS === "android") if (Platform.OS === "android")
return ( return (
<View <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" className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
{...props} {...props}
> >
<CastButton style={{ tintColor: "white", height, width }} /> <Feather name="cast" size={22} color={"white"} />
</View> </TouchableOpacity>
); );
return ( return (
<BlurView <TouchableOpacity
intensity={100} onPress={() => {
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center" if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props} {...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 { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react"; 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 { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector"; import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button"; import { Button } from "./Button";
@@ -54,11 +54,14 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
value: undefined, value: undefined,
}); });
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
/** /**
* Bottom sheet * Bottom sheet
*/ */
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["50%"], []);
const handlePresentModalPress = useCallback(() => { const handlePresentModalPress = useCallback(() => {
bottomSheetModalRef.current?.present(); bottomSheetModalRef.current?.present();
@@ -286,14 +289,21 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
<Button <Button
className="mt-auto" className="mt-auto"
onPress={() => { onPress={() => {
closeModal(); if (userCanDownload === true) {
queueActions.enqueue(queue, setQueue, { closeModal();
id: item.Id!, queueActions.enqueue(queue, setQueue, {
execute: async () => { id: item.Id!,
await initiateDownload(); execute: async () => {
}, await initiateDownload();
item, },
}); item,
});
} else {
Alert.alert(
"Disabled",
"This user is not allowed to download files."
);
}
}} }}
color="purple" color="purple"
> >

View File

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

View File

@@ -1,115 +1,156 @@
import { usePlayback } from "@/providers/PlaybackProvider"; import { usePlayback } from "@/providers/PlaybackProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; 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 { TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
PlayServicesState, PlayServicesState,
useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } 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, { import Animated, {
useSharedValue, Easing,
useAnimatedStyle, interpolate,
withTiming,
interpolateColor, interpolateColor,
runOnJS,
useAnimatedReaction, useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated"; } 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> { interface Props extends React.ComponentProps<typeof Button> {
item?: BaseItemDto | null; item?: BaseItemDto | null;
url?: string | null; url?: string | null;
} }
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => { export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const { setCurrentlyPlayingState } = usePlayback(); 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 memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
const progress = useSharedValue(0); const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
// Create shared values for start and end colors const startWidth = useSharedValue(0);
const startColor = useSharedValue(color); const targetWidth = useSharedValue(0);
const endColor = useSharedValue(color); const endColor = useSharedValue(memoizedColor);
const startColor = useSharedValue(memoizedColor);
useEffect(() => { const widthProgress = useSharedValue(0);
// When color changes, update end color and animate progress const colorChangeProgress = useSharedValue(0);
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 onPress = async () => { const onPress = async () => {
if (!url || !item) return; if (!url || !item) return;
if (!client) { if (!client) {
setCurrentlyPlayingState({ item, url }); setCurrentlyPlayingState({ item, url });
return; return;
} }
const options = ["Chromecast", "Device", "Cancel"]; const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2; const cancelButtonIndex = 2;
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
options, options,
cancelButtonIndex, cancelButtonIndex,
}, },
async (selectedIndex: number | undefined) => { async (selectedIndex: number | undefined) => {
if (!api) return;
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
const isOpeningCurrentlyPlayingMedia =
currentTitle && currentTitle === item?.Name;
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
await CastContext.getPlayServicesState().then((state) => { await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS) if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state); CastContext.showPlayServicesErrorDialog(state);
else { else {
client.loadMedia({ // If we're opening a currently playing item, don't restart the media.
mediaInfo: { // Instead just open controls.
contentUrl: url, if (isOpeningCurrentlyPlayingMedia) {
contentType: "video/mp4", CastContext.showExpandedControls();
metadata: { return;
type: item.Type === "Episode" ? "tvShow" : "movie", }
title: item.Name || "", client
subtitle: item.Overview || "", .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; break;
@@ -123,38 +164,123 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
); );
}; };
const playbackPercent = useMemo(() => { const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0; if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
const userData = item.UserData; const userData = memoizedItem.UserData;
if (!userData) return 0; if (userData && userData.PlaybackPositionTicks) {
const PlaybackPositionTicks = userData.PlaybackPositionTicks; return userData.PlaybackPositionTicks > 0
if (!PlaybackPositionTicks) return 0; ? Math.max(
return (PlaybackPositionTicks / item.RunTimeTicks) * 100; (userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
}, [item]); 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 ( 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 <Animated.View
style={[ style={[animatedAverageStyle]}
animatedPrimaryStyle, className="absolute w-full h-full top-0 left-0 rounded-xl"
{
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 "
/> />
<View <View
style={{ style={{
borderWidth: 1, borderWidth: 1,
borderColor: color.primary, borderColor: colorAtom.primary,
borderStyle: "solid", borderStyle: "solid",
}} }}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full " className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "

View File

@@ -1,93 +1,83 @@
import { useImageColors } from "@/hooks/useImageColors"; import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image, ImageProps, ImageSource } from "expo-image"; import { Image, ImageProps, ImageSource } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { View } from "react-native";
interface Props extends ImageProps { interface Props extends ImageProps {
item: BaseItemDto; item: BaseItemDto;
variant?: "Backdrop" | "Primary" | "Thumb" | "Logo"; variant?:
| "Primary"
| "Backdrop"
| "ParentBackdrop"
| "ParentLogo"
| "Logo"
| "AlbumPrimary"
| "SeriesPrimary"
| "Screenshot"
| "Thumb";
quality?: number; quality?: number;
width?: number; width?: number;
useThemeColor?: boolean;
onError?: () => void;
} }
export const ItemImage: React.FC<Props> = ({ export const ItemImage: React.FC<Props> = ({
item, item,
variant, variant = "Primary",
quality = 90, quality = 90,
width = 1000, width = 1000,
useThemeColor = false,
onError,
...props ...props
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const source = useMemo(() => { const source = useMemo(() => {
if (!api) return null; if (!api) {
onError && onError();
let tag: string | null | undefined; return;
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;
} }
return getItemImage({
item,
api,
variant,
quality,
width,
});
}, [api, item, quality, variant, width]);
return src; // return placeholder icon if no source
}, [item.ImageTags]); if (!source?.uri)
return (
useImageColors(source?.uri); <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 ( return (
<Image <Image
cachePolicy={"memory-disk"}
transition={300} transition={300}
placeholder={{ placeholder={{
blurhash: source?.blurhash, blurhash: source?.blurhash,
}} }}
style={{
width: "100%",
height: "100%",
}}
source={{ source={{
uri: source?.uri, uri: source?.uri,
}} }}

View File

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

View File

@@ -23,7 +23,7 @@ export const FilterButton = <T,>({
queryFn, queryFn,
queryKey, queryKey,
set, set,
values, values, // selected values
title, title,
renderItemLabel, renderItemLabel,
searchFilter, 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" className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
> >
<Text>{renderItemLabel(item)}</Text> <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-on" size={24} color="white" />
) : ( ) : (
<Ionicons name="radio-button-off" 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": { "production": {
"channel": "0.10.2", "channel": "0.10.3",
"android": { "android": {
"image": "latest" "image": "latest"
} }
}, },
"production-apk": { "production-apk": {
"channel": "0.10.2", "channel": "0.10.3",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "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 { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useAtom } from "jotai"; 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); const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
useEffect(() => { useEffect(() => {
if (disabled) return;
if (uri) { if (uri) {
getColors(uri, { getColors(uri, {
fallback: "#fff", fallback: "#fff",
@@ -38,5 +42,5 @@ export const useImageColors = (uri: string | undefined | null) => {
console.error("Error getting colors", error); 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 { useInterval } from "@/hooks/useInterval";
import { Api, Jellyfin } from "@jellyfin/sdk"; import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; 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 AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
@@ -62,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin( setJellyfin(
() => () =>
new Jellyfin({ new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.10.2" }, clientInfo: { name: "Streamyfin", version: "0.10.3" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, 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 [isPolling, setIsPolling] = useState<boolean>(false);
const [secret, setSecret] = useState<string | null>(null); 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(() => { const headers = useMemo(() => {
if (!deviceId) return {}; if (!deviceId) return {};
return { return {
authorization: `MediaBrowser Client="Streamyfin", Device=${ authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS" Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.10.2"`, }, DeviceId="${deviceId}", Version="0.10.3"`,
}; };
}, [deviceId]); }, [deviceId]);

View File

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

View File

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