mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-14 08:12:22 +00:00
Merge branch 'master' into master
This commit is contained in:
@@ -12,8 +12,8 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
||||
</div>
|
||||
|
||||
## 🌟 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.
|
||||
|
||||
4
app.json
4
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"
|
||||
},
|
||||
|
||||
@@ -25,10 +25,9 @@ export default function IndexLayout() {
|
||||
onPress={() => {
|
||||
router.push("/(auth)/settings");
|
||||
}}
|
||||
className="p-2 "
|
||||
>
|
||||
<View className="h-10 aspect-square flex items-center justify-center rounded">
|
||||
<Feather name="settings" color={"white"} size={22} />
|
||||
</View>
|
||||
<Feather name="settings" color={"white"} size={22} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
|
||||
@@ -211,43 +211,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],
|
||||
@@ -282,6 +273,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],
|
||||
@@ -371,7 +371,7 @@ export default function index() {
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (e1 || e2)
|
||||
if (e1 || e2 || !api)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
||||
@@ -395,39 +395,69 @@ export default function index() {
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
}
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
className="flex flex-col space-y-4 mb-20"
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
className="flex flex-col pt-4 pb-24 gap-y-2"
|
||||
>
|
||||
<LargeMovieCarousel />
|
||||
<LargeMovieCarousel />
|
||||
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "ScrollingCollectionList") {
|
||||
return (
|
||||
<ScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
/>
|
||||
);
|
||||
} else if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</View>
|
||||
<ScrollingCollectionList
|
||||
key="nextUp"
|
||||
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|| []
|
||||
}
|
||||
orientation={"horizontal"}
|
||||
/>
|
||||
|
||||
<ScrollingCollectionList
|
||||
key="continueWatching"
|
||||
title={"Continue Watching"}
|
||||
queryKey={["continueWatching", user?.Id]}
|
||||
queryFn={async () =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user?.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
})
|
||||
).data.Items || []
|
||||
}
|
||||
orientation={"horizontal"}
|
||||
/>
|
||||
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "ScrollingCollectionList") {
|
||||
return (
|
||||
<ScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
/>
|
||||
);
|
||||
} else if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,9 +91,7 @@ export default function settings() {
|
||||
<Text className="font-bold text-lg mb-2">Tests</Text>
|
||||
<Button
|
||||
onPress={() => {
|
||||
toast.success("Download started", {
|
||||
invert: true,
|
||||
});
|
||||
toast.success("Download started");
|
||||
}}
|
||||
color="black"
|
||||
>
|
||||
|
||||
48
app/(auth)/play.tsx
Normal file
48
app/(auth)/play.tsx
Normal file
@@ -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 (
|
||||
<View className="">
|
||||
<StatusBar hidden />
|
||||
<FullScreenVideoPlayer />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -120,14 +120,33 @@ function Layout() {
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/play"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<FullScreenVideoPlayer />
|
||||
<Toaster />
|
||||
<Toaster
|
||||
duration={2000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</PlaybackProvider>
|
||||
</JellyfinProvider>
|
||||
|
||||
@@ -76,10 +76,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
{progress > 0 && (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
width: `100%`,
|
||||
}}
|
||||
className={`absolute bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
|
||||
className={`absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
|
||||
></View>
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -1,49 +1,44 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Dimensions,
|
||||
BackHandler,
|
||||
Pressable,
|
||||
Touchable,
|
||||
} from "react-native";
|
||||
import Video, { OnProgressData } from "react-native-video";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { itemRouter } from "./common/TouchableItemRouter";
|
||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { formatTimeString, ticksToSeconds } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
BackHandler,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
runOnJS,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import {
|
||||
useSafeAreaFrame,
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||
import {
|
||||
formatTimeString,
|
||||
runtimeTicksToSeconds,
|
||||
ticksToSeconds,
|
||||
} from "@/utils/time";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Video, { OnProgressData } from "react-native-video";
|
||||
import { Text } from "./common/Text";
|
||||
import { itemRouter } from "./common/TouchableItemRouter";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
const windowDimensions = Dimensions.get("window");
|
||||
const screenDimensions = Dimensions.get("screen");
|
||||
|
||||
export const FullScreenVideoPlayer: React.FC = () => {
|
||||
const {
|
||||
@@ -51,7 +46,6 @@ export const FullScreenVideoPlayer: React.FC = () => {
|
||||
pauseVideo,
|
||||
playVideo,
|
||||
stopPlayback,
|
||||
setVolume,
|
||||
setIsPlaying,
|
||||
isPlaying,
|
||||
videoRef,
|
||||
@@ -72,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);
|
||||
@@ -85,7 +78,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]);
|
||||
|
||||
@@ -100,6 +106,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,
|
||||
@@ -123,7 +141,13 @@ export const FullScreenVideoPlayer: React.FC = () => {
|
||||
onPress: () => null,
|
||||
style: "cancel",
|
||||
},
|
||||
{ text: "Yes", onPress: () => stopPlayback() },
|
||||
{
|
||||
text: "Yes",
|
||||
onPress: () => {
|
||||
stopPlayback();
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
@@ -136,7 +160,7 @@ export const FullScreenVideoPlayer: React.FC = () => {
|
||||
);
|
||||
|
||||
return () => backHandler.remove();
|
||||
}, [currentlyPlaying, stopPlayback]);
|
||||
}, [currentlyPlaying, stopPlayback, router]);
|
||||
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.OrientationLock.UNKNOWN
|
||||
@@ -154,6 +178,10 @@ export const FullScreenVideoPlayer: React.FC = () => {
|
||||
}
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((orientation) => {
|
||||
setOrientation(orientationToOrientationLock(orientation));
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
@@ -199,25 +227,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 +259,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,78 +303,37 @@ 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 { 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 = async () => {
|
||||
if (!introTimestamps || !videoRef.current) return;
|
||||
try {
|
||||
videoRef.current.seek(introTimestamps.IntroEnd);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error skipping intro", error);
|
||||
}
|
||||
};
|
||||
const toggleIgnoreSafeArea = useCallback(() => {
|
||||
setIgnoreSafeArea((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
if (!currentlyPlaying) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
width: dimensions.window.width,
|
||||
height: dimensions.window.height,
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
>
|
||||
<StatusBar hidden={isStatusBarHidden} />
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
onPress={toggleControls}
|
||||
style={[
|
||||
{
|
||||
@@ -368,8 +343,8 @@ export const FullScreenVideoPlayer: React.FC = () => {
|
||||
left: ignoreSafeArea ? 0 : insets.left,
|
||||
right: ignoreSafeArea ? 0 : insets.right,
|
||||
width: ignoreSafeArea
|
||||
? screenWidth
|
||||
: screenWidth - (insets.left + insets.right),
|
||||
? dimensions.window.width
|
||||
: dimensions.window.width - (insets.left + insets.right),
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -383,45 +358,82 @@ 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}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
|
||||
{(showControls || isBuffering) && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
{
|
||||
top: 0,
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
width: dimensions.window.width,
|
||||
height: dimensions.window.height,
|
||||
},
|
||||
]}
|
||||
className=" bg-black/50 z-0"
|
||||
></View>
|
||||
)}
|
||||
|
||||
{isBuffering && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
className="fixed top-0 brightness-50 bg-black/50 left-0 w-screen h-screen flex flex-col items-center justify-center"
|
||||
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{introTimestamps &&
|
||||
currentTime > introTimestamps.ShowSkipPromptAt &&
|
||||
currentTime < introTimestamps.HideSkipPromptAt && (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
|
||||
right: isLandscape ? insets.right + 32 : insets.right + 16,
|
||||
height: 70,
|
||||
zIndex: 10,
|
||||
},
|
||||
]}
|
||||
className=""
|
||||
{showSkipButton && (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
|
||||
right: isLandscape ? insets.right + 32 : insets.right + 16,
|
||||
height: 70,
|
||||
},
|
||||
]}
|
||||
className="z-10"
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={skipIntro}
|
||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={skipIntro}
|
||||
className="bg-purple-600 rounded-full p-2"
|
||||
>
|
||||
<Text className="text-white">Skip Intro</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
<Text className="text-white">Skip Intro</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showSkipCreditButton && (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
|
||||
right: isLandscape ? insets.right + 32 : insets.right + 16,
|
||||
height: 70,
|
||||
},
|
||||
]}
|
||||
className="z-10"
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={skipCredit}
|
||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||
>
|
||||
<Text className="text-white">Skip Credits</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showControls && (
|
||||
<>
|
||||
@@ -430,8 +442,9 @@ export const FullScreenVideoPlayer: React.FC = () => {
|
||||
{
|
||||
position: "absolute",
|
||||
top: insets.top,
|
||||
right: isLandscape ? insets.right + 32 : insets.right + 8,
|
||||
right: isLandscape ? insets.right + 32 : insets.right + 16,
|
||||
height: 70,
|
||||
zIndex: 10,
|
||||
},
|
||||
]}
|
||||
className="flex flex-row items-center space-x-2 z-10"
|
||||
@@ -447,7 +460,10 @@ export const FullScreenVideoPlayer: React.FC = () => {
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={stopPlayback}
|
||||
onPress={() => {
|
||||
stopPlayback();
|
||||
router.back();
|
||||
}}
|
||||
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
@@ -461,8 +477,8 @@ export const FullScreenVideoPlayer: React.FC = () => {
|
||||
bottom: insets.bottom + 8,
|
||||
left: isLandscape ? insets.left + 32 : insets.left + 16,
|
||||
width: isLandscape
|
||||
? screenWidth - insets.left - insets.right - 64
|
||||
: screenWidth - insets.left - insets.right - 32,
|
||||
? dimensions.window.width - insets.left - insets.right - 64
|
||||
: dimensions.window.width - insets.left - insets.right - 32,
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -27,6 +27,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -45,6 +46,8 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
|
||||
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
|
||||
|
||||
@@ -63,6 +66,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
if (!url || !item) return;
|
||||
if (!client) {
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
router.push("/play");
|
||||
return;
|
||||
}
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
@@ -159,7 +163,9 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
});
|
||||
break;
|
||||
case 1:
|
||||
console.log("Device");
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
router.push("/play");
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
|
||||
@@ -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<EpisodeCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const router = useRouter();
|
||||
|
||||
const { startDownloadedFilePlayback } = usePlayback();
|
||||
|
||||
@@ -30,6 +32,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
item,
|
||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
router.push("/play");
|
||||
}, [item, startDownloadedFilePlayback]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<MovieCardProps> = ({ 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<MovieCardProps> = ({ item }) => {
|
||||
item,
|
||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
router.push("/play");
|
||||
}, [item, startDownloadedFilePlayback]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -84,13 +84,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
|
||||
const width = Dimensions.get("screen").width;
|
||||
|
||||
if (l1 || l2)
|
||||
return (
|
||||
<View className="h-[242px] flex items-center justify-center">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (l1 || l2) return null;
|
||||
if (!popularItems) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -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<BaseItemDto[]>;
|
||||
@@ -26,13 +24,11 @@ interface Props extends ViewProps {
|
||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
height={orientation === "vertical" ? 247 : 164}
|
||||
extraData={[orientation, isLoading]}
|
||||
loading={isLoading}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableItemRouter
|
||||
key={index}
|
||||
item={item}
|
||||
className={`flex flex-col
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
key={index}
|
||||
style={{
|
||||
width: orientation === "horizontal" ? 176 : 112,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Episode" && orientation === "vertical" && (
|
||||
<SeriesPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Movie" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Movie" && orientation === "vertical" && (
|
||||
<MoviePoster item={item} />
|
||||
)}
|
||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Episode" && orientation === "vertical" && (
|
||||
<SeriesPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Movie" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Movie" && orientation === "vertical" && (
|
||||
<MoviePoster item={item} />
|
||||
)}
|
||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -49,7 +49,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
renderItem={(item, index) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
key={index}
|
||||
className="flex flex-col w-44"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} useEpisodePoster />
|
||||
|
||||
@@ -144,7 +144,7 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||
>
|
||||
<Text>-</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||
{settings.forwardSkipTime}s
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
@@ -182,7 +182,7 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||
>
|
||||
<Text>-</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||
{settings.rewindSkipTime}s
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -97,6 +97,113 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
pointerEvents={settings.autoRotate ? "none" : "auto"}
|
||||
className={`
|
||||
${
|
||||
settings.autoRotate
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "opacity-100"
|
||||
}
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Video orientation</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Set the full screen video player orientation.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.DEFAULT,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.DEFAULT
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="3"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="4"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Use external player (VLC)</Text>
|
||||
@@ -256,106 +363,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View className="flex flex-col">
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Video orientation</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Set the full screen video player orientation.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.DEFAULT,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.DEFAULT
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="3"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="4"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
|
||||
4
eas.json
4
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"
|
||||
|
||||
72
hooks/useCreditSkipper.ts
Normal file
72
hooks/useCreditSkipper.ts
Normal file
@@ -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<any>
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||
|
||||
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
|
||||
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 };
|
||||
};
|
||||
68
hooks/useIntroSkipper.ts
Normal file
68
hooks/useIntroSkipper.ts
Normal file
@@ -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<any>
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||
|
||||
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
|
||||
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 };
|
||||
};
|
||||
22
package.json
22
package.json
@@ -17,27 +17,27 @@
|
||||
"dependencies": {
|
||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||
"@expo/react-native-action-sheet": "^4.1.0",
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@expo/vector-icons": "^14.0.3",
|
||||
"@gorhom/bottom-sheet": "^4",
|
||||
"@jellyfin/sdk": "^0.10.0",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/netinfo": "11.3.1",
|
||||
"@react-native-menu/menu": "^1.1.2",
|
||||
"@react-native-menu/menu": "^1.1.3",
|
||||
"@react-navigation/native": "^6.0.2",
|
||||
"@shopify/flash-list": "1.6.4",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/lodash": "^4.17.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"expo": "^51.0.32",
|
||||
"expo": "~51.0.34",
|
||||
"expo-blur": "~13.0.2",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-dev-client": "~4.0.26",
|
||||
"expo-dev-client": "~4.0.27",
|
||||
"expo-device": "~6.0.2",
|
||||
"expo-font": "~12.0.10",
|
||||
"expo-haptics": "~13.0.1",
|
||||
"expo-image": "~1.12.15",
|
||||
"expo-image": "~1.13.0",
|
||||
"expo-keep-awake": "~13.0.2",
|
||||
"expo-linear-gradient": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
@@ -46,13 +46,13 @@
|
||||
"expo-router": "~3.5.23",
|
||||
"expo-screen-orientation": "~7.0.5",
|
||||
"expo-sensors": "~13.0.9",
|
||||
"expo-splash-screen": "~0.27.5",
|
||||
"expo-splash-screen": "~0.27.6",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-system-ui": "~3.0.7",
|
||||
"expo-updates": "~0.25.24",
|
||||
"expo-updates": "~0.25.25",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"jotai": "^2.9.3",
|
||||
"jotai": "^2.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "18.2.0",
|
||||
@@ -74,9 +74,9 @@
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"react-native-video": "^6.5.0",
|
||||
"react-native-video": "^6.6.2",
|
||||
"react-native-web": "~0.19.10",
|
||||
"sonner-native": "^0.9.2",
|
||||
"sonner-native": "^0.14.2",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.3",
|
||||
"uuid": "^10.0.0",
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ type Settings = {
|
||||
forwardSkipTime: number;
|
||||
rewindSkipTime: number;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* The settings atom is a Jotai atom that stores the user's settings.
|
||||
|
||||
Reference in New Issue
Block a user