Compare commits

...

39 Commits

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

fix #104 fix #102 fix #103
2024-08-30 00:14:33 +02:00
Fredrik Burmester
6c1db4bbb9 Revert "fix: #104 #103 #102"
This reverts commit bbaab1994a.
2024-08-30 00:13:45 +02:00
Fredrik Burmester
bbaab1994a fix: #104 #103 #102 2024-08-30 00:13:15 +02:00
Fredrik Burmester
8c0e7f7db8 fix: item page for item not associated with movie/tv-show not loading 2024-08-29 23:03:51 +02:00
Fredrik Burmester
8b3b492f5e fix: small design fixes 2024-08-29 13:10:54 +02:00
Fredrik Burmester
78189c8246 fix: download url not correct for direct streams 2024-08-29 12:58:51 +02:00
Fredrik Burmester
dc02db6463 chore: version 2024-08-29 09:11:50 +02:00
Fredrik Burmester
c168d79377 Merge branch 'fix/landscape-design' 2024-08-29 08:45:20 +02:00
Fredrik Burmester
f756a663fe Merge branch 'fix/music-pages' 2024-08-29 08:45:07 +02:00
Fredrik Burmester
2baf57156e fix: landscape design 2024-08-29 08:44:58 +02:00
Fredrik Burmester
a97610a51d fix: audio poster + links 2024-08-29 08:44:47 +02:00
Fredrik Burmester
79b87b3d72 fix: song pages 2024-08-29 08:42:16 +02:00
Fredrik Burmester
d52f025873 fix: landscape design 2024-08-29 08:40:55 +02:00
Fredrik Burmester
b22ffee707 Merge branch 'master' into pr/106 2024-08-25 12:13:35 +02:00
jakequade
688c343a35 iOS support 2024-08-25 00:08:13 +10:00
jakequade
fb6e3dc690 chromecast controls 2024-08-24 15:14:14 +10:00
jakequade
e9783d293d extended cast controls on android 2024-08-24 14:37:49 +10:00
37 changed files with 980 additions and 694 deletions

1
.gitignore vendored
View File

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

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.10.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",
{ {

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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 ">

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

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

View File

@@ -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;

View File

@@ -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={{

View File

@@ -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} />}

View File

@@ -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" && (

View File

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

View File

@@ -1,10 +1,12 @@
import { Feather } from "@expo/vector-icons";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { 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>
); );
}; };

View File

@@ -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"

View File

@@ -22,7 +22,7 @@ import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router"; import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native"; import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector"; import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button"; import { Button } from "./Button";
@@ -54,11 +54,14 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
value: undefined, value: undefined,
}); });
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
/** /**
* Bottom sheet * Bottom sheet
*/ */
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["50%"], []);
const handlePresentModalPress = useCallback(() => { const handlePresentModalPress = useCallback(() => {
bottomSheetModalRef.current?.present(); bottomSheetModalRef.current?.present();
@@ -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"
> >

View File

@@ -11,8 +11,10 @@ import { ItemImage } from "@/components/common/ItemImage";
import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries"; import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getItemImage } from "@/utils/getItemImage";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
@@ -25,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>

View File

@@ -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>

View File

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

View File

@@ -1,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,
}} }}

View File

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

View File

@@ -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>

View File

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

View File

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

View File

@@ -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,

View File

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

View File

@@ -17,7 +17,6 @@ const routes = [
"artists/[artistId]", "artists/[artistId]",
"collections/[collectionId]", "collections/[collectionId]",
"items/page", "items/page",
"songs/[songId]",
"series/[id]", "series/[id]",
]; ];

View File

@@ -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"

View File

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

View File

@@ -0,0 +1,42 @@
const { withAndroidManifest } = require("@expo/config-plugins");
function addAttributesToMainActivity(androidManifest, attributes) {
const { manifest } = androidManifest;
if (!Array.isArray(manifest["application"])) {
console.warn("withAndroidMainActivityAttributes: No application array in manifest?");
return androidManifest;
}
const application = manifest["application"].find(
(item) => item.$["android:name"] === ".MainApplication"
);
if (!application) {
console.warn("withAndroidMainActivityAttributes: No .MainApplication?");
return androidManifest;
}
if (!Array.isArray(application["activity"])) {
console.warn("withAndroidMainActivityAttributes: No activity array in .MainApplication?");
return androidManifest;
}
const activity = application["activity"].find(
(item) => item.$["android:name"] === ".MainActivity"
);
if (!activity) {
console.warn("withAndroidMainActivityAttributes: No .MainActivity?");
return androidManifest;
}
activity.$ = { ...activity.$, ...attributes };
return androidManifest;
}
module.exports = function withAndroidMainActivityAttributes(config, attributes) {
return withAndroidManifest(config, (config) => {
config.modResults = addAttributesToMainActivity(config.modResults, attributes);
return config;
});
};

View File

@@ -0,0 +1,20 @@
const { withAppDelegate } = require("@expo/config-plugins");
const withExpandedController = (config) => {
return withAppDelegate(config, async (config) => {
const contents = config.modResults.contents;
// Looking for the initialProps string inside didFinishLaunchingWithOptions,
// and injecting expanded controller config.
// Should be updated once there is an expo config option - see https://github.com/react-native-google-cast/react-native-google-cast/discussions/537
const injectionIndex = contents.indexOf("self.initialProps = @{};");
config.modResults.contents =
contents.substring(0, injectionIndex) +
`\n [GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true; \n` +
contents.substring(injectionIndex);
return config;
});
};
module.exports = withExpandedController;

View File

@@ -1,6 +1,7 @@
import { useInterval } from "@/hooks/useInterval"; import { useInterval } from "@/hooks/useInterval";
import { Api, Jellyfin } from "@jellyfin/sdk"; import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
@@ -62,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin( setJellyfin(
() => () =>
new Jellyfin({ new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.10.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]);

View File

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

View File

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

View File

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

87
utils/getItemImage.ts Normal file
View File

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

View File

@@ -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");
}
}; };