mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-21 00:04:42 +01:00
Compare commits
30 Commits
v0.10.2
...
wip/genera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4252682be | ||
|
|
7b9bad630f | ||
|
|
10e0a45cd4 | ||
|
|
fb0b9c83ae | ||
|
|
58b72b8b75 | ||
|
|
b771c90dfc | ||
|
|
7fa729f89f | ||
|
|
682ab4dd31 | ||
|
|
3d73f604ac | ||
|
|
318940f7c4 | ||
|
|
2ee6573a90 | ||
|
|
3bd1177c45 | ||
|
|
080de162ec | ||
|
|
cca28d7e21 | ||
|
|
e29b3787b9 | ||
|
|
ef8bb3e717 | ||
|
|
61cb205f93 | ||
|
|
ffea51ccb0 | ||
|
|
0a53cf6b17 | ||
|
|
32ac4ec62f | ||
|
|
30678813b4 | ||
|
|
68cfe99421 | ||
|
|
55b1c3ae45 | ||
|
|
6c1db4bbb9 | ||
|
|
bbaab1994a | ||
|
|
8c0e7f7db8 | ||
|
|
b22ffee707 | ||
|
|
688c343a35 | ||
|
|
fb6e3dc690 | ||
|
|
e9783d293d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,6 +21,7 @@ build-*
|
|||||||
*.mp4
|
*.mp4
|
||||||
build-*
|
build-*
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
|||||||
11
app.json
11
app.json
@@ -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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const FilterButton = <T,>({
|
|||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
set,
|
set,
|
||||||
values,
|
values, // selected values
|
||||||
title,
|
title,
|
||||||
renderItemLabel,
|
renderItemLabel,
|
||||||
searchFilter,
|
searchFilter,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
53
components/posters/ItemPoster.tsx
Normal file
53
components/posters/ItemPoster.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
eas.json
4
eas.json
@@ -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"
|
||||||
|
|||||||
@@ -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]);
|
||||||
};
|
};
|
||||||
|
|||||||
42
plugins/withAndroidMainActivityAttributes.js
Normal file
42
plugins/withAndroidMainActivityAttributes.js
Normal 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;
|
||||||
|
});
|
||||||
|
};
|
||||||
20
plugins/withExpandedController.js
Normal file
20
plugins/withExpandedController.js
Normal 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;
|
||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
7
utils/atoms/orientation.ts
Normal file
7
utils/atoms/orientation.ts
Normal 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
87
utils/getItemImage.ts
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user