From 0ebacd4bd35cac560fb1d848ee42f9f9cacf97ed Mon Sep 17 00:00:00 2001 From: ryan0204 Date: Wed, 8 Jan 2025 11:29:49 +0800 Subject: [PATCH 01/34] Auto hide control after 5 seconds --- components/video-player/controls/Controls.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 1f436f7f..3927587a 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -280,8 +280,38 @@ export const Controls: React.FC = ({ useEffect(() => { prefetchAllTrickplayImages(); }, []); + + const CONTROLS_TIMEOUT = 5000; + const controlsTimeoutRef = useRef(); + + useEffect(() => { + const resetControlsTimeout = () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + + if (showControls && !isSliding && !EpisodeView) { + controlsTimeoutRef.current = setTimeout(() => { + setShowControls(false); + setShowAudioSlider(false); + }, CONTROLS_TIMEOUT); + } + }; + + resetControlsTimeout(); + + return () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + }; + }, [showControls, isSliding, EpisodeView]); + const toggleControls = () => { if (showControls) { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } setShowAudioSlider(false); setShowControls(false); } else { @@ -289,6 +319,18 @@ export const Controls: React.FC = ({ } }; + const handleControlsInteraction = () => { + if (showControls) { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + controlsTimeoutRef.current = setTimeout(() => { + setShowControls(false); + setShowAudioSlider(false); + }, CONTROLS_TIMEOUT); + } + }; + const handleSliderStart = useCallback(() => { if (showControls === false) return; @@ -731,6 +773,7 @@ export const Controls: React.FC = ({ }, ]} className={`flex flex-col p-4`} + onTouchStart={handleControlsInteraction} > Date: Tue, 7 Jan 2025 23:53:10 -0500 Subject: [PATCH 02/34] [Jellyseerr] Show media configuration for admins implements #331 --- .../jellyseerr/page.tsx | 57 +++-- augmentations/number.ts | 18 +- components/common/Dropdown.tsx | 95 +++++++ components/jellyseerr/RequestModal.tsx | 233 ++++++++++++++++++ components/posters/JellyseerrPoster.tsx | 2 +- components/series/JellyseerrSeasons.tsx | 48 ++-- hooks/useJellyseerr.ts | 25 ++ utils/_jellyseerr/useJellyseerrCanRequest.ts | 17 +- utils/jellyseerr | 2 +- 9 files changed, 454 insertions(+), 43 deletions(-) create mode 100644 components/common/Dropdown.tsx create mode 100644 components/jellyseerr/RequestModal.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx index 228d67eb..3605a665 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx @@ -28,10 +28,13 @@ import { import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; import { TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as DropdownMenu from "zeego/dropdown-menu"; +import RequestModal from "@/components/jellyseerr/RequestModal"; +import {ANIME_KEYWORD_ID} from "@/utils/jellyseerr/server/api/themoviedb/constants"; +import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; const Page: React.FC = () => { const insets = useSafeAreaInsets(); @@ -49,6 +52,7 @@ const Page: React.FC = () => { const [issueType, setIssueType] = useState(); const [issueMessage, setIssueMessage] = useState(); + const advancedReqModalRef = useRef(null); const bottomSheetModalRef = useRef(null); const { @@ -72,7 +76,7 @@ const Page: React.FC = () => { }, }); - const canRequest = useJellyseerrCanRequest(details); + const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( @@ -98,19 +102,27 @@ const Page: React.FC = () => { }, [jellyseerrApi, details, result, issueType, issueMessage]); const request = useCallback(async () => { - requestMedia( - mediaTitle, - { - mediaId: Number(result.id!!), - mediaType: result.mediaType!!, - tvdbId: details?.externalIds?.tvdbId, - seasons: (details as TvDetails)?.seasons - ?.filter?.((s) => s.seasonNumber !== 0) - ?.map?.((s) => s.seasonNumber), - }, - refetch - ); - }, [details, result, requestMedia]); + const body: MediaRequestBody = { + mediaId: Number(result.id!!), + mediaType: result.mediaType!!, + tvdbId: details?.externalIds?.tvdbId, + seasons: (details as TvDetails)?.seasons + ?.filter?.((s) => s.seasonNumber !== 0) + ?.map?.((s) => s.seasonNumber), + } + + if (hasAdvancedRequestPermission) { + advancedReqModalRef?.current?.present?.(body) + return + } + + requestMedia(mediaTitle, body, refetch); + }, [details, result, requestMedia, hasAdvancedRequestPermission]); + + const isAnime = useMemo( + () => (details?.keywords.some(k => k.id === ANIME_KEYWORD_ID) || false) && result.mediaType === MediaType.TV, + [details] + ) useEffect(() => { if (details) { @@ -229,6 +241,10 @@ const Page: React.FC = () => { result={result as TvResult} details={details as TvDetails} refetch={refetch} + hasAdvancedRequest={hasAdvancedRequestPermission} + onAdvancedRequest={(data) => + advancedReqModalRef?.current?.present(data) + } /> )} { + { + advancedReqModalRef?.current?.close() + refetch() + }} + /> = 1) return `${gb.toFixed(0)} GB`; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - const mb = bytes / 1024.0 / 1024.0; - if (mb >= 1) return `${mb.toFixed(0)} MB`; + const i = Math.floor(Math.log(bytes) / Math.log(k)); - const kb = bytes / 1024.0; - if (kb >= 1) return `${kb.toFixed(0)} KB`; - - return `${bytes.toFixed(2)} B`; + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; }; Number.prototype.secondsToMilliseconds = function () { diff --git a/components/common/Dropdown.tsx b/components/common/Dropdown.tsx new file mode 100644 index 00000000..d9612d48 --- /dev/null +++ b/components/common/Dropdown.tsx @@ -0,0 +1,95 @@ +import * as DropdownMenu from "zeego/dropdown-menu"; +import {TouchableOpacity, View, ViewProps} from "react-native"; +import {Text} from "@/components/common/Text"; +import React, {PropsWithChildren, useEffect, useState} from "react"; + +interface Props { + data: T[] + placeholderText?: string, + keyExtractor: (item: T) => string + titleExtractor: (item: T) => string + title: string, + label: string, + onSelected: (...item: T[]) => void + multi?: boolean +} + +const Dropdown = ({ + data, + placeholderText, + keyExtractor, + titleExtractor, + title, + label, + onSelected, + multi = false, + ...props +}: PropsWithChildren & ViewProps>) => { + const [selected, setSelected] = useState(); + + useEffect(() => { + if (selected !== undefined) { + onSelected(...selected) + } + }, [selected]); + + return ( + + + + + + {title} + + + + {selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText} + + + + + + {label} + {data.map((item, idx) => ( + multi ? ( + keyExtractor(s) == keyExtractor(item)) ? 'on' : 'off'} + key={keyExtractor(item)} + onValueChange={(next, previous) => + setSelected((p) => { + const prev = p || [] + if (next == 'on') { + return [...prev, item] + } + return [...prev.filter(p => keyExtractor(p) !== keyExtractor(item))] + }) + } + > + {titleExtractor(item)} + + ) + : ( + setSelected([item])} + > + {titleExtractor(item)} + + ) + ))} + + + + ) +}; + +export default Dropdown; \ No newline at end of file diff --git a/components/jellyseerr/RequestModal.tsx b/components/jellyseerr/RequestModal.tsx new file mode 100644 index 00000000..2f735bd7 --- /dev/null +++ b/components/jellyseerr/RequestModal.tsx @@ -0,0 +1,233 @@ +import React, {forwardRef, useCallback, useMemo, useState} from "react"; +import {View, ViewProps} from "react-native"; +import {useJellyseerr} from "@/hooks/useJellyseerr"; +import {useQuery} from "@tanstack/react-query"; +import {MediaType} from "@/utils/jellyseerr/server/constants/media"; +import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet"; +import Dropdown from "@/components/common/Dropdown"; +import {QualityProfile, RootFolder, Tag} from "@/utils/jellyseerr/server/api/servarr/base"; +import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import {BottomSheetModalMethods} from "@gorhom/bottom-sheet/lib/typescript/types"; +import {Button} from "@/components/Button"; +import {Text} from "@/components/common/Text"; + +interface Props { + id: number; + title: string, + type: MediaType; + isAnime?: boolean; + is4k?: boolean; + onRequested?: () => void; +} + +const RequestModal = forwardRef>(({ + id, + title, + type, + isAnime = false, + onRequested, + ...props +}, ref) => { + const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr(); + const [requestOverrides, setRequestOverrides] = + useState({ + mediaId: Number(id), + mediaType: type, + userId: jellyseerrUser?.id + }); + + const [modalRequestProps, setModalRequestProps] = useState(); + + const {data: serviceSettings} = useQuery({ + queryKey: ["jellyseerr", "request", type, 'service'], + queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'), + enabled: !!jellyseerrApi && !!jellyseerrUser, + refetchOnMount: 'always' + }); + + const {data: users} = useQuery({ + queryKey: ["jellyseerr", "users"], + queryFn: async () => jellyseerrApi?.user({take: 1000, sort: 'displayname'}), + enabled: !!jellyseerrApi && !!jellyseerrUser, + refetchOnMount: 'always' + }); + + const defaultService = useMemo( + () => serviceSettings?.find?.(v => v.isDefault), + [serviceSettings] + ); + + const {data: defaultServiceDetails} = useQuery({ + queryKey: ["jellyseerr", "request", type, 'service', 'details', defaultService?.id], + queryFn: async () => { + setRequestOverrides((prev) => ({ + ...prev, + serverId: defaultService?.id + })) + return jellyseerrApi?.serviceDetails(type === 'movie' ? 'radarr' : 'sonarr', defaultService!!.id) + }, + enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService, + refetchOnMount: 'always', + }); + + const defaultProfile: QualityProfile = useMemo( + () => defaultServiceDetails?.profiles + .find(p => + p.id === (isAnime ? defaultServiceDetails.server?.activeAnimeProfileId : defaultServiceDetails.server?.activeProfileId) + ), + [defaultServiceDetails] + ); + + const defaultFolder: RootFolder = useMemo( + () => defaultServiceDetails?.rootFolders + .find(f => + f.path === (isAnime ? defaultServiceDetails?.server.activeAnimeDirectory : defaultServiceDetails.server?.activeDirectory) + ), + [defaultServiceDetails] + ); + + const defaultTags: Tag[] = useMemo( + () => { + const tags = defaultServiceDetails?.tags + .filter(t => + (isAnime + ? defaultServiceDetails?.server.activeAnimeTags + : defaultServiceDetails?.server.activeTags + )?.includes(t.id) + ) ?? [] + + console.log(tags) + return tags + }, + [defaultServiceDetails] + ); + + const seasonTitle = useMemo( + () => modalRequestProps?.seasons?.length ? `Season (${modalRequestProps?.seasons})` : undefined, + [modalRequestProps?.seasons] + ); + + const request = useCallback(() => {requestMedia( + seasonTitle ? `${title}, ${seasonTitle}` : title, + { + is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k, + profileId: defaultProfile.id, + rootFolder: defaultFolder.path, + tags: defaultTags.map(t => t.id), + ...modalRequestProps, + ...requestOverrides + }, + onRequested + ) + }, [requestOverrides, defaultProfile, defaultFolder, defaultTags]); + + const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`; + + return ( + setModalRequestProps(undefined)} + handleIndicatorStyle={{ + backgroundColor: "white", + }} + backgroundStyle={{ + backgroundColor: "#171717", + }} + backdropComponent={(sheetProps: BottomSheetBackdropProps) => + + } + > + {(data) => { + setModalRequestProps(data?.data as MediaRequestBody) + return + + + Advanced + {seasonTitle && + {seasonTitle} + } + + + {(defaultService && defaultServiceDetails && users) && ( + <> + item.name} + placeholderText={defaultProfile.name} + keyExtractor={(item) => item.id.toString()} + label={"Quality Profile"} + onSelected={(item) => + item && setRequestOverrides((prev) => ({ + ...prev, + profileId: item?.id + })) + } + title={"Quality Profile"} + /> + item.id.toString()} + label={"Root Folder"} + onSelected={(item) => + item && setRequestOverrides((prev) => ({ + ...prev, + rootFolder: item.path + }))} + title={"Root Folder"} + /> + item.label} + placeholderText={defaultTags.map(t => t.label).join(",")} + keyExtractor={(item) => item.id.toString()} + label={"Tags"} + onSelected={(...item) => + item && setRequestOverrides((prev) => ({ + ...prev, + tags: item.map(i => i.id) + })) + } + title={"Tags"} + /> + item.displayName} + placeholderText={jellyseerrUser!!.displayName} + keyExtractor={(item) => item.id.toString() || ""} + label={"Request As"} + onSelected={(item) => + item && setRequestOverrides((prev) => ({ + ...prev, + userId: item?.id + })) + } + title={"Request As"} + /> + + ) + } + + + + + }} + + ); +}); + +export default RequestModal; \ No newline at end of file diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/JellyseerrPoster.tsx index 11fb4941..1c3ce45b 100644 --- a/components/posters/JellyseerrPoster.tsx +++ b/components/posters/JellyseerrPoster.tsx @@ -57,7 +57,7 @@ const JellyseerrPoster: React.FC = ({ item, ...props }) => { [item] ); - const canRequest = useJellyseerrCanRequest(item); + const [canRequest] = useJellyseerrCanRequest(item); return ( void; refetch: (options?: (RefetchOptions | undefined)) => Promise>; -}> = ({ isLoading, result, details, refetch }) => { +}> = ({ + isLoading, + result, + details, + refetch, + hasAdvancedRequest, + onAdvancedRequest, +}) => { if (!details) return null; const { jellyseerrApi, requestMedia } = useJellyseerr(); @@ -142,7 +152,7 @@ const JellyseerrSeasons: React.FC<{ const requestAll = useCallback(() => { if (details && jellyseerrApi) { - requestMedia(result?.name!!, { + const body: MediaRequestBody = { mediaId: details.id, mediaType: MediaType.TV, tvdbId: details.externalIds?.tvdbId, @@ -151,9 +161,15 @@ const JellyseerrSeasons: React.FC<{ (s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0 ) .map((s) => s.seasonNumber), - }); + } + + if (hasAdvancedRequest) { + return onAdvancedRequest?.(body) + } + + requestMedia(result?.name!!, body, refetch); } - }, [jellyseerrApi, seasons, details]); + }, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]); const promptRequestAll = useCallback( () => @@ -172,18 +188,20 @@ const JellyseerrSeasons: React.FC<{ const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => { if (canRequest) { - requestMedia( - `${result?.name!!}, Season ${seasonNumber}`, - { - mediaId: details.id, - mediaType: MediaType.TV, - tvdbId: details.externalIds?.tvdbId, - seasons: [seasonNumber], - }, - refetch - ) + const body: MediaRequestBody = { + mediaId: details.id, + mediaType: MediaType.TV, + tvdbId: details.externalIds?.tvdbId, + seasons: [seasonNumber], + } + + if (hasAdvancedRequest) { + return onAdvancedRequest?.(body) + } + + requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch); } - }, [requestMedia]); + }, [requestMedia, hasAdvancedRequest, onAdvancedRequest]); if (isLoading) return ( diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index 8d3eba30..c6eb9a34 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -34,6 +34,11 @@ import { } from "@/utils/jellyseerr/server/models/Person"; import { useQueryClient } from "@tanstack/react-query"; import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; +import {UserResultsResponse} from "@/utils/jellyseerr/server/interfaces/api/userInterfaces"; +import { + ServiceCommonServer, + ServiceCommonServerWithDetails +} from "@/utils/jellyseerr/server/interfaces/api/serviceInterfaces"; interface SearchParams { query: string; @@ -66,6 +71,8 @@ export enum Endpoints { MOVIE = "/movie", RATINGS = "/ratings", ISSUE = "/issue", + USER = "/user", + SERVICE = "/service", TV = "/tv", SETTINGS = "/settings", NETWORK = "/network", @@ -282,6 +289,12 @@ export class JellyseerrApi { }); } + async user(params: any) { + return this.axios + ?.get(`${Endpoints.API_V1}${Endpoints.USER}`, { params }) + .then(({data}) => data.results) + } + imageProxy( path?: string, filter: string = "original", @@ -315,6 +328,18 @@ export class JellyseerrApi { }); } + async service(type: 'radarr' | 'sonarr') { + return this.axios + ?.get(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}`) + .then(({data}) => data); + } + + async serviceDetails(type: 'radarr' | 'sonarr', id: number) { + return this.axios + ?.get(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}` + `/${id}`) + .then(({data}) => data); + } + private setInterceptors() { this.axios.interceptors.response.use( async (response) => { diff --git a/utils/_jellyseerr/useJellyseerrCanRequest.ts b/utils/_jellyseerr/useJellyseerrCanRequest.ts index ba692df3..c58a8928 100644 --- a/utils/_jellyseerr/useJellyseerrCanRequest.ts +++ b/utils/_jellyseerr/useJellyseerrCanRequest.ts @@ -48,5 +48,20 @@ export const useJellyseerrCanRequest = ( return userHasPermission && !canNotRequest; }, [item, jellyseerrUser]); - return canRequest; + const hasAdvancedRequestPermission = useMemo(() => { + if (!jellyseerrUser) return false; + + return hasPermission( + [ + Permission.REQUEST_ADVANCED, + Permission.MANAGE_REQUESTS + ], + jellyseerrUser.permissions, + {type: 'or'} + ) + }, + [jellyseerrUser] + ); + + return [canRequest, hasAdvancedRequestPermission]; }; diff --git a/utils/jellyseerr b/utils/jellyseerr index a15f2ab3..4401b164 160000 --- a/utils/jellyseerr +++ b/utils/jellyseerr @@ -1 +1 @@ -Subproject commit a15f2ab336936f49e38ea37f8b224da40e12588e +Subproject commit 4401b16414af604a7372dacac326c38b18ad8555 From e4de11127fda053e15b7159ce29f8609f11a6c30 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 8 Jan 2025 11:39:50 +0100 Subject: [PATCH 03/34] chore --- .gitignore | 4 +- .idea/.gitignore | 3 - .idea/caches/deviceStreaming.xml | 329 ------------------------------- .idea/misc.xml | 6 - .idea/modules.xml | 8 - .idea/streamyfin.iml | 9 - .idea/vcs.xml | 6 - 7 files changed, 3 insertions(+), 362 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/caches/deviceStreaming.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/streamyfin.iml delete mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index 33ed8e6d..1878db42 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ credentials.json *.ipa .continuerc.json -.vscode/ \ No newline at end of file +.vscode/ +.idea/ +.ruby-lsp \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d33521..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml deleted file mode 100644 index b81700b5..00000000 --- a/.idea/caches/deviceStreaming.xml +++ /dev/null @@ -1,329 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 639900d1..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index ba6d5c31..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/streamyfin.iml b/.idea/streamyfin.iml deleted file mode 100644 index d6ebd480..00000000 --- a/.idea/streamyfin.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddf..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From dcd458bd3d63950a02fc9ebaf2b291176949341d Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Wed, 8 Jan 2025 08:04:48 -0500 Subject: [PATCH 04/34] [Jellyseerr] "Currently Streaming On" misaligned text fixes #392 --- components/jellyseerr/DetailFacts.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/jellyseerr/DetailFacts.tsx b/components/jellyseerr/DetailFacts.tsx index 782ede8b..d7d65419 100644 --- a/components/jellyseerr/DetailFacts.tsx +++ b/components/jellyseerr/DetailFacts.tsx @@ -29,8 +29,8 @@ const Facts: React.FC< > = ({ title, facts, ...props }) => facts && facts?.length > 0 && ( - - {title} + + {title} {facts.map((f, idx) => From c553cff9d197280c63021cf51c738f944976aa14 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Wed, 8 Jan 2025 08:05:06 -0500 Subject: [PATCH 05/34] Added clean script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index ad6231ad..78e5daa8 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "1.0.0", "scripts": { "submodule-reload": "git submodule update --init --remote --recursive", + "clean": "echo y | expo prebuild --clean", "start": "bun run submodule-reload && expo start", "reset-project": "node ./scripts/reset-project.js", "android": "bun run submodule-reload && expo run:android", From aad6093852fb78159b8b0c756e9283e9a78a31cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20FORTIN?= <38886040+topiga@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:22:11 +0100 Subject: [PATCH 06/34] Added 1 Mb/s as bitrate --- components/BitrateSelector.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index 0f1bd28b..d08a939a 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -27,6 +27,10 @@ export const BITRATES: Bitrate[] = [ key: "2 Mb/s", value: 2000000, }, + { + key: "1 Mb/s", + value: 1000000, + }, { key: "500 Kb/s", value: 500000, From 76cdb2b3f8efe823620177733fa6e72efa14c243 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Wed, 8 Jan 2025 17:31:39 -0500 Subject: [PATCH 07/34] fix cast npe --- components/jellyseerr/Cast.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/jellyseerr/Cast.tsx b/components/jellyseerr/Cast.tsx index f5474caf..fd6fc753 100644 --- a/components/jellyseerr/Cast.tsx +++ b/components/jellyseerr/Cast.tsx @@ -10,7 +10,7 @@ const CastSlide: React.FC< { details?: MovieDetails | TvDetails } & ViewProps > = ({ details, ...props }) => { return ( - details?.credits?.cast?.length && + details?.credits?.cast && details?.credits?.cast?.length > 0 && ( Cast From 75820adcbcfc8a7e48e7f6be8970ba7eb215691f Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Wed, 8 Jan 2025 21:52:31 -0500 Subject: [PATCH 08/34] initial changes --- augmentations/mmkv.ts | 7 +++- providers/JellyfinProvider.tsx | 18 +++++++++- utils/atoms/settings.ts | 60 ++++++++++++++++++++++++++++++++-- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/augmentations/mmkv.ts b/augmentations/mmkv.ts index 80fbeede..5667502f 100644 --- a/augmentations/mmkv.ts +++ b/augmentations/mmkv.ts @@ -13,5 +13,10 @@ MMKV.prototype.get = function (key: string): T | undefined { } MMKV.prototype.setAny = function (key: string, value: any | undefined): void { - this.set(key, JSON.stringify(value)); + if (value === undefined) { + this.delete(key) + } + else { + this.set(key, JSON.stringify(value)); + } } \ No newline at end of file diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index f4ccce75..2b602323 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -1,3 +1,4 @@ +import "@/augmentations"; import { useInterval } from "@/hooks/useInterval"; import { storage } from "@/utils/mmkv"; import { Api, Jellyfin } from "@jellyfin/sdk"; @@ -19,7 +20,8 @@ import React, { import { Platform } from "react-native"; import uuid from "react-native-uuid"; import { getDeviceName } from "react-native-device-info"; -import { toast } from "sonner-native"; +import { useSettings } from "@/utils/atoms/settings"; +import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; interface Server { address: string; @@ -70,6 +72,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const [user, setUser] = useAtom(userAtom); const [isPolling, setIsPolling] = useState(false); const [secret, setSecret] = useState(null); + const [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] = useSettings(); + const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(); useQuery({ queryKey: ["user", api], @@ -226,6 +230,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ storage.set("user", JSON.stringify(auth.data.User)); setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken)); storage.set("token", auth.data?.AccessToken); + + const recentPluginSettings = await refreshStreamyfinPluginSettings(); + if (recentPluginSettings?.jellyseerrServerUrl?.value) { + const jellyseerrApi = new JellyseerrApi(recentPluginSettings.jellyseerrServerUrl.value); + await jellyseerrApi.test().then((result) => { + if (result.isValid && result.requiresPass) { + jellyseerrApi.login(username, password).then(setJellyseerrUser); + } + }) + } } } catch (error) { if (axios.isAxiosError(error)) { @@ -262,6 +276,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ mutationFn: async () => { storage.delete("token"); setUser(null); + setPluginSettings(undefined); + await clearAllJellyseerData(); }, onError: (error) => { console.error("Logout failed:", error); diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index b473198d..eac6826e 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -1,12 +1,19 @@ import { atom, useAtom } from "jotai"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import * as ScreenOrientation from "expo-screen-orientation"; import { storage } from "../mmkv"; import { Platform } from "react-native"; import { CultureDto, + PluginStatus, SubtitlePlaybackMode, } from "@jellyfin/sdk/lib/generated-client"; +import {apiAtom} from "@/providers/JellyfinProvider"; +import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api"; +import {writeErrorLog} from "@/utils/log"; +import {AUTHORIZATION_HEADER} from "@jellyfin/sdk"; + +const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS" export type DownloadQuality = "original" | "high" | "low"; @@ -92,6 +99,13 @@ export type Settings = { hiddenLibraries?: string[]; }; +export interface Lockable { + lockable: boolean; + value: T +} + +export type PluginLockableSettings = { [K in keyof Settings]: Lockable }; + const loadSettings = (): Settings => { const defaultValues: Settings = { autoRotate: true, @@ -150,9 +164,12 @@ const saveSettings = (settings: Settings) => { }; export const settingsAtom = atom(null); +export const pluginSettingsAtom = atom(storage.get(STREAMYFIN_PLUGIN_SETTINGS)); export const useSettings = () => { + const [api] = useAtom(apiAtom); const [settings, setSettings] = useAtom(settingsAtom); + const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom); useEffect(() => { if (settings === null) { @@ -161,6 +178,45 @@ export const useSettings = () => { } }, [settings, setSettings]); + const setPluginSettings = useCallback((settings: PluginLockableSettings | undefined) => { + storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings) + _setPluginSettings(settings) + }, + [_setPluginSettings] + ) + + const refreshStreamyfinPluginSettings = useCallback( + async () => { + if (!api) + return + + const plugins = await getPluginsApi(api).getPlugins().then(({data}) => data); + + if (plugins && plugins.length > 0) { + const streamyfinPlugin = plugins.find(plugin => plugin.Name === "Streamyfin"); + + if (streamyfinPlugin?.Status != PluginStatus.Active) { + writeErrorLog( + "Streamyfin plugin is currently not active.\n" + + `Current status is: ${streamyfinPlugin?.Status}` + ); + setPluginSettings(undefined); + return; + } + + const settings = await api.axiosInstance + .get(`${api.basePath}/Streamyfin/config`, { headers: { [AUTHORIZATION_HEADER]: api.authorizationHeader } }) + .then(response => { + return response.data['settings'] as PluginLockableSettings + }) + + setPluginSettings(settings); + return settings; + } + }, + [api] + ) + const updateSettings = (update: Partial) => { if (settings) { const newSettings = { ...settings, ...update }; @@ -170,5 +226,5 @@ export const useSettings = () => { } }; - return [settings, updateSettings] as const; + return [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] as const; }; From 33ea657a5c4627a44ab632240473bcf74233f774 Mon Sep 17 00:00:00 2001 From: sarendsen Date: Thu, 9 Jan 2025 10:13:57 +0100 Subject: [PATCH 09/34] Filter out duplicate names in media sources --- components/MediaSourceSelector.tsx | 31 +++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index 34f02fd9..4888692a 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -29,6 +29,27 @@ export const MediaSourceSelector: React.FC = ({ [item, selected] ); + const commonPrefix = useMemo(() => { + const mediaSources = item.MediaSources || []; + if (!mediaSources.length) return ""; + + let commonPrefix = ""; + for (let i = 0; i < mediaSources[0].Name.length; i++) { + const char = mediaSources[0].Name[i]; + if (mediaSources.every((source) => source.Name[i] === char)) { + commonPrefix += char; + } else { + commonPrefix = commonPrefix.slice(0, -1); + break; + } + } + return commonPrefix; + }, [item.MediaSources]); + + const name = (name?: string | null) => { + return name?.replace(commonPrefix, "").toLowerCase(); + }; + return ( = ({ }} > - {`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits( - source.Size - )}`} + {`${name(source.Name)}`} ))} @@ -74,9 +93,3 @@ export const MediaSourceSelector: React.FC = ({ ); }; - -const name = (name?: string | null) => { - if (name && name.length > 40) - return name.substring(0, 20) + " [...] " + name.substring(name.length - 20); - return name; -}; From 882d0ea188f26d1fde1c00643089bfcf46628ab9 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Thu, 9 Jan 2025 08:51:53 -0500 Subject: [PATCH 10/34] api augmentations & added streamyfin plugin id --- augmentations/api.ts | 30 ++++++++++++++++++++++++++++++ utils/atoms/settings.ts | 16 ++++++++-------- 2 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 augmentations/api.ts diff --git a/augmentations/api.ts b/augmentations/api.ts new file mode 100644 index 00000000..72b8c45f --- /dev/null +++ b/augmentations/api.ts @@ -0,0 +1,30 @@ +import {Api, AUTHORIZATION_HEADER} from "@jellyfin/sdk"; +import {AxiosRequestConfig, AxiosResponse} from "axios"; +import {StreamyfinPluginConfig} from "@/utils/atoms/settings"; + +declare module '@jellyfin/sdk' { + interface Api { + get(url: string, config?: AxiosRequestConfig): Promise> + post(url: string, data: D, config?: AxiosRequestConfig): Promise> + getStreamyfinPluginConfig(): Promise> + } +} + +Api.prototype.get = function (url: string, config: AxiosRequestConfig = {}): Promise> { + return this.axiosInstance.get(url, { + ...(config ?? {}), + headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader } + }) +} + +Api.prototype.post = function (url: string, data: D, config: AxiosRequestConfig): Promise> { + return this.axiosInstance.get(`${this.basePath}${url}`, { + ...(config || {}), + data, + headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader }} + ) +} + +Api.prototype.getStreamyfinPluginConfig = function (): Promise> { + return this.get("/Streamyfin/config") +} \ No newline at end of file diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index eac6826e..becb1ea8 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -11,8 +11,8 @@ import { import {apiAtom} from "@/providers/JellyfinProvider"; import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api"; import {writeErrorLog} from "@/utils/log"; -import {AUTHORIZATION_HEADER} from "@jellyfin/sdk"; +const STREAMYFIN_PLUGIN_ID = "1e9e5d38-6e67-4615-8719-e98a5c34f004" const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS" export type DownloadQuality = "original" | "high" | "low"; @@ -105,6 +105,9 @@ export interface Lockable { } export type PluginLockableSettings = { [K in keyof Settings]: Lockable }; +export type StreamyfinPluginConfig = { + settings: PluginLockableSettings +} const loadSettings = (): Settings => { const defaultValues: Settings = { @@ -193,9 +196,9 @@ export const useSettings = () => { const plugins = await getPluginsApi(api).getPlugins().then(({data}) => data); if (plugins && plugins.length > 0) { - const streamyfinPlugin = plugins.find(plugin => plugin.Name === "Streamyfin"); + const streamyfinPlugin = plugins.find(plugin => plugin.Id === STREAMYFIN_PLUGIN_ID); - if (streamyfinPlugin?.Status != PluginStatus.Active) { + if (!streamyfinPlugin || streamyfinPlugin.Status != PluginStatus.Active) { writeErrorLog( "Streamyfin plugin is currently not active.\n" + `Current status is: ${streamyfinPlugin?.Status}` @@ -204,11 +207,8 @@ export const useSettings = () => { return; } - const settings = await api.axiosInstance - .get(`${api.basePath}/Streamyfin/config`, { headers: { [AUTHORIZATION_HEADER]: api.authorizationHeader } }) - .then(response => { - return response.data['settings'] as PluginLockableSettings - }) + const settings = await api.getStreamyfinPluginConfig() + .then(({data}) => data.settings) setPluginSettings(settings); return settings; From e1720a00da611794cbd5ea950a476c41619f9a2b Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Wed, 8 Jan 2025 21:52:31 -0500 Subject: [PATCH 11/34] initial changes --- augmentations/mmkv.ts | 7 +++- providers/JellyfinProvider.tsx | 18 +++++++++- utils/atoms/settings.ts | 60 ++++++++++++++++++++++++++++++++-- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/augmentations/mmkv.ts b/augmentations/mmkv.ts index 80fbeede..5667502f 100644 --- a/augmentations/mmkv.ts +++ b/augmentations/mmkv.ts @@ -13,5 +13,10 @@ MMKV.prototype.get = function (key: string): T | undefined { } MMKV.prototype.setAny = function (key: string, value: any | undefined): void { - this.set(key, JSON.stringify(value)); + if (value === undefined) { + this.delete(key) + } + else { + this.set(key, JSON.stringify(value)); + } } \ No newline at end of file diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index f4ccce75..2b602323 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -1,3 +1,4 @@ +import "@/augmentations"; import { useInterval } from "@/hooks/useInterval"; import { storage } from "@/utils/mmkv"; import { Api, Jellyfin } from "@jellyfin/sdk"; @@ -19,7 +20,8 @@ import React, { import { Platform } from "react-native"; import uuid from "react-native-uuid"; import { getDeviceName } from "react-native-device-info"; -import { toast } from "sonner-native"; +import { useSettings } from "@/utils/atoms/settings"; +import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; interface Server { address: string; @@ -70,6 +72,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const [user, setUser] = useAtom(userAtom); const [isPolling, setIsPolling] = useState(false); const [secret, setSecret] = useState(null); + const [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] = useSettings(); + const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(); useQuery({ queryKey: ["user", api], @@ -226,6 +230,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ storage.set("user", JSON.stringify(auth.data.User)); setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken)); storage.set("token", auth.data?.AccessToken); + + const recentPluginSettings = await refreshStreamyfinPluginSettings(); + if (recentPluginSettings?.jellyseerrServerUrl?.value) { + const jellyseerrApi = new JellyseerrApi(recentPluginSettings.jellyseerrServerUrl.value); + await jellyseerrApi.test().then((result) => { + if (result.isValid && result.requiresPass) { + jellyseerrApi.login(username, password).then(setJellyseerrUser); + } + }) + } } } catch (error) { if (axios.isAxiosError(error)) { @@ -262,6 +276,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ mutationFn: async () => { storage.delete("token"); setUser(null); + setPluginSettings(undefined); + await clearAllJellyseerData(); }, onError: (error) => { console.error("Logout failed:", error); diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index b473198d..eac6826e 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -1,12 +1,19 @@ import { atom, useAtom } from "jotai"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import * as ScreenOrientation from "expo-screen-orientation"; import { storage } from "../mmkv"; import { Platform } from "react-native"; import { CultureDto, + PluginStatus, SubtitlePlaybackMode, } from "@jellyfin/sdk/lib/generated-client"; +import {apiAtom} from "@/providers/JellyfinProvider"; +import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api"; +import {writeErrorLog} from "@/utils/log"; +import {AUTHORIZATION_HEADER} from "@jellyfin/sdk"; + +const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS" export type DownloadQuality = "original" | "high" | "low"; @@ -92,6 +99,13 @@ export type Settings = { hiddenLibraries?: string[]; }; +export interface Lockable { + lockable: boolean; + value: T +} + +export type PluginLockableSettings = { [K in keyof Settings]: Lockable }; + const loadSettings = (): Settings => { const defaultValues: Settings = { autoRotate: true, @@ -150,9 +164,12 @@ const saveSettings = (settings: Settings) => { }; export const settingsAtom = atom(null); +export const pluginSettingsAtom = atom(storage.get(STREAMYFIN_PLUGIN_SETTINGS)); export const useSettings = () => { + const [api] = useAtom(apiAtom); const [settings, setSettings] = useAtom(settingsAtom); + const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom); useEffect(() => { if (settings === null) { @@ -161,6 +178,45 @@ export const useSettings = () => { } }, [settings, setSettings]); + const setPluginSettings = useCallback((settings: PluginLockableSettings | undefined) => { + storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings) + _setPluginSettings(settings) + }, + [_setPluginSettings] + ) + + const refreshStreamyfinPluginSettings = useCallback( + async () => { + if (!api) + return + + const plugins = await getPluginsApi(api).getPlugins().then(({data}) => data); + + if (plugins && plugins.length > 0) { + const streamyfinPlugin = plugins.find(plugin => plugin.Name === "Streamyfin"); + + if (streamyfinPlugin?.Status != PluginStatus.Active) { + writeErrorLog( + "Streamyfin plugin is currently not active.\n" + + `Current status is: ${streamyfinPlugin?.Status}` + ); + setPluginSettings(undefined); + return; + } + + const settings = await api.axiosInstance + .get(`${api.basePath}/Streamyfin/config`, { headers: { [AUTHORIZATION_HEADER]: api.authorizationHeader } }) + .then(response => { + return response.data['settings'] as PluginLockableSettings + }) + + setPluginSettings(settings); + return settings; + } + }, + [api] + ) + const updateSettings = (update: Partial) => { if (settings) { const newSettings = { ...settings, ...update }; @@ -170,5 +226,5 @@ export const useSettings = () => { } }; - return [settings, updateSettings] as const; + return [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] as const; }; From 54af64abeffe79ccae9110a35845bd7895ae4710 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Thu, 9 Jan 2025 08:51:53 -0500 Subject: [PATCH 12/34] api augmentations & added streamyfin plugin id --- augmentations/api.ts | 30 ++++++++++++++++++++++++++++++ utils/atoms/settings.ts | 16 ++++++++-------- 2 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 augmentations/api.ts diff --git a/augmentations/api.ts b/augmentations/api.ts new file mode 100644 index 00000000..17673298 --- /dev/null +++ b/augmentations/api.ts @@ -0,0 +1,30 @@ +import {Api, AUTHORIZATION_HEADER} from "@jellyfin/sdk"; +import {AxiosRequestConfig, AxiosResponse} from "axios"; +import {StreamyfinPluginConfig} from "@/utils/atoms/settings"; + +declare module '@jellyfin/sdk' { + interface Api { + get(url: string, config?: AxiosRequestConfig): Promise> + post(url: string, data: D, config?: AxiosRequestConfig): Promise> + getStreamyfinPluginConfig(): Promise> + } +} + +Api.prototype.get = function (url: string, config: AxiosRequestConfig = {}): Promise> { + return this.axiosInstance.get(`${this.basePath}${url}`, { + ...(config ?? {}), + headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader } + }) +} + +Api.prototype.post = function (url: string, data: D, config: AxiosRequestConfig): Promise> { + return this.axiosInstance.post(`${this.basePath}${url}`, { + ...(config || {}), + data, + headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader }} + ) +} + +Api.prototype.getStreamyfinPluginConfig = function (): Promise> { + return this.get("/Streamyfin/config") +} \ No newline at end of file diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index eac6826e..becb1ea8 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -11,8 +11,8 @@ import { import {apiAtom} from "@/providers/JellyfinProvider"; import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api"; import {writeErrorLog} from "@/utils/log"; -import {AUTHORIZATION_HEADER} from "@jellyfin/sdk"; +const STREAMYFIN_PLUGIN_ID = "1e9e5d38-6e67-4615-8719-e98a5c34f004" const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS" export type DownloadQuality = "original" | "high" | "low"; @@ -105,6 +105,9 @@ export interface Lockable { } export type PluginLockableSettings = { [K in keyof Settings]: Lockable }; +export type StreamyfinPluginConfig = { + settings: PluginLockableSettings +} const loadSettings = (): Settings => { const defaultValues: Settings = { @@ -193,9 +196,9 @@ export const useSettings = () => { const plugins = await getPluginsApi(api).getPlugins().then(({data}) => data); if (plugins && plugins.length > 0) { - const streamyfinPlugin = plugins.find(plugin => plugin.Name === "Streamyfin"); + const streamyfinPlugin = plugins.find(plugin => plugin.Id === STREAMYFIN_PLUGIN_ID); - if (streamyfinPlugin?.Status != PluginStatus.Active) { + if (!streamyfinPlugin || streamyfinPlugin.Status != PluginStatus.Active) { writeErrorLog( "Streamyfin plugin is currently not active.\n" + `Current status is: ${streamyfinPlugin?.Status}` @@ -204,11 +207,8 @@ export const useSettings = () => { return; } - const settings = await api.axiosInstance - .get(`${api.basePath}/Streamyfin/config`, { headers: { [AUTHORIZATION_HEADER]: api.authorizationHeader } }) - .then(response => { - return response.data['settings'] as PluginLockableSettings - }) + const settings = await api.getStreamyfinPluginConfig() + .then(({data}) => data.settings) setPluginSettings(settings); return settings; From 9dfcc01f17eae598402b3523b3b32117a9e3629d Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Fri, 10 Jan 2025 20:39:32 -0500 Subject: [PATCH 13/34] chore --- augmentations/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/augmentations/index.ts b/augmentations/index.ts index 22ca2cb0..abec02c9 100644 --- a/augmentations/index.ts +++ b/augmentations/index.ts @@ -1,3 +1,4 @@ +export * from "./api"; export * from "./mmkv"; export * from "./number"; export * from "./string"; From 2c6823eb53283f2f53eb810ca40f46d056e8ba08 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Fri, 10 Jan 2025 22:20:10 -0500 Subject: [PATCH 14/34] feat: [StreamyfinPlugin] Jellyseerr, Search Engine, & Download settings - Added DisabledSetting.tsx component - Added DownloadMethod enum - cleanup --- app/(auth)/(tabs)/(home)/downloads/index.tsx | 4 +- app/(auth)/(tabs)/(home)/settings.tsx | 2 +- .../(home)/settings/jellyseerr/page.tsx | 76 ++---------- .../(home)/settings/marlin-search/page.tsx | 117 ++++++++++-------- .../(home)/settings/optimized-server/page.tsx | 34 ++--- components/DownloadItem.tsx | 4 +- components/downloads/ActiveDownloads.tsx | 6 +- components/settings/DisabledSetting.tsx | 26 ++++ components/settings/DownloadSettings.tsx | 58 +++++---- components/settings/Jellyseerr.tsx | 2 +- providers/DownloadProvider.tsx | 6 +- utils/atoms/settings.ts | 37 ++++-- 12 files changed, 193 insertions(+), 179 deletions(-) create mode 100644 components/settings/DisabledSetting.tsx diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 56f21af3..2e78e84f 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -4,7 +4,7 @@ import { MovieCard } from "@/components/downloads/MovieCard"; import { SeriesCard } from "@/components/downloads/SeriesCard"; import { DownloadedItem, useDownload } from "@/providers/DownloadProvider"; import { queueAtom } from "@/utils/atoms/queue"; -import { useSettings } from "@/utils/atoms/settings"; +import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { Ionicons } from "@expo/vector-icons"; import { useNavigation, useRouter } from "expo-router"; import { useAtom } from "jotai"; @@ -96,7 +96,7 @@ export default function page() { > - {settings?.downloadMethod === "remux" && ( + {settings?.downloadMethod === DownloadMethod.Remux && ( Queue diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 38a0d34a..b96802a9 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -15,7 +15,7 @@ import { useJellyfin } from "@/providers/JellyfinProvider"; import { clearLogs } from "@/utils/log"; import { useHaptic } from "@/hooks/useHaptic"; import { useNavigation, useRouter } from "expo-router"; -import { useEffect } from "react"; +import React, { useEffect } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { storage } from "@/utils/mmkv"; diff --git a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx index af4247d5..5da08ff1 100644 --- a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx @@ -1,78 +1,16 @@ -import { Text } from "@/components/common/Text"; import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; -import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm"; -import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { getOrSetDeviceId } from "@/utils/device"; -import { getStatistics } from "@/utils/optimize-server"; -import { useMutation } from "@tanstack/react-query"; -import { useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; -import { ActivityIndicator, TouchableOpacity, View } from "react-native"; -import { toast } from "sonner-native"; +import DisabledSetting from "@/components/settings/DisabledSetting"; export default function page() { - const navigation = useNavigation(); - - const [api] = useAtom(apiAtom); - const [settings, updateSettings] = useSettings(); - - const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = - useState(settings?.optimizedVersionsServerUrl || ""); - - const saveMutation = useMutation({ - mutationFn: async (newVal: string) => { - if (newVal.length === 0 || !newVal.startsWith("http")) { - toast.error("Invalid URL"); - return; - } - - const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/"; - - updateSettings({ - optimizedVersionsServerUrl: updatedUrl, - }); - - return await getStatistics({ - url: settings?.optimizedVersionsServerUrl, - authHeader: api?.accessToken, - deviceId: getOrSetDeviceId(), - }); - }, - onSuccess: (data) => { - if (data) { - toast.success("Connected"); - } else { - toast.error("Could not connect"); - } - }, - onError: () => { - toast.error("Could not connect"); - }, - }); - - const onSave = (newVal: string) => { - saveMutation.mutate(newVal); - }; - - // useEffect(() => { - // navigation.setOptions({ - // title: "Optimized Server", - // headerRight: () => - // saveMutation.isPending ? ( - // - // ) : ( - // onSave(optimizedVersionsServerUrl)}> - // Save - // - // ), - // }); - // }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]); + const [settings, updateSettings, pluginSettings] = useSettings(); return ( - + - + ); } diff --git a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx index b8255c6e..dab489cb 100644 --- a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx @@ -1,12 +1,10 @@ import { Text } from "@/components/common/Text"; import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/list/ListItem"; -import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; +import React, {useEffect, useMemo, useState} from "react"; import { Linking, Switch, @@ -15,11 +13,12 @@ import { View, } from "react-native"; import { toast } from "sonner-native"; +import DisabledSetting from "@/components/settings/DisabledSetting"; export default function page() { const navigation = useNavigation(); - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const queryClient = useQueryClient(); const [value, setValue] = useState(settings?.marlinServerUrl || ""); @@ -35,69 +34,81 @@ export default function page() { Linking.openURL("https://github.com/fredrikburmester/marlin-search"); }; + const disabled = useMemo(() => { + return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true + }, [pluginSettings]); + useEffect(() => { - navigation.setOptions({ - headerRight: () => ( - onSave(value)}> - Save - - ), - }); + if (!pluginSettings?.marlinServerUrl?.locked) { + navigation.setOptions({ + headerRight: () => ( + onSave(value)}> + Save + + ), + }); + } }, [navigation, value]); if (!settings) return null; return ( - + - { - updateSettings({ searchEngine: "Jellyfin" }); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} + - { - updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" }); + { + updateSettings({ searchEngine: "Jellyfin" }); queryClient.invalidateQueries({ queryKey: ["search"] }); }} - /> - + > + { + updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + }} + /> + + - - - - URL - setValue(text)} - /> - + + URL + setValue(text)} + /> - - Enter the URL for the Marlin server. The URL should include http or - https and optionally the port.{" "} - - Read more about Marlin. - + + + Enter the URL for the Marlin server. The URL should include http or + https and optionally the port.{" "} + + Read more about Marlin. - - + + ); } diff --git a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx index b47d565f..11930607 100644 --- a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx @@ -10,12 +10,13 @@ import { useAtom } from "jotai"; import { useEffect, useState } from "react"; import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { toast } from "sonner-native"; +import DisabledSetting from "@/components/settings/DisabledSetting"; export default function page() { const navigation = useNavigation(); const [api] = useAtom(apiAtom); - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = useState(settings?.optimizedVersionsServerUrl || ""); @@ -56,25 +57,30 @@ export default function page() { }; useEffect(() => { - navigation.setOptions({ - title: "Optimized Server", - headerRight: () => - saveMutation.isPending ? ( - - ) : ( - onSave(optimizedVersionsServerUrl)}> - Save - - ), - }); + if (!pluginSettings?.optimizedVersionsServerUrl?.locked) { + navigation.setOptions({ + title: "Optimized Server", + headerRight: () => + saveMutation.isPending ? ( + + ) : ( + onSave(optimizedVersionsServerUrl)}> + Save + + ), + }); + } }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]); return ( - + - + ); } diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 4618bb4f..35ed063c 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { queueActions, queueAtom } from "@/utils/atoms/queue"; -import { useSettings } from "@/utils/atoms/settings"; +import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; @@ -74,7 +74,7 @@ export const DownloadItems: React.FC = ({ [user] ); const usingOptimizedServer = useMemo( - () => settings?.downloadMethod === "optimized", + () => settings?.downloadMethod === DownloadMethod.Optimized, [settings] ); diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 556ae8c7..e42027ab 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -1,7 +1,6 @@ import { Text } from "@/components/common/Text"; import { useDownload } from "@/providers/DownloadProvider"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; +import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { JobStatus } from "@/utils/optimize-server"; import { formatTimeString } from "@/utils/time"; import { Ionicons } from "@expo/vector-icons"; @@ -9,7 +8,6 @@ import { checkForExistingDownloads } from "@kesha-antonov/react-native-backgroun import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import { FFmpegKit } from "ffmpeg-kit-react-native"; -import { useAtom } from "jotai"; import { ActivityIndicator, TouchableOpacity, @@ -62,7 +60,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { mutationFn: async (id: string) => { if (!process) throw new Error("No active download"); - if (settings?.downloadMethod === "optimized") { + if (settings?.downloadMethod === DownloadMethod.Optimized) { try { const tasks = await checkForExistingDownloads(); for (const task of tasks) { diff --git a/components/settings/DisabledSetting.tsx b/components/settings/DisabledSetting.tsx new file mode 100644 index 00000000..b340fb96 --- /dev/null +++ b/components/settings/DisabledSetting.tsx @@ -0,0 +1,26 @@ +import {View, ViewProps} from "react-native"; +import {Text} from "@/components/common/Text"; + +const DisabledSetting: React.FC<{disabled: boolean, showText?: boolean, text?: string} & ViewProps> = ({ + disabled = false, + showText = true, + text, + children, + ...props +}) => ( + + + {disabled && showText && + {text ?? "Currently disabled by admin."} + } + {children} + + +) + +export default DisabledSetting; \ No newline at end of file diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx index f330dc04..7937b8a8 100644 --- a/components/settings/DownloadSettings.tsx +++ b/components/settings/DownloadSettings.tsx @@ -1,33 +1,47 @@ -import { Stepper } from "@/components/inputs/Stepper"; -import { useDownload } from "@/providers/DownloadProvider"; -import { Settings, useSettings } from "@/utils/atoms/settings"; -import { Ionicons } from "@expo/vector-icons"; -import { useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "expo-router"; -import React from "react"; -import { Switch, TouchableOpacity, View } from "react-native"; +import {Stepper} from "@/components/inputs/Stepper"; +import {useDownload} from "@/providers/DownloadProvider"; +import {DownloadMethod, Settings, useSettings} from "@/utils/atoms/settings"; +import {Ionicons} from "@expo/vector-icons"; +import {useQueryClient} from "@tanstack/react-query"; +import {useRouter} from "expo-router"; +import React, {useMemo} from "react"; +import {Switch, TouchableOpacity} from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; -import { Text } from "../common/Text"; -import { ListGroup } from "../list/ListGroup"; -import { ListItem } from "../list/ListItem"; +import {Text} from "../common/Text"; +import {ListGroup} from "../list/ListGroup"; +import {ListItem} from "../list/ListItem"; +import DisabledSetting from "@/components/settings/DisabledSetting"; export const DownloadSettings: React.FC = ({ ...props }) => { - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const { setProcesses } = useDownload(); const router = useRouter(); const queryClient = useQueryClient(); + const disabled = useMemo(() => ( + pluginSettings?.downloadMethod?.locked === true && + pluginSettings?.remuxConcurrentLimit?.locked === true && + pluginSettings?.autoDownload.locked === true + ), [pluginSettings]) + if (!settings) return null; return ( - + - + - {settings.downloadMethod === "remux" + {settings.downloadMethod === DownloadMethod.Remux ? "Default" : "Optimized"} @@ -51,7 +65,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => { { - updateSettings({ downloadMethod: "remux" }); + updateSettings({ downloadMethod: DownloadMethod.Remux }); setProcesses([]); }} > @@ -60,7 +74,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => { { - updateSettings({ downloadMethod: "optimized" }); + updateSettings({ downloadMethod: DownloadMethod.Optimized }); setProcesses([]); queryClient.invalidateQueries({ queryKey: ["search"] }); }} @@ -73,7 +87,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => { { updateSettings({ autoDownload: value })} /> router.push("/settings/optimized-server/page")} showArrow title="Optimized Versions Server" > - + ); }; diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx index 148f1823..d0ebd9df 100644 --- a/components/settings/Jellyseerr.tsx +++ b/components/settings/Jellyseerr.tsx @@ -21,7 +21,7 @@ export const JellyseerrSettings = () => { } = useJellyseerr(); const [user] = useAtom(userAtom); - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const [promptForJellyseerrPass, setPromptForJellyseerrPass] = useState(false); diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index fb8b137f..51f1f35c 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -1,4 +1,4 @@ -import { useSettings } from "@/utils/atoms/settings"; +import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { getOrSetDeviceId } from "@/utils/device"; import { useLog, writeToLog } from "@/utils/log"; import { @@ -106,7 +106,7 @@ function useDownloadProvider() { const url = settings?.optimizedVersionsServerUrl; if ( - settings?.downloadMethod !== "optimized" || + settings?.downloadMethod !== DownloadMethod.Optimized || !url || !deviceId || !authHeader @@ -166,7 +166,7 @@ function useDownloadProvider() { }, staleTime: 0, refetchInterval: 2000, - enabled: settings?.downloadMethod === "optimized", + enabled: settings?.downloadMethod === DownloadMethod.Optimized, }); useEffect(() => { diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index becb1ea8..0ed9199b 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -1,5 +1,5 @@ import { atom, useAtom } from "jotai"; -import { useCallback, useEffect } from "react"; +import {useCallback, useEffect, useMemo} from "react"; import * as ScreenOrientation from "expo-screen-orientation"; import { storage } from "../mmkv"; import { Platform } from "react-native"; @@ -12,7 +12,7 @@ import {apiAtom} from "@/providers/JellyfinProvider"; import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api"; import {writeErrorLog} from "@/utils/log"; -const STREAMYFIN_PLUGIN_ID = "1e9e5d38-6e67-4615-8719-e98a5c34f004" +const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004" const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS" export type DownloadQuality = "original" | "high" | "low"; @@ -66,6 +66,11 @@ export type DefaultLanguageOption = { label: string; }; +export enum DownloadMethod { + Remux = "remux", + Optimized = "optimized" +} + export type Settings = { autoRotate?: boolean; forceLandscapeInVideoPlayer?: boolean; @@ -88,7 +93,7 @@ export type Settings = { forwardSkipTime: number; rewindSkipTime: number; optimizedVersionsServerUrl?: string | null; - downloadMethod: "optimized" | "remux"; + downloadMethod: DownloadMethod; autoDownload: boolean; showCustomMenuLinks: boolean; disableHapticFeedback: boolean; @@ -100,7 +105,7 @@ export type Settings = { }; export interface Lockable { - lockable: boolean; + locked: boolean; value: T } @@ -138,7 +143,7 @@ const loadSettings = (): Settings => { forwardSkipTime: 30, rewindSkipTime: 10, optimizedVersionsServerUrl: null, - downloadMethod: "remux", + downloadMethod: DownloadMethod.Remux, autoDownload: false, showCustomMenuLinks: false, disableHapticFeedback: false, @@ -171,15 +176,15 @@ export const pluginSettingsAtom = atom(storage.get(STREA export const useSettings = () => { const [api] = useAtom(apiAtom); - const [settings, setSettings] = useAtom(settingsAtom); + const [_settings, setSettings] = useAtom(settingsAtom); const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom); useEffect(() => { - if (settings === null) { + if (_settings === null) { const loadedSettings = loadSettings(); setSettings(loadedSettings); } - }, [settings, setSettings]); + }, [_settings, setSettings]); const setPluginSettings = useCallback((settings: PluginLockableSettings | undefined) => { storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings) @@ -217,6 +222,22 @@ export const useSettings = () => { [api] ) + // We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting. + const settings: Settings = useMemo(() => { + const overrideSettings = Object.entries(pluginSettings || {}) + .reduce((acc, [key, value]) => { + if (value) { + acc = Object.assign(acc, {[key]: value.value}) + } + return acc + }, {} as Settings) + + return { + ..._settings, + ...overrideSettings + } + }, [_settings, setSettings, pluginSettings, _setPluginSettings, setPluginSettings]) + const updateSettings = (update: Partial) => { if (settings) { const newSettings = { ...settings, ...update }; From 2d9aaccfe0ce2a20e84fb5885302b33a92176eb6 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Fri, 10 Jan 2025 22:38:26 -0500 Subject: [PATCH 15/34] feat: [StreamyfinPlugin] Media Toggles settings --- components/inputs/Stepper.tsx | 11 +++- components/settings/MediaToggles.tsx | 95 ++++++++++++---------------- utils/atoms/settings.ts | 11 +++- 3 files changed, 59 insertions(+), 58 deletions(-) diff --git a/components/inputs/Stepper.tsx b/components/inputs/Stepper.tsx index eb5032cf..86a4ffa9 100644 --- a/components/inputs/Stepper.tsx +++ b/components/inputs/Stepper.tsx @@ -1,8 +1,10 @@ import {TouchableOpacity, View} from "react-native"; import {Text} from "@/components/common/Text"; +import DisabledSetting from "@/components/settings/DisabledSetting"; interface StepperProps { value: number, + disabled?: boolean, step: number, min: number, max: number, @@ -12,6 +14,7 @@ interface StepperProps { export const Stepper: React.FC = ({ value, + disabled, step, min, max, @@ -19,7 +22,11 @@ export const Stepper: React.FC = ({ appendValue }) => { return ( - + onUpdate(Math.max(min, value - step))} className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" @@ -39,6 +46,6 @@ export const Stepper: React.FC = ({ > + - + ) } \ No newline at end of file diff --git a/components/settings/MediaToggles.tsx b/components/settings/MediaToggles.tsx index 7e4c4346..6283cb03 100644 --- a/components/settings/MediaToggles.tsx +++ b/components/settings/MediaToggles.tsx @@ -1,72 +1,61 @@ -import React from "react"; -import { TouchableOpacity, View, ViewProps } from "react-native"; +import React, {useMemo} from "react"; +import { ViewProps } from "react-native"; import { useSettings } from "@/utils/atoms/settings"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; -import { Text } from "../common/Text"; +import DisabledSetting from "@/components/settings/DisabledSetting"; +import {Stepper} from "@/components/inputs/Stepper"; interface Props extends ViewProps {} export const MediaToggles: React.FC = ({ ...props }) => { - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); if (!settings) return null; - const renderSkipControl = ( - value: number, - onDecrease: () => void, - onIncrease: () => void - ) => ( - - - - - - - {value}s - - - + - - - ); + const disabled = useMemo(() => ( + pluginSettings?.forwardSkipTime?.locked === true && + pluginSettings?.rewindSkipTime?.locked === true + ), + [pluginSettings] + ) return ( - + - - {renderSkipControl( - settings.forwardSkipTime, - () => - updateSettings({ - forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5), - }), - () => - updateSettings({ - forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5), - }) - )} + + updateSettings({forwardSkipTime})} + /> - - {renderSkipControl( - settings.rewindSkipTime, - () => - updateSettings({ - rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5), - }), - () => - updateSettings({ - rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5), - }) - )} + + updateSettings({rewindSkipTime})} + /> - + ); }; diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 0ed9199b..993c722d 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -223,11 +223,16 @@ export const useSettings = () => { ) // We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting. + // If admin sets locked to false but provides a value, + // use user settings first and fallback on admin setting if required. const settings: Settings = useMemo(() => { const overrideSettings = Object.entries(pluginSettings || {}) - .reduce((acc, [key, value]) => { - if (value) { - acc = Object.assign(acc, {[key]: value.value}) + .reduce((acc, [key, setting]) => { + if (setting) { + const {value, locked} = setting + acc = Object.assign(acc, { + [key]: locked ? value : _settings?.[key as keyof Settings] ?? value + }) } return acc }, {} as Settings) From 0f974ef2a3ca2c27ca714ae1a93376f4c1b79bde Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Fri, 10 Jan 2025 22:42:57 -0500 Subject: [PATCH 16/34] feat: [StreamyfinPlugin] Audio Toggles settings --- components/settings/AudioToggles.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index 62aea437..6afaedf4 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -6,11 +6,13 @@ import { Switch } from "react-native-gesture-handler"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; import { Ionicons } from "@expo/vector-icons"; +import {useSettings} from "@/utils/atoms/settings"; interface Props extends ViewProps {} export const AudioToggles: React.FC = ({ ...props }) => { const media = useMedia(); + const [_, __, pluginSettings] = useSettings(); const { settings, updateSettings } = media; const cultures = media.cultures; @@ -26,9 +28,13 @@ export const AudioToggles: React.FC = ({ ...props }) => { } > - + updateSettings({ rememberAudioSelections: value }) } From 455bf08213902b61ab8cae704892583d58d49759 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Fri, 10 Jan 2025 23:16:58 -0500 Subject: [PATCH 17/34] feat: [StreamyfinPlugin] Subtitle Toggles settings - Used stepper & dropdown components to simplify page --- components/common/Dropdown.tsx | 43 ++++--- components/settings/SubtitleToggles.tsx | 146 +++++++++--------------- 2 files changed, 80 insertions(+), 109 deletions(-) diff --git a/components/common/Dropdown.tsx b/components/common/Dropdown.tsx index d9612d48..fec36d2f 100644 --- a/components/common/Dropdown.tsx +++ b/components/common/Dropdown.tsx @@ -1,14 +1,16 @@ import * as DropdownMenu from "zeego/dropdown-menu"; import {TouchableOpacity, View, ViewProps} from "react-native"; import {Text} from "@/components/common/Text"; -import React, {PropsWithChildren, useEffect, useState} from "react"; +import React, {PropsWithChildren, ReactNode, useEffect, useState} from "react"; +import DisabledSetting from "@/components/settings/DisabledSetting"; interface Props { data: T[] + disabled?: boolean placeholderText?: string, keyExtractor: (item: T) => string - titleExtractor: (item: T) => string - title: string, + titleExtractor: (item: T) => string | undefined + title: string | ReactNode, label: string, onSelected: (...item: T[]) => void multi?: boolean @@ -16,6 +18,7 @@ interface Props { const Dropdown = ({ data, + disabled, placeholderText, keyExtractor, titleExtractor, @@ -34,20 +37,30 @@ const Dropdown = ({ }, [selected]); return ( - + - - - {title} - - - - {selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText} + {typeof title === 'string' ? ( + + + {title} - - + + + {selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText} + + + + ) : ( + <> + {title} + + )} ({ ))} - + ) }; diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 66c514b1..6719171d 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -7,11 +7,15 @@ import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; import { Ionicons } from "@expo/vector-icons"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; +import {useSettings} from "@/utils/atoms/settings"; +import {Stepper} from "@/components/inputs/Stepper"; +import Dropdown from "@/components/common/Dropdown"; interface Props extends ViewProps {} export const SubtitleToggles: React.FC = ({ ...props }) => { const media = useMedia(); + const [_, __, pluginSettings] = useSettings(); const { settings, updateSettings } = media; const cultures = media.cultures; @@ -36,8 +40,11 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { } > - - + item?.ThreeLetterISOLanguageName ?? "unknown"} + titleExtractor={(item) => item?.DisplayName} + title={ {settings?.defaultSubtitleLanguage?.DisplayName || "None"} @@ -48,48 +55,28 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { color="#5A5960" /> - - - Languages - { - updateSettings({ - defaultSubtitleLanguage: null, - }); - }} - > - None - - {cultures?.map((l) => ( - { - updateSettings({ - defaultSubtitleLanguage: l, - }); - }} - > - - {l.DisplayName} - - - ))} - - + } + label="Languages" + onSelected={(defaultSubtitleLanguage) => + updateSettings({ + defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === "None" + ? null + : defaultSubtitleLanguage + }) + } + /> - - - + + {settings?.subtitleMode || "Loading"} @@ -100,68 +87,39 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { color="#5A5960" /> - - - Subtitle Mode - {subtitleModes?.map((l) => ( - { - updateSettings({ - subtitleMode: l, - }); - }} - > - {l} - - ))} - - + } + label="Subtitle Mode" + onSelected={(subtitleMode) => + updateSettings({subtitleMode}) + } + /> - + updateSettings({ rememberSubtitleSelections: value }) } /> - - - - updateSettings({ - subtitleSize: Math.max(0, settings.subtitleSize - 5), - }) - } - className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" - > - - - - - {settings.subtitleSize} - - - updateSettings({ - subtitleSize: Math.min(120, settings.subtitleSize + 5), - }) - } - > - + - - + + updateSettings({subtitleSize})} + /> From dc498d62d8ec95e5758862d243ef226c6f32f1b1 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Fri, 10 Jan 2025 23:44:37 -0500 Subject: [PATCH 18/34] feat: [StreamyfinPlugin] Other settings --- .../(home)/settings/hide-libraries/page.tsx | 10 +- components/settings/OtherSettings.tsx | 245 ++++++++---------- 2 files changed, 113 insertions(+), 142 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx index 6919441e..35200bc1 100644 --- a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx @@ -8,9 +8,10 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { Switch, View } from "react-native"; +import DisabledSetting from "@/components/settings/DisabledSetting"; export default function page() { - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); @@ -35,7 +36,10 @@ export default function page() { ); return ( - + {data?.map((view) => ( {}}> @@ -56,6 +60,6 @@ export default function page() { Select the libraries you want to hide from the Library tab and home page sections. - + ); } diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index cbd8fc18..dcea3f26 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -9,19 +9,18 @@ import * as BackgroundFetch from "expo-background-fetch"; import { useRouter } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import * as TaskManager from "expo-task-manager"; -import React, { useEffect } from "react"; -import { Linking, Switch, TouchableOpacity, ViewProps } from "react-native"; +import React, {useEffect, useMemo} from "react"; +import { Linking, Switch, TouchableOpacity } from "react-native"; import { toast } from "sonner-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; - -interface Props extends ViewProps {} +import DisabledSetting from "@/components/settings/DisabledSetting"; +import Dropdown from "@/components/common/Dropdown"; export const OtherSettings: React.FC = () => { const router = useRouter(); - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); /******************** * Background task @@ -53,146 +52,114 @@ export const OtherSettings: React.FC = () => { /********************** *********************/ + const disabled = useMemo(() => ( + pluginSettings?.autoRotate?.locked === true && + pluginSettings?.defaultVideoOrientation?.locked === true && + pluginSettings?.safeAreaInControlsEnabled?.locked === true && + pluginSettings?.showCustomMenuLinks?.locked === true && + pluginSettings?.hiddenLibraries?.locked === true && + pluginSettings?.disableHapticFeedback?.locked === true + ), [pluginSettings]); + + const orientations = [ + ScreenOrientation.OrientationLock.DEFAULT, + ScreenOrientation.OrientationLock.PORTRAIT_UP, + ScreenOrientation.OrientationLock.LANDSCAPE_LEFT, + ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT + ] + if (!settings) return null; return ( - - - updateSettings({ autoRotate: value })} - /> - + + + + updateSettings({autoRotate: value})} + /> + - - - - - - {ScreenOrientationEnum[settings.defaultVideoOrientation]} - - - - - - Orientation - { - updateSettings({ - defaultVideoOrientation: - ScreenOrientation.OrientationLock.DEFAULT, - }); - }} - > - - { - ScreenOrientationEnum[ - ScreenOrientation.OrientationLock.DEFAULT - ] - } - - - { - updateSettings({ - defaultVideoOrientation: - ScreenOrientation.OrientationLock.PORTRAIT_UP, - }); - }} - > - - { - ScreenOrientationEnum[ - ScreenOrientation.OrientationLock.PORTRAIT_UP - ] - } - - - { - updateSettings({ - defaultVideoOrientation: - ScreenOrientation.OrientationLock.LANDSCAPE_LEFT, - }); - }} - > - - { - ScreenOrientationEnum[ - ScreenOrientation.OrientationLock.LANDSCAPE_LEFT - ] - } - - - { - updateSettings({ - defaultVideoOrientation: - ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT, - }); - }} - > - - { - ScreenOrientationEnum[ - ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT - ] - } - - - - - + + + ScreenOrientationEnum[item] + } + title={ + + + {ScreenOrientationEnum[settings.defaultVideoOrientation]} + + + + } + label="Orientation" + onSelected={(defaultVideoOrientation) => + updateSettings({defaultVideoOrientation}) + } + /> + - - - updateSettings({ safeAreaInControlsEnabled: value }) - } - /> - + + + updateSettings({safeAreaInControlsEnabled: value}) + } + /> + - - Linking.openURL( - "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links" - ) - } - > - - updateSettings({ showCustomMenuLinks: value }) + + Linking.openURL( + "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links" + ) } + > + + updateSettings({showCustomMenuLinks: value}) + } + /> + + router.push("/settings/hide-libraries/page")} + title="Hide Libraries" + showArrow /> - - router.push("/settings/hide-libraries/page")} - title="Hide Libraries" - showArrow - /> - - - updateSettings({ disableHapticFeedback: value }) - } - /> - - + + + updateSettings({disableHapticFeedback}) + } + /> + + + ); }; From 1727125ea76ae6c943ccad7f6aa64c404a892161 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Fri, 10 Jan 2025 23:52:58 -0500 Subject: [PATCH 19/34] feat: [StreamyfinPlugin] Popular Plugin settings --- .../(home)/settings/popular-lists/page.tsx | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx index 43cf76c4..c7d66e75 100644 --- a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx @@ -9,6 +9,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { Linking, Switch, View } from "react-native"; +import {useMemo} from "react"; +import DisabledSetting from "@/components/settings/DisabledSetting"; export default function page() { const navigation = useNavigation(); @@ -16,7 +18,7 @@ export default function page() { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const handleOpenLink = () => { Linking.openURL( @@ -48,13 +50,22 @@ export default function page() { staleTime: 0, }); + const disabled = useMemo(() => ( + pluginSettings?.usePopularPlugin?.locked === true && + pluginSettings?.mediaListCollectionIds?.locked === true + ), [pluginSettings]); + if (!settings) return null; return ( - + { updateSettings({ usePopularPlugin: true }); queryClient.invalidateQueries({ queryKey: ["search"] }); @@ -62,9 +73,10 @@ export default function page() { > { - updateSettings({ usePopularPlugin: value }); - }} + disabled={pluginSettings?.usePopularPlugin?.locked} + onValueChange={(usePopularPlugin) => + updateSettings({ usePopularPlugin }) + } /> @@ -88,11 +100,14 @@ export default function page() { <> {mediaListCollections?.map((mlc) => ( - + { if (!settings.mediaListCollectionIds) { updateSettings({ @@ -130,6 +145,6 @@ export default function page() { )} )} - + ); } From d0ae63235daddc59626b309589a292629ee547bf Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Sat, 11 Jan 2025 00:15:56 -0500 Subject: [PATCH 20/34] feat: [StreamyfinPlugin] Library Options settings --- app/(auth)/(tabs)/(libraries)/_layout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index 17813ed1..439e41df 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -6,7 +6,7 @@ import { Platform } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; export default function IndexLayout() { - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); if (!settings?.libraryOptions) return null; @@ -25,6 +25,7 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios" ? true : false, headerShadowVisible: false, headerRight: () => ( + !pluginSettings?.libraryOptions?.locked && Date: Sat, 11 Jan 2025 16:41:41 +0800 Subject: [PATCH 21/34] prevent opening control when user swipe on screen --- components/video-player/controls/Controls.tsx | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 3927587a..85cc5e62 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -40,6 +40,7 @@ import { TouchableOpacity, useWindowDimensions, View, + GestureResponderEvent, } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { @@ -531,6 +532,36 @@ export const Controls: React.FC = ({ // Used when user changes audio through audio button on device. const [showAudioSlider, setShowAudioSlider] = useState(false); + // Prevent opening controls when user swipes on the screen. + const touchStartTime = useRef(0); + const touchStartPosition = useRef({ x: 0, y: 0 }); + + const handleTouchStart = (event: GestureResponderEvent) => { + touchStartTime.current = Date.now(); + touchStartPosition.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + }; + + const handleTouchEnd = (event: GestureResponderEvent) => { + const touchEndTime = Date.now(); + const touchEndPosition = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + + const touchDuration = touchEndTime - touchStartTime.current; + const touchDistance = Math.sqrt( + Math.pow(touchEndPosition.x - touchStartPosition.current.x, 2) + + Math.pow(touchEndPosition.y - touchStartPosition.current.y, 2) + ); + + if (touchDuration < 200 && touchDistance < 10) { + toggleControls(); + } + }; + return ( = ({ ) : ( <> { - toggleControls(); - }} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} style={{ position: "absolute", width: screenWidth, @@ -560,7 +590,7 @@ export const Controls: React.FC = ({ bottom: 0, opacity: showControls ? 0.5 : 0, }} - > + /> Date: Sat, 11 Jan 2025 10:09:53 +0100 Subject: [PATCH 22/34] feat: add centralised plugin info --- app/(auth)/(tabs)/(home)/intro/page.tsx | 70 ++++++++++++++++++------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/intro/page.tsx b/app/(auth)/(tabs)/(home)/intro/page.tsx index cef9db31..e23015a5 100644 --- a/app/(auth)/(tabs)/(home)/intro/page.tsx +++ b/app/(auth)/(tabs)/(home)/intro/page.tsx @@ -5,7 +5,7 @@ import { Feather, Ionicons } from "@expo/vector-icons"; import { Image } from "expo-image"; import { useFocusEffect, useRouter } from "expo-router"; import { useCallback } from "react"; -import { TouchableOpacity, View } from "react-native"; +import { Linking, TouchableOpacity, View } from "react-native"; export default function page() { const router = useRouter(); @@ -17,7 +17,7 @@ export default function page() { ); return ( - + Welcome to Streamyfin @@ -85,25 +85,55 @@ export default function page() { + + + + + + Centralised Settings Plugin + + Configure settings from a centralised location on your Jellyfin + server. All client settings for all users will be synced + automatically.{" "} + { + Linking.openURL( + "https://github.com/streamyfin/jellyfin-plugin-streamyfin" + ); + }} + > + Read more + + + + + + + + { + router.back(); + router.push("/settings"); + }} + className="mt-4" + > + Go to settings + - - - { - router.back(); - router.push("/settings"); - }} - className="mt-4" - > - Go to settings - ); } From cab5e4d980a615e7a3cafe5fe21f8edb452b42b2 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 11 Jan 2025 10:10:00 +0100 Subject: [PATCH 23/34] chore: rename var --- components/settings/DownloadSettings.tsx | 60 ++++++++++++++---------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx index 7937b8a8..4e79e973 100644 --- a/components/settings/DownloadSettings.tsx +++ b/components/settings/DownloadSettings.tsx @@ -1,15 +1,15 @@ -import {Stepper} from "@/components/inputs/Stepper"; -import {useDownload} from "@/providers/DownloadProvider"; -import {DownloadMethod, Settings, useSettings} from "@/utils/atoms/settings"; -import {Ionicons} from "@expo/vector-icons"; -import {useQueryClient} from "@tanstack/react-query"; -import {useRouter} from "expo-router"; -import React, {useMemo} from "react"; -import {Switch, TouchableOpacity} from "react-native"; +import { Stepper } from "@/components/inputs/Stepper"; +import { useDownload } from "@/providers/DownloadProvider"; +import { DownloadMethod, Settings, useSettings } from "@/utils/atoms/settings"; +import { Ionicons } from "@expo/vector-icons"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "expo-router"; +import React, { useMemo } from "react"; +import { Switch, TouchableOpacity } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; -import {Text} from "../common/Text"; -import {ListGroup} from "../list/ListGroup"; -import {ListItem} from "../list/ListItem"; +import { Text } from "../common/Text"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; import DisabledSetting from "@/components/settings/DisabledSetting"; export const DownloadSettings: React.FC = ({ ...props }) => { @@ -18,20 +18,18 @@ export const DownloadSettings: React.FC = ({ ...props }) => { const router = useRouter(); const queryClient = useQueryClient(); - const disabled = useMemo(() => ( - pluginSettings?.downloadMethod?.locked === true && - pluginSettings?.remuxConcurrentLimit?.locked === true && - pluginSettings?.autoDownload.locked === true - ), [pluginSettings]) + const allDisabled = useMemo( + () => + pluginSettings?.downloadMethod?.locked === true && + pluginSettings?.remuxConcurrentLimit?.locked === true && + pluginSettings?.autoDownload.locked === true, + [pluginSettings] + ); if (!settings) return null; return ( - + { { updateSettings({ autoDownload: value })} /> router.push("/settings/optimized-server/page")} showArrow title="Optimized Versions Server" From a2145fd7e8705a4e9bd438bb251390c1840049aa Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 11 Jan 2025 10:20:20 +0100 Subject: [PATCH 24/34] chore: update deps --- bun.lockb | Bin 592757 -> 592757 bytes package.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lockb b/bun.lockb index 4459cefd7c8d3bf69ce2257566d4ec69b54b7a33..df0e1988d57413c75adb62fda367ddb7b78568ff 100755 GIT binary patch delta 183 zcmV;o07(D!mL&C-B#)3paupRS(>~!qRVTErNXB+FJ;t;jrmyJ_KP;fljqf z0XC46z+^0wuviF(xR3#dxR3(3xR3*VEI=pD&mS;h;z`y`17rx(I4v_7^OR{ZZ_r6! zv@B;+$36*}^4Fp0JV&b9wXsytGyj*uwRpbk;Vn^f#^$KfcT$ISy#t4Jy#%*)y#=Wm l12Qf)hauMmhauMnhauMomm${((*ZJ<5xEBWdC*n>B0LPY_`WMlqj0WR{Q}qkTtSvfljqf z0XC46L}E0DxR3#dxR3(3xR3*VEI`B?H4h<TOwlJaem^X)Yy#t4Jy#%*)y#=Wm12HZ# fhauMmhauMnhauMomm${()0e~v2_m-^*a%-UUO7 Date: Sat, 11 Jan 2025 11:21:36 +0100 Subject: [PATCH 25/34] feat: server discovery during login --- app/login.tsx | 17 ++-- bun.lockb | Bin 592757 -> 593505 bytes components/JellyfinServerDiscovery.tsx | 44 ++++++++++ hooks/useJellyfinDiscovery.tsx | 109 +++++++++++++++++++++++++ package.json | 1 + providers/JellyfinProvider.tsx | 23 +++++- 6 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 components/JellyfinServerDiscovery.tsx create mode 100644 hooks/useJellyfinDiscovery.tsx diff --git a/app/login.tsx b/app/login.tsx index 614ad793..9ac2d06c 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -1,16 +1,12 @@ import { Button } from "@/components/Button"; import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; +import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; import { PreviousServersList } from "@/components/PreviousServersList"; import { Colors } from "@/constants/Colors"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; -import { - Ionicons, - MaterialCommunityIcons, - MaterialIcons, -} from "@expo/vector-icons"; +import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; -import { getSystemApi } from "@jellyfin/sdk/lib/utils/api"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; @@ -313,6 +309,15 @@ const Login: React.FC = () => { > Connect + { + setServerURL(server.address); + if (server.serverName) { + setServerName(server.serverName); + } + handleConnect(server.address); + }} + /> { handleConnect(s.address); diff --git a/bun.lockb b/bun.lockb index df0e1988d57413c75adb62fda367ddb7b78568ff..935cb03c1af4949cacc791146afacef67f8b1329 100755 GIT binary patch delta 93551 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 t^D2sW7aH*=Biat2?^Xr-N~gS7f*pvtX0~!DT delta 93433 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�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 U$Rz#sbXL4Wudm48p}+a|zq%Q(u>b%7 diff --git a/components/JellyfinServerDiscovery.tsx b/components/JellyfinServerDiscovery.tsx new file mode 100644 index 00000000..5c310d64 --- /dev/null +++ b/components/JellyfinServerDiscovery.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { View, Text, TouchableOpacity } from "react-native"; +import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery"; +import { Button } from "./Button"; +import { ListGroup } from "./list/ListGroup"; +import { ListItem } from "./list/ListItem"; + +interface Props { + onServerSelect?: (server: { address: string; serverName?: string }) => void; +} + +const JellyfinServerDiscovery: React.FC = ({ onServerSelect }) => { + const { servers, isSearching, startDiscovery } = useJellyfinDiscovery(); + + return ( + + + + {servers.length ? ( + + {servers.map((server) => ( + + onServerSelect?.({ + address: server.address, + serverName: server.serverName, + }) + } + title={server.address} + showArrow + /> + ))} + + ) : null} + + ); +}; + +export default JellyfinServerDiscovery; diff --git a/hooks/useJellyfinDiscovery.tsx b/hooks/useJellyfinDiscovery.tsx new file mode 100644 index 00000000..eab6076a --- /dev/null +++ b/hooks/useJellyfinDiscovery.tsx @@ -0,0 +1,109 @@ +import { useState } from "react"; +import dgram from "react-native-udp"; + +const JELLYFIN_DISCOVERY_PORT = 7359; +const DISCOVERY_MESSAGE = "Who is JellyfinServer?"; + +interface ServerInfo { + address: string; + port: number; + serverId?: string; + serverName?: string; +} + +const stringToUint8Array = (str: string): Uint8Array => { + const arr = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + arr[i] = str.charCodeAt(i); + } + return arr; +}; + +export const useJellyfinDiscovery = () => { + const [servers, setServers] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + const startDiscovery = () => { + setIsSearching(true); + setServers([]); + + const discoveredServers = new Set(); + + const socket = dgram.createSocket({ + type: "udp4", + reusePort: true, + debug: __DEV__, + }); + + socket.on("error", (err) => { + console.error("Socket error:", err); + socket.close(); + setIsSearching(false); + }); + + socket.bind(0, () => { + console.log("UDP socket bound successfully"); + + try { + socket.setBroadcast(true); + const messageBuffer = stringToUint8Array(DISCOVERY_MESSAGE); + + socket.send( + messageBuffer, + 0, + messageBuffer.length, + JELLYFIN_DISCOVERY_PORT, + "255.255.255.255", + (err) => { + if (err) { + console.error("Failed to send discovery message:", err); + return; + } + console.log("Discovery message sent successfully"); + } + ); + + setTimeout(() => { + setIsSearching(false); + socket.close(); + }, 5000); + } catch (error) { + console.error("Error during discovery:", error); + setIsSearching(false); + } + }); + + socket.on("message", (msg, rinfo: any) => { + if (discoveredServers.has(rinfo.address)) { + return; + } + + try { + const response = String.fromCharCode(...new Uint8Array(msg)); + const serverInfo = JSON.parse(response); + discoveredServers.add(rinfo.address); + + const newServer: ServerInfo = { + address: `http://${rinfo.address}:${serverInfo.Port || 8096}`, + port: serverInfo.Port || 8096, + serverId: serverInfo.Id, + serverName: serverInfo.Name, + }; + + setServers((prev) => [...prev, newServer]); + } catch (error) { + console.error("Error parsing server response:", error); + } + }); + + return () => { + socket.close(); + }; + }; + + return { + servers, + isSearching, + startDiscovery, + }; +}; diff --git a/package.json b/package.json index 7c6463e0..07fedc03 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "react-native-screens": "3.31.1", "react-native-svg": "15.2.0", "react-native-tab-view": "^3.5.2", + "react-native-udp": "^4.1.7", "react-native-uitextview": "^1.4.0", "react-native-url-polyfill": "^2.0.0", "react-native-uuid": "^2.0.2", diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 2b602323..1002e55b 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -50,6 +50,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const [jellyfin, setJellyfin] = useState(undefined); const [deviceId, setDeviceId] = useState(undefined); + useEffect(() => { + async () => { + const servers = jellyfin?.discovery.getRecommendedServerCandidates( + "demo.jellyfin.org/stable" + ); + console.log(servers); + }; + }, [jellyfin]); + useEffect(() => { (async () => { const id = getOrSetDeviceId(); @@ -72,7 +81,13 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const [user, setUser] = useAtom(userAtom); const [isPolling, setIsPolling] = useState(false); const [secret, setSecret] = useState(null); - const [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] = useSettings(); + const [ + settings, + updateSettings, + pluginSettings, + setPluginSettings, + refreshStreamyfinPluginSettings, + ] = useSettings(); const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(); useQuery({ @@ -233,12 +248,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const recentPluginSettings = await refreshStreamyfinPluginSettings(); if (recentPluginSettings?.jellyseerrServerUrl?.value) { - const jellyseerrApi = new JellyseerrApi(recentPluginSettings.jellyseerrServerUrl.value); + const jellyseerrApi = new JellyseerrApi( + recentPluginSettings.jellyseerrServerUrl.value + ); await jellyseerrApi.test().then((result) => { if (result.isValid && result.requiresPass) { jellyseerrApi.login(username, password).then(setJellyseerrUser); } - }) + }); } } } catch (error) { From a14063a736b007225b8efe7f298bb2139155495c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=82=F0=9D=90=A1=F0=9D=90=AB=F0=9D=90=A2?= =?UTF-8?q?=F0=9D=90=AC?= <182387676+whoopsi-daisy@users.noreply.github.com> Date: Sun, 12 Jan 2025 00:52:17 +0800 Subject: [PATCH 26/34] Update README.md Adjusted the Jellyseerr screenshot height to match the others and corrected a typo, along with rephrasing a sentence for clarity --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 87a6420c..c1abe08e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp - + ## 🌟 Features @@ -70,7 +70,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r ### Beta testing -To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where i'll post APKs and IPAs. This won't give automatic access to the TestFlight however, so you need to send me a DM with the email you use for Apple so that i can manually add you. +To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you. **Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas. @@ -90,7 +90,7 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea 1. Use node `>20` 2. Install dependencies `bun i && bun run submodule-reload` 3. Make sure you have xcode and/or android studio installed. -4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app. +4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on your computer and run the app. ## 📄 License From c8da365a0048306e49701e8d57161f65411f02bc Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 12 Jan 2025 09:36:23 +0100 Subject: [PATCH 27/34] fix: issues listed in pr --- hooks/useJellyfinDiscovery.tsx | 25 +++++++++++-------------- providers/JellyfinProvider.tsx | 9 --------- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/hooks/useJellyfinDiscovery.tsx b/hooks/useJellyfinDiscovery.tsx index eab6076a..963dfe81 100644 --- a/hooks/useJellyfinDiscovery.tsx +++ b/hooks/useJellyfinDiscovery.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useCallback } from "react"; import dgram from "react-native-udp"; const JELLYFIN_DISCOVERY_PORT = 7359; @@ -11,23 +11,16 @@ interface ServerInfo { serverName?: string; } -const stringToUint8Array = (str: string): Uint8Array => { - const arr = new Uint8Array(str.length); - for (let i = 0; i < str.length; i++) { - arr[i] = str.charCodeAt(i); - } - return arr; -}; - export const useJellyfinDiscovery = () => { const [servers, setServers] = useState([]); const [isSearching, setIsSearching] = useState(false); - const startDiscovery = () => { + const startDiscovery = useCallback(() => { setIsSearching(true); setServers([]); const discoveredServers = new Set(); + let discoveryTimeout: NodeJS.Timeout; const socket = dgram.createSocket({ type: "udp4", @@ -46,7 +39,7 @@ export const useJellyfinDiscovery = () => { try { socket.setBroadcast(true); - const messageBuffer = stringToUint8Array(DISCOVERY_MESSAGE); + const messageBuffer = new TextEncoder().encode(DISCOVERY_MESSAGE); socket.send( messageBuffer, @@ -63,7 +56,7 @@ export const useJellyfinDiscovery = () => { } ); - setTimeout(() => { + discoveryTimeout = setTimeout(() => { setIsSearching(false); socket.close(); }, 5000); @@ -79,7 +72,7 @@ export const useJellyfinDiscovery = () => { } try { - const response = String.fromCharCode(...new Uint8Array(msg)); + const response = new TextDecoder().decode(msg); const serverInfo = JSON.parse(response); discoveredServers.add(rinfo.address); @@ -97,9 +90,13 @@ export const useJellyfinDiscovery = () => { }); return () => { + clearTimeout(discoveryTimeout); + if (isSearching) { + setIsSearching(false); + } socket.close(); }; - }; + }, []); return { servers, diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 1002e55b..4455dfe1 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -50,15 +50,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const [jellyfin, setJellyfin] = useState(undefined); const [deviceId, setDeviceId] = useState(undefined); - useEffect(() => { - async () => { - const servers = jellyfin?.discovery.getRecommendedServerCandidates( - "demo.jellyfin.org/stable" - ); - console.log(servers); - }; - }, [jellyfin]); - useEffect(() => { (async () => { const id = getOrSetDeviceId(); From 4a0a51ef1d0ce7b600b24c799709dd64f5cd472b Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 12 Jan 2025 10:07:49 +0100 Subject: [PATCH 28/34] chore: refactor --- components/home/LargeMovieCarousel.tsx | 11 +- components/video-player/controls/Controls.tsx | 263 +++++++----------- .../controls/VideoTouchOverlay.tsx | 38 +++ .../controls/useControlsTimeout.ts | 56 ++++ .../video-player/controls/useTapDetection.tsx | 48 ++++ 5 files changed, 246 insertions(+), 170 deletions(-) create mode 100644 components/video-player/controls/VideoTouchOverlay.tsx create mode 100644 components/video-player/controls/useControlsTimeout.ts create mode 100644 components/video-player/controls/useTapDetection.tsx diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx index 00767621..6f9d3a1e 100644 --- a/components/home/LargeMovieCarousel.tsx +++ b/components/home/LargeMovieCarousel.tsx @@ -1,3 +1,4 @@ +import { useHaptic } from "@/hooks/useHaptic"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; @@ -6,9 +7,11 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; +import { useRouter, useSegments } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useMemo } from "react"; -import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native"; +import { Dimensions, View, ViewProps } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { runOnJS, useSharedValue, @@ -18,11 +21,7 @@ import Carousel, { ICarouselInstance, Pagination, } from "react-native-reanimated-carousel"; -import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter"; -import { Loader } from "../Loader"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import { useRouter, useSegments } from "expo-router"; -import { useHaptic } from "@/hooks/useHaptic"; +import { itemRouter } from "../common/TouchableItemRouter"; interface Props extends ViewProps {} diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 85cc5e62..ea53a2d3 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -36,11 +36,11 @@ import { useAtom } from "jotai"; import { debounce } from "lodash"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { + GestureResponderEvent, Pressable, TouchableOpacity, useWindowDimensions, View, - GestureResponderEvent, } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { @@ -60,6 +60,9 @@ import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding"; import { EpisodeList } from "./EpisodeList"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import SkipButton from "./SkipButton"; +import { useTapDetection } from "./useTapDetection"; +import { VideoTouchOverlay } from "./VideoTouchOverlay"; +import { useControlsTimeout } from "./useControlsTimeout"; interface Props { item: BaseItemDto; @@ -90,6 +93,8 @@ interface Props { isVlc?: boolean; } +const CONTROLS_TIMEOUT = 4000; + export const Controls: React.FC = ({ item, seek, @@ -122,6 +127,12 @@ export const Controls: React.FC = ({ const insets = useSafeAreaInsets(); const [api] = useAtom(apiAtom); + const [episodeView, setEpisodeView] = useState(false); + const [isSliding, setIsSliding] = useState(false); + + // Used when user changes audio through audio button on device. + const [showAudioSlider, setShowAudioSlider] = useState(false); + const { height: screenHeight, width: screenWidth } = useWindowDimensions(); const { previousItem, nextItem } = useAdjacentItems({ item }); const { @@ -140,6 +151,23 @@ export const Controls: React.FC = ({ const wasPlayingRef = useRef(false); const lastProgressRef = useRef(0); + const lightHapticFeedback = useHaptic("light"); + + useEffect(() => { + prefetchAllTrickplayImages(); + }, []); + + useEffect(() => { + if (item) { + progress.value = isVlc + ? ticksToMs(item?.UserData?.PlaybackPositionTicks) + : item?.UserData?.PlaybackPositionTicks || 0; + max.value = isVlc + ? ticksToMs(item.RunTimeTicks || 0) + : item.RunTimeTicks || 0; + } + }, [item, isVlc]); + const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{ bitrateValue: string; audioIndex: string; @@ -162,8 +190,6 @@ export const Controls: React.FC = ({ isVlc ); - const lightHapticFeedback = useHaptic("light"); - const goToPreviousItem = useCallback(() => { if (!previousItem || !settings) return; @@ -267,52 +293,21 @@ export const Controls: React.FC = ({ [updateTimes] ); - useEffect(() => { - if (item) { - progress.value = isVlc - ? ticksToMs(item?.UserData?.PlaybackPositionTicks) - : item?.UserData?.PlaybackPositionTicks || 0; - max.value = isVlc - ? ticksToMs(item.RunTimeTicks || 0) - : item.RunTimeTicks || 0; - } - }, [item, isVlc]); - - useEffect(() => { - prefetchAllTrickplayImages(); + const hideControls = useCallback(() => { + setShowControls(false); + setShowAudioSlider(false); }, []); - const CONTROLS_TIMEOUT = 5000; - const controlsTimeoutRef = useRef(); - - useEffect(() => { - const resetControlsTimeout = () => { - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - - if (showControls && !isSliding && !EpisodeView) { - controlsTimeoutRef.current = setTimeout(() => { - setShowControls(false); - setShowAudioSlider(false); - }, CONTROLS_TIMEOUT); - } - }; - - resetControlsTimeout(); - - return () => { - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - }; - }, [showControls, isSliding, EpisodeView]); + const { handleControlsInteraction } = useControlsTimeout({ + showControls, + isSliding, + episodeView, + onHideControls: hideControls, + timeout: CONTROLS_TIMEOUT, + }); const toggleControls = () => { if (showControls) { - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } setShowAudioSlider(false); setShowControls(false); } else { @@ -320,18 +315,6 @@ export const Controls: React.FC = ({ } }; - const handleControlsInteraction = () => { - if (showControls) { - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - controlsTimeoutRef.current = setTimeout(() => { - setShowControls(false); - setShowAudioSlider(false); - }, CONTROLS_TIMEOUT); - } - }; - const handleSliderStart = useCallback(() => { if (showControls === false) return; @@ -343,16 +326,13 @@ export const Controls: React.FC = ({ isSeeking.value = true; }, [showControls, isPlaying]); - const [isSliding, setIsSliding] = useState(false); const handleSliderComplete = useCallback( async (value: number) => { isSeeking.value = false; progress.value = value; setIsSliding(false); - await seek( - Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))) - ); + seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))); if (wasPlayingRef.current === true) play(); }, [isVlc] @@ -382,7 +362,7 @@ export const Controls: React.FC = ({ const newTime = isVlc ? Math.max(0, curr - secondsToMs(settings.rewindSkipTime)) : Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime); - await seek(newTime); + seek(newTime); if (wasPlayingRef.current === true) play(); } } catch (error) { @@ -400,7 +380,7 @@ export const Controls: React.FC = ({ const newTime = isVlc ? curr + secondsToMs(settings.forwardSkipTime) : ticksToSeconds(curr) + settings.forwardSkipTime; - await seek(Math.max(0, newTime)); + seek(Math.max(0, newTime)); if (wasPlayingRef.current === true) play(); } } catch (error) { @@ -408,11 +388,62 @@ export const Controls: React.FC = ({ } }, [settings, isPlaying, isVlc]); + const goToItem = useCallback( + async (itemId: string) => { + try { + const gotoItem = await getItemById(api, itemId); + if (!settings || !gotoItem) return; + + lightHapticFeedback(); + + const previousIndexes: previousIndexes = { + subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, + audioIndex: audioIndex ? parseInt(audioIndex) : undefined, + }; + + const { + mediaSource: newMediaSource, + audioIndex: defaultAudioIndex, + subtitleIndex: defaultSubtitleIndex, + } = getDefaultPlaySettings( + gotoItem, + settings, + previousIndexes, + mediaSource ?? undefined + ); + + const queryParams = new URLSearchParams({ + itemId: gotoItem.Id ?? "", // Ensure itemId is a string + audioIndex: defaultAudioIndex?.toString() ?? "", + subtitleIndex: defaultSubtitleIndex?.toString() ?? "", + mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string + bitrateValue: bitrateValue.toString(), + }).toString(); + + if (!bitrateValue) { + // @ts-expect-error + router.replace(`player/direct-player?${queryParams}`); + return; + } + // @ts-expect-error + router.replace(`player/transcoding-player?${queryParams}`); + } catch (error) { + console.error("Error in gotoEpisode:", error); + } + }, + [settings, subtitleIndex, audioIndex] + ); + const toggleIgnoreSafeAreas = useCallback(() => { setIgnoreSafeAreas((prev) => !prev); lightHapticFeedback(); }, []); + const switchOnEpisodeMode = useCallback(() => { + setEpisodeView(true); + if (isPlaying) togglePlay(); + }, [isPlaying, togglePlay]); + const memoizedRenderBubble = useCallback(() => { if (!trickPlayUrl || !trickplayInfo) { return null; @@ -476,99 +507,13 @@ export const Controls: React.FC = ({ ); }, [trickPlayUrl, trickplayInfo, time]); - const [EpisodeView, setEpisodeView] = useState(false); - - const switchOnEpisodeMode = () => { - setEpisodeView(true); - if (isPlaying) togglePlay(); - }; - - const goToItem = useCallback( - async (itemId: string) => { - try { - const gotoItem = await getItemById(api, itemId); - if (!settings || !gotoItem) return; - - lightHapticFeedback(); - - const previousIndexes: previousIndexes = { - subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, - audioIndex: audioIndex ? parseInt(audioIndex) : undefined, - }; - - const { - mediaSource: newMediaSource, - audioIndex: defaultAudioIndex, - subtitleIndex: defaultSubtitleIndex, - } = getDefaultPlaySettings( - gotoItem, - settings, - previousIndexes, - mediaSource ?? undefined - ); - - const queryParams = new URLSearchParams({ - itemId: gotoItem.Id ?? "", // Ensure itemId is a string - audioIndex: defaultAudioIndex?.toString() ?? "", - subtitleIndex: defaultSubtitleIndex?.toString() ?? "", - mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string - bitrateValue: bitrateValue.toString(), - }).toString(); - - if (!bitrateValue) { - // @ts-expect-error - router.replace(`player/direct-player?${queryParams}`); - return; - } - // @ts-expect-error - router.replace(`player/transcoding-player?${queryParams}`); - } catch (error) { - console.error("Error in gotoEpisode:", error); - } - }, - [settings, subtitleIndex, audioIndex] - ); - - // Used when user changes audio through audio button on device. - const [showAudioSlider, setShowAudioSlider] = useState(false); - - // Prevent opening controls when user swipes on the screen. - const touchStartTime = useRef(0); - const touchStartPosition = useRef({ x: 0, y: 0 }); - - const handleTouchStart = (event: GestureResponderEvent) => { - touchStartTime.current = Date.now(); - touchStartPosition.current = { - x: event.nativeEvent.pageX, - y: event.nativeEvent.pageY, - }; - }; - - const handleTouchEnd = (event: GestureResponderEvent) => { - const touchEndTime = Date.now(); - const touchEndPosition = { - x: event.nativeEvent.pageX, - y: event.nativeEvent.pageY, - }; - - const touchDuration = touchEndTime - touchStartTime.current; - const touchDistance = Math.sqrt( - Math.pow(touchEndPosition.x - touchStartPosition.current.x, 2) + - Math.pow(touchEndPosition.y - touchStartPosition.current.y, 2) - ); - - if (touchDuration < 200 && touchDistance < 10) { - toggleControls(); - } - }; - return ( - {EpisodeView ? ( + {episodeView ? ( setEpisodeView(false)} @@ -576,22 +521,12 @@ export const Controls: React.FC = ({ /> ) : ( <> - - void; +} + +export const VideoTouchOverlay = ({ + screenWidth, + screenHeight, + showControls, + onToggleControls, +}: Props) => { + const { handleTouchStart, handleTouchEnd } = useTapDetection({ + onValidTap: onToggleControls, + }); + + return ( + + ); +}; diff --git a/components/video-player/controls/useControlsTimeout.ts b/components/video-player/controls/useControlsTimeout.ts new file mode 100644 index 00000000..ac10fff3 --- /dev/null +++ b/components/video-player/controls/useControlsTimeout.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef } from "react"; + +interface UseControlsTimeoutProps { + showControls: boolean; + isSliding: boolean; + episodeView: boolean; + onHideControls: () => void; + timeout?: number; +} + +export const useControlsTimeout = ({ + showControls, + isSliding, + episodeView, + onHideControls, + timeout = 4000, +}: UseControlsTimeoutProps) => { + const controlsTimeoutRef = useRef(); + + useEffect(() => { + const resetControlsTimeout = () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + + if (showControls && !isSliding && !episodeView) { + controlsTimeoutRef.current = setTimeout(() => { + onHideControls(); + }, timeout); + } + }; + + resetControlsTimeout(); + + return () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + }; + }, [showControls, isSliding, episodeView, timeout, onHideControls]); + + const handleControlsInteraction = () => { + if (showControls) { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + controlsTimeoutRef.current = setTimeout(() => { + onHideControls(); + }, timeout); + } + }; + + return { + handleControlsInteraction, + }; +}; diff --git a/components/video-player/controls/useTapDetection.tsx b/components/video-player/controls/useTapDetection.tsx new file mode 100644 index 00000000..041e6d39 --- /dev/null +++ b/components/video-player/controls/useTapDetection.tsx @@ -0,0 +1,48 @@ +import { useRef } from "react"; +import { GestureResponderEvent } from "react-native"; + +interface TapDetectionOptions { + maxDuration?: number; + maxDistance?: number; + onValidTap?: () => void; +} + +export const useTapDetection = ({ + maxDuration = 200, + maxDistance = 10, + onValidTap, +}: TapDetectionOptions = {}) => { + const touchStartTime = useRef(0); + const touchStartPosition = useRef({ x: 0, y: 0 }); + + const handleTouchStart = (event: GestureResponderEvent) => { + touchStartTime.current = Date.now(); + touchStartPosition.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + }; + + const handleTouchEnd = (event: GestureResponderEvent) => { + const touchEndTime = Date.now(); + const touchEndPosition = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + + const touchDuration = touchEndTime - touchStartTime.current; + const touchDistance = Math.sqrt( + Math.pow(touchEndPosition.x - touchStartPosition.current.x, 2) + + Math.pow(touchEndPosition.y - touchStartPosition.current.y, 2) + ); + + if (touchDuration < maxDuration && touchDistance < maxDistance) { + onValidTap?.(); + } + }; + + return { + handleTouchStart, + handleTouchEnd, + }; +}; From 7832ea4d0a241a3a88102b3539709e2379cac1f7 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 12 Jan 2025 10:10:18 +0100 Subject: [PATCH 29/34] chore: deps --- components/video-player/controls/Controls.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index ea53a2d3..6a5f3caa 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -35,13 +35,7 @@ import * as ScreenOrientation from "expo-screen-orientation"; import { useAtom } from "jotai"; import { debounce } from "lodash"; import React, { useCallback, useEffect, useRef, useState } from "react"; -import { - GestureResponderEvent, - Pressable, - TouchableOpacity, - useWindowDimensions, - View, -} from "react-native"; +import { TouchableOpacity, useWindowDimensions, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { runOnJS, @@ -60,9 +54,8 @@ import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding"; import { EpisodeList } from "./EpisodeList"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import SkipButton from "./SkipButton"; -import { useTapDetection } from "./useTapDetection"; -import { VideoTouchOverlay } from "./VideoTouchOverlay"; import { useControlsTimeout } from "./useControlsTimeout"; +import { VideoTouchOverlay } from "./VideoTouchOverlay"; interface Props { item: BaseItemDto; From b28c4a56f337d4763bb6c714b38b88c56bf9eb7a Mon Sep 17 00:00:00 2001 From: retardgerman <78982850+retardgerman@users.noreply.github.com> Date: Sun, 12 Jan 2025 13:39:43 +0100 Subject: [PATCH 30/34] fix: add new Releases to dropdown --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c8f30a95..2fe497ab 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -44,8 +44,8 @@ body: description: What version of Streamyfin are you running? options: - 0.25.0 - - 0.22.0 - - 0.21.0 + - 0.24.0 + - 0.23.0 - older validations: required: true From 7c77c70024fec709e125b21f528ec799d869427b Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 12 Jan 2025 13:40:01 +0100 Subject: [PATCH 31/34] chore: remove everything related to music --- .../albums/[albumId].tsx | 128 ------ .../artists/[artistId].tsx | 130 ------ .../artists/index.tsx | 117 ----- .../collections/[collectionId].tsx | 2 +- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 2 - app/(auth)/(tabs)/(libraries)/index.tsx | 6 +- app/(auth)/(tabs)/(search)/index.tsx | 82 +--- app/(auth)/player/_layout.tsx | 9 - app/(auth)/player/music-player.tsx | 419 ------------------ app/(auth)/player/transcoding-player.tsx | 2 - bun.lockb | Bin 593505 -> 593049 bytes components/common/TouchableItemRouter.tsx | 12 - components/home/Favorites.tsx | 20 - components/library/LibraryItemCard.tsx | 4 - components/music/SongsList.tsx | 35 -- components/music/SongsListItem.tsx | 128 ------ components/posters/AlbumCover.tsx | 82 ---- components/posters/ArtistPoster.tsx | 57 --- components/stacks/NestedTabPageStack.tsx | 9 +- components/video-player/controls/Controls.tsx | 45 +- .../controls/VideoTouchOverlay.tsx | 2 +- .../controls/dropdown/DropdownViewDirect.tsx | 2 +- .../dropdown/DropdownViewTranscoding.tsx | 2 +- package.json | 4 +- providers/PlaySettingsProvider.tsx | 9 - utils/collectionTypeToItemType.ts | 4 - 26 files changed, 32 insertions(+), 1280 deletions(-) delete mode 100644 app/(auth)/(tabs)/(home,libraries,search,favorites)/albums/[albumId].tsx delete mode 100644 app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx delete mode 100644 app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx delete mode 100644 app/(auth)/player/music-player.tsx delete mode 100644 components/music/SongsList.tsx delete mode 100644 components/music/SongsListItem.tsx delete mode 100644 components/posters/AlbumCover.tsx delete mode 100644 components/posters/ArtistPoster.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/albums/[albumId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/albums/[albumId].tsx deleted file mode 100644 index 565f84c8..00000000 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/albums/[albumId].tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { Chromecast } from "@/components/Chromecast"; -import { ItemImage } from "@/components/common/ItemImage"; -import { Text } from "@/components/common/Text"; -import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; -import { SongsList } from "@/components/music/SongsList"; -import { ParallaxScrollView } from "@/components/ParallaxPage"; -import ArtistPoster from "@/components/posters/ArtistPoster"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; -import { router, useLocalSearchParams, useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; -import { ScrollView, TouchableOpacity, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - -export default function page() { - const searchParams = useLocalSearchParams(); - const { collectionId, artistId, albumId } = searchParams as { - collectionId: string; - artistId: string; - albumId: string; - }; - - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - - const navigation = useNavigation(); - - useEffect(() => { - navigation.setOptions({ - headerRight: () => ( - - - - ), - }); - }); - - const { data: album } = useQuery({ - queryKey: ["album", albumId, artistId], - queryFn: async () => { - if (!api) return null; - const response = await getItemsApi(api).getItems({ - userId: user?.Id, - ids: [albumId], - }); - const data = response.data.Items?.[0]; - return data; - }, - enabled: !!api && !!user?.Id && !!albumId, - staleTime: 0, - }); - - const { - data: songs, - isLoading, - isError, - } = useQuery<{ - Items: BaseItemDto[]; - TotalRecordCount: number; - }>({ - queryKey: ["songs", artistId, albumId], - queryFn: async () => { - if (!api) - return { - Items: [], - TotalRecordCount: 0, - }; - - const response = await getItemsApi(api).getItems({ - userId: user?.Id, - parentId: albumId, - fields: [ - "ItemCounts", - "PrimaryImageAspectRatio", - "CanDelete", - "MediaSourceCount", - ], - sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"], - }); - - const data = response.data.Items; - - return { - Items: data || [], - TotalRecordCount: response.data.TotalRecordCount || 0, - }; - }, - enabled: !!api && !!user?.Id, - }); - - const insets = useSafeAreaInsets(); - - if (!album) return null; - - return ( - - } - > - - {album?.Name} - - {songs?.TotalRecordCount} songs - - - - - - - ); -} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx deleted file mode 100644 index 8d82d205..00000000 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx +++ /dev/null @@ -1,130 +0,0 @@ -import ArtistPoster from "@/components/posters/ArtistPoster"; -import { Text } from "@/components/common/Text"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; -import { router, useLocalSearchParams, useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; -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() { - const searchParams = useLocalSearchParams(); - const { artistId } = searchParams as { - artistId: string; - }; - - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - - const navigation = useNavigation(); - - const [startIndex, setStartIndex] = useState(0); - - const { data: artist } = useQuery({ - queryKey: ["album", artistId], - queryFn: async () => { - if (!api) return null; - const response = await getItemsApi(api).getItems({ - userId: user?.Id, - ids: [artistId], - }); - const data = response.data.Items?.[0]; - return data; - }, - enabled: !!api && !!user?.Id && !!artistId, - staleTime: 0, - }); - - const { - data: albums, - isLoading, - isError, - } = useQuery<{ - Items: BaseItemDto[]; - TotalRecordCount: number; - }>({ - queryKey: ["albums", artistId, startIndex], - queryFn: async () => { - if (!api) - return { - Items: [], - TotalRecordCount: 0, - }; - - const response = await getItemsApi(api).getItems({ - userId: user?.Id, - parentId: artistId, - sortOrder: ["Descending", "Descending", "Ascending"], - includeItemTypes: ["MusicAlbum"], - recursive: true, - fields: [ - "ParentId", - "PrimaryImageAspectRatio", - "ParentId", - "PrimaryImageAspectRatio", - ], - collapseBoxSetItems: false, - albumArtistIds: [artistId], - startIndex, - limit: 100, - sortBy: ["PremiereDate", "ProductionYear", "SortName"], - }); - - const data = response.data.Items; - - return { - Items: data || [], - TotalRecordCount: response.data.TotalRecordCount || 0, - }; - }, - enabled: !!api && !!user?.Id, - }); - - const insets = useSafeAreaInsets(); - - if (!artist || !albums) return null; - - return ( - - } - > - - {artist?.Name} - - {albums.TotalRecordCount} albums - - - - {albums.Items.map((item, idx) => ( - - - - {item.Name} - {item.ProductionYear} - - - ))} - - - ); -} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx deleted file mode 100644 index 4827287e..00000000 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; -import ArtistPoster from "@/components/posters/ArtistPoster"; -import MoviePoster from "@/components/posters/MoviePoster"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; -import { router, useLocalSearchParams } from "expo-router"; -import { useAtom } from "jotai"; -import { useMemo, useState } from "react"; -import { FlatList, TouchableOpacity, View } from "react-native"; - -export default function page() { - const searchParams = useLocalSearchParams(); - const { collectionId } = searchParams as { collectionId: string }; - - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - - const { data: collection } = useQuery({ - queryKey: ["collection", collectionId], - queryFn: async () => { - if (!api) return null; - const response = await getItemsApi(api).getItems({ - userId: user?.Id, - ids: [collectionId], - }); - const data = response.data.Items?.[0]; - return data; - }, - enabled: !!api && !!user?.Id && !!collectionId, - staleTime: 0, - }); - - const [startIndex, setStartIndex] = useState(0); - - const { data, isLoading, isError } = useQuery<{ - Items: BaseItemDto[]; - TotalRecordCount: number; - }>({ - queryKey: ["collection-items", collection?.Id, startIndex], - queryFn: async () => { - if (!api || !collectionId) - return { - Items: [], - TotalRecordCount: 0, - }; - - const response = await getArtistsApi(api).getArtists({ - sortBy: ["SortName"], - sortOrder: ["Ascending"], - fields: ["PrimaryImageAspectRatio", "SortName"], - imageTypeLimit: 1, - enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], - parentId: collectionId, - userId: user?.Id, - }); - - const data = response.data.Items; - - return { - Items: data || [], - TotalRecordCount: response.data.TotalRecordCount || 0, - }; - }, - enabled: !!collection?.Id && !!api && !!user?.Id, - }); - - const totalItems = useMemo(() => { - return data?.TotalRecordCount; - }, [data]); - - if (!data) return null; - - return ( - - Artists - - } - nestedScrollEnabled - data={data.Items} - numColumns={3} - columnWrapperStyle={{ - justifyContent: "space-between", - }} - renderItem={({ item, index }) => ( - - - {collection?.CollectionType === "movies" && ( - - )} - {collection?.CollectionType === "music" && ( - - )} - {item.Name} - {item.ProductionYear} - - - )} - keyExtractor={(item) => item.Id || ""} - /> - ); -} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx index 4c2b72ae..57393ce5 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx @@ -109,7 +109,7 @@ const page: React.FC = () => { genres: selectedGenres, tags: selectedTags, years: selectedYears.map((year) => parseInt(year)), - includeItemTypes: ["Movie", "Series", "MusicAlbum"], + includeItemTypes: ["Movie", "Series"], }); return response.data || null; diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 7c0dbc91..414f6e90 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -150,8 +150,6 @@ const Page = () => { itemType = "Series"; } else if (library.CollectionType === "boxsets") { itemType = "BoxSet"; - } else if (library.CollectionType === "music") { - itemType = "MusicAlbum"; } const response = await getItemsApi(api).getItems({ diff --git a/app/(auth)/(tabs)/(libraries)/index.tsx b/app/(auth)/(tabs)/(libraries)/index.tsx index e8c3f766..08adacc5 100644 --- a/app/(auth)/(tabs)/(libraries)/index.tsx +++ b/app/(auth)/(tabs)/(libraries)/index.tsx @@ -33,7 +33,11 @@ export default function index() { }); const libraries = useMemo( - () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), + () => + data + ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)) + .filter((l) => l.CollectionType !== "music") + .filter((l) => l.CollectionType !== "books") || [], [data, settings?.hiddenLibraries] ); diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 39bb0b2a..b420be69 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -5,7 +5,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import { Tag } from "@/components/GenreTags"; import { ItemCardText } from "@/components/ItemCardText"; import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage"; -import AlbumCover from "@/components/posters/AlbumCover"; import MoviePoster from "@/components/posters/MoviePoster"; import SeriesPoster from "@/components/posters/SeriesPoster"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; @@ -184,52 +183,19 @@ export default function search() { enabled: searchType === "Library" && debouncedSearch.length > 0, }); - const { data: artists, isFetching: l4 } = useQuery({ - queryKey: ["search", "artists", debouncedSearch], - queryFn: () => - searchFn({ - query: debouncedSearch, - types: ["MusicArtist"], - }), - enabled: searchType === "Library" && debouncedSearch.length > 0, - }); - - const { data: albums, isFetching: l5 } = useQuery({ - queryKey: ["search", "albums", debouncedSearch], - queryFn: () => - searchFn({ - query: debouncedSearch, - types: ["MusicAlbum"], - }), - enabled: searchType === "Library" && debouncedSearch.length > 0, - }); - - const { data: songs, isFetching: l6 } = useQuery({ - queryKey: ["search", "songs", debouncedSearch], - queryFn: () => - searchFn({ - query: debouncedSearch, - types: ["Audio"], - }), - enabled: searchType === "Library" && debouncedSearch.length > 0, - }); - const noResults = useMemo(() => { return !( - artists?.length || - albums?.length || - songs?.length || movies?.length || episodes?.length || series?.length || collections?.length || actors?.length ); - }, [artists, episodes, albums, songs, movies, series, collections, actors]); + }, [episodes, movies, series, collections, actors]); const loading = useMemo(() => { - return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8; - }, [l1, l2, l3, l4, l5, l6, l7, l8]); + return l1 || l2 || l3 || l7 || l8; + }, [l1, l2, l3, l7, l8]); return ( <> @@ -365,48 +331,6 @@ export default function search() { )} /> - m.Id!)} - header="Artists" - renderItem={(item: BaseItemDto) => ( - - - - - )} - /> - m.Id!)} - header="Albums" - renderItem={(item: BaseItemDto) => ( - - - - - )} - /> - m.Id!)} - header="Songs" - renderItem={(item: BaseItemDto) => ( - - - - - )} - /> ) : ( diff --git a/app/(auth)/player/_layout.tsx b/app/(auth)/player/_layout.tsx index 96d08058..4b0ae1c1 100644 --- a/app/(auth)/player/_layout.tsx +++ b/app/(auth)/player/_layout.tsx @@ -25,15 +25,6 @@ export default function Layout() { animation: "fade", }} /> - ); diff --git a/app/(auth)/player/music-player.tsx b/app/(auth)/player/music-player.tsx deleted file mode 100644 index fc4b8863..00000000 --- a/app/(auth)/player/music-player.tsx +++ /dev/null @@ -1,419 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { Loader } from "@/components/Loader"; -import { Controls } from "@/components/video-player/controls/Controls"; -import { useOrientation } from "@/hooks/useOrientation"; -import { useOrientationSettings } from "@/hooks/useOrientationSettings"; -import { useWebSocket } from "@/hooks/useWebsockets"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; -import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { secondsToTicks } from "@/utils/secondsToTicks"; -import { Api } from "@jellyfin/sdk"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { - getPlaystateApi, - getUserLibraryApi, -} from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; -import { useHaptic } from "@/hooks/useHaptic"; -import { Image } from "expo-image"; -import { useFocusEffect, useLocalSearchParams } from "expo-router"; -import { useAtomValue } from "jotai"; -import React, { useCallback, useMemo, useRef, useState } from "react"; -import { Pressable, useWindowDimensions, View } from "react-native"; -import { useSharedValue } from "react-native-reanimated"; -import Video, { OnProgressData, VideoRef } from "react-native-video"; - -export default function page() { - const api = useAtomValue(apiAtom); - const user = useAtomValue(userAtom); - const [settings] = useSettings(); - const videoRef = useRef(null); - const windowDimensions = useWindowDimensions(); - - const firstTime = useRef(true); - - const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); - const [showControls, setShowControls] = useState(true); - const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); - const [isPlaying, setIsPlaying] = useState(false); - const [isBuffering, setIsBuffering] = useState(true); - - const progress = useSharedValue(0); - const isSeeking = useSharedValue(false); - const cacheProgress = useSharedValue(0); - - const lightHapticFeedback = useHaptic("light"); - - const { - itemId, - audioIndex: audioIndexStr, - subtitleIndex: subtitleIndexStr, - mediaSourceId, - bitrateValue: bitrateValueStr, - } = useLocalSearchParams<{ - itemId: string; - audioIndex: string; - subtitleIndex: string; - mediaSourceId: string; - bitrateValue: string; - }>(); - - const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined; - const subtitleIndex = subtitleIndexStr - ? parseInt(subtitleIndexStr, 10) - : undefined; - const bitrateValue = bitrateValueStr - ? parseInt(bitrateValueStr, 10) - : undefined; - - const { - data: item, - isLoading: isLoadingItem, - isError: isErrorItem, - } = useQuery({ - queryKey: ["item", itemId], - queryFn: async () => { - if (!api) return; - const res = await getUserLibraryApi(api).getItem({ - itemId, - userId: user?.Id, - }); - - return res.data; - }, - enabled: !!itemId && !!api, - staleTime: 0, - }); - - const { - data: stream, - isLoading: isLoadingStreamUrl, - isError: isErrorStreamUrl, - } = useQuery({ - queryKey: ["stream-url"], - queryFn: async () => { - if (!api) return; - const res = await getStreamUrl({ - api, - item, - startTimeTicks: item?.UserData?.PlaybackPositionTicks!, - userId: user?.Id, - audioStreamIndex: audioIndex, - maxStreamingBitrate: bitrateValue, - mediaSourceId: mediaSourceId, - subtitleStreamIndex: subtitleIndex, - }); - - if (!res) return null; - - const { mediaSource, sessionId, url } = res; - - if (!sessionId || !mediaSource || !url) return null; - - return { - mediaSource, - sessionId, - url, - }; - }, - }); - - const poster = usePoster(item, api); - const videoSource = useVideoSource(item, api, poster, stream?.url); - - const togglePlay = useCallback( - async (ticks: number) => { - lightHapticFeedback(); - if (isPlaying) { - videoRef.current?.pause(); - await getPlaystateApi(api!).onPlaybackProgress({ - itemId: item?.Id!, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: Math.floor(ticks), - isPaused: true, - playMethod: stream?.url.includes("m3u8") - ? "Transcode" - : "DirectStream", - playSessionId: stream?.sessionId, - }); - } else { - videoRef.current?.resume(); - await getPlaystateApi(api!).onPlaybackProgress({ - itemId: item?.Id!, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: Math.floor(ticks), - isPaused: false, - playMethod: stream?.url.includes("m3u8") - ? "Transcode" - : "DirectStream", - playSessionId: stream?.sessionId, - }); - } - }, - [ - isPlaying, - api, - item, - videoRef, - settings, - audioIndex, - subtitleIndex, - mediaSourceId, - stream, - ] - ); - - const play = useCallback(() => { - videoRef.current?.resume(); - reportPlaybackStart(); - }, [videoRef]); - - const pause = useCallback(() => { - videoRef.current?.pause(); - }, [videoRef]); - - const stop = useCallback(() => { - setIsPlaybackStopped(true); - videoRef.current?.pause(); - reportPlaybackStopped(); - }, [videoRef]); - - const seek = useCallback( - (seconds: number) => { - videoRef.current?.seek(seconds); - }, - [videoRef] - ); - - const reportPlaybackStopped = async () => { - if (!item?.Id) return; - await getPlaystateApi(api!).onPlaybackStopped({ - itemId: item.Id, - mediaSourceId: mediaSourceId, - positionTicks: Math.floor(progress.value), - playSessionId: stream?.sessionId, - }); - }; - - const reportPlaybackStart = async () => { - if (!item?.Id) return; - await getPlaystateApi(api!).onPlaybackStart({ - itemId: item?.Id, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: stream?.sessionId, - }); - }; - - const onProgress = useCallback( - async (data: OnProgressData) => { - if (isSeeking.value === true) return; - if (isPlaybackStopped === true) return; - - const ticks = data.currentTime * 10000000; - - progress.value = secondsToTicks(data.currentTime); - cacheProgress.value = secondsToTicks(data.playableDuration); - setIsBuffering(data.playableDuration === 0); - - if (!item?.Id || data.currentTime === 0) return; - - await getPlaystateApi(api!).onPlaybackProgress({ - itemId: item.Id!, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: Math.round(ticks), - isPaused: !isPlaying, - playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: stream?.sessionId, - }); - }, - [ - item, - isPlaying, - api, - isPlaybackStopped, - audioIndex, - subtitleIndex, - mediaSourceId, - stream, - ] - ); - - useFocusEffect( - useCallback(() => { - play(); - - return () => { - stop(); - }; - }, [play, stop]) - ); - - useOrientation(); - useOrientationSettings(); - - useWebSocket({ - isPlaying: isPlaying, - pauseVideo: pause, - playVideo: play, - stopPlayback: stop, - }); - - if (isLoadingItem || isLoadingStreamUrl) - return ( - - - - ); - - if (isErrorItem || isErrorStreamUrl) - return ( - - Error - - ); - - if (!item || !stream) - return ( - - Error - - ); - - return ( - - - - - - { - setShowControls(!showControls); - }} - className="absolute z-0 h-full w-full opacity-0" - > - {videoSource && ( - - - - - ); -} - -export function usePoster( - item: BaseItemDto | null | undefined, - api: Api | null -): string | undefined { - const poster = useMemo(() => { - if (!item || !api) return undefined; - return item.Type === "Audio" - ? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` - : getBackdropUrl({ - api, - item: item, - quality: 70, - width: 200, - }); - }, [item, api]); - - return poster ?? undefined; -} - -export function useVideoSource( - item: BaseItemDto | null | undefined, - api: Api | null, - poster: string | undefined, - url?: string | null -) { - const videoSource = useMemo(() => { - if (!item || !api || !url) { - return null; - } - - const startPosition = item?.UserData?.PlaybackPositionTicks - ? Math.round(item.UserData.PlaybackPositionTicks / 10000) - : 0; - - return { - uri: url, - isNetwork: true, - startPosition, - headers: getAuthHeaders(api), - metadata: { - artist: item?.AlbumArtist ?? undefined, - title: item?.Name || "Unknown", - description: item?.Overview ?? undefined, - imageUri: poster, - subtitle: item?.Album ?? undefined, - }, - }; - }, [item, api, poster]); - - return videoSource; -} diff --git a/app/(auth)/player/transcoding-player.tsx b/app/(auth)/player/transcoding-player.tsx index 971410f4..8c9a4b84 100644 --- a/app/(auth)/player/transcoding-player.tsx +++ b/app/(auth)/player/transcoding-player.tsx @@ -414,7 +414,6 @@ const Player = () => { playWhenInactive={true} allowsExternalPlayback={true} playInBackground={true} - pictureInPicture={true} showNotificationControls={true} ignoreSilentSwitch="ignore" fullscreen={false} @@ -532,7 +531,6 @@ export function useVideoSource( startPosition, headers: getAuthHeaders(api), metadata: { - artist: item?.AlbumArtist ?? undefined, title: item?.Name || "Unknown", description: item?.Overview ?? undefined, imageUri: poster, diff --git a/bun.lockb b/bun.lockb index 935cb03c1af4949cacc791146afacef67f8b1329..de11fa7d5b0be268681518f5ad9c3a1cd7dce36e 100755 GIT binary patch delta 12332 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 { () => fetchFavoritesByType("Playlist"), [fetchFavoritesByType] ); - const fetchFavoriteMusicAlbum = useCallback( - () => fetchFavoritesByType("MusicAlbum"), - [fetchFavoritesByType] - ); - const fetchFavoriteAudio = useCallback( - () => fetchFavoritesByType("Audio"), - [fetchFavoritesByType] - ); return ( @@ -102,18 +94,6 @@ export const Favorites = () => { title="Playlists" hideIfEmpty /> - - ); }; diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx index 17595db6..7a370405 100644 --- a/components/library/LibraryItemCard.tsx +++ b/components/library/LibraryItemCard.tsx @@ -60,8 +60,6 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => { _itemType = "Series"; } else if (library.CollectionType === "boxsets") { _itemType = "BoxSet"; - } else if (library.CollectionType === "music") { - _itemType = "MusicAlbum"; } return _itemType; @@ -76,8 +74,6 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => { nameStr = "series"; } else if (library.CollectionType === "boxsets") { nameStr = "box sets"; - } else if (library.CollectionType === "music") { - nameStr = "albums"; } else { nameStr = "items"; } diff --git a/components/music/SongsList.tsx b/components/music/SongsList.tsx deleted file mode 100644 index 4d576f3c..00000000 --- a/components/music/SongsList.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useRouter } from "expo-router"; -import { View, ViewProps } from "react-native"; -import { SongsListItem } from "./SongsListItem"; - -interface Props extends ViewProps { - songs?: BaseItemDto[] | null; - collectionId: string; - artistId: string; - albumId: string; -} - -export const SongsList: React.FC = ({ - collectionId, - artistId, - albumId, - songs = [], - ...props -}) => { - const router = useRouter(); - return ( - - {songs?.map((item: BaseItemDto, index: number) => ( - - ))} - - ); -}; diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx deleted file mode 100644 index 552baa69..00000000 --- a/components/music/SongsListItem.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { usePlaySettings } from "@/providers/PlaySettingsProvider"; -import { runtimeTicksToSeconds } from "@/utils/time"; -import { useActionSheet } from "@expo/react-native-action-sheet"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useRouter } from "expo-router"; -import { useAtom } from "jotai"; -import { useCallback } from "react"; -import { TouchableOpacity, TouchableOpacityProps, View } from "react-native"; -import CastContext, { - PlayServicesState, - useCastDevice, - useRemoteMediaClient, -} from "react-native-google-cast"; - -interface Props extends TouchableOpacityProps { - collectionId: string; - artistId: string; - albumId: string; - item: BaseItemDto; - index: number; -} - -export const SongsListItem: React.FC = ({ - collectionId, - artistId, - albumId, - item, - index, - ...props -}) => { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - const castDevice = useCastDevice(); - const router = useRouter(); - const client = useRemoteMediaClient(); - const { showActionSheetWithOptions } = useActionSheet(); - - const { setPlaySettings } = usePlaySettings(); - - const openSelect = () => { - if (!castDevice?.deviceId) { - play("device"); - return; - } - - const options = ["Chromecast", "Device", "Cancel"]; - const cancelButtonIndex = 2; - - showActionSheetWithOptions( - { - options, - cancelButtonIndex, - }, - (selectedIndex: number | undefined) => { - switch (selectedIndex) { - case 0: - play("cast"); - break; - case 1: - play("device"); - break; - case cancelButtonIndex: - break; - } - } - ); - }; - - const play = useCallback(async (type: "device" | "cast") => { - if (!user?.Id || !api || !item.Id) { - console.warn("No user, api or item", user, api, item.Id); - return; - } - - const data = await setPlaySettings({ - item, - }); - - if (!data?.url) { - throw new Error("play-music ~ No stream url"); - } - - if (type === "cast" && client) { - await CastContext.getPlayServicesState().then((state) => { - if (state && state !== PlayServicesState.SUCCESS) - CastContext.showPlayServicesErrorDialog(state); - else { - client.loadMedia({ - mediaInfo: { - contentUrl: data.url!, - contentType: "video/mp4", - metadata: { - type: item.Type === "Episode" ? "tvShow" : "movie", - title: item.Name || "", - subtitle: item.Overview || "", - }, - }, - startTime: 0, - }); - } - }); - } else { - console.log("Playing on device", data.url, item.Id); - router.push("/music-player"); - } - }, []); - - return ( - { - openSelect(); - }} - {...props} - > - - {index + 1} - - {item.Name} - - {runtimeTicksToSeconds(item.RunTimeTicks)} - - - - - ); -}; diff --git a/components/posters/AlbumCover.tsx b/components/posters/AlbumCover.tsx deleted file mode 100644 index 870dce6a..00000000 --- a/components/posters/AlbumCover.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { Image } from "expo-image"; -import { useAtom } from "jotai"; -import { useMemo } from "react"; -import { View } from "react-native"; - -type ArtistPosterProps = { - item?: BaseItemDto | null; - id?: string | null; - showProgress?: boolean; -}; - -const AlbumCover: React.FC = ({ item, id }) => { - const [api] = useAtom(apiAtom); - - const url = useMemo(() => { - const u = getPrimaryImageUrl({ - api, - item, - }); - return u; - }, [item]); - - const url2 = useMemo(() => { - const u = getPrimaryImageUrlById({ - api, - id, - quality: 85, - width: 300, - }); - return u; - }, [item]); - - if (!item && id) - return ( - - - - ); - - if (item) - return ( - - - - ); -}; - -export default AlbumCover; diff --git a/components/posters/ArtistPoster.tsx b/components/posters/ArtistPoster.tsx deleted file mode 100644 index d64818b6..00000000 --- a/components/posters/ArtistPoster.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { Image } from "expo-image"; -import { useAtom } from "jotai"; -import { useMemo } from "react"; -import { View } from "react-native"; - -type ArtistPosterProps = { - item: BaseItemDto; - showProgress?: boolean; -}; - -const ArtistPoster: React.FC = ({ - item, - showProgress = false, -}) => { - const [api] = useAtom(apiAtom); - - const url = useMemo( - () => - getPrimaryImageUrl({ - api, - item, - }), - [item] - ); - - if (!url) - return ( - - ); - - return ( - - - - ); -}; - -export default ArtistPoster; diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx index 024b1272..2cfeed1d 100644 --- a/components/stacks/NestedTabPageStack.tsx +++ b/components/stacks/NestedTabPageStack.tsx @@ -17,14 +17,7 @@ export const commonScreenOptions: ICommonScreenOptions = { headerLeft: () => , }; -const routes = [ - "actors/[actorId]", - "albums/[albumId]", - "artists/index", - "artists/[artistId]", - "items/page", - "series/[id]", -]; +const routes = ["actors/[actorId]", "items/page", "series/[id]"]; export const nestedTabPageScreenOptions: Record = Object.fromEntries(routes.map((route) => [route, commonScreenOptions])); diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 6a5f3caa..f209a411 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -449,12 +449,11 @@ export const Controls: React.FC = ({ = ({ }, ]} pointerEvents={showControls ? "auto" : "none"} - className={`flex flex-row w-full p-4 `} + className={`flex flex-row w-full pt-2`} > = ({ onPress={() => { switchOnEpisodeMode(); }} - className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" + className="aspect-square flex flex-col rounded-xl items-center justify-center p-2" > @@ -565,7 +564,7 @@ export const Controls: React.FC = ({ {previousItem && !offline && ( @@ -574,7 +573,7 @@ export const Controls: React.FC = ({ {nextItem && !offline && ( @@ -583,7 +582,7 @@ export const Controls: React.FC = ({ {/* {mediaSource?.TranscodingUrl && ( */} = ({ ); router.back(); }} - className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" + className="aspect-square flex flex-col rounded-xl items-center justify-center p-2" > @@ -730,11 +729,11 @@ export const Controls: React.FC = ({ bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0, }, ]} - className={`flex flex-col p-4`} + className={`flex flex-col px-2`} onTouchStart={handleControlsInteraction} > = ({ }} pointerEvents={showControls ? "box-none" : "none"} > - {item?.Name} {item?.Type === "Episode" && ( - {item.SeriesName} + + {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} + )} + {item?.Name} {item?.Type === "Movie" && ( {item?.ProductionYear} @@ -786,7 +787,7 @@ export const Controls: React.FC = ({ = ({ bubbleTextColor: "#666", heartbeatColor: "#999", }} - renderThumb={() => ( - - )} + renderThumb={() => null} cache={cacheProgress} onSlidingStart={handleSliderStart} onSlidingComplete={handleSliderComplete} @@ -829,7 +818,7 @@ export const Controls: React.FC = ({ minimumValue={min} maximumValue={max} /> - + {formatTimeString(currentTime, isVlc ? "ms" : "s")} diff --git a/components/video-player/controls/VideoTouchOverlay.tsx b/components/video-player/controls/VideoTouchOverlay.tsx index 03b0b8a3..85385acf 100644 --- a/components/video-player/controls/VideoTouchOverlay.tsx +++ b/components/video-player/controls/VideoTouchOverlay.tsx @@ -31,7 +31,7 @@ export const VideoTouchOverlay = ({ right: 0, top: 0, bottom: 0, - opacity: showControls ? 0.5 : 0, + opacity: showControls ? 0.75 : 0, }} /> ); diff --git a/components/video-player/controls/dropdown/DropdownViewDirect.tsx b/components/video-player/controls/dropdown/DropdownViewDirect.tsx index 28b55fa0..e2ba25fd 100644 --- a/components/video-player/controls/dropdown/DropdownViewDirect.tsx +++ b/components/video-player/controls/dropdown/DropdownViewDirect.tsx @@ -74,7 +74,7 @@ const DropdownViewDirect: React.FC = ({ return ( - + diff --git a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx index d57cc126..5a05dd17 100644 --- a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx +++ b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx @@ -121,7 +121,7 @@ const DropdownView: React.FC = ({ showControls }) => { - + diff --git a/package.json b/package.json index 07fedc03..f3cc630f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "preset": "jest-expo" }, "dependencies": { - "@bottom-tabs/react-navigation": "^0.7.1", + "@bottom-tabs/react-navigation": "0.7.8", + "react-native-bottom-tabs": "0.7.8", "@config-plugins/ffmpeg-kit-react-native": "^8.0.0", "@expo/react-native-action-sheet": "^4.1.0", "@expo/vector-icons": "^14.0.4", @@ -73,7 +74,6 @@ "react-dom": "18.2.0", "react-native": "0.74.5", "react-native-awesome-slider": "^2.5.6", - "react-native-bottom-tabs": "0.7.8", "react-native-circular-progress": "^1.4.1", "react-native-compressor": "^1.9.0", "react-native-country-flag": "^2.0.2", diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx index 50f780cd..ff80bb9e 100644 --- a/providers/PlaySettingsProvider.tsx +++ b/providers/PlaySettingsProvider.tsx @@ -38,7 +38,6 @@ type PlaySettingsContextType = { setPlayUrl: React.Dispatch>; playSessionId?: string | null; setOfflineSettings: (data: PlaybackType) => void; - setMusicPlaySettings: (item: BaseItemDto, url: string) => void; }; const PlaySettingsContext = createContext( @@ -61,13 +60,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ _setPlaySettings(data); }, []); - const setMusicPlaySettings = (item: BaseItemDto, url: string) => { - setPlaySettings({ - item: item, - }); - setPlayUrl(url); - }; - const setPlaySettings = useCallback( async ( dataOrUpdater: @@ -147,7 +139,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ setPlaySettings, playUrl, setPlayUrl, - setMusicPlaySettings, setOfflineSettings, playSessionId, mediaSource, diff --git a/utils/collectionTypeToItemType.ts b/utils/collectionTypeToItemType.ts index 64c23ff0..f37fe5f4 100644 --- a/utils/collectionTypeToItemType.ts +++ b/utils/collectionTypeToItemType.ts @@ -10,8 +10,6 @@ import { * readonly Unknown: "unknown"; readonly Movies: "movies"; readonly Tvshows: "tvshows"; - readonly Music: "music"; - readonly Musicvideos: "musicvideos"; readonly Trailers: "trailers"; readonly Homevideos: "homevideos"; readonly Boxsets: "boxsets"; @@ -33,8 +31,6 @@ export const colletionTypeToItemType = ( return BaseItemKind.Series; case CollectionType.Homevideos: return BaseItemKind.Video; - case CollectionType.Musicvideos: - return BaseItemKind.MusicVideo; case CollectionType.Books: return BaseItemKind.Book; case CollectionType.Playlists: From d81ae94ce8863a09c777b825fc3c820961885da1 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 12 Jan 2025 13:41:33 +0100 Subject: [PATCH 32/34] fix: add version to issue template --- .github/ISSUE_TEMPLATE/bug_report.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2fe497ab..6d36f734 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -46,6 +46,11 @@ body: - 0.25.0 - 0.24.0 - 0.23.0 +<<<<<<< Updated upstream +======= + - 0.22.0 + - 0.21.0 +>>>>>>> Stashed changes - older validations: required: true From 49c0437f81df40dcb09bed1d213fca13091b6038 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 12 Jan 2025 14:04:12 +0100 Subject: [PATCH 33/34] fix: change opacity on press --- components/home/LargeMovieCarousel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx index 6f9d3a1e..5b228901 100644 --- a/components/home/LargeMovieCarousel.tsx +++ b/components/home/LargeMovieCarousel.tsx @@ -161,7 +161,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => { const tap = Gesture.Tap() .maxDuration(2000) .onBegin(() => { - opacity.value = withTiming(0.5, { duration: 100 }); + opacity.value = withTiming(0.8, { duration: 100 }); }) .onEnd(() => { runOnJS(handleRoute)(); From ff4c5f28af83927db4ad928b3d11fd42f5486719 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 12 Jan 2025 14:11:09 +0100 Subject: [PATCH 34/34] chore --- .../(home)/settings/popular-lists/page.tsx | 38 +++++---- app/+not-found.tsx | 5 +- components/MediaSourceSelector.tsx | 10 +-- components/filters/FilterButton.tsx | 2 +- .../jellyseerr/discover/CompanySlide.tsx | 40 +++++---- components/jellyseerr/discover/GenreSlide.tsx | 83 +++++++++++-------- components/list/ListItem.tsx | 2 +- components/settings/StorageSettings.tsx | 5 +- .../video-player/controls/EpisodeList.tsx | 30 +++---- 9 files changed, 115 insertions(+), 100 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx index c7d66e75..6dfcb3c8 100644 --- a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx @@ -2,19 +2,16 @@ import { Text } from "@/components/common/Text"; import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/list/ListItem"; import { Loader } from "@/components/Loader"; +import DisabledSetting from "@/components/settings/DisabledSetting"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigation } from "expo-router"; import { useAtom } from "jotai"; -import { Linking, Switch, View } from "react-native"; -import {useMemo} from "react"; -import DisabledSetting from "@/components/settings/DisabledSetting"; +import { useMemo } from "react"; +import { Linking, Switch } from "react-native"; export default function page() { - const navigation = useNavigation(); - const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -50,18 +47,17 @@ export default function page() { staleTime: 0, }); - const disabled = useMemo(() => ( - pluginSettings?.usePopularPlugin?.locked === true && - pluginSettings?.mediaListCollectionIds?.locked === true - ), [pluginSettings]); + const disabled = useMemo( + () => + pluginSettings?.usePopularPlugin?.locked === true && + pluginSettings?.mediaListCollectionIds?.locked === true, + [pluginSettings] + ); if (!settings) return null; return ( - + updateSettings({ usePopularPlugin }) - } + } /> @@ -103,11 +99,17 @@ export default function page() { { if (!settings.mediaListCollectionIds) { updateSettings({ diff --git a/app/+not-found.tsx b/app/+not-found.tsx index 41968287..5a8c1964 100644 --- a/app/+not-found.tsx +++ b/app/+not-found.tsx @@ -1,13 +1,10 @@ -import { Link, Stack, usePathname } from "expo-router"; +import { Link, Stack } from "expo-router"; import { StyleSheet } from "react-native"; import { ThemedText } from "@/components/ThemedText"; import { ThemedView } from "@/components/ThemedView"; -import { useEffect } from "react"; export default function NotFoundScreen() { - const pathname = usePathname(); - return ( <> diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index 4888692a..6a76e714 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -1,13 +1,11 @@ -import { tc } from "@/utils/textTools"; import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { TouchableOpacity, View } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; -import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb"; interface Props extends React.ComponentProps { item: BaseItemDto; @@ -34,9 +32,9 @@ export const MediaSourceSelector: React.FC = ({ if (!mediaSources.length) return ""; let commonPrefix = ""; - for (let i = 0; i < mediaSources[0].Name.length; i++) { - const char = mediaSources[0].Name[i]; - if (mediaSources.every((source) => source.Name[i] === char)) { + for (let i = 0; i < mediaSources[0].Name!.length; i++) { + const char = mediaSources[0].Name![i]; + if (mediaSources.every((source) => source.Name![i] === char)) { commonPrefix += char; } else { commonPrefix = commonPrefix.slice(0, -1); diff --git a/components/filters/FilterButton.tsx b/components/filters/FilterButton.tsx index 6d976b26..de8caa2e 100644 --- a/components/filters/FilterButton.tsx +++ b/components/filters/FilterButton.tsx @@ -1,7 +1,7 @@ import { Text } from "@/components/common/Text"; import { FontAwesome, Ionicons } from "@expo/vector-icons"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { TouchableOpacity, View, ViewProps } from "react-native"; import { FilterSheet } from "./FilterSheet"; diff --git a/components/jellyseerr/discover/CompanySlide.tsx b/components/jellyseerr/discover/CompanySlide.tsx index b30df3d7..abee4a9d 100644 --- a/components/jellyseerr/discover/CompanySlide.tsx +++ b/components/jellyseerr/discover/CompanySlide.tsx @@ -1,23 +1,30 @@ -import React, {useCallback} from "react"; -import { - useJellyseerr, -} from "@/hooks/useJellyseerr"; -import {TouchableOpacity, ViewProps} from "react-native"; -import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide"; -import {COMPANY_LOGO_IMAGE_FILTER, Network} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider"; import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard"; -import {Studio} from "@/utils/jellyseerr/src/components/Discover/StudioSlider"; -import {router, useSegments} from "expo-router"; +import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { + COMPANY_LOGO_IMAGE_FILTER, + Network, +} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider"; +import { Studio } from "@/utils/jellyseerr/src/components/Discover/StudioSlider"; +import { router, useSegments } from "expo-router"; +import React, { useCallback } from "react"; +import { TouchableOpacity, ViewProps } from "react-native"; -const CompanySlide: React.FC<{data: Network[] | Studio[]} & SlideProps & ViewProps> = ({ slide, data, ...props }) => { +const CompanySlide: React.FC< + { data: Network[] | Studio[] } & SlideProps & ViewProps +> = ({ slide, data, ...props }) => { const segments = useSegments(); const { jellyseerrApi } = useJellyseerr(); const from = segments[2]; - const navigate = useCallback(({id, image, name}: Network | Studio) => router.push({ - pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`, - params: {id, image, name, type: slide.type } - }), [slide]); + const navigate = useCallback( + ({ id, image, name }: Network | Studio) => + router.push({ + pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`, + params: { id, image, name, type: slide.type }, + }), + [slide] + ); return ( )} diff --git a/components/jellyseerr/discover/GenreSlide.tsx b/components/jellyseerr/discover/GenreSlide.tsx index 551ee2de..36623e20 100644 --- a/components/jellyseerr/discover/GenreSlide.tsx +++ b/components/jellyseerr/discover/GenreSlide.tsx @@ -1,55 +1,66 @@ -import React, {useCallback} from "react"; -import {Endpoints, useJellyseerr,} from "@/hooks/useJellyseerr"; -import {TouchableOpacity, ViewProps} from "react-native"; -import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide"; import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard"; -import {router, useSegments} from "expo-router"; -import {useQuery} from "@tanstack/react-query"; -import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover"; -import {genreColorMap} from "@/utils/jellyseerr/src/components/Discover/constants"; -import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; +import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide"; +import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; +import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; +import { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; +import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants"; +import { useQuery } from "@tanstack/react-query"; +import { router, useSegments } from "expo-router"; +import React, { useCallback } from "react"; +import { TouchableOpacity, ViewProps } from "react-native"; const GenreSlide: React.FC = ({ slide, ...props }) => { const segments = useSegments(); const { jellyseerrApi } = useJellyseerr(); const from = segments[2]; - const navigate = useCallback((genre: GenreSliderItem) => router.push({ - pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`, - params: {type: slide.type, name: genre.name} - }), [slide]); + const navigate = useCallback( + (genre: GenreSliderItem) => + router.push({ + pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`, + params: { type: slide.type, name: genre.name }, + }), + [slide] + ); - const {data, isFetching, isLoading } = useQuery({ - queryKey: ['jellyseerr', 'discover', slide.type, slide.id], + const { data, isFetching, isLoading } = useQuery({ + queryKey: ["jellyseerr", "discover", slide.type, slide.id], queryFn: async () => { return jellyseerrApi?.getGenreSliders( slide.type == DiscoverSliderType.MOVIE_GENRES ? Endpoints.MOVIE : Endpoints.TV - ) + ); }, - enabled: !!jellyseerrApi - }) + enabled: !!jellyseerrApi, + }); return ( - data && item.id.toString()} - renderItem={(item, index) => ( - navigate(item)}> - - - )} - /> + data && ( + item.id.toString()} + renderItem={(item, index) => ( + navigate(item)}> + + + )} + /> + ) ); }; diff --git a/components/list/ListItem.tsx b/components/list/ListItem.tsx index 46856b00..403b33dc 100644 --- a/components/list/ListItem.tsx +++ b/components/list/ListItem.tsx @@ -1,3 +1,4 @@ +import { Ionicons } from "@expo/vector-icons"; import { PropsWithChildren, ReactNode } from "react"; import { TouchableOpacity, @@ -6,7 +7,6 @@ import { ViewProps, } from "react-native"; import { Text } from "../common/Text"; -import { Ionicons } from "@expo/vector-icons"; interface Props extends TouchableOpacityProps, ViewProps { title?: string | null | undefined; diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx index 9064bc14..ca7743e0 100644 --- a/components/settings/StorageSettings.tsx +++ b/components/settings/StorageSettings.tsx @@ -1,12 +1,9 @@ -import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; +import { useHaptic } from "@/hooks/useHaptic"; import { useDownload } from "@/providers/DownloadProvider"; -import { clearLogs } from "@/utils/log"; import { useQuery } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; -import { useHaptic } from "@/hooks/useHaptic"; import { View } from "react-native"; -import * as Progress from "react-native-progress"; import { toast } from "sonner-native"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx index db7c5c71..422a2fc3 100644 --- a/components/video-player/controls/EpisodeList.tsx +++ b/components/video-player/controls/EpisodeList.tsx @@ -1,26 +1,26 @@ -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { runtimeTicksToSeconds } from "@/utils/time"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { atom, useAtom } from "jotai"; -import { useEffect, useMemo, useState, useRef } from "react"; -import { View, TouchableOpacity } from "react-native"; -import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; -import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; -import { Ionicons } from "@expo/vector-icons"; -import { Loader } from "@/components/Loader"; -import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; -import { Text } from "@/components/common/Text"; -import { DownloadSingleItem } from "@/components/DownloadItem"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { HorizontalScroll, HorizontalScrollRef, } from "@/components/common/HorrizontalScroll"; +import { Text } from "@/components/common/Text"; +import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; +import { DownloadSingleItem } from "@/components/DownloadItem"; +import { Loader } from "@/components/Loader"; import { SeasonDropdown, SeasonIndexState, } from "@/components/series/SeasonDropdown"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; +import { runtimeTicksToSeconds } from "@/utils/time"; +import { Ionicons } from "@expo/vector-icons"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { atom, useAtom } from "jotai"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; type Props = { item: BaseItemDto;