Compare commits

..

9 Commits

Author SHA1 Message Date
Fredrik Burmester
6c6a0f69ae wip 2024-08-21 09:13:17 +02:00
Fredrik Burmester
7abc629a10 fix: #87 2024-08-21 08:01:46 +02:00
Fredrik Burmester
70a250df5b fix: #86 and add docsting 2024-08-21 07:48:45 +02:00
Fredrik Burmester
dbdf56b990 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-08-21 07:33:33 +02:00
Fredrik Burmester
8b2204896a chore 2024-08-21 07:33:22 +02:00
Fredrik Burmester
89729a95cd Merge pull request #85 from yihaolee85/master
Update [collectionId].tsx to fix sort order
2024-08-21 07:31:38 +02:00
yihaolee85
57dd3b8446 Update [collectionId].tsx to fix sort order
Update [collectionId].tsx to fix sort order
2024-08-21 10:02:15 +08:00
Fredrik Burmester
8d2a0378ca fix: correct sf collection use 2024-08-20 22:39:36 +02:00
Fredrik Burmester
cbe01a0012 feat: use external player (vlc) 2024-08-20 21:51:16 +02:00
19 changed files with 383 additions and 312 deletions

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.6.2",
"version": "0.7.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -30,7 +30,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 17,
"versionCode": 19,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
},
@@ -96,17 +96,6 @@
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
],
[
"react-native-vlc-media-player",
{
"ios": {
"includeVLCKit": false // should be true if react-native version < 0.61
},
"android": {
"legacyJetifier": false // should be true if react-native version < 0.71
}
}
]
],
"experiments": {

View File

@@ -171,30 +171,19 @@ export default function index() {
});
const { data: mediaListCollections } = useQuery({
queryKey: [
"mediaListCollections-home",
user?.Id,
settings?.mediaListCollectionIds,
],
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["medialist", "promoted"],
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
const ids =
response.data.Items?.filter(
(c) =>
c.Name !== "cf_carousel" &&
settings?.mediaListCollectionIds?.includes(c.Id!)
) ?? [];
return ids;
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
@@ -208,7 +197,10 @@ export default function index() {
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
await queryClient.refetchQueries({
queryKey: ["mediaListCollections-home"],
queryKey: ["sf_promoted"],
});
await queryClient.refetchQueries({
queryKey: ["sf_carousel"],
});
setLoading(false);
}, [queryClient, user?.Id]);

View File

@@ -1,4 +1,3 @@
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
@@ -7,9 +6,10 @@ import { Loader } from "@/components/Loader";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
currentCollectionIdAtom,
genreFilterAtom,
sortByAtom,
sortOptions,
sortByOptions,
sortOrderAtom,
sortOrderOptions,
tagsFilterAtom,
@@ -25,7 +25,7 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { NativeScrollEvent, ScrollView, View } from "react-native";
@@ -46,9 +46,14 @@ const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { libraryId } = searchParams as { libraryId: string };
const [, setCurrentCollectionId] = useAtom(currentCollectionIdAtom);
useEffect(() => {
setCurrentCollectionId(libraryId);
}, [libraryId]);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
@@ -204,7 +209,7 @@ const page: React.FC = () => {
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
set={(value) => setSelectedGenres(value, libraryId)}
values={selectedGenres}
title="Genres"
renderItemLabel={(item) => item.toString()}
@@ -226,7 +231,7 @@ const page: React.FC = () => {
});
return response.data.Tags || [];
}}
set={setSelectedTags}
set={(value) => setSelectedTags(value, libraryId)}
values={selectedTags}
title="Tags"
renderItemLabel={(item) => item.toString()}
@@ -252,7 +257,7 @@ const page: React.FC = () => {
) || []
);
}}
set={setSelectedYears}
set={(value) => setSelectedYears(value, libraryId)}
values={selectedYears}
title="Years"
renderItemLabel={(item) => item.toString()}
@@ -265,9 +270,9 @@ const page: React.FC = () => {
collectionId={libraryId}
queryKey="sortByFilter"
queryFn={async () => {
return sortOptions;
return sortByOptions;
}}
set={setSortBy}
set={(value) => setSortBy(value, libraryId)}
values={sortBy}
title="Sort by"
renderItemLabel={(item) => item.value}
@@ -285,7 +290,7 @@ const page: React.FC = () => {
queryFn={async () => {
return sortOrderOptions;
}}
set={setSortOrder}
set={(value) => setSortOrder(value, libraryId)}
values={sortOrder}
title="Order by"
renderItemLabel={(item) => item.value}

View File

@@ -1,6 +1,7 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { currentCollectionIdAtom } from "@/utils/atoms/filters";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
@@ -66,6 +67,10 @@ const LibraryItemCard: React.FC<Props> = ({ library }) => {
const [api] = useAtom(apiAtom);
const [currentCollection, setCurrentCollection] = useAtom(
currentCollectionIdAtom
);
const url = useMemo(
() =>
getPrimaryImageUrl({
@@ -80,6 +85,8 @@ const LibraryItemCard: React.FC<Props> = ({ library }) => {
return (
<TouchableOpacity
onPress={() => {
if (!library.Id) return;
setCurrentCollection(library.Id);
router.push(`/libraries/${library.Id}`);
}}
>

View File

@@ -7,9 +7,10 @@ import { Loader } from "@/components/Loader";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
currentCollectionIdAtom,
genreFilterAtom,
sortByAtom,
sortOptions,
sortByOptions,
sortOrderAtom,
sortOrderOptions,
tagsFilterAtom,
@@ -55,20 +56,29 @@ const page: React.FC = () => {
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const [currentCollection, setCurrentCollection] = useAtom(
currentCollectionIdAtom
);
useEffect(() => {
setSortBy([
{
key: "ProductionYear",
value: "Production Year",
},
]);
setSortOrder([
{
key: "Descending",
value: "Descending",
},
]);
setSortBy(
[
{
key: "PremiereDate",
value: "Premiere Date",
},
],
collectionId
);
setSortOrder(
[
{
key: "Ascending",
value: "Ascending",
},
],
collectionId
);
}, []);
const { data: collection } = useQuery({
@@ -208,7 +218,7 @@ const page: React.FC = () => {
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
set={(value) => setSelectedGenres(value, collectionId)}
values={selectedGenres}
title="Genres"
renderItemLabel={(item) => item.toString()}
@@ -230,7 +240,7 @@ const page: React.FC = () => {
});
return response.data.Tags || [];
}}
set={setSelectedTags}
set={(value) => setSelectedTags(value, collectionId)}
values={selectedTags}
title="Tags"
renderItemLabel={(item) => item.toString()}
@@ -256,7 +266,7 @@ const page: React.FC = () => {
) || []
);
}}
set={setSelectedYears}
set={(value) => setSelectedYears(value, collectionId)}
values={selectedYears}
title="Years"
renderItemLabel={(item) => item.toString()}
@@ -269,9 +279,9 @@ const page: React.FC = () => {
collectionId={collectionId}
queryKey="sortByFilter"
queryFn={async () => {
return sortOptions;
return sortByOptions;
}}
set={setSortBy}
set={(value) => setSortBy(value, collectionId)}
values={sortBy}
title="Sort by"
renderItemLabel={(item) => item.value}
@@ -289,7 +299,7 @@ const page: React.FC = () => {
queryFn={async () => {
return sortOrderOptions;
}}
set={setSortOrder}
set={(value) => setSortOrder(value, collectionId)}
values={sortOrder}
title="Order by"
renderItemLabel={(item) => item.value}

View File

@@ -44,8 +44,11 @@ const Login: React.FC = () => {
await login(credentials.username, credentials.password);
}
} catch (error) {
const e = error as AxiosError;
setError(e.message);
if (error instanceof Error) {
setError(error.message);
} else {
setError("An unexpected error occurred");
}
} finally {
setLoading(false);
}

BIN
bun.lockb

Binary file not shown.

View File

@@ -17,7 +17,6 @@ import Animated, {
import Video from "react-native-video";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
import { VLCPlayer, VlCPlayerView } from "react-native-vlc-media-player";
export const CurrentlyPlayingBar: React.FC = () => {
const segments = useSegments();
@@ -138,80 +137,68 @@ export const CurrentlyPlayingBar: React.FC = () => {
`}
>
{currentlyPlaying?.url && (
// <Video
// ref={videoRef}
// allowsExternalPlayback
// style={{ width: "100%", height: "100%" }}
// playWhenInactive={true}
// playInBackground={true}
// showNotificationControls={true}
// ignoreSilentSwitch="ignore"
// controls={false}
// pictureInPicture={true}
// poster={
// backdropUrl && currentlyPlaying.item?.Type === "Audio"
// ? backdropUrl
// : undefined
// }
// debug={{
// enable: true,
// thread: true,
// }}
// paused={!isPlaying}
// onProgress={(e) => onProgress(e)}
// subtitleStyle={{
// fontSize: 16,
// }}
// source={{
// uri: currentlyPlaying.url,
// isNetwork: true,
// startPosition,
// headers: getAuthHeaders(api),
// }}
// onBuffer={(e) =>
// e.isBuffering ? console.log("Buffering...") : null
// }
// onFullscreenPlayerDidDismiss={() => {}}
// onFullscreenPlayerDidPresent={() => {}}
// onPlaybackStateChanged={(e) => {
// if (e.isPlaying) {
// setIsPlaying(true);
// } else if (e.isSeeking) {
// return;
// } else {
// setIsPlaying(false);
// }
// }}
// progressUpdateInterval={2000}
// onError={(e) => {
// console.log(e);
// writeToLog(
// "ERROR",
// "Video playback error: " + JSON.stringify(e)
// );
// Alert.alert("Error", "Cannot play this video file.");
// setIsPlaying(false);
// // setCurrentlyPlaying(null);
// }}
// renderLoader={
// currentlyPlaying.item?.Type !== "Audio" && (
// <View className="flex flex-col items-center justify-center h-full">
// <Loader />
// </View>
// )
// }
// />
<VlCPlayerView
style={{
width: "100%",
height: "100%",
<Video
ref={videoRef}
allowsExternalPlayback
style={{ width: "100%", height: "100%" }}
playWhenInactive={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
controls={false}
pictureInPicture={true}
poster={
backdropUrl && currentlyPlaying.item?.Type === "Audio"
? backdropUrl
: undefined
}
debug={{
enable: true,
thread: true,
}}
paused={!isPlaying}
onProgress={(e) => onProgress(e)}
subtitleStyle={{
fontSize: 16,
}}
source={{
uri: encodeURIComponent(currentlyPlaying.url),
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
}}
key={"1"}
autoAspectRatio={true}
resizeMode="cover"
onBuffer={(e) =>
e.isBuffering ? console.log("Buffering...") : null
}
onFullscreenPlayerDidDismiss={() => {}}
onFullscreenPlayerDidPresent={() => {}}
onPlaybackStateChanged={(e) => {
if (e.isPlaying) {
setIsPlaying(true);
} else if (e.isSeeking) {
return;
} else {
setIsPlaying(false);
}
}}
progressUpdateInterval={2000}
onError={(e) => {
console.log(e);
writeToLog(
"ERROR",
"Video playback error: " + JSON.stringify(e)
);
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
// setCurrentlyPlaying(null);
}}
renderLoader={
currentlyPlaying.item?.Type !== "Audio" && (
<View className="flex flex-col items-center justify-center h-full">
<Loader />
</View>
)
}
/>
)}
</TouchableOpacity>

View File

@@ -34,6 +34,34 @@ interface Props<T> extends ViewProps {
const LIMIT = 100;
/**
* FilterSheet Component
*
* This component creates a bottom sheet modal for filtering and selecting items from a list.
*
* @template T - The type of items in the list
*
* @param {Object} props - The component props
* @param {boolean} props.open - Whether the bottom sheet is open
* @param {function} props.setOpen - Function to set the open state
* @param {T[] | null} [props.data] - The full list of items to filter from
* @param {T[]} props.values - The currently selected items
* @param {function} props.set - Function to update the selected items
* @param {string} props.title - The title of the bottom sheet
* @param {function} props.searchFilter - Function to filter items based on search query
* @param {function} props.renderItemLabel - Function to render the label for each item
* @param {boolean} [props.showSearch=true] - Whether to show the search input
*
* @returns {React.ReactElement} The FilterSheet component
*
* Features:
* - Displays a list of items in a bottom sheet
* - Allows searching and filtering of items
* - Supports single selection of items
* - Loads items in batches for performance optimization
* - Customizable item rendering
*/
export const FilterSheet = <T,>({
values,
data: _data,
@@ -65,6 +93,8 @@ export const FilterSheet = <T,>({
return results.slice(0, 100);
}, [search, _data, searchFilter]);
// Loads data in batches of LIMIT size, starting from offset,
// to implement efficient "load more" functionality
useEffect(() => {
if (!_data || _data.length === 0) return;
const tmp = new Set(data);
@@ -146,14 +176,12 @@ export const FilterSheet = <T,>({
<>
<TouchableOpacity
onPress={() => {
set(
values.includes(item)
? values.filter((i) => i !== item)
: [item]
);
setTimeout(() => {
setOpen(false);
}, 250);
if (!values.includes(item)) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}}
key={`${index}`}
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"

View File

@@ -1,93 +0,0 @@
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { TouchableOpacity, View, ViewProps } from "react-native";
import {
sortByAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
} from "@/utils/atoms/filters";
interface Props extends ViewProps {
title: string;
}
export const SortButton: React.FC<Props> = ({ title, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity>
<View
className={`
px-3 py-2 rounded-full flex flex-row items-center space-x-2 bg-neutral-900
`}
{...props}
>
<Text>Sort by</Text>
<Ionicons
name="filter"
size={16}
color="white"
style={{ opacity: 0.5 }}
/>
</View>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
{sortOptions?.map((g) => (
<DropdownMenu.CheckboxItem
value={sortBy.key === g.key ? "on" : "off"}
onValueChange={(next, previous) => {
if (next === "on") {
setSortBy(g);
} else {
setSortBy(sortOptions[0]);
}
}}
key={g.key}
textValue={g.value}
>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
))}
<DropdownMenu.Separator />
<DropdownMenu.Group>
{sortOrderOptions.map((g) => (
<DropdownMenu.CheckboxItem
value={sortOrder.key === g.key ? "on" : "off"}
onValueChange={(next, previous) => {
if (next === "on") {
setSortOrder(g);
} else {
setSortOrder(sortOrderOptions[0]);
}
}}
key={g.key}
textValue={g.value}
>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

@@ -31,6 +31,25 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: sf_carousel, isFetching: l1 } = useQuery({
queryKey: ["sf_carousel", user?.Id, settings?.mediaListCollectionIds],
queryFn: async () => {
if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_carousel"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items?.[0].Id || null;
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
const onPressPagination = (index: number) => {
ref.current?.scrollTo({
/**
@@ -42,40 +61,20 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
});
};
const { data: mediaListCollection, isLoading: l1 } = useQuery<string | null>({
queryKey: ["mediaListCollection", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["medialist", "promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
const id = response.data.Items?.find((c) => c.Name === "sf_carousel")?.Id;
return id || null;
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
const { data: popularItems, isLoading: l2 } = useQuery<BaseItemDto[]>({
const { data: popularItems, isFetching: l2 } = useQuery<BaseItemDto[]>({
queryKey: ["popular", user?.Id],
queryFn: async () => {
if (!api || !user?.Id || !mediaListCollection) return [];
if (!api || !user?.Id || !sf_carousel) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
parentId: mediaListCollection,
parentId: sf_carousel,
limit: 10,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!mediaListCollection,
enabled: !!api && !!user?.Id && !!sf_carousel,
staleTime: 0,
});

View File

@@ -25,22 +25,19 @@ export const SettingToggles: React.FC = () => {
data: mediaListCollections,
isLoading: isLoadingMediaListCollections,
} = useQuery({
queryKey: ["mediaListCollections", user?.Id],
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["medialist", "promoted"],
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
const ids =
response.data.Items?.filter((c) => c.Name !== "sf_carousel") ?? [];
return ids;
return response.data.Items ?? [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
@@ -159,6 +156,23 @@ export const SettingToggles: React.FC = () => {
onValueChange={(value) => updateSettings({ forceDirectPlay: value })}
/>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Use external player (VLC)</Text>
<Text className="text-xs opacity-50 shrink">
Open all videos in VLC instead of the default player. This requries
VLC to be installed on the phone.
</Text>
</View>
<Switch
value={settings?.openInVLC}
onValueChange={(value) => {
updateSettings({ openInVLC: value, forceDirectPlay: value });
}}
/>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4

View File

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

View File

@@ -38,7 +38,7 @@
"expo-device": "~6.0.2",
"expo-font": "~12.0.9",
"expo-haptics": "~13.0.1",
"expo-image": "~1.12.13",
"expo-image": "~1.12.14",
"expo-keep-awake": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7",
@@ -72,7 +72,6 @@
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
"react-native-video": "^6.4.3",
"react-native-vlc-media-player": "^1.0.69",
"react-native-web": "~0.19.10",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.3",
@@ -86,7 +85,7 @@
"@types/react": "~18.2.45",
"@types/react-test-renderer": "^18.0.7",
"jest": "^29.2.1",
"jest-expo": "~51.0.3",
"jest-expo": "~51.0.4",
"react-test-renderer": "18.2.0",
"typescript": "~5.3.3"
},

View File

@@ -7,6 +7,7 @@ import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query";
import axios from "axios";
import { router, useSegments } from "expo-router";
import { atom, useAtom } from "jotai";
import React, {
@@ -63,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.6.2" },
clientInfo: { name: "Streamyfin", version: "0.7.0" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
})
);
@@ -115,15 +116,40 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}) => {
if (!api || !jellyfin) throw new Error("API not initialized");
const auth = await api.authenticateUserByName(username, password);
try {
const auth = await api.authenticateUserByName(username, password);
if (auth.data.AccessToken && auth.data.User) {
setUser(auth.data.User);
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
await AsyncStorage.setItem("token", auth.data?.AccessToken);
} else {
throw new Error("Invalid username or password");
if (auth.data.AccessToken && auth.data.User) {
setUser(auth.data.User);
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
await AsyncStorage.setItem("token", auth.data?.AccessToken);
}
} catch (error) {
if (axios.isAxiosError(error)) {
console.log("Axios error", error.response?.status);
switch (error.response?.status) {
case 401:
throw new Error("Invalid username or password");
case 403:
throw new Error("User does not have permission to log in");
case 408:
throw new Error(
"Server is taking too long to respond, try again later"
);
case 429:
throw new Error(
"Server received too many requests, try again later"
);
case 500:
throw new Error("There is a server error");
default:
throw new Error(
"An unexpected error occurred. Did you enter the server URL correctly?"
);
}
}
throw error;
}
},
onError: (error) => {

View File

@@ -21,6 +21,8 @@ import { useAtom } from "jotai";
import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider";
import { getDeviceId } from "@/utils/device";
import * as Linking from "expo-linking";
import { Platform } from "react-native";
type CurrentlyPlayingState = {
url: string;
@@ -87,19 +89,29 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
queryFn: getDeviceId,
});
const setCurrentlyPlayingState = (state: CurrentlyPlayingState | null) => {
if (state) {
setCurrentlyPlaying(state);
setIsPlaying(true);
const setCurrentlyPlayingState = useCallback(
(state: CurrentlyPlayingState | null) => {
const vlcLink = "vlc://" + state?.url;
console.log(vlcLink, settings?.openInVLC, Platform.OS === "ios");
if (vlcLink && settings?.openInVLC) {
Linking.openURL("vlc://" + state?.url || "");
return;
}
if (settings?.openFullScreenVideoPlayerByDefault)
presentFullscreenPlayer();
} else {
setCurrentlyPlaying(null);
setIsFullscreen(false);
setIsPlaying(false);
}
};
if (state) {
setCurrentlyPlaying(state);
setIsPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault)
presentFullscreenPlayer();
} else {
setCurrentlyPlaying(null);
setIsFullscreen(false);
setIsPlaying(false);
}
},
[settings]
);
// Define control methods
const playVideo = useCallback(() => {
@@ -167,7 +179,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
}, []);
useEffect(() => {
if (!deviceId || !api) return;
if (!deviceId || !api || !user) return;
const url = `wss://${api?.basePath
.replace("https://", "")
@@ -212,7 +224,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
}
newWebSocket.close();
};
}, [api, deviceId]);
}, [api, deviceId, user]);
useEffect(() => {
if (!ws) return;

View File

@@ -5,8 +5,9 @@ import {
SortOrder,
} from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export const sortOptions: {
export const sortByOptions: {
key: ItemSortBy;
value: string;
}[] = [
@@ -38,10 +39,111 @@ export const sortOrderOptions: {
{ key: "Descending", value: "Descending" },
];
export const genreFilterAtom = atom<string[]>([]);
export const tagsFilterAtom = atom<string[]>([]);
export const yearFilterAtom = atom<string[]>([]);
export const sortByAtom = atom<[typeof sortOptions][number]>([sortOptions[0]]);
export const sortOrderAtom = atom<[typeof sortOrderOptions][number]>([
sortOrderOptions[0],
]);
// Define the keys for our preferences
type PreferenceKey =
| "genreFilter"
| "tagsFilter"
| "yearFilter"
| "sortBy"
| "sortOrder";
// Define the type for a single collection's preferences
type CollectionPreference = {
genreFilter: string[];
tagsFilter: string[];
yearFilter: string[];
sortBy: [typeof sortByOptions][number];
sortOrder: [typeof sortOrderOptions][number];
};
// Define the type for all sort preferences
type SortPreference = {
[collectionId: string]: CollectionPreference;
};
// Create a base atom with storage
const baseSortPreferenceAtom = atomWithStorage<SortPreference>(
"sortPreferences",
{}
);
// Create a derived atom with logging
export const sortPreferenceAtom = atom(
(get) => {
const value = get(baseSortPreferenceAtom);
console.log("Getting sortPreferences:", value);
return value;
},
(get, set, newValue: SortPreference) => {
console.log("Setting sortPreferences:", newValue);
set(baseSortPreferenceAtom, newValue);
}
);
export const currentCollectionIdAtom = atomWithStorage<string | null>(
"currentCollectionId",
null
);
// Helper function to create an atom with custom getter and setter
const createFilterAtom = <T extends CollectionPreference[PreferenceKey]>(
key: PreferenceKey,
initialValue: T
) => {
const baseAtom = atom<T>(initialValue);
return atom(
(get): T => {
const preferences = get(sortPreferenceAtom);
const currentCollectionId = get(currentCollectionIdAtom);
if (currentCollectionId && preferences[currentCollectionId]) {
const preferenceValue = preferences[currentCollectionId][key];
// Ensure the returned value matches the expected type T
if (Array.isArray(initialValue) && Array.isArray(preferenceValue)) {
return preferenceValue as T;
} else if (
typeof initialValue === "object" &&
typeof preferenceValue === "object"
) {
return preferenceValue as T;
} else if (typeof initialValue === typeof preferenceValue) {
return preferenceValue as T;
}
}
return get(baseAtom);
},
(get, set, newValue: T, collectionId: string) => {
set(baseAtom, newValue);
const preferences = get(sortPreferenceAtom);
console.log("Set", preferences);
set(sortPreferenceAtom, {
...preferences,
[collectionId]: {
...preferences[collectionId],
[key]: newValue,
},
});
}
);
};
type SortByOption = ItemSortBy | { key: ItemSortBy; value: string };
type SortOrderOption = SortOrder | { key: SortOrder; value: string };
function getSortKey(
option: SortByOption | SortOrderOption
): ItemSortBy | SortOrder {
return typeof option === "string" ? option : option.key;
}
export const genreFilterAtom = createFilterAtom<string[]>("genreFilter", []);
export const tagsFilterAtom = createFilterAtom<string[]>("tagsFilter", []);
export const yearFilterAtom = createFilterAtom<string[]>("yearFilter", []);
export const sortByAtom = createFilterAtom<[typeof sortByOptions][number]>(
"sortBy",
[sortByOptions[0]]
);
export const sortOrderAtom = createFilterAtom<
[typeof sortOrderOptions][number]
>("sortOrder", [sortOrderOptions[0]]);

View File

@@ -12,6 +12,7 @@ type Settings = {
mediaListCollectionIds?: string[];
searchEngine: "Marlin" | "Jellyfin";
marlinServerUrl?: string;
openInVLC?: boolean;
};
/**
@@ -37,6 +38,7 @@ const loadSettings = async (): Promise<Settings> => {
mediaListCollectionIds: [],
searchEngine: "Jellyfin",
marlinServerUrl: "",
openInVLC: false,
};
};

View File

@@ -53,7 +53,7 @@ export const getStreamUrl = async ({
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
},
);
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
@@ -69,16 +69,7 @@ export const getStreamUrl = async ({
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
if (item.MediaType === "Video") {
console.log("Using direct stream for video!");
const params = new URLSearchParams({
mediaSourceId: itemId,
Static: "true",
deviceId: api.deviceInfo.id,
api_key: api.accessToken,
Tag: item.MediaSources?.[0].ETag || "",
});
return `${api.basePath}/Videos/${itemId}/stream.mp4?${params.toString()}`;
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({
@@ -96,9 +87,7 @@ export const getStreamUrl = async ({
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
return `${
api.basePath
}/Audio/${itemId}/universal?${searchParams.toString()}`;
return `${api.basePath}/Audio/${itemId}/universal?${searchParams.toString()}`;
}
}