mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-22 00:34:43 +01:00
Compare commits
39 Commits
v0.10.0
...
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 | ||
|
|
8b3b492f5e | ||
|
|
78189c8246 | ||
|
|
dc02db6463 | ||
|
|
c168d79377 | ||
|
|
f756a663fe | ||
|
|
2baf57156e | ||
|
|
a97610a51d | ||
|
|
79b87b3d72 | ||
|
|
d52f025873 | ||
|
|
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.0",
|
"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": 29,
|
"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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { FFmpegKit } from "ffmpeg-kit-react-native";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const downloads: React.FC = () => {
|
const downloads: React.FC = () => {
|
||||||
const [process, setProcess] = useAtom(runningProcesses);
|
const [process, setProcess] = useAtom(runningProcesses);
|
||||||
@@ -53,6 +54,8 @@ const downloads: React.FC = () => {
|
|||||||
return formatNumber(timeLeft / 10000);
|
return formatNumber(timeLeft / 10000);
|
||||||
}, [process]);
|
}, [process]);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
||||||
@@ -62,7 +65,13 @@ const downloads: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView>
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<View className="px-4 py-4">
|
<View className="px-4 py-4">
|
||||||
<View className="mb-4 flex flex-col space-y-4">
|
<View className="mb-4 flex flex-col space-y-4">
|
||||||
<View>
|
<View>
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
import { RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
type BaseSection = {
|
type BaseSection = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -270,6 +271,8 @@ export default function index() {
|
|||||||
// );
|
// );
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (e1 || e2)
|
if (e1 || e2)
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||||
@@ -286,6 +289,7 @@ export default function index() {
|
|||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
@@ -294,7 +298,13 @@ export default function index() {
|
|||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-4 pb-24 gap-y-4">
|
<View
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
className="flex flex-col pt-4 pb-24 gap-y-4"
|
||||||
|
>
|
||||||
<LargeMovieCarousel />
|
<LargeMovieCarousel />
|
||||||
|
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
@@ -23,9 +24,17 @@ export default function settings() {
|
|||||||
refetchInterval: 1000,
|
refetchInterval: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView>
|
<ScrollView
|
||||||
<View className="p-4 flex flex-col gap-y-4 pb-12">
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
<Text className="font-bold text-2xl">Information</Text>
|
<Text className="font-bold text-2xl">Information</Text>
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
|
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 { SongsList } from "@/components/music/SongsList";
|
import { SongsList } from "@/components/music/SongsList";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
@@ -11,6 +13,7 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -88,30 +91,31 @@ export default function page() {
|
|||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (!album) return null;
|
if (!album) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView>
|
<ParallaxScrollView
|
||||||
<View className="px-4 pb-24">
|
headerHeight={400}
|
||||||
<View className="flex flex-row space-x-4 items-start mb-4">
|
headerImage={
|
||||||
<View className="w-24">
|
<ItemImage
|
||||||
<ArtistPoster item={album} />
|
variant={"Primary"}
|
||||||
</View>
|
item={album}
|
||||||
<View className="flex flex-col shrink">
|
style={{
|
||||||
<Text className="font-bold text-3xl">{album?.Name}</Text>
|
width: "100%",
|
||||||
<Text className="">{album?.ProductionYear}</Text>
|
height: "100%",
|
||||||
|
}}
|
||||||
<View className="flex flex-row space-x-2 mt-1">
|
/>
|
||||||
{album.AlbumArtists?.map((a) => (
|
}
|
||||||
<TouchableItemRouter key={a.Id} item={album}>
|
>
|
||||||
<Text className="font-bold text-purple-600">
|
<View className="px-4 mb-8">
|
||||||
{album?.AlbumArtist}
|
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
|
||||||
</Text>
|
<Text className="text-neutral-500">
|
||||||
</TouchableItemRouter>
|
{songs?.TotalRecordCount} songs
|
||||||
))}
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<View className="px-4">
|
||||||
</View>
|
|
||||||
<SongsList
|
<SongsList
|
||||||
albumId={albumId}
|
albumId={albumId}
|
||||||
songs={songs?.Items}
|
songs={songs?.Items}
|
||||||
@@ -119,6 +123,6 @@ export default function page() {
|
|||||||
artistId={artistId}
|
artistId={artistId}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ParallaxScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
|
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -82,50 +86,45 @@ export default function page() {
|
|||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const insets = useSafeAreaInsets();
|
||||||
navigation.setOptions({
|
|
||||||
title: albums?.Items?.[0]?.AlbumArtist || "",
|
|
||||||
});
|
|
||||||
}, [albums]);
|
|
||||||
|
|
||||||
if (!artist || !albums) return null;
|
if (!artist || !albums) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<ParallaxScrollView
|
||||||
contentContainerStyle={{
|
headerHeight={400}
|
||||||
padding: 16,
|
headerImage={
|
||||||
paddingBottom: 140,
|
<ItemImage
|
||||||
}}
|
variant={"Primary"}
|
||||||
ListHeaderComponent={
|
item={artist}
|
||||||
<View className="mb-2">
|
style={{
|
||||||
<View className="w-32 mb-4">
|
width: "100%",
|
||||||
<ArtistPoster item={artist} />
|
height: "100%",
|
||||||
</View>
|
|
||||||
<Text className="font-bold text-2xl mb-4">Albums</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
nestedScrollEnabled
|
|
||||||
data={albums.Items}
|
|
||||||
numColumns={3}
|
|
||||||
columnWrapperStyle={{
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{ width: "30%" }}
|
|
||||||
key={index}
|
|
||||||
onPress={() => {
|
|
||||||
router.push(`/albums/${item.Id}`);
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<View className="flex flex-col gap-y-2">
|
}
|
||||||
<ArtistPoster item={item} />
|
>
|
||||||
<Text>{item.Name}</Text>
|
<View className="px-4 mb-8">
|
||||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
|
||||||
</View>
|
<Text className="text-neutral-500">
|
||||||
</TouchableOpacity>
|
{albums.TotalRecordCount} albums
|
||||||
)}
|
</Text>
|
||||||
keyExtractor={(item) => item.Id || ""}
|
</View>
|
||||||
/>
|
<View className="flex flex-row flex-wrap justify-between px-4">
|
||||||
|
{albums.Items.map((item, idx) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
style={{ width: "30%", marginBottom: 20 }}
|
||||||
|
key={idx}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col gap-y-2">
|
||||||
|
<ArtistPoster item={item} />
|
||||||
|
<Text numberOfLines={2}>{item.Name}</Text>
|
||||||
|
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
|
||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
|
||||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
|
||||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import ios from "@/utils/profiles/ios";
|
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { ScrollView, View } from "react-native";
|
|
||||||
import CastContext, {
|
|
||||||
PlayServicesState,
|
|
||||||
useCastDevice,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
|
||||||
const local = useLocalSearchParams();
|
|
||||||
const { songId: id } = local as { songId: string };
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const { setCurrentlyPlayingState } = usePlayback();
|
|
||||||
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<View className="">
|
|
||||||
<Chromecast />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
|
||||||
useState<number>(0);
|
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
|
||||||
key: "Max",
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: item, isLoading: l1 } = useQuery({
|
|
||||||
queryKey: ["item", id],
|
|
||||||
queryFn: async () =>
|
|
||||||
await getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user?.Id,
|
|
||||||
itemId: id,
|
|
||||||
}),
|
|
||||||
enabled: !!id && !!api,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
|
||||||
() =>
|
|
||||||
getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 1000,
|
|
||||||
}),
|
|
||||||
[item]
|
|
||||||
);
|
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
|
||||||
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
|
||||||
[item]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: sessionData } = useQuery({
|
|
||||||
queryKey: ["sessionData", item?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id || !item?.Id) return null;
|
|
||||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
|
||||||
itemId: item?.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return playbackData.data;
|
|
||||||
},
|
|
||||||
enabled: !!item?.Id && !!api && !!user?.Id,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: playbackUrl } = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"playbackUrl",
|
|
||||||
item?.Id,
|
|
||||||
maxBitrate,
|
|
||||||
castDevice,
|
|
||||||
selectedAudioStream,
|
|
||||||
selectedSubtitleStream,
|
|
||||||
],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id || !sessionData) return null;
|
|
||||||
|
|
||||||
const url = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
userId: user.Id,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
|
||||||
sessionData,
|
|
||||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
|
||||||
audioStreamIndex: selectedAudioStream,
|
|
||||||
subtitleStreamIndex: selectedSubtitleStream,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Transcode URL: ", url);
|
|
||||||
|
|
||||||
return url;
|
|
||||||
},
|
|
||||||
enabled: !!sessionData,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
|
|
||||||
const onPressPlay = useCallback(
|
|
||||||
async (type: "device" | "cast" = "device") => {
|
|
||||||
if (!playbackUrl || !item) return;
|
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
|
||||||
else {
|
|
||||||
client.loadMedia({
|
|
||||||
mediaInfo: {
|
|
||||||
contentUrl: playbackUrl,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
metadata: {
|
|
||||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setCurrentlyPlayingState({
|
|
||||||
item,
|
|
||||||
url: playbackUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[playbackUrl, item]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (l1)
|
|
||||||
return (
|
|
||||||
<View className="justify-center items-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!item?.Id || !backdropUrl) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerImage={
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: backdropUrl,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
logo={
|
|
||||||
<>
|
|
||||||
{logoUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: logoUrl,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
height: 130,
|
|
||||||
width: "100%",
|
|
||||||
resizeMode: "contain",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col px-4 pt-4">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<MoviesTitleHeader item={item} />
|
|
||||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
|
||||||
{playbackUrl ? (
|
|
||||||
<DownloadItem item={item} />
|
|
||||||
) : (
|
|
||||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col p-4 w-full">
|
|
||||||
<View className="flex flex-row items-center space-x-2 w-full">
|
|
||||||
<BitrateSelector
|
|
||||||
onChange={(val) => setMaxBitrate(val)}
|
|
||||||
selected={maxBitrate}
|
|
||||||
/>
|
|
||||||
<AudioTrackSelector
|
|
||||||
item={item}
|
|
||||||
onChange={setSelectedAudioStream}
|
|
||||||
selected={selectedAudioStream}
|
|
||||||
/>
|
|
||||||
<SubtitleTrackSelector
|
|
||||||
item={item}
|
|
||||||
onChange={setSelectedSubtitleStream}
|
|
||||||
selected={selectedSubtitleStream}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row items-center justify-between w-full">
|
|
||||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
|
||||||
<PlayButton item={item} className="grow" />
|
|
||||||
<NextEpisodeButton item={item} className="ml-2" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal className="flex px-4 mb-4">
|
|
||||||
<View className="flex flex-row space-x-2 ">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="text-sm opacity-70">Audio</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="text-sm opacity-70">
|
|
||||||
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<SimilarItems itemId={item.Id} />
|
|
||||||
|
|
||||||
<View className="h-12"></View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default page;
|
|
||||||
@@ -9,20 +9,23 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { FlatList, RefreshControl, View } from "react-native";
|
import { FlatList, 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,
|
||||||
@@ -30,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,
|
||||||
@@ -38,7 +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 { orientationAtom } from "@/utils/atoms/orientation";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
|
||||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
|
|
||||||
@@ -48,7 +52,7 @@ 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 [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
@@ -56,39 +60,19 @@ 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(() => {
|
||||||
|
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
|
||||||
|
if (screenWidth < 600) return 5;
|
||||||
|
if (screenWidth < 960) return 6;
|
||||||
|
if (screenWidth < 1280) return 7;
|
||||||
|
return 6;
|
||||||
|
}, [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({
|
||||||
@@ -118,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,
|
||||||
@@ -193,23 +177,25 @@ const Page = () => {
|
|||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
marginBottom:
|
marginBottom: 4,
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
|
||||||
}}
|
}}
|
||||||
item={item}
|
item={item}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
alignSelf:
|
alignSelf:
|
||||||
index % 3 === 0
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
? "flex-end"
|
? index % 3 === 0
|
||||||
: (index + 1) % 3 === 0
|
? "flex-end"
|
||||||
? "flex-start"
|
: (index + 1) % 3 === 0
|
||||||
|
? "flex-start"
|
||||||
|
: "center"
|
||||||
: "center",
|
: "center",
|
||||||
width: "89%",
|
width: "89%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} />
|
{/* <MoviePoster item={item} /> */}
|
||||||
|
<ItemPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</MemoizedTouchableItemRouter>
|
</MemoizedTouchableItemRouter>
|
||||||
@@ -322,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())
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -340,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())
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -375,6 +365,8 @@ const Page = () => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (isLoading || isLibraryLoading)
|
if (isLoading || isLibraryLoading)
|
||||||
return (
|
return (
|
||||||
<View className="w-full h-full flex items-center justify-center">
|
<View className="w-full h-full flex items-center justify-center">
|
||||||
@@ -399,11 +391,10 @@ 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={
|
numColumns={getNumberOfColumns()}
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
|
||||||
}
|
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
@@ -411,7 +402,11 @@ const Page = () => {
|
|||||||
}}
|
}}
|
||||||
onEndReachedThreshold={1}
|
onEndReachedThreshold={1}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
ItemSeparatorComponent={() => (
|
ItemSeparatorComponent={() => (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useNavigation } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function index() {
|
export default function index() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -54,6 +55,8 @@ export default function index() {
|
|||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className="justify-center items-center h-full">
|
||||||
@@ -76,6 +79,8 @@ export default function index() {
|
|||||||
paddingTop: 17,
|
paddingTop: 17,
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||||
paddingBottom: 150,
|
paddingBottom: 150,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
const exampleSearches = [
|
const exampleSearches = [
|
||||||
@@ -41,6 +42,7 @@ const exampleSearches = [
|
|||||||
|
|
||||||
export default function search() {
|
export default function search() {
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||||
|
|
||||||
@@ -220,6 +222,10 @@ export default function search() {
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
keyboardDismissMode="on-drag"
|
keyboardDismissMode="on-drag"
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-4 pb-32">
|
<View className="flex flex-col pt-4 pb-32">
|
||||||
{Platform.OS === "android" && (
|
{Platform.OS === "android" && (
|
||||||
|
|||||||
@@ -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 { 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,21 +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"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Platform.OS === "android")
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
|
else CastContext.showCastDialog();
|
||||||
|
}}
|
||||||
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import Animated, {
|
|||||||
import Video from "react-native-video";
|
import Video from "react-native-video";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { debounce } from "lodash";
|
|
||||||
|
|
||||||
export const CurrentlyPlayingBar: React.FC = () => {
|
export const CurrentlyPlayingBar: React.FC = () => {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
@@ -25,7 +24,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
currentlyPlaying,
|
currentlyPlaying,
|
||||||
pauseVideo,
|
pauseVideo,
|
||||||
playVideo,
|
playVideo,
|
||||||
setCurrentlyPlayingState,
|
|
||||||
stopPlayback,
|
stopPlayback,
|
||||||
setVolume,
|
setVolume,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
@@ -36,7 +34,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
} = usePlayback();
|
} = usePlayback();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const aBottom = useSharedValue(0);
|
const aBottom = useSharedValue(0);
|
||||||
const aPadding = useSharedValue(0);
|
const aPadding = useSharedValue(0);
|
||||||
@@ -66,6 +63,8 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const from = useMemo(() => segments[2], [segments]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (segments.find((s) => s.includes("tabs"))) {
|
if (segments.find((s) => s.includes("tabs"))) {
|
||||||
// Tab screen - i.e. home
|
// Tab screen - i.e. home
|
||||||
@@ -94,19 +93,20 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
[currentlyPlaying?.item]
|
[currentlyPlaying?.item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
const poster = useMemo(() => {
|
||||||
() =>
|
if (currentlyPlaying?.item.Type === "Audio")
|
||||||
getBackdropUrl({
|
return `${api?.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`;
|
||||||
|
else
|
||||||
|
return getBackdropUrl({
|
||||||
api,
|
api,
|
||||||
item: currentlyPlaying?.item,
|
item: currentlyPlaying?.item,
|
||||||
quality: 70,
|
quality: 70,
|
||||||
width: 200,
|
width: 200,
|
||||||
}),
|
});
|
||||||
[currentlyPlaying?.item, api]
|
}, [currentlyPlaying?.item.Id, api]);
|
||||||
);
|
|
||||||
|
|
||||||
const videoSource = useMemo(() => {
|
const videoSource = useMemo(() => {
|
||||||
if (!api || !currentlyPlaying || !backdropUrl) return null;
|
if (!api || !currentlyPlaying || !poster) return null;
|
||||||
return {
|
return {
|
||||||
uri: currentlyPlaying.url,
|
uri: currentlyPlaying.url,
|
||||||
isNetwork: true,
|
isNetwork: true,
|
||||||
@@ -120,13 +120,13 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
description: currentlyPlaying.item?.Overview
|
description: currentlyPlaying.item?.Overview
|
||||||
? currentlyPlaying.item?.Overview
|
? currentlyPlaying.item?.Overview
|
||||||
: undefined,
|
: undefined,
|
||||||
imageUri: backdropUrl,
|
imageUri: poster,
|
||||||
subtitle: currentlyPlaying.item?.Album
|
subtitle: currentlyPlaying.item?.Album
|
||||||
? currentlyPlaying.item?.Album
|
? currentlyPlaying.item?.Album
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [currentlyPlaying, startPosition, api, backdropUrl]);
|
}, [currentlyPlaying, startPosition, api, poster]);
|
||||||
|
|
||||||
if (!api || !currentlyPlaying) return null;
|
if (!api || !currentlyPlaying) return null;
|
||||||
|
|
||||||
@@ -174,8 +174,8 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
controls={false}
|
controls={false}
|
||||||
pictureInPicture={true}
|
pictureInPicture={true}
|
||||||
poster={
|
poster={
|
||||||
backdropUrl && currentlyPlaying.item?.Type === "Audio"
|
poster && currentlyPlaying.item?.Type === "Audio"
|
||||||
? backdropUrl
|
? poster
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
debug={{
|
debug={{
|
||||||
@@ -228,10 +228,17 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
<View className="shrink text-xs">
|
<View className="shrink text-xs">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (currentlyPlaying.item?.Type === "Audio")
|
if (currentlyPlaying.item?.Type === "Audio") {
|
||||||
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
router.push(
|
||||||
else
|
// @ts-ignore
|
||||||
router.push(`/items/page?id=${currentlyPlaying.item?.Id}`);
|
`/(auth)/(tabs)/${from}/albums/${currentlyPlaying.item.AlbumId}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.push(
|
||||||
|
// @ts-ignore
|
||||||
|
`/(auth)/(tabs)/${from}/items/page?id=${currentlyPlaying.item?.Id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text>{currentlyPlaying.item?.Name}</Text>
|
<Text>{currentlyPlaying.item?.Name}</Text>
|
||||||
@@ -240,7 +247,8 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/(auth)/series/${currentlyPlaying.item.SeriesId}`
|
// @ts-ignore
|
||||||
|
`/(auth)/(tabs)/${from}/series/${currentlyPlaying.item.SeriesId}`
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="text-xs opacity-50"
|
className="text-xs opacity-50"
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -145,15 +148,13 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
item.Id
|
item.Id
|
||||||
}/universal?${searchParams.toString()}`;
|
}/universal?${searchParams.toString()}`;
|
||||||
}
|
}
|
||||||
}
|
} else if (mediaSource.TranscodingUrl) {
|
||||||
|
|
||||||
if (mediaSource.TranscodingUrl) {
|
|
||||||
console.log("Using transcoded stream!");
|
console.log("Using transcoded stream!");
|
||||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||||
} else {
|
|
||||||
throw new Error("No transcoding url");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!url) throw new Error("No url");
|
||||||
|
|
||||||
return await startRemuxing(url);
|
return await startRemuxing(url);
|
||||||
}, [
|
}, [
|
||||||
api,
|
api,
|
||||||
@@ -288,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,21 +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";
|
||||||
|
|
||||||
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);
|
||||||
@@ -59,9 +62,28 @@ 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(
|
||||||
|
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||||
|
(event) => {
|
||||||
|
setOrientation(event.orientationInfo.orientation);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||||
|
setOrientation(initialOrientation);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
return {
|
return {
|
||||||
opacity: opacity.value,
|
opacity: opacity.value,
|
||||||
@@ -80,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,
|
||||||
@@ -138,8 +160,13 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
||||||
|
headerHeightRef.current = 230;
|
||||||
|
return;
|
||||||
|
}
|
||||||
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({
|
||||||
@@ -169,7 +196,8 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
settings,
|
settings,
|
||||||
],
|
],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id || !sessionData) return null;
|
if (!api || !user?.Id || !sessionData || !selectedMediaSource?.Id)
|
||||||
|
return null;
|
||||||
|
|
||||||
let deviceProfile: any = ios;
|
let deviceProfile: any = ios;
|
||||||
|
|
||||||
@@ -193,7 +221,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
subtitleStreamIndex: selectedSubtitleStream,
|
subtitleStreamIndex: selectedSubtitleStream,
|
||||||
forceDirectPlay: settings?.forceDirectPlay,
|
forceDirectPlay: settings?.forceDirectPlay,
|
||||||
height: maxBitrate.height,
|
height: maxBitrate.height,
|
||||||
mediaSourceId: selectedMediaSource?.Id,
|
mediaSourceId: selectedMediaSource.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.info("Stream URL:", url);
|
console.info("Stream URL:", url);
|
||||||
@@ -205,15 +233,33 @@ 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();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 relative">
|
<View
|
||||||
|
className="flex-1 relative"
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{loading && (
|
{loading && (
|
||||||
<View className="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex flex-col justify-center items-center z-50">
|
<View className="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex flex-col justify-center items-center z-50">
|
||||||
<Loader />
|
<Loader />
|
||||||
@@ -227,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"
|
||||||
@@ -237,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>
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
if (mediaSources?.length) onChange(mediaSources[0]);
|
if (mediaSources?.length) onChange(mediaSources[0]);
|
||||||
}, [mediaSources]);
|
}, [mediaSources]);
|
||||||
|
|
||||||
|
const name = (name?: string | null) => {
|
||||||
|
if (name && name.length > 40)
|
||||||
|
return (
|
||||||
|
name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
|
||||||
|
);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
@@ -69,7 +77,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
onChange(source);
|
onChange(source);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>{source.Name}</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
|
{name(source.Name)}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
|
|||||||
@@ -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,95 +1,83 @@
|
|||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { 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 { useEffect, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { getColors } from "react-native-image-colors";
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<View className="flex flex-row items-center justify-between">
|
<View className="flex flex-row items-center justify-between">
|
||||||
<Text className="text-2xl font-bold">{items[0].SeriesName}</Text>
|
<Text className="text-2xl font-bold shrink">{items[0].SeriesName}</Text>
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
<Text className="text-xs font-bold">{items.length}</Text>
|
<Text className="text-xs font-bold">{items.length}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const play = async (type: "device" | "cast") => {
|
const play = async (type: "device" | "cast") => {
|
||||||
if (!user?.Id || !api || !item.Id) return;
|
if (!user?.Id || !api || !item.Id) {
|
||||||
|
console.warn("No user, api or item", user, api, item.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await getMediaInfoApi(api!).getPlaybackInfo({
|
const response = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||||
itemId: item?.Id,
|
itemId: item?.Id,
|
||||||
@@ -87,9 +90,13 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||||
sessionData,
|
sessionData,
|
||||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
||||||
|
mediaSourceId: item.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url || !item) return;
|
if (!url || !item) {
|
||||||
|
console.warn("No url or item", url, item.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
if (type === "cast" && client) {
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
await CastContext.getPlayServicesState().then((state) => {
|
||||||
@@ -111,6 +118,7 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
console.log("Playing on device", url, item.Id);
|
||||||
setCurrentlyPlayingState({
|
setCurrentlyPlayingState({
|
||||||
item,
|
item,
|
||||||
url,
|
url,
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,7 +17,6 @@ const routes = [
|
|||||||
"artists/[artistId]",
|
"artists/[artistId]",
|
||||||
"collections/[collectionId]",
|
"collections/[collectionId]",
|
||||||
"items/page",
|
"items/page",
|
||||||
"songs/[songId]",
|
|
||||||
"series/[id]",
|
"series/[id]",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.10.0",
|
"channel": "0.10.3",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.10.0",
|
"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.0" },
|
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.0"`,
|
}, 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;
|
||||||
|
};
|
||||||
@@ -31,7 +31,7 @@ export const getStreamUrl = async ({
|
|||||||
subtitleStreamIndex?: number;
|
subtitleStreamIndex?: number;
|
||||||
forceDirectPlay?: boolean;
|
forceDirectPlay?: boolean;
|
||||||
height?: number;
|
height?: number;
|
||||||
mediaSourceId?: string | null;
|
mediaSourceId: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
if (!api || !userId || !item?.Id || !mediaSourceId) {
|
if (!api || !userId || !item?.Id || !mediaSourceId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -76,10 +76,12 @@ export const getStreamUrl = async ({
|
|||||||
throw new Error("no PlaySessionId");
|
throw new Error("no PlaySessionId");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let url: string | null | undefined;
|
||||||
|
|
||||||
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
||||||
if (item.MediaType === "Video") {
|
if (item.MediaType === "Video") {
|
||||||
console.log("Using direct stream for video!");
|
console.log("Using direct stream for video!");
|
||||||
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||||
} else if (item.MediaType === "Audio") {
|
} else if (item.MediaType === "Audio") {
|
||||||
console.log("Using direct stream for audio!");
|
console.log("Using direct stream for audio!");
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
@@ -97,16 +99,16 @@ export const getStreamUrl = async ({
|
|||||||
EnableRedirection: "true",
|
EnableRedirection: "true",
|
||||||
EnableRemoteMedia: "false",
|
EnableRemoteMedia: "false",
|
||||||
});
|
});
|
||||||
return `${
|
url = `${
|
||||||
api.basePath
|
api.basePath
|
||||||
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
||||||
}
|
}
|
||||||
|
} else if (mediaSource.TranscodingUrl) {
|
||||||
|
console.log("Using transcoded stream!");
|
||||||
|
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaSource.TranscodingUrl) {
|
if (!url) throw new Error("No url");
|
||||||
console.log("Using transcoded stream!");
|
|
||||||
return `${api.basePath}${mediaSource.TranscodingUrl}`;
|
return url;
|
||||||
} else {
|
|
||||||
throw new Error("No transcoding url");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user