diff --git a/app/(auth)/(tabs)/library/collections/[collectionId].tsx b/app/(auth)/(tabs)/library/collections/[collectionId].tsx index 947d849b..90156f77 100644 --- a/app/(auth)/(tabs)/library/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/library/collections/[collectionId].tsx @@ -2,14 +2,15 @@ import { ColumnItem } from "@/components/common/ColumnItem"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { FilterButton } from "@/components/filters/FilterButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; -import { SortButton } from "@/components/filters/SortButton"; import { ItemCardText } from "@/components/ItemCardText"; import MoviePoster from "@/components/posters/MoviePoster"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { genreFilterAtom, sortByAtom, + sortOptions, sortOrderAtom, + sortOrderOptions, tagsFilterAtom, yearFilterAtom, } from "@/utils/atoms/filters"; @@ -35,8 +36,8 @@ const page: React.FC = () => { const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); - const [sortBy] = useAtom(sortByAtom); - const [sortOrder] = useAtom(sortOrderAtom); + const [sortBy, setSortBy] = useAtom(sortByAtom); + const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); const { data: collection } = useQuery({ queryKey: ["collection", collectionId], @@ -85,8 +86,8 @@ const page: React.FC = () => { parentId: collectionId, limit: 50, startIndex: pageParam, - sortBy: [sortBy.key, "SortName", "ProductionYear"], - sortOrder: [sortOrder.key], + sortBy: [sortBy[0].key, "SortName", "ProductionYear"], + sortOrder: [sortOrder[0].key], includeItemTypes, enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], recursive: true, @@ -112,17 +113,7 @@ const page: React.FC = () => { ] ); - const { - status, - data, - error, - isFetching, - isFetchingNextPage, - isFetchingPreviousPage, - fetchNextPage, - fetchPreviousPage, - hasPreviousPage, - } = useInfiniteQuery({ + const { data, isFetching, fetchNextPage } = useInfiniteQuery({ queryKey: [ "library-items", collection, @@ -205,6 +196,10 @@ const page: React.FC = () => { set={setSelectedGenres} values={selectedGenres} title="Genres" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } /> { set={setSelectedTags} values={selectedTags} title="Tags" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } /> { set={setSelectedYears} values={selectedYears} title="Years" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + { + return sortOptions; + }} + set={setSortBy} + values={sortBy} + title="Sort by" + renderItemLabel={(item) => item.value} + searchFilter={(item, search) => + item.value.toLowerCase().includes(search.toLowerCase()) || + item.value.toLowerCase().includes(search.toLowerCase()) + } + showSearch={false} + /> + { + return sortOrderOptions; + }} + set={setSortOrder} + values={sortOrder} + title="Order by" + renderItemLabel={(item) => item.value} + searchFilter={(item, search) => + item.value.toLowerCase().includes(search.toLowerCase()) || + item.value.toLowerCase().includes(search.toLowerCase()) + } /> - {!type && isFetching && ( diff --git a/app/+not-found.tsx b/app/+not-found.tsx index 7d6ea6be..41968287 100644 --- a/app/+not-found.tsx +++ b/app/+not-found.tsx @@ -8,10 +8,6 @@ import { useEffect } from "react"; export default function NotFoundScreen() { const pathname = usePathname(); - useEffect(() => { - console.log(`Navigated to ${pathname}`); - }, [pathname]); - return ( <> diff --git a/app/_layout.tsx b/app/_layout.tsx index 0161ca59..19150605 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -16,6 +16,7 @@ import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { useKeepAwake } from "expo-keep-awake"; import { useSettings } from "@/utils/atoms/settings"; import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); @@ -79,101 +80,103 @@ function Layout() { - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/login.tsx b/app/login.tsx index 694079bc..d4c1d51c 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -6,7 +6,13 @@ import { Ionicons } from "@expo/vector-icons"; import { AxiosError } from "axios"; import { useAtom } from "jotai"; import React, { useMemo, useState } from "react"; -import { KeyboardAvoidingView, Platform, View } from "react-native"; +import { + Alert, + KeyboardAvoidingView, + Platform, + SafeAreaView, + View, +} from "react-native"; import { z } from "zod"; @@ -46,102 +52,134 @@ const Login: React.FC = () => { }; const handleConnect = (url: string) => { + if (!url.startsWith("http")) { + Alert.alert("Error", "URL needs to start with http or https."); + return; + } setServer({ address: url.trim() }); }; if (api?.basePath) { return ( - - - - Streamyfin - Server: {api.basePath} + + + + + + + Streamyfin + + Server: {api.basePath} + + + + + + Log in + + Log in to any user account + + + setCredentials({ ...credentials, username: text }) + } + value={credentials.username} + autoFocus + secureTextEntry={false} + keyboardType="default" + returnKeyType="done" + autoCapitalize="none" + textContentType="username" + clearButtonMode="while-editing" + maxLength={500} + /> + + + setCredentials({ ...credentials, password: text }) + } + value={credentials.password} + secureTextEntry + keyboardType="default" + returnKeyType="done" + autoCapitalize="none" + textContentType="password" + clearButtonMode="while-editing" + maxLength={500} + /> + + + {error} + + - - Log in - - setCredentials({ ...credentials, username: text }) - } - value={credentials.username} - autoFocus - secureTextEntry={false} - keyboardType="default" - returnKeyType="done" - autoCapitalize="none" - textContentType="username" - clearButtonMode="while-editing" - maxLength={500} - /> - - - setCredentials({ ...credentials, password: text }) - } - value={credentials.password} - secureTextEntry - keyboardType="default" - returnKeyType="done" - autoCapitalize="none" - textContentType="password" - clearButtonMode="while-editing" - maxLength={500} - /> - - - {error} - - - - + + ); } return ( - - - - Streamyfin - Enter a server adress - - + + + + + + Streamyfin + + Connect to your Jellyfin server + + + + Server URL requires http or https + + + - - + + ); }; diff --git a/bun.lockb b/bun.lockb index 5ec4bf6c..05d1cf9e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index 846a7efc..cecbb85e 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -45,7 +45,7 @@ export const CurrentlyPlayingBar: React.FC = () => { const [user] = useAtom(userAtom); const [playing, setPlaying] = useAtom(playingAtom); const [currentlyPlaying, setCurrentlyPlaying] = useAtom( - currentlyPlayingItemAtom, + currentlyPlayingItemAtom ); const [fullScreen, setFullScreen] = useAtom(fullScreenAtom); @@ -143,7 +143,7 @@ export const CurrentlyPlayingBar: React.FC = () => { sessionId: sessionData.PlaySessionId, }); }, - [sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id], + [sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id] ); useEffect(() => { @@ -185,7 +185,7 @@ export const CurrentlyPlayingBar: React.FC = () => { item?.UserData?.PlaybackPositionTicks ? Math.round(item.UserData.PlaybackPositionTicks / 10000) : 0, - [item], + [item] ); const backdropUrl = useMemo( @@ -196,7 +196,7 @@ export const CurrentlyPlayingBar: React.FC = () => { quality: 70, width: 200, }), - [item], + [item] ); if (!currentlyPlaying || !api) return null; @@ -283,7 +283,7 @@ export const CurrentlyPlayingBar: React.FC = () => { console.log(e); writeToLog( "ERROR", - "Video playback error: " + JSON.stringify(e), + "Video playback error: " + JSON.stringify(e) ); Alert.alert("Error", "Cannot play this video file."); setPlaying(false); @@ -302,7 +302,6 @@ export const CurrentlyPlayingBar: React.FC = () => { { - console.log(JSON.stringify(item)); if (item?.Type === "Audio") router.push(`/albums/${item?.AlbumId}`); else router.push(`/items/${item?.Id}`); @@ -330,7 +329,6 @@ export const CurrentlyPlayingBar: React.FC = () => { {item?.Type === "Audio" && ( { - console.log(JSON.stringify(item)); router.push(`/albums/${item?.AlbumId}`); }} > diff --git a/components/common/Input.tsx b/components/common/Input.tsx index 60a60778..62f19023 100644 --- a/components/common/Input.tsx +++ b/components/common/Input.tsx @@ -1,17 +1,9 @@ -import { useFocusEffect } from "expo-router"; -import React, { useEffect } from "react"; -import { TextInputProps, TextProps } from "react-native"; -import { TextInput } from "react-native"; +import React from "react"; +import { TextInput, TextInputProps } from "react-native"; export function Input(props: TextInputProps) { const { style, ...otherProps } = props; const inputRef = React.useRef(null); - useFocusEffect( - React.useCallback(() => { - inputRef.current?.focus(); - }, []), - ); - return ( extends ViewProps { collectionId: string; queryFn: (params: any) => Promise; queryKey: string; - set: (value: string[]) => void; - values: string[]; + set: (value: T[]) => void; + values: T[]; title: string; + searchFilter: (item: T, query: string) => boolean; + renderItemLabel: (item: T) => React.ReactNode; + showSearch?: boolean; } -export const FilterButton: React.FC = ({ +export const FilterButton = ({ collectionId, queryFn, queryKey, set, values, title, + renderItemLabel, + searchFilter, + showSearch = true, ...props -}) => { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); +}: FilterButtonProps) => { + const [open, setOpen] = useState(false); - const { data: filters } = useQuery({ + const { data: filters } = useQuery({ queryKey: [queryKey, collectionId], queryFn, staleTime: 0, @@ -36,52 +42,35 @@ export const FilterButton: React.FC = ({ if (filters?.length === 0) return null; return ( - - - - + setOpen(true)}> + 0 ? "bg-purple-600" : "bg-neutral-900"} `} - {...props} - > - {title} - - - - - - {filters?.map((g) => ( - { - if (next === "on") { - set([...values, g]); - } else { - set(values.filter((v) => v !== g)); - } - }} - key={g} - textValue={g} - > - - - ))} - - + {...props} + > + {title} + + + + + title={title} + open={open} + setOpen={setOpen} + data={filters} + values={values} + set={set} + renderItemLabel={renderItemLabel} + searchFilter={searchFilter} + showSearch={showSearch} + /> + ); }; diff --git a/components/filters/FilterSheet.tsx b/components/filters/FilterSheet.tsx new file mode 100644 index 00000000..7f839b99 --- /dev/null +++ b/components/filters/FilterSheet.tsx @@ -0,0 +1,190 @@ +import { + BottomSheetBackdrop, + BottomSheetBackdropProps, + BottomSheetFlatList, + BottomSheetModal, + BottomSheetScrollView, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { Text } from "@/components/common/Text"; +import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { Button } from "../Button"; +import { Input } from "../common/Input"; + +interface Props extends ViewProps { + open: boolean; + setOpen: (open: boolean) => void; + data?: T[] | null; + values: T[]; + set: (value: T[]) => void; + title: string; + searchFilter: (item: T, query: string) => boolean; + renderItemLabel: (item: T) => React.ReactNode; + showSearch?: boolean; +} + +const LIMIT = 100; + +export const FilterSheet = ({ + values, + data: _data, + open, + set, + setOpen, + title, + searchFilter, + renderItemLabel, + showSearch = true, + ...props +}: Props) => { + const bottomSheetModalRef = useRef(null); + const snapPoints = useMemo(() => ["80%"], []); + + const [data, setData] = useState([]); + const [offset, setOffset] = useState(0); + + const [search, setSearch] = useState(""); + + const filteredData = useMemo(() => { + if (!search) return _data; + const results = []; + for (let i = 0; i < (_data?.length || 0); i++) { + if (_data && searchFilter(_data[i], search)) { + results.push(_data[i]); + } + } + return results.slice(0, 100); + }, [search, _data, searchFilter]); + + useEffect(() => { + if (!_data || _data.length === 0) return; + const tmp = new Set(data); + for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) { + tmp.add(_data[i]); + } + setData(Array.from(tmp)); + }, [offset, _data]); + + useEffect(() => { + if (open) bottomSheetModalRef.current?.present(); + else bottomSheetModalRef.current?.dismiss(); + }, [open]); + + const handleSheetChanges = useCallback((index: number) => { + if (index === -1) { + setOpen(false); + } + }, []); + + const renderData = useMemo(() => { + if (search.length > 0 && showSearch) return filteredData; + return data; + }, [search, filteredData, data]); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [] + ); + + return ( + + + + {title} + {_data?.length} items + {showSearch && ( + { + setSearch(text); + }} + returnKeyType="done" + /> + )} + + {renderData?.map((item, index) => ( + <> + { + set( + values.includes(item) + ? values.filter((i) => i !== item) + : [item] + ); + setTimeout(() => { + setOpen(false); + }, 250); + }} + key={index} + className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between" + > + {renderItemLabel(item)} + {values.includes(item) ? ( + + ) : ( + + )} + + + + ))} + + {data.length < (_data?.length || 0) && ( + + )} + + + + ); +}; diff --git a/components/filters/SortButton.tsx b/components/filters/_SortButton.tsx similarity index 100% rename from components/filters/SortButton.tsx rename to components/filters/_SortButton.tsx diff --git a/components/posters/AlbumCover.tsx b/components/posters/AlbumCover.tsx index c1c376e1..6a4ad770 100644 --- a/components/posters/AlbumCover.tsx +++ b/components/posters/AlbumCover.tsx @@ -21,7 +21,6 @@ const AlbumCover: React.FC = ({ item, id }) => { api, item, }); - console.log("Image A", u); return u; }, [item]); @@ -32,7 +31,6 @@ const AlbumCover: React.FC = ({ item, id }) => { quality: 85, width: 300, }); - console.log("Image B", u); return u; }, [item]); diff --git a/package.json b/package.json index 4355bd03..fff15664 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@config-plugins/ffmpeg-kit-react-native": "^8.0.0", "@expo/react-native-action-sheet": "^4.1.0", "@expo/vector-icons": "^14.0.2", + "@gorhom/bottom-sheet": "^4", "@jellyfin/sdk": "^0.10.0", "@kesha-antonov/react-native-background-downloader": "^3.2.0", "@react-native-async-storage/async-storage": "1.23.1", diff --git a/utils/atoms/filters.ts b/utils/atoms/filters.ts index 870286b2..e9ab2e95 100644 --- a/utils/atoms/filters.ts +++ b/utils/atoms/filters.ts @@ -41,7 +41,7 @@ export const sortOrderOptions: { export const genreFilterAtom = atom([]); export const tagsFilterAtom = atom([]); export const yearFilterAtom = atom([]); -export const sortByAtom = atom<(typeof sortOptions)[number]>(sortOptions[0]); -export const sortOrderAtom = atom<(typeof sortOrderOptions)[number]>( +export const sortByAtom = atom<[typeof sortOptions][number]>([sortOptions[0]]); +export const sortOrderAtom = atom<[typeof sortOrderOptions][number]>([ sortOrderOptions[0], -); +]);