From d1b6a265a110b202ef9a09cdd57f502cb3abb9ac Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 24 Sep 2024 18:15:52 +0200 Subject: [PATCH 01/16] fix: #135 --- app/(auth)/(tabs)/(home)/_layout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 298edae5..5bea39a0 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -26,6 +26,7 @@ export default function IndexLayout() { onPress={() => { router.push("/(auth)/downloads"); }} + className="p-2" > @@ -37,10 +38,9 @@ export default function IndexLayout() { onPress={() => { router.push("/(auth)/settings"); }} + className="p-2 " > - - - + ), From 9fcff04c0d44b9cb0339f235c498dac269ba8dd4 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 24 Sep 2024 18:16:00 +0200 Subject: [PATCH 02/16] chore: hide button --- app/(auth)/(tabs)/(home)/settings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 79accca1..f0980743 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -87,7 +87,7 @@ export default function settings() { - + {/* Tests - + */} Account and storage From 9aa0dc0a3d02a071c5edcf81e05474b9b5874485 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 24 Sep 2024 18:16:26 +0200 Subject: [PATCH 03/16] wip: full screen player --- app/(auth)/play.tsx | 48 +++++++++++ app/_layout.tsx | 6 +- components/FullScreenVideoPlayer.tsx | 116 +++++++++++++++++---------- components/PlayButton.tsx | 6 ++ 4 files changed, 133 insertions(+), 43 deletions(-) create mode 100644 app/(auth)/play.tsx diff --git a/app/(auth)/play.tsx b/app/(auth)/play.tsx new file mode 100644 index 00000000..0d3b3891 --- /dev/null +++ b/app/(auth)/play.tsx @@ -0,0 +1,48 @@ +import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer"; +import { useSettings } from "@/utils/atoms/settings"; +import * as NavigationBar from "expo-navigation-bar"; +import { StatusBar } from "expo-status-bar"; +import { useEffect, useState } from "react"; +import { Platform, View, ViewProps } from "react-native"; +import * as ScreenOrientation from "expo-screen-orientation"; + +interface Props extends ViewProps {} + +export default function page() { + const [settings] = useSettings(); + + useEffect(() => { + if (settings?.autoRotate) { + // Don't need to do anything + } else if (settings?.defaultVideoOrientation) { + ScreenOrientation.lockAsync(settings.defaultVideoOrientation); + } + + if (Platform.OS === "android") { + NavigationBar.setVisibilityAsync("hidden"); + NavigationBar.setBehaviorAsync("overlay-swipe"); + } + + return () => { + if (settings?.autoRotate) { + ScreenOrientation.unlockAsync(); + } else { + ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP + ); + } + + if (Platform.OS === "android") { + NavigationBar.setVisibilityAsync("visible"); + NavigationBar.setBehaviorAsync("inset-swipe"); + } + }; + }, [settings]); + + return ( + + + ); +} diff --git a/app/_layout.tsx b/app/_layout.tsx index cac8f2ea..e610f447 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -120,13 +120,17 @@ function Layout() { title: "", }} /> + - + {/* */} diff --git a/components/FullScreenVideoPlayer.tsx b/components/FullScreenVideoPlayer.tsx index d9faf3f4..c80fdb76 100644 --- a/components/FullScreenVideoPlayer.tsx +++ b/components/FullScreenVideoPlayer.tsx @@ -45,6 +45,9 @@ import { ticksToSeconds, } from "@/utils/time"; +const windowDimensions = Dimensions.get("window"); +const screenDimensions = Dimensions.get("screen"); + export const FullScreenVideoPlayer: React.FC = () => { const { currentlyPlaying, @@ -85,7 +88,20 @@ export const FullScreenVideoPlayer: React.FC = () => { const min = useSharedValue(0); const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0); - const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); + const [dimensions, setDimensions] = useState({ + window: windowDimensions, + screen: screenDimensions, + }); + + useEffect(() => { + const subscription = Dimensions.addEventListener( + "change", + ({ window, screen }) => { + setDimensions({ window, screen }); + } + ); + return () => subscription?.remove(); + }); const from = useMemo(() => segments[2], [segments]); @@ -123,7 +139,13 @@ export const FullScreenVideoPlayer: React.FC = () => { onPress: () => null, style: "cancel", }, - { text: "Yes", onPress: () => stopPlayback() }, + { + text: "Yes", + onPress: () => { + stopPlayback(); + router.back(); + }, + }, ]); return true; } @@ -136,7 +158,7 @@ export const FullScreenVideoPlayer: React.FC = () => { ); return () => backHandler.remove(); - }, [currentlyPlaying, stopPlayback]); + }, [currentlyPlaying, stopPlayback, router]); const [orientation, setOrientation] = useState( ScreenOrientation.OrientationLock.UNKNOWN @@ -154,6 +176,10 @@ export const FullScreenVideoPlayer: React.FC = () => { } ); + ScreenOrientation.getOrientationAsync().then((orientation) => { + setOrientation(orientationToOrientationLock(orientation)); + }); + return () => { subscription.remove(); }; @@ -199,25 +225,13 @@ export const FullScreenVideoPlayer: React.FC = () => { }, [currentlyPlaying, api, poster]); useEffect(() => { - if (!currentlyPlaying) { - ScreenOrientation.unlockAsync(); - progress.value = 0; - max.value = 0; - setShowControls(true); - setIsStatusBarHidden(false); - isSeeking.value = false; - } else { - setIsStatusBarHidden(true); - ScreenOrientation.lockAsync( - settings?.defaultVideoOrientation || - ScreenOrientation.OrientationLock.DEFAULT - ); + if (currentlyPlaying) { progress.value = currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; max.value = currentlyPlaying.item.RunTimeTicks || 0; setShowControls(true); } - }, [currentlyPlaying, settings]); + }, [currentlyPlaying]); const toggleControls = () => setShowControls(!showControls); @@ -243,10 +257,10 @@ export const FullScreenVideoPlayer: React.FC = () => { [setIsPlaying] ); - const handlePlayPause = () => { + const handlePlayPause = useCallback(() => { if (isPlaying) pauseVideo(); else playVideo(); - }; + }, [isPlaying, pauseVideo, playVideo]); const handleSliderComplete = (value: number) => { progress.value = value; @@ -287,25 +301,25 @@ export const FullScreenVideoPlayer: React.FC = () => { } }, [settings]); - const handleGoToPreviousItem = () => { + const handleGoToPreviousItem = useCallback(() => { if (!previousItem || !from) return; const url = itemRouter(previousItem, from); stopPlayback(); // @ts-ignore router.push(url); - }; + }, [previousItem, from, stopPlayback, router]); - const handleGoToNextItem = () => { + const handleGoToNextItem = useCallback(() => { if (!nextItem || !from) return; const url = itemRouter(nextItem, from); stopPlayback(); // @ts-ignore router.push(url); - }; + }, [nextItem, from, stopPlayback, router]); - const toggleIgnoreSafeArea = () => { - setIgnoreSafeArea(!ignoreSafeArea); - }; + const toggleIgnoreSafeArea = useCallback(() => { + setIgnoreSafeArea((prev) => !prev); + }, []); const { data: introTimestamps } = useQuery({ queryKey: ["introTimestamps", currentlyPlaying?.item.Id], @@ -338,27 +352,26 @@ export const FullScreenVideoPlayer: React.FC = () => { enabled: !!currentlyPlaying?.item.Id, }); - const skipIntro = async () => { + const skipIntro = useCallback(async () => { if (!introTimestamps || !videoRef.current) return; try { videoRef.current.seek(introTimestamps.IntroEnd); } catch (error) { writeToLog("ERROR", "Error skipping intro", error); } - }; + }, [introTimestamps]); if (!currentlyPlaying) return null; return ( - + + + Video orientation + + Set the full screen video player orientation. + + + + + + + {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 + ] + } + + + + + + Use external player (VLC) @@ -256,106 +363,6 @@ export const SettingToggles: React.FC = ({ ...props }) => { - - - Video orientation - - Set the full screen video player orientation. - - - - - - - {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 - ] - } - - - - - Date: Tue, 24 Sep 2024 20:04:50 +0200 Subject: [PATCH 07/16] chore --- app/(auth)/(tabs)/(home)/settings.tsx | 8 +++----- app/_layout.tsx | 14 +++++++++++++- bun.lockb | Bin 585868 -> 588170 bytes package.json | 22 +++++++++++----------- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index f0980743..cf3d44ec 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -87,19 +87,17 @@ export default function settings() { - {/* + Tests - */} + Account and storage diff --git a/app/_layout.tsx b/app/_layout.tsx index e610f447..600192bf 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -131,7 +131,19 @@ function Layout() { {/* */} - + diff --git a/bun.lockb b/bun.lockb index 465c6be21d89a5c4a8de4297af76caa14d2316e9..e457c1c20e61a1510b2261e4dab32ab77e3def75 100755 GIT binary patch delta 28607 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 Date: Tue, 24 Sep 2024 20:04:58 +0200 Subject: [PATCH 08/16] chore: refactor --- hooks/useIntroSkipper.ts | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 hooks/useIntroSkipper.ts diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts new file mode 100644 index 00000000..df05462e --- /dev/null +++ b/hooks/useIntroSkipper.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; +import { writeToLog } from "@/utils/log"; + +interface IntroTimestamps { + EpisodeId: string; + HideSkipPromptAt: number; + IntroEnd: number; + IntroStart: number; + ShowSkipPromptAt: number; + Valid: boolean; +} + +export const useIntroSkipper = ( + itemId: string | undefined, + currentTime: number, + videoRef: React.RefObject +) => { + const [api] = useAtom(apiAtom); + const [showSkipButton, setShowSkipButton] = useState(false); + + const { data: introTimestamps } = useQuery({ + queryKey: ["introTimestamps", itemId], + queryFn: async () => { + if (!itemId) { + console.log("No item id"); + return null; + } + + const res = await api?.axiosInstance.get( + `${api.basePath}/Episode/${itemId}/IntroTimestamps`, + { + headers: getAuthHeaders(api), + } + ); + + if (res?.status !== 200) { + return null; + } + + return res?.data; + }, + enabled: !!itemId, + }); + + useEffect(() => { + if (introTimestamps) { + setShowSkipButton( + currentTime > introTimestamps.ShowSkipPromptAt && + currentTime < introTimestamps.HideSkipPromptAt + ); + } + }, [introTimestamps, currentTime]); + + const skipIntro = useCallback(() => { + if (!introTimestamps || !videoRef.current) return; + try { + videoRef.current.seek(introTimestamps.IntroEnd); + } catch (error) { + writeToLog("ERROR", "Error skipping intro", error); + } + }, [introTimestamps, videoRef]); + + return { showSkipButton, skipIntro }; +}; From e82b15403265e8960131c4d951d195b2b44c13f6 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 24 Sep 2024 20:05:04 +0200 Subject: [PATCH 09/16] feat: skip credits --- components/FullScreenVideoPlayer.tsx | 115 +++++++++++++-------------- hooks/useCreditSkipper.ts | 72 +++++++++++++++++ 2 files changed, 126 insertions(+), 61 deletions(-) create mode 100644 hooks/useCreditSkipper.ts diff --git a/components/FullScreenVideoPlayer.tsx b/components/FullScreenVideoPlayer.tsx index c80fdb76..2729aa98 100644 --- a/components/FullScreenVideoPlayer.tsx +++ b/components/FullScreenVideoPlayer.tsx @@ -44,6 +44,8 @@ import { runtimeTicksToSeconds, ticksToSeconds, } from "@/utils/time"; +import { useIntroSkipper } from "@/hooks/useIntroSkipper"; +import { useCreditSkipper } from "@/hooks/useCreditSkipper"; const windowDimensions = Dimensions.get("window"); const screenDimensions = Dimensions.get("screen"); @@ -116,6 +118,18 @@ export const FullScreenVideoPlayer: React.FC = () => { [] ); + const { showSkipButton, skipIntro } = useIntroSkipper( + currentlyPlaying?.item.Id, + currentTime, + videoRef + ); + + const { showSkipCreditButton, skipCredit } = useCreditSkipper( + currentlyPlaying?.item.Id, + currentTime, + videoRef + ); + useAnimatedReaction( () => ({ progress: progress.value, @@ -321,46 +335,6 @@ export const FullScreenVideoPlayer: React.FC = () => { setIgnoreSafeArea((prev) => !prev); }, []); - const { data: introTimestamps } = useQuery({ - queryKey: ["introTimestamps", currentlyPlaying?.item.Id], - queryFn: async () => { - if (!currentlyPlaying?.item.Id) { - console.log("No item id"); - return null; - } - - const res = await api?.axiosInstance.get( - `${api.basePath}/Episode/${currentlyPlaying.item.Id}/IntroTimestamps`, - { - headers: getAuthHeaders(api), - } - ); - - if (res?.status !== 200) { - return null; - } - - return res?.data as { - EpisodeId: string; - HideSkipPromptAt: number; - IntroEnd: number; - IntroStart: number; - ShowSkipPromptAt: number; - Valid: boolean; - }; - }, - enabled: !!currentlyPlaying?.item.Id, - }); - - const skipIntro = useCallback(async () => { - if (!introTimestamps || !videoRef.current) return; - try { - videoRef.current.seek(introTimestamps.IntroEnd); - } catch (error) { - writeToLog("ERROR", "Error skipping intro", error); - } - }, [introTimestamps]); - if (!currentlyPlaying) return null; return ( @@ -428,28 +402,47 @@ export const FullScreenVideoPlayer: React.FC = () => { )} - {introTimestamps && - currentTime > introTimestamps.ShowSkipPromptAt && - currentTime < introTimestamps.HideSkipPromptAt && ( - + - - Skip Intro - - - )} + Skip Intro + + + )} + + {showSkipCreditButton && ( + + + Skip Credits + + + )} {showControls && ( <> diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts new file mode 100644 index 00000000..2fb2940d --- /dev/null +++ b/hooks/useCreditSkipper.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; +import { writeToLog } from "@/utils/log"; + +interface CreditTimestamps { + Introduction: { + Start: number; + End: number; + Valid: boolean; + }; + Credits: { + Start: number; + End: number; + Valid: boolean; + }; +} + +export const useCreditSkipper = ( + itemId: string | undefined, + currentTime: number, + videoRef: React.RefObject +) => { + const [api] = useAtom(apiAtom); + const [showSkipCreditButton, setShowSkipCreditButton] = useState(false); + + const { data: creditTimestamps } = useQuery({ + queryKey: ["creditTimestamps", itemId], + queryFn: async () => { + if (!itemId) { + console.log("No item id"); + return null; + } + + const res = await api?.axiosInstance.get( + `${api.basePath}/Episode/${itemId}/Timestamps`, + { + headers: getAuthHeaders(api), + } + ); + + if (res?.status !== 200) { + return null; + } + + return res?.data; + }, + enabled: !!itemId, + }); + + useEffect(() => { + if (creditTimestamps) { + setShowSkipCreditButton( + currentTime > creditTimestamps.Credits.Start && + currentTime < creditTimestamps.Credits.End + ); + } + }, [creditTimestamps, currentTime]); + + const skipCredit = useCallback(() => { + if (!creditTimestamps || !videoRef.current) return; + try { + videoRef.current.seek(creditTimestamps.Credits.End); + } catch (error) { + writeToLog("ERROR", "Error skipping intro", error); + } + }, [creditTimestamps, videoRef]); + + return { showSkipCreditButton, skipCredit }; +}; From 94b6de60669519ecdbe49bb16cb46e8452f94893 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 25 Sep 2024 07:42:32 +0200 Subject: [PATCH 10/16] fix: open new full screen player --- components/downloads/EpisodeCard.tsx | 3 +++ components/downloads/MovieCard.tsx | 15 +++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index 2f276b3b..92ac061b 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -10,6 +10,7 @@ import { Text } from "../common/Text"; import { useFiles } from "@/hooks/useFiles"; import { useSettings } from "@/utils/atoms/settings"; import { usePlayback } from "@/providers/PlaybackProvider"; +import { useRouter } from "expo-router"; interface EpisodeCardProps { item: BaseItemDto; @@ -22,6 +23,7 @@ interface EpisodeCardProps { */ export const EpisodeCard: React.FC = ({ item }) => { const { deleteFile } = useFiles(); + const router = useRouter(); const { startDownloadedFilePlayback } = usePlayback(); @@ -30,6 +32,7 @@ export const EpisodeCard: React.FC = ({ item }) => { item, url: `${FileSystem.documentDirectory}/${item.Id}.mp4`, }); + router.push("/play"); }, [item, startDownloadedFilePlayback]); /** diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx index 94e41cae..0943a89f 100644 --- a/components/downloads/MovieCard.tsx +++ b/components/downloads/MovieCard.tsx @@ -1,17 +1,16 @@ -import React, { useCallback } from "react"; -import { TouchableOpacity, View } from "react-native"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import * as ContextMenu from "zeego/context-menu"; import * as FileSystem from "expo-file-system"; import * as Haptics from "expo-haptics"; -import { useAtom } from "jotai"; +import React, { useCallback } from "react"; +import { TouchableOpacity, View } from "react-native"; +import * as ContextMenu from "zeego/context-menu"; -import { Text } from "../common/Text"; import { useFiles } from "@/hooks/useFiles"; import { runtimeTicksToMinutes } from "@/utils/time"; +import { Text } from "../common/Text"; -import { useSettings } from "@/utils/atoms/settings"; import { usePlayback } from "@/providers/PlaybackProvider"; +import { useRouter } from "expo-router"; interface MovieCardProps { item: BaseItemDto; @@ -24,8 +23,7 @@ interface MovieCardProps { */ export const MovieCard: React.FC = ({ item }) => { const { deleteFile } = useFiles(); - const [settings] = useSettings(); - + const router = useRouter(); const { startDownloadedFilePlayback } = usePlayback(); const handleOpenFile = useCallback(() => { @@ -33,6 +31,7 @@ export const MovieCard: React.FC = ({ item }) => { item, url: `${FileSystem.documentDirectory}/${item.Id}.mp4`, }); + router.push("/play"); }, [item, startDownloadedFilePlayback]); /** From eff12b7350dec35a3cece4928f1479332548fe9a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 25 Sep 2024 07:42:36 +0200 Subject: [PATCH 11/16] fix: pip --- components/FullScreenVideoPlayer.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/FullScreenVideoPlayer.tsx b/components/FullScreenVideoPlayer.tsx index 2729aa98..c1201a98 100644 --- a/components/FullScreenVideoPlayer.tsx +++ b/components/FullScreenVideoPlayer.tsx @@ -370,7 +370,10 @@ export const FullScreenVideoPlayer: React.FC = () => { onLoad={(data) => (max.value = secondsToTicks(data.duration))} onError={handleVideoError} playWhenInactive={true} + allowsExternalPlayback={true} playInBackground={true} + pictureInPicture={true} + showNotificationControls={true} ignoreSilentSwitch="ignore" fullscreen={false} /> From 7c10c467f3263f22e18e7c9b6dbe9f51a64ab041 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 25 Sep 2024 07:42:41 +0200 Subject: [PATCH 12/16] chore --- app.json | 4 ++-- eas.json | 4 ++-- providers/JellyfinProvider.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app.json b/app.json index b1f99dc8..36c126c2 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.14.0", + "version": "0.15.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -33,7 +33,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 40, + "versionCode": 41, "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, diff --git a/eas.json b/eas.json index b7d775da..1bfada25 100644 --- a/eas.json +++ b/eas.json @@ -21,13 +21,13 @@ } }, "production": { - "channel": "0.14.0", + "channel": "0.15.0", "android": { "image": "latest" } }, "production-apk": { - "channel": "0.14.0", + "channel": "0.15.0", "android": { "buildType": "apk", "image": "latest" diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 967f6445..2ae72c73 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -63,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.14.0" }, + clientInfo: { name: "Streamyfin", version: "0.15.0" }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, }) ); @@ -97,7 +97,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.14.0"`, + }, DeviceId="${deviceId}", Version="0.15.0"`, }; }, [deviceId]); From 8936a559deaa8ab5174c5d6255b1001ec08ae66f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 25 Sep 2024 08:28:26 +0200 Subject: [PATCH 13/16] feat: fade in player --- app/_layout.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index 600192bf..b942db93 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -122,7 +122,11 @@ function Layout() { /> - {/* */} Date: Wed, 25 Sep 2024 08:28:36 +0200 Subject: [PATCH 14/16] chore --- app/(auth)/(tabs)/(home)/index.tsx | 168 ++++++++++++-------- components/ContinueWatchingPoster.tsx | 5 +- components/FullScreenVideoPlayer.tsx | 66 ++++---- components/home/LargeMovieCarousel.tsx | 8 +- components/home/ScrollingCollectionList.tsx | 45 +++--- 5 files changed, 148 insertions(+), 144 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index c665059d..a4621335 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -157,43 +157,34 @@ export default function index() { if (!api || !user?.Id) return []; const ss: Section[] = [ - { - title: "Continue Watching", - queryKey: ["resumeItems", user.Id], - queryFn: async () => - ( - await getItemsApi(api).getResumeItems({ - userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - }) - ).data.Items || [], - type: "ScrollingCollectionList", - orientation: "horizontal", - }, - { - title: "Next Up", - queryKey: ["nextUp-all", user?.Id], - queryFn: async () => - ( - await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount"], - limit: 20, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - }) - ).data.Items || [], - type: "ScrollingCollectionList", - orientation: "horizontal", - }, - ...(mediaListCollections?.map( - (ml) => - ({ - title: ml.Name || "", - queryKey: ["mediaList", ml.Id], - queryFn: async () => ml, - type: "MediaListSection", - } as MediaListSection) - ) || []), + // { + // title: "Continue Watching", + // queryKey: ["resumeItems", user.Id], + // queryFn: async () => + // ( + // await getItemsApi(api).getResumeItems({ + // userId: user.Id, + // enableImageTypes: ["Primary", "Backdrop", "Thumb"], + // }) + // ).data.Items || [], + // type: "ScrollingCollectionList", + // orientation: "horizontal", + // }, + // { + // title: "Next Up", + // queryKey: ["nextUp-all", user?.Id], + // queryFn: async () => + // ( + // await getTvShowsApi(api).getNextUp({ + // userId: user?.Id, + // fields: ["MediaSourceCount"], + // limit: 20, + // enableImageTypes: ["Primary", "Backdrop", "Thumb"], + // }) + // ).data.Items || [], + // type: "ScrollingCollectionList", + // orientation: "horizontal", + // }, { title: "Recently Added in Movies", queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId], @@ -228,6 +219,15 @@ export default function index() { ).data || [], type: "ScrollingCollectionList", }, + ...(mediaListCollections?.map( + (ml) => + ({ + title: ml.Name || "", + queryKey: ["mediaList", ml.Id], + queryFn: async () => ml, + type: "MediaListSection", + } as MediaListSection) + ) || []), { title: "Suggested Movies", queryKey: ["suggestedMovies", user?.Id], @@ -317,7 +317,7 @@ export default function index() { const insets = useSafeAreaInsets(); - if (e1 || e2) + if (e1 || e2 || !api) return ( Oops! @@ -341,39 +341,69 @@ export default function index() { refreshControl={ } + key={"home"} + contentContainerStyle={{ + paddingLeft: insets.left, + paddingRight: insets.right, + }} + className="flex flex-col space-y-4 mb-20" > - - + - {sections.map((section, index) => { - if (section.type === "ScrollingCollectionList") { - return ( - - ); - } else if (section.type === "MediaListSection") { - return ( - - ); - } - return null; - })} - + + ( + await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + fields: ["MediaSourceCount"], + limit: 20, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }) + ).data.Items|| [] + } + orientation={"horizontal"} + /> + + + ( + await getItemsApi(api).getResumeItems({ + userId: user?.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }) + ).data.Items || [] + } + orientation={"horizontal"} + /> + + {sections.map((section, index) => { + if (section.type === "ScrollingCollectionList") { + return ( + + ); + } else if (section.type === "MediaListSection") { + return ( + + ); + } + return null; + })} ); } diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index c93ec275..7dda9dfc 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -76,10 +76,7 @@ const ContinueWatchingPoster: React.FC = ({ {progress > 0 && ( <> { pauseVideo, playVideo, stopPlayback, - setVolume, setIsPlaying, isPlaying, videoRef, @@ -77,7 +66,6 @@ export const FullScreenVideoPlayer: React.FC = () => { const [showControls, setShowControls] = useState(true); const [isBuffering, setIsBufferingState] = useState(true); const [ignoreSafeArea, setIgnoreSafeArea] = useState(false); - const [isStatusBarHidden, setIsStatusBarHidden] = useState(false); // Seconds const [currentTime, setCurrentTime] = useState(0); diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx index fa77d736..11676b88 100644 --- a/components/home/LargeMovieCarousel.tsx +++ b/components/home/LargeMovieCarousel.tsx @@ -84,13 +84,7 @@ export const LargeMovieCarousel: React.FC = ({ ...props }) => { const width = Dimensions.get("screen").width; - if (l1 || l2) - return ( - - - - ); - + if (l1 || l2) return null; if (!popularItems) return null; return ( diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 5e117467..e8420d72 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -1,6 +1,5 @@ import { Text } from "@/components/common/Text"; import MoviePoster from "@/components/posters/MoviePoster"; -import { useSettings } from "@/utils/atoms/settings"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useQuery, @@ -17,7 +16,6 @@ import SeriesPoster from "../posters/SeriesPoster"; interface Props extends ViewProps { title?: string | null; orientation?: "horizontal" | "vertical"; - height?: "small" | "large"; disabled?: boolean; queryKey: QueryKey; queryFn: QueryFunction; @@ -26,13 +24,11 @@ interface Props extends ViewProps { export const ScrollingCollectionList: React.FC = ({ title, orientation = "vertical", - height = "small", disabled = false, queryFn, queryKey, ...props }) => { - const [settings] = useSettings(); const { data, isLoading } = useQuery({ queryKey, queryFn, @@ -49,32 +45,31 @@ export const ScrollingCollectionList: React.FC = ({ ( - - {item.Type === "Episode" && orientation === "horizontal" && ( - - )} - {item.Type === "Episode" && orientation === "vertical" && ( - - )} - {item.Type === "Movie" && orientation === "horizontal" && ( - - )} - {item.Type === "Movie" && orientation === "vertical" && ( - - )} - {item.Type === "Series" && } - - + {item.Type === "Episode" && orientation === "horizontal" && ( + + )} + {item.Type === "Episode" && orientation === "vertical" && ( + + )} + {item.Type === "Movie" && orientation === "horizontal" && ( + + )} + {item.Type === "Movie" && orientation === "vertical" && ( + + )} + {item.Type === "Series" && } + )} /> From d672882c4bbff4e3e955d52a96e500226ecfc5cd Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 26 Sep 2024 13:34:03 +0200 Subject: [PATCH 15/16] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f1a737c1..3a70d9c9 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp ## 🌟 Features - -- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more. +- 🚀 **Skp intro / credits support** +- 🚀 **Trickplay images**: The new golden standard for chapter previews when seeking. - 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone. - 🔊 **Background audio**: Stream music in the background, even when locking the phone. - 📥 **Download media** (Experimental): Save your media locally and watch it offline. From d7eb25edf4917b850d4a2a821d866bbb580cebdc Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 26 Sep 2024 13:34:19 +0200 Subject: [PATCH 16/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a70d9c9..8bddf816 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp ## 🌟 Features - 🚀 **Skp intro / credits support** -- 🚀 **Trickplay images**: The new golden standard for chapter previews when seeking. +- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking. - 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone. - 🔊 **Background audio**: Stream music in the background, even when locking the phone. - 📥 **Download media** (Experimental): Save your media locally and watch it offline.