mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
38 Commits
feat/syncp
...
feat/new-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a7d8721b3 | ||
|
|
f45139ff90 | ||
|
|
65579c88e5 | ||
|
|
d716e42c20 | ||
|
|
ffe1003710 | ||
|
|
5c008f64b5 | ||
|
|
721cd093f4 | ||
|
|
402bdec5ab | ||
|
|
595120229f | ||
|
|
09363bffdc | ||
|
|
c3237571a8 | ||
|
|
e3c4a291f0 | ||
|
|
ce2e5e0fb8 | ||
|
|
c7703df3ce | ||
|
|
b7629f6f2b | ||
|
|
409e2de6c8 | ||
|
|
7cb67d73ec | ||
|
|
1fe1438ecf | ||
|
|
611f5ae37b | ||
|
|
d2701254b3 | ||
|
|
994dd44fc5 | ||
|
|
f7e04dfa2d | ||
|
|
cd126bb1c7 | ||
|
|
ddbfb91260 | ||
|
|
caac40c4b1 | ||
|
|
2632feb3e8 | ||
|
|
778447c1fd | ||
|
|
5a1f555703 | ||
|
|
2ed18d6588 | ||
|
|
c494b8e9f9 | ||
|
|
354fdd6791 | ||
|
|
f48e0348ad | ||
|
|
23eaddf87c | ||
|
|
a90dfb2805 | ||
|
|
78d168050a | ||
|
|
b92d55b9a0 | ||
|
|
907f6193b5 | ||
|
|
6f34f2e6a6 |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -2,7 +2,7 @@
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
labels: '❌ bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -2,7 +2,7 @@
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
labels: '✨ enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.12.0",
|
||||
"version": "0.14.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 36,
|
||||
"versionCode": 40,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
},
|
||||
|
||||
@@ -61,6 +61,16 @@ export default function IndexLayout() {
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name="collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { clearLogs, readFromLog } from "@/utils/log";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { Alert, ScrollView, View } from "react-native";
|
||||
import { red } from "react-native-reanimated/lib/typescript/reanimated2/Colors";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
|
||||
export default function settings() {
|
||||
const { logout } = useJellyfin();
|
||||
@@ -26,6 +30,36 @@ export default function settings() {
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const openQuickConnectAuthCodeInput = () => {
|
||||
Alert.prompt(
|
||||
"Quick connect",
|
||||
"Enter the quick connect code",
|
||||
async (text) => {
|
||||
if (text) {
|
||||
try {
|
||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||
code: text,
|
||||
userId: user?.Id,
|
||||
});
|
||||
console.log(res.status, res.statusText, res.data);
|
||||
if (res.status === 200) {
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
Alert.alert("Success", "Quick connect authorized");
|
||||
} else {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", "Invalid code");
|
||||
}
|
||||
} catch (e) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", "Invalid code");
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
@@ -38,41 +72,65 @@ export default function settings() {
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Information</Text>
|
||||
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||
<ListItem title="User" subTitle={user?.Name} />
|
||||
<ListItem title="Server" subTitle={api?.basePath} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
||||
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
||||
Authorize
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<SettingToggles />
|
||||
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Button color="black" onPress={logout}>
|
||||
Log out
|
||||
</Button>
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Tests</Text>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
onPress={() => {
|
||||
toast.success("Download started", {
|
||||
invert: true,
|
||||
});
|
||||
}}
|
||||
color="black"
|
||||
>
|
||||
Delete all downloaded files
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await clearLogs();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete all logs
|
||||
Test toast
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Account and storage</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Button color="black" onPress={logout}>
|
||||
Log out
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete all downloaded files
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await clearLogs();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete all logs
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useMemo } from "react";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import React from "react";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
|
||||
const memoizedContent = useMemo(() => <ItemContent id={id} />, [id]);
|
||||
|
||||
return memoizedContent;
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ autoHideHomeIndicator: true }} />
|
||||
<ItemContent id={id} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Page);
|
||||
export default Page;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import {
|
||||
useFocusEffect,
|
||||
useLocalSearchParams,
|
||||
useNavigation,
|
||||
} from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
||||
@@ -128,6 +132,13 @@ const Page = () => {
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: library?.Name || "",
|
||||
});
|
||||
}, [library]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
|
||||
@@ -195,6 +195,16 @@ export default function IndexLayout() {
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name="collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,16 @@ export default function SearchLayout() {
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name="collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -278,9 +278,9 @@ export default function search() {
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/series/${item.Id}`)}
|
||||
item={item}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
@@ -290,7 +290,7 @@ export default function search() {
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -302,14 +302,14 @@ export default function search() {
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||
className="flex flex-col w-44"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -321,16 +321,16 @@ export default function search() {
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28"
|
||||
onPress={() => router.push(`/collections/${item.Id}`)}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||
import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer";
|
||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
||||
@@ -19,6 +19,7 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "react-native-reanimated";
|
||||
import * as Linking from "expo-linking";
|
||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||
import { Toaster } from "sonner-native";
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
@@ -106,7 +107,12 @@ function Layout() {
|
||||
<PlaybackProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack
|
||||
initialRouteName="/home"
|
||||
screenOptions={{
|
||||
autoHideHomeIndicator: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
@@ -120,7 +126,8 @@ function Layout() {
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
<FullScreenVideoPlayer />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</PlaybackProvider>
|
||||
</JellyfinProvider>
|
||||
|
||||
@@ -45,7 +45,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
}
|
||||
const index = source.DefaultAudioStreamIndex;
|
||||
if (index !== undefined && index !== null) {
|
||||
console.log("DefaultAudioStreamIndex", index);
|
||||
onChange(index);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Video from "react-native-video";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
export const CurrentlyPlayingBar: React.FC = () => {
|
||||
const segments = useSegments();
|
||||
const {
|
||||
currentlyPlaying,
|
||||
pauseVideo,
|
||||
playVideo,
|
||||
stopPlayback,
|
||||
setVolume,
|
||||
setIsPlaying,
|
||||
isPlaying,
|
||||
videoRef,
|
||||
presentFullscreenPlayer,
|
||||
onProgress,
|
||||
} = usePlayback();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const aBottom = useSharedValue(0);
|
||||
const aPadding = useSharedValue(0);
|
||||
const aHeight = useSharedValue(100);
|
||||
const router = useRouter();
|
||||
const animatedOuterStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
bottom: withTiming(aBottom.value, { duration: 500 }),
|
||||
height: withTiming(aHeight.value, { duration: 500 }),
|
||||
padding: withTiming(aPadding.value, { duration: 500 }),
|
||||
};
|
||||
});
|
||||
|
||||
const aPaddingBottom = useSharedValue(30);
|
||||
const aPaddingInner = useSharedValue(12);
|
||||
const aBorderRadiusBottom = useSharedValue(12);
|
||||
const animatedInnerStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
padding: withTiming(aPaddingInner.value, { duration: 500 }),
|
||||
paddingBottom: withTiming(aPaddingBottom.value, { duration: 500 }),
|
||||
borderBottomLeftRadius: withTiming(aBorderRadiusBottom.value, {
|
||||
duration: 500,
|
||||
}),
|
||||
borderBottomRightRadius: withTiming(aBorderRadiusBottom.value, {
|
||||
duration: 500,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const from = useMemo(() => segments[2], [segments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (segments.find((s) => s.includes("tabs"))) {
|
||||
// Tab screen - i.e. home
|
||||
aBottom.value = Platform.OS === "ios" ? 78 : 50;
|
||||
aHeight.value = 80;
|
||||
aPadding.value = 8;
|
||||
aPaddingBottom.value = 8;
|
||||
aPaddingInner.value = 8;
|
||||
} else {
|
||||
// Inside a normal screen
|
||||
aBottom.value = Platform.OS === "ios" ? 0 : 0;
|
||||
aHeight.value = Platform.OS === "ios" ? 110 : 80;
|
||||
aPadding.value = Platform.OS === "ios" ? 0 : 8;
|
||||
aPaddingInner.value = Platform.OS === "ios" ? 12 : 8;
|
||||
aPaddingBottom.value = Platform.OS === "ios" ? 40 : 12;
|
||||
}
|
||||
}, [segments]);
|
||||
|
||||
const startPosition = useMemo(
|
||||
() =>
|
||||
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(
|
||||
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
|
||||
)
|
||||
: 0,
|
||||
[currentlyPlaying?.item]
|
||||
);
|
||||
|
||||
const poster = useMemo(() => {
|
||||
if (currentlyPlaying?.item.Type === "Audio")
|
||||
return `${api?.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`;
|
||||
else
|
||||
return getBackdropUrl({
|
||||
api,
|
||||
item: currentlyPlaying?.item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [currentlyPlaying?.item.Id, api]);
|
||||
|
||||
const videoSource = useMemo(() => {
|
||||
if (!api || !currentlyPlaying || !poster) return null;
|
||||
return {
|
||||
uri: currentlyPlaying.url,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: currentlyPlaying.item?.AlbumArtist
|
||||
? currentlyPlaying.item?.AlbumArtist
|
||||
: undefined,
|
||||
title: currentlyPlaying.item?.Name || "Unknown",
|
||||
description: currentlyPlaying.item?.Overview
|
||||
? currentlyPlaying.item?.Overview
|
||||
: undefined,
|
||||
imageUri: poster,
|
||||
subtitle: currentlyPlaying.item?.Album
|
||||
? currentlyPlaying.item?.Album
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}, [currentlyPlaying, startPosition, api, poster]);
|
||||
|
||||
if (!api || !currentlyPlaying) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[animatedOuterStyle]}
|
||||
className="absolute left-0 w-screen"
|
||||
>
|
||||
<BlurView
|
||||
intensity={Platform.OS === "android" ? 60 : 100}
|
||||
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
|
||||
className={`h-full w-full rounded-xl overflow-hidden ${
|
||||
Platform.OS === "android" && "bg-black"
|
||||
}`}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{ padding: 8, borderTopLeftRadius: 12, borderTopEndRadius: 12 },
|
||||
animatedInnerStyle,
|
||||
]}
|
||||
className="h-full w-full flex flex-row items-center justify-between overflow-hidden"
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-4 shrink">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
videoRef.current?.presentFullscreenPlayer();
|
||||
}}
|
||||
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||
${
|
||||
currentlyPlaying.item?.Type === "Audio"
|
||||
? "aspect-square"
|
||||
: "aspect-video"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{videoSource && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
allowsExternalPlayback
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
playWhenInactive={true}
|
||||
playInBackground={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
controls={false}
|
||||
pictureInPicture={true}
|
||||
poster={
|
||||
poster && currentlyPlaying.item?.Type === "Audio"
|
||||
? poster
|
||||
: undefined
|
||||
}
|
||||
debug={{
|
||||
enable: true,
|
||||
thread: true,
|
||||
}}
|
||||
onProgress={(e) => onProgress(e)}
|
||||
subtitleStyle={{
|
||||
fontSize: 16,
|
||||
}}
|
||||
source={videoSource}
|
||||
onRestoreUserInterfaceForPictureInPictureStop={() => {
|
||||
setTimeout(() => {
|
||||
presentFullscreenPlayer();
|
||||
}, 300);
|
||||
}}
|
||||
onFullscreenPlayerDidDismiss={() => {}}
|
||||
onFullscreenPlayerDidPresent={() => {}}
|
||||
onPlaybackStateChanged={(e) => {
|
||||
if (e.isPlaying === true) {
|
||||
playVideo(false);
|
||||
} else if (e.isPlaying === false) {
|
||||
pauseVideo(false);
|
||||
}
|
||||
}}
|
||||
onVolumeChange={(e) => {
|
||||
setVolume(e.volume);
|
||||
}}
|
||||
progressUpdateInterval={4000}
|
||||
onError={(e) => {
|
||||
console.log(e);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Video playback error: " + JSON.stringify(e)
|
||||
);
|
||||
Alert.alert("Error", "Cannot play this video file.");
|
||||
setIsPlaying(false);
|
||||
// setCurrentlyPlaying(null);
|
||||
}}
|
||||
renderLoader={
|
||||
currentlyPlaying.item?.Type !== "Audio" && (
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View className="shrink text-xs">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (currentlyPlaying.item?.Type === "Audio") {
|
||||
router.push(
|
||||
// @ts-ignore
|
||||
`/(auth)/(tabs)/${from}/albums/${currentlyPlaying.item.AlbumId}`
|
||||
);
|
||||
} else {
|
||||
router.push(
|
||||
// @ts-ignore
|
||||
`/(auth)/(tabs)/${from}/items/page?id=${currentlyPlaying.item?.Id}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text>{currentlyPlaying.item?.Name}</Text>
|
||||
</TouchableOpacity>
|
||||
{currentlyPlaying.item?.Type === "Episode" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(
|
||||
// @ts-ignore
|
||||
`/(auth)/(tabs)/${from}/series/${currentlyPlaying.item.SeriesId}`
|
||||
);
|
||||
}}
|
||||
className="text-xs opacity-50"
|
||||
>
|
||||
<Text>{currentlyPlaying.item.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{currentlyPlaying.item?.Type === "Movie" && (
|
||||
<View>
|
||||
<Text className="text-xs opacity-50">
|
||||
{currentlyPlaying.item?.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{currentlyPlaying.item?.Type === "Audio" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
||||
}}
|
||||
>
|
||||
<Text className="text-xs opacity-50">
|
||||
{currentlyPlaying.item?.Album}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (isPlaying) pauseVideo();
|
||||
else playVideo();
|
||||
}}
|
||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Ionicons name="pause" size={24} color="white" />
|
||||
) : (
|
||||
<Ionicons name="play" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
stopPlayback();
|
||||
}}
|
||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
@@ -126,7 +126,6 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
|
||||
if (mediaSource.SupportsDirectPlay) {
|
||||
if (item.MediaType === "Video") {
|
||||
console.log("Using direct stream for video!");
|
||||
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||
} else if (item.MediaType === "Audio") {
|
||||
console.log("Using direct stream for audio!");
|
||||
@@ -149,7 +148,6 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
}/universal?${searchParams.toString()}`;
|
||||
}
|
||||
} else if (mediaSource.TranscodingUrl) {
|
||||
console.log("Using transcoded stream!");
|
||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
}
|
||||
|
||||
|
||||
700
components/FullScreenVideoPlayer.tsx
Normal file
700
components/FullScreenVideoPlayer.tsx
Normal file
@@ -0,0 +1,700 @@
|
||||
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useControlsVisibility } from "@/hooks/useControlsVisibility";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
AppState,
|
||||
AppStateStatus,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
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";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
|
||||
async function setOrientation(orientation: ScreenOrientation.OrientationLock) {
|
||||
await ScreenOrientation.lockAsync(orientation);
|
||||
}
|
||||
|
||||
async function resetOrientation() {
|
||||
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||
}
|
||||
|
||||
export const FullScreenVideoPlayer: React.FC = () => {
|
||||
const {
|
||||
currentlyPlaying,
|
||||
pauseVideo,
|
||||
playVideo,
|
||||
stopPlayback,
|
||||
setVolume,
|
||||
setIsPlaying,
|
||||
isPlaying,
|
||||
videoRef,
|
||||
onProgress,
|
||||
isBuffering: _isBuffering,
|
||||
setIsBuffering,
|
||||
} = usePlayback();
|
||||
|
||||
const [settings] = useSettings();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
|
||||
useTrickplay(currentlyPlaying);
|
||||
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
|
||||
const { showControls, hideControls, opacity } = useControlsVisibility(3000);
|
||||
const [isInteractive, setIsInteractive] = useState(true);
|
||||
|
||||
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
|
||||
const from = useMemo(() => segments[2], [segments]);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const min = useSharedValue(0);
|
||||
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
|
||||
const sliding = useRef(false);
|
||||
const localIsBuffering = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||
|
||||
const poster = useMemo(() => {
|
||||
if (!currentlyPlaying?.item || !api) return "";
|
||||
return currentlyPlaying.item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: currentlyPlaying.item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [currentlyPlaying?.item, api]);
|
||||
|
||||
const videoSource = useMemo(() => {
|
||||
if (!api || !currentlyPlaying || !poster) return null;
|
||||
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
return {
|
||||
uri: currentlyPlaying.url,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
|
||||
title: currentlyPlaying.item?.Name || "Unknown",
|
||||
description: currentlyPlaying.item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: currentlyPlaying.item?.Album ?? undefined, // Change here
|
||||
},
|
||||
};
|
||||
}, [currentlyPlaying, api, poster]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
if (nextAppState === "active") {
|
||||
setIsInteractive(true);
|
||||
showControls();
|
||||
} else {
|
||||
setIsInteractive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
handleAppStateChange
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [showControls]);
|
||||
|
||||
useEffect(() => {
|
||||
max.value = currentlyPlaying?.item.RunTimeTicks || 0;
|
||||
}, [currentlyPlaying?.item.RunTimeTicks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentlyPlaying) {
|
||||
resetOrientation();
|
||||
progress.value = 0;
|
||||
min.value = 0;
|
||||
max.value = 0;
|
||||
cacheProgress.value = 0;
|
||||
localIsBuffering.value = false;
|
||||
sliding.current = false;
|
||||
hideControls();
|
||||
} else {
|
||||
setOrientation(
|
||||
settings?.defaultVideoOrientation ||
|
||||
ScreenOrientation.OrientationLock.DEFAULT
|
||||
);
|
||||
progress.value =
|
||||
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
|
||||
max.value = currentlyPlaying.item.RunTimeTicks || 0;
|
||||
showControls();
|
||||
}
|
||||
}, [currentlyPlaying, settings]);
|
||||
|
||||
const animatedStyles = {
|
||||
controls: useAnimatedStyle(() => ({
|
||||
opacity: withTiming(opacity.value, { duration: 300 }),
|
||||
})),
|
||||
videoContainer: useAnimatedStyle(() => ({
|
||||
opacity: withTiming(
|
||||
opacity.value === 1 || localIsBuffering.value ? 0.5 : 1,
|
||||
{
|
||||
duration: 300,
|
||||
}
|
||||
),
|
||||
})),
|
||||
loader: useAnimatedStyle(() => ({
|
||||
opacity: withTiming(localIsBuffering.value ? 1 : 0, { duration: 300 }),
|
||||
})),
|
||||
};
|
||||
|
||||
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 animatedIntroSkipperStyle = useAnimatedStyle(() => {
|
||||
const showButtonAt = secondsToTicks(introTimestamps?.ShowSkipPromptAt || 0);
|
||||
const hideButtonAt = secondsToTicks(introTimestamps?.HideSkipPromptAt || 0);
|
||||
const showButton =
|
||||
progress.value > showButtonAt && progress.value < hideButtonAt;
|
||||
return {
|
||||
opacity: withTiming(
|
||||
localIsBuffering.value === false && opacity.value === 1 && showButton
|
||||
? 1
|
||||
: 0,
|
||||
{
|
||||
duration: 300,
|
||||
}
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const toggleIgnoreSafeArea = useCallback(() => {
|
||||
setIgnoreSafeArea((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleToggleControlsPress = useCallback(() => {
|
||||
if (opacity.value === 1) {
|
||||
hideControls();
|
||||
} else {
|
||||
showControls();
|
||||
}
|
||||
}, [opacity.value, hideControls, showControls]);
|
||||
|
||||
const skipIntro = useCallback(async () => {
|
||||
if (!introTimestamps || !videoRef.current) return;
|
||||
try {
|
||||
videoRef.current.seek(introTimestamps.IntroEnd);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error skipping intro", error);
|
||||
}
|
||||
}, [introTimestamps]);
|
||||
|
||||
const handleVideoProgress = useCallback(
|
||||
(e: OnProgressData) => {
|
||||
if (e.playableDuration === 0) {
|
||||
setIsBuffering(true);
|
||||
localIsBuffering.value = true;
|
||||
} else {
|
||||
setIsBuffering(false);
|
||||
localIsBuffering.value = false;
|
||||
}
|
||||
|
||||
if (sliding.current) return;
|
||||
onProgress(e);
|
||||
progress.value = secondsToTicks(e.currentTime);
|
||||
cacheProgress.value = secondsToTicks(e.playableDuration);
|
||||
},
|
||||
[onProgress, setIsBuffering]
|
||||
);
|
||||
|
||||
const handleVideoError = useCallback(
|
||||
(e: any) => {
|
||||
console.log(e);
|
||||
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
|
||||
Alert.alert("Error", "Cannot play this video file.");
|
||||
setIsPlaying(false);
|
||||
},
|
||||
[setIsPlaying]
|
||||
);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
try {
|
||||
const curr = await videoRef.current?.getCurrentPosition();
|
||||
if (curr !== undefined) {
|
||||
videoRef.current?.seek(Math.max(0, curr - 15));
|
||||
showControls();
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||
}
|
||||
}, [videoRef, showControls]);
|
||||
|
||||
const handleSkipForward = useCallback(async () => {
|
||||
try {
|
||||
const curr = await videoRef.current?.getCurrentPosition();
|
||||
if (curr !== undefined) {
|
||||
videoRef.current?.seek(Math.max(0, curr + 15));
|
||||
showControls();
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
}
|
||||
}, [videoRef, showControls]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (isPlaying) pauseVideo();
|
||||
else playVideo();
|
||||
showControls();
|
||||
}, [isPlaying, pauseVideo, playVideo, showControls]);
|
||||
|
||||
const handleSliderStart = useCallback(() => {
|
||||
sliding.current = true;
|
||||
}, []);
|
||||
|
||||
const handleSliderComplete = useCallback(
|
||||
(val: number) => {
|
||||
const tick = Math.floor(val);
|
||||
videoRef.current?.seek(tick / 10000000);
|
||||
sliding.current = false;
|
||||
},
|
||||
[videoRef]
|
||||
);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
(val: number) => {
|
||||
const tick = Math.floor(val);
|
||||
progress.value = tick;
|
||||
calculateTrickplayUrl(progress);
|
||||
showControls();
|
||||
},
|
||||
[progress, calculateTrickplayUrl, showControls]
|
||||
);
|
||||
|
||||
const handleGoToPreviousItem = useCallback(() => {
|
||||
if (!previousItem || !from) return;
|
||||
const url = itemRouter(previousItem, from);
|
||||
stopPlayback();
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}, [previousItem, from, stopPlayback, router]);
|
||||
|
||||
const handleGoToNextItem = useCallback(() => {
|
||||
if (!nextItem || !from) return;
|
||||
const url = itemRouter(nextItem, from);
|
||||
stopPlayback();
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}, [nextItem, from, stopPlayback, router]);
|
||||
|
||||
const videoTap = Gesture.Tap().onBegin(() => {
|
||||
runOnJS(handleToggleControlsPress)();
|
||||
});
|
||||
|
||||
const toggleIgnoreSafeAreaGesture = Gesture.Tap()
|
||||
.enabled(opacity.value !== 0)
|
||||
.onStart(() => {
|
||||
runOnJS(toggleIgnoreSafeArea)();
|
||||
});
|
||||
|
||||
const playPauseGesture = Gesture.Tap()
|
||||
.enabled(opacity.value !== 0)
|
||||
.onStart(() => {
|
||||
runOnJS(handlePlayPause)();
|
||||
});
|
||||
|
||||
const goToPreviouItemGesture = Gesture.Tap()
|
||||
.enabled(opacity.value !== 0)
|
||||
.onStart(() => {
|
||||
runOnJS(handleGoToPreviousItem)();
|
||||
});
|
||||
|
||||
const goToNextItemGesture = Gesture.Tap()
|
||||
.enabled(opacity.value !== 0)
|
||||
.onStart(() => {
|
||||
runOnJS(handleGoToNextItem)();
|
||||
});
|
||||
|
||||
const skipBackwardGesture = Gesture.Tap()
|
||||
.enabled(opacity.value !== 0)
|
||||
.onStart(() => {
|
||||
runOnJS(handleSkipBackward)();
|
||||
});
|
||||
|
||||
const skipForwardGesture = Gesture.Tap()
|
||||
.enabled(opacity.value !== 0)
|
||||
.onStart(() => {
|
||||
runOnJS(handleSkipForward)();
|
||||
});
|
||||
|
||||
const skipIntroGesture = Gesture.Tap()
|
||||
.enabled(opacity.value !== 0)
|
||||
.onStart(() => {
|
||||
runOnJS(skipIntro)();
|
||||
});
|
||||
|
||||
if (!api || !currentlyPlaying) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
>
|
||||
<GestureDetector gesture={videoTap}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: ignoreSafeArea ? 0 : insets.left,
|
||||
right: ignoreSafeArea ? 0 : insets.right,
|
||||
width: ignoreSafeArea
|
||||
? screenWidth
|
||||
: screenWidth - (insets.left + insets.right),
|
||||
},
|
||||
animatedStyles.videoContainer,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{videoSource && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
allowsExternalPlayback
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
resizeMode="contain"
|
||||
playWhenInactive={true}
|
||||
playInBackground={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
controls={false}
|
||||
pictureInPicture={true}
|
||||
onProgress={handleVideoProgress}
|
||||
subtitleStyle={{
|
||||
fontSize: 16,
|
||||
}}
|
||||
source={videoSource}
|
||||
onPlaybackStateChanged={(e) => {
|
||||
if (e.isPlaying === true) {
|
||||
playVideo(false);
|
||||
} else if (e.isPlaying === false) {
|
||||
pauseVideo(false);
|
||||
}
|
||||
}}
|
||||
onBuffer={(e) => {
|
||||
if (e.isBuffering) {
|
||||
setIsBuffering(true);
|
||||
localIsBuffering.value = true;
|
||||
}
|
||||
}}
|
||||
onRestoreUserInterfaceForPictureInPictureStop={() => {
|
||||
showControls();
|
||||
}}
|
||||
onVolumeChange={(e) => {
|
||||
setVolume(e.volume);
|
||||
}}
|
||||
progressUpdateInterval={1000}
|
||||
onError={handleVideoError}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
{
|
||||
position: "absolute" as const,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: ignoreSafeArea ? 0 : insets.left,
|
||||
right: ignoreSafeArea ? 0 : insets.right,
|
||||
width: ignoreSafeArea
|
||||
? screenWidth
|
||||
: screenWidth - (insets.left + insets.right),
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
animatedStyles.loader,
|
||||
]}
|
||||
>
|
||||
<Loader />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 8 * 8,
|
||||
right: insets.right + 32,
|
||||
zIndex: 10,
|
||||
},
|
||||
animatedIntroSkipperStyle,
|
||||
]}
|
||||
>
|
||||
<View className="flex flex-row items-center h-full">
|
||||
<TouchableOpacity className="flex flex-col items-center justify-center px-2 py-1.5 bg-purple-600 rounded-full">
|
||||
<GestureDetector gesture={skipIntroGesture}>
|
||||
<Text>Skip intro</Text>
|
||||
</GestureDetector>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: insets.top,
|
||||
right: insets.right + 20,
|
||||
height: 70,
|
||||
},
|
||||
animatedStyles.controls,
|
||||
]}
|
||||
>
|
||||
<View className="flex flex-row items-center h-full">
|
||||
<GestureDetector gesture={toggleIgnoreSafeAreaGesture}>
|
||||
<TouchableOpacity className="aspect-square rounded flex flex-col items-center justify-center p-2">
|
||||
<Ionicons
|
||||
name={ignoreSafeArea ? "contract-outline" : "expand"}
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</GestureDetector>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
stopPlayback();
|
||||
}}
|
||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 8,
|
||||
left: insets.left + 32,
|
||||
width: screenWidth - insets.left - insets.right - 64,
|
||||
borderRadius: 100,
|
||||
},
|
||||
animatedStyles.controls,
|
||||
]}
|
||||
>
|
||||
<View className="shrink flex flex-col justify-center h-full mb-2">
|
||||
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
|
||||
{currentlyPlaying.item?.Type === "Episode" && (
|
||||
<Text className="opacity-50">
|
||||
{currentlyPlaying.item.SeriesName}
|
||||
</Text>
|
||||
)}
|
||||
{currentlyPlaying.item?.Type === "Movie" && (
|
||||
<Text className="text-xs opacity-50">
|
||||
{currentlyPlaying.item?.ProductionYear}
|
||||
</Text>
|
||||
)}
|
||||
{currentlyPlaying.item?.Type === "Audio" && (
|
||||
<Text className="text-xs opacity-50">
|
||||
{currentlyPlaying.item?.Album}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex flex-row items-center space-x-6 rounded-full py-2 pl-4 pr-4 bg-neutral-800">
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
opacity: !previousItem ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<GestureDetector gesture={goToPreviouItemGesture}>
|
||||
<Ionicons name="play-skip-back" size={20} color="white" />
|
||||
</GestureDetector>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity>
|
||||
<GestureDetector gesture={skipBackwardGesture}>
|
||||
<Ionicons
|
||||
name="refresh-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
style={{
|
||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||
}}
|
||||
/>
|
||||
</GestureDetector>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity>
|
||||
<GestureDetector gesture={playPauseGesture}>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={26}
|
||||
color="white"
|
||||
/>
|
||||
</GestureDetector>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity>
|
||||
<GestureDetector gesture={skipForwardGesture}>
|
||||
<Ionicons name="refresh-outline" size={24} color="white" />
|
||||
</GestureDetector>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
opacity: !nextItem ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<GestureDetector gesture={goToNextItemGesture}>
|
||||
<Ionicons name="play-skip-forward" size={20} color="white" />
|
||||
</GestureDetector>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="flex flex-col w-full shrink">
|
||||
<Slider
|
||||
disable={opacity.value === 0}
|
||||
theme={{
|
||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||
minimumTrackTintColor: "#fff",
|
||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||
bubbleBackgroundColor: "#fff",
|
||||
bubbleTextColor: "#000",
|
||||
heartbeatColor: "#999",
|
||||
}}
|
||||
cache={cacheProgress}
|
||||
onSlidingStart={handleSliderStart}
|
||||
onSlidingComplete={handleSliderComplete}
|
||||
onValueChange={handleSliderChange}
|
||||
containerStyle={{
|
||||
borderRadius: 100,
|
||||
}}
|
||||
renderBubble={() => {
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return null;
|
||||
}
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
|
||||
const tileWidth = 150;
|
||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
marginLeft: -tileWidth / 4,
|
||||
marginTop: -tileHeight / 4 - 60,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
className=" bg-neutral-800 overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
style={{
|
||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||
height:
|
||||
(150 / trickplayInfo.aspectRatio!) *
|
||||
trickplayInfo?.data.TileHeight!,
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
],
|
||||
}}
|
||||
source={{ uri: url }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
sliderHeight={10}
|
||||
thumbWidth={0}
|
||||
progress={progress}
|
||||
minimumValue={min}
|
||||
maximumValue={max}
|
||||
/>
|
||||
<View className="flex flex-row items-center justify-between -mb-0.5">
|
||||
<Text className="text-[12px] text-neutral-400">
|
||||
{runtimeTicksToSeconds(progress.value)}
|
||||
</Text>
|
||||
<Text className="text-[12px] text-neutral-400">
|
||||
-{runtimeTicksToSeconds(max.value - progress.value)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -26,7 +26,7 @@ import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { Stack, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -56,7 +56,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
useState<MediaSourceInfo | null>(null);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
useState<number>(-1);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
@@ -117,6 +117,8 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
itemId: id,
|
||||
});
|
||||
|
||||
console.log("itemID", res?.Id);
|
||||
|
||||
return res;
|
||||
},
|
||||
enabled: !!id && !!api,
|
||||
|
||||
@@ -57,7 +57,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
|
||||
const directStream = useMemo(() => {
|
||||
return !url?.includes("m3u8");
|
||||
}, []);
|
||||
}, [url]);
|
||||
|
||||
const onPress = async () => {
|
||||
if (!url || !item) return;
|
||||
@@ -226,7 +226,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.average, endColor.value.average]
|
||||
[startColor.value.primary, endColor.value.primary]
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -279,7 +279,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle]}
|
||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||
/>
|
||||
<View
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
@@ -21,7 +22,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
if (background === "transparent")
|
||||
if (background === "transparent" && Platform.OS !== "android")
|
||||
return (
|
||||
<BlurView
|
||||
{...props}
|
||||
@@ -52,7 +53,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
className="drop-shadow-2xl"
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color="#077DF2"
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,42 @@ interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
if (item.Type === "Series") {
|
||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "MusicAlbum") {
|
||||
return `/(auth)/(tabs)/${from}/albums/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "Audio") {
|
||||
return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`;
|
||||
}
|
||||
|
||||
if (item.Type === "MusicArtist") {
|
||||
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "Person") {
|
||||
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "BoxSet") {
|
||||
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "UserView") {
|
||||
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "CollectionFolder") {
|
||||
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
||||
}
|
||||
|
||||
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
||||
};
|
||||
|
||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
item,
|
||||
children,
|
||||
@@ -23,54 +59,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
if (item.Type === "Series") {
|
||||
router.push(`/(auth)/(tabs)/${from}/series/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "MusicAlbum") {
|
||||
router.push(`/(auth)/(tabs)/${from}/albums/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "Audio") {
|
||||
router.push(`/(auth)/(tabs)/${from}/albums/${item.AlbumId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "MusicArtist") {
|
||||
router.push(`/(auth)/(tabs)/${from}/artists/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "Person") {
|
||||
router.push(`/(auth)/(tabs)/${from}/actors/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "BoxSet") {
|
||||
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "UserView") {
|
||||
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "CollectionFolder") {
|
||||
router.push(`/(auth)/(tabs)/(libraries)/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same as default
|
||||
// if (item.Type === "Episode") {
|
||||
// router.push(`/items/${item.Id}`);
|
||||
// return;
|
||||
// }
|
||||
|
||||
router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`);
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -125,7 +125,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
{library.Name}
|
||||
</Text>
|
||||
{settings?.libraryOptions?.showStats && (
|
||||
<Text className="font-bold text-xs text-neutral-500 text-start px-4 ml-auto">
|
||||
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
|
||||
{itemsCount} items
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -2,12 +2,19 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
DefaultLanguageOption,
|
||||
DownloadOptions,
|
||||
ScreenOrientationEnum,
|
||||
useSettings,
|
||||
} from "@/utils/atoms/settings";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
Linking,
|
||||
Switch,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
@@ -15,8 +22,11 @@ import { Input } from "../common/Input";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../Button";
|
||||
import { MediaToggles } from "./MediaToggles";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
|
||||
export const SettingToggles: React.FC = () => {
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -48,8 +58,10 @@ export const SettingToggles: React.FC = () => {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View {...props}>
|
||||
{/* <View>
|
||||
<Text className="text-lg font-bold mb-2">Look and feel</Text>
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
|
||||
@@ -70,7 +82,7 @@ export const SettingToggles: React.FC = () => {
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Auto rotate</Text>
|
||||
@@ -80,27 +92,11 @@ export const SettingToggles: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.autoRotate}
|
||||
value={settings.autoRotate}
|
||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Start videos in fullscreen</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Clicking a video will start it in fullscreen mode, instead of
|
||||
inline.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openFullScreenVideoPlayerByDefault}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ openFullScreenVideoPlayerByDefault: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Use external player (VLC)</Text>
|
||||
@@ -110,7 +106,7 @@ export const SettingToggles: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openInVLC}
|
||||
value={settings.openInVLC}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
||||
}}
|
||||
@@ -133,13 +129,13 @@ export const SettingToggles: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.usePopularPlugin}
|
||||
value={settings.usePopularPlugin}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ usePopularPlugin: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
{settings?.usePopularPlugin && (
|
||||
{settings.usePopularPlugin && (
|
||||
<View className="flex flex-col py-2 bg-neutral-900">
|
||||
{mediaListCollections?.map((mlc) => (
|
||||
<View
|
||||
@@ -150,9 +146,7 @@ export const SettingToggles: React.FC = () => {
|
||||
<Text className="font-semibold">{mlc.Name}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.mediaListCollectionIds?.includes(
|
||||
mlc.Id!
|
||||
)}
|
||||
value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
|
||||
onValueChange={(value) => {
|
||||
if (!settings.mediaListCollectionIds) {
|
||||
updateSettings({
|
||||
@@ -163,11 +157,11 @@ export const SettingToggles: React.FC = () => {
|
||||
|
||||
updateSettings({
|
||||
mediaListCollectionIds:
|
||||
settings?.mediaListCollectionIds.includes(mlc.Id!)
|
||||
? settings?.mediaListCollectionIds.filter(
|
||||
settings.mediaListCollectionIds.includes(mlc.Id!)
|
||||
? settings.mediaListCollectionIds.filter(
|
||||
(id) => id !== mlc.Id
|
||||
)
|
||||
: [...settings?.mediaListCollectionIds, mlc.Id!],
|
||||
: [...settings.mediaListCollectionIds, mlc.Id!],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -198,7 +192,7 @@ export const SettingToggles: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.forceDirectPlay}
|
||||
value={settings.forceDirectPlay}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ forceDirectPlay: value })
|
||||
}
|
||||
@@ -208,7 +202,7 @@ export const SettingToggles: React.FC = () => {
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
|
||||
${settings.forceDirectPlay ? "opacity-50 select-none" : ""}
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
@@ -221,7 +215,7 @@ export const SettingToggles: React.FC = () => {
|
||||
<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>{settings?.deviceProfile}</Text>
|
||||
<Text>{settings.deviceProfile}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
@@ -264,8 +258,108 @@ export const SettingToggles: React.FC = () => {
|
||||
<View className="flex flex-col">
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
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
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Search engine</Text>
|
||||
@@ -276,7 +370,7 @@ export const SettingToggles: React.FC = () => {
|
||||
<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>{settings?.searchEngine}</Text>
|
||||
<Text>{settings.searchEngine}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
@@ -310,7 +404,7 @@ export const SettingToggles: React.FC = () => {
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
{settings?.searchEngine === "Marlin" && (
|
||||
{settings.searchEngine === "Marlin" && (
|
||||
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
||||
<>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
@@ -338,7 +432,7 @@ export const SettingToggles: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<Text className="text-neutral-500 mt-2">
|
||||
{settings?.marlinServerUrl}
|
||||
{settings.marlinServerUrl}
|
||||
</Text>
|
||||
</>
|
||||
</View>
|
||||
|
||||
@@ -15,7 +15,6 @@ const routes = [
|
||||
"albums/[albumId]",
|
||||
"artists/index",
|
||||
"artists/[artistId]",
|
||||
"collections/[collectionId]",
|
||||
"items/page",
|
||||
"series/[id]",
|
||||
];
|
||||
|
||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.12.0",
|
||||
"channel": "0.14.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.12.0",
|
||||
"channel": "0.14.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
76
hooks/useAdjacentEpisodes.ts
Normal file
76
hooks/useAdjacentEpisodes.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
interface AdjacentEpisodesProps {
|
||||
currentlyPlaying?: CurrentlyPlayingState | null;
|
||||
}
|
||||
|
||||
export const useAdjacentEpisodes = ({
|
||||
currentlyPlaying,
|
||||
}: AdjacentEpisodesProps) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const { data: previousItem } = useQuery({
|
||||
queryKey: [
|
||||
"previousItem",
|
||||
currentlyPlaying?.item.ParentId,
|
||||
currentlyPlaying?.item.IndexNumber,
|
||||
],
|
||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||
if (
|
||||
!api ||
|
||||
!currentlyPlaying?.item.ParentId ||
|
||||
currentlyPlaying?.item.IndexNumber === undefined ||
|
||||
currentlyPlaying?.item.IndexNumber === null ||
|
||||
currentlyPlaying.item.IndexNumber - 2 < 0
|
||||
) {
|
||||
console.log("No previous item");
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getItemsApi(api).getItems({
|
||||
parentId: currentlyPlaying.item.ParentId!,
|
||||
startIndex: currentlyPlaying.item.IndexNumber! - 2,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
return res.data.Items?.[0] || null;
|
||||
},
|
||||
enabled: currentlyPlaying?.item.Type === "Episode",
|
||||
});
|
||||
|
||||
const { data: nextItem } = useQuery({
|
||||
queryKey: [
|
||||
"nextItem",
|
||||
currentlyPlaying?.item.ParentId,
|
||||
currentlyPlaying?.item.IndexNumber,
|
||||
],
|
||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||
if (
|
||||
!api ||
|
||||
!currentlyPlaying?.item.ParentId ||
|
||||
currentlyPlaying?.item.IndexNumber === undefined ||
|
||||
currentlyPlaying?.item.IndexNumber === null
|
||||
) {
|
||||
console.log("No next item");
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getItemsApi(api).getItems({
|
||||
parentId: currentlyPlaying.item.ParentId!,
|
||||
startIndex: currentlyPlaying.item.IndexNumber!,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
return res.data.Items?.[0] || null;
|
||||
},
|
||||
enabled: currentlyPlaying?.item.Type === "Episode",
|
||||
});
|
||||
|
||||
return { previousItem, nextItem };
|
||||
};
|
||||
41
hooks/useControlsVisibility.ts
Normal file
41
hooks/useControlsVisibility.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
runOnJS,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
export const useControlsVisibility = (timeout: number = 3000) => {
|
||||
const opacity = useSharedValue(1);
|
||||
|
||||
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const showControls = useCallback(() => {
|
||||
opacity.value = 1;
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
hideControlsTimerRef.current = setTimeout(() => {
|
||||
opacity.value = 0;
|
||||
}, timeout);
|
||||
}, [timeout]);
|
||||
|
||||
const hideControls = useCallback(() => {
|
||||
opacity.value = 0;
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { opacity, showControls, hideControls };
|
||||
};
|
||||
@@ -28,7 +28,7 @@ export const useImageColors = (
|
||||
secondary = colors.muted;
|
||||
} else if (colors.platform === "ios") {
|
||||
primary = colors.primary;
|
||||
secondary = colors.detail;
|
||||
secondary = colors.secondary;
|
||||
average = colors.background;
|
||||
}
|
||||
|
||||
|
||||
27
hooks/useNavigationBarVisibility.ts
Normal file
27
hooks/useNavigationBarVisibility.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// hooks/useNavigationBarVisibility.ts
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
|
||||
export const useNavigationBarVisibility = (isPlaying: boolean | null) => {
|
||||
useEffect(() => {
|
||||
const handleVisibility = async () => {
|
||||
if (Platform.OS === "android") {
|
||||
if (isPlaying) {
|
||||
await NavigationBar.setVisibilityAsync("hidden");
|
||||
} else {
|
||||
await NavigationBar.setVisibilityAsync("visible");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleVisibility();
|
||||
|
||||
return () => {
|
||||
if (Platform.OS === "android") {
|
||||
NavigationBar.setVisibilityAsync("visible");
|
||||
}
|
||||
};
|
||||
}, [isPlaying]);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner-native";
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
@@ -28,6 +29,10 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
|
||||
const startRemuxing = useCallback(
|
||||
async (url: string) => {
|
||||
toast.success("Download started", {
|
||||
invert: true,
|
||||
});
|
||||
|
||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||
|
||||
writeToLog(
|
||||
|
||||
100
hooks/useTrickplay.ts
Normal file
100
hooks/useTrickplay.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// hooks/useTrickplay.ts
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { SharedValue } from "react-native-reanimated";
|
||||
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
interface TrickplayData {
|
||||
Interval?: number;
|
||||
TileWidth?: number;
|
||||
TileHeight?: number;
|
||||
Height?: number;
|
||||
Width?: number;
|
||||
ThumbnailCount?: number;
|
||||
}
|
||||
|
||||
interface TrickplayInfo {
|
||||
resolution: string;
|
||||
aspectRatio: number;
|
||||
data: TrickplayData;
|
||||
}
|
||||
|
||||
interface TrickplayUrl {
|
||||
x: number;
|
||||
y: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const useTrickplay = (
|
||||
currentlyPlaying?: CurrentlyPlayingState | null
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||
|
||||
const trickplayInfo = useMemo(() => {
|
||||
if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaSourceId = currentlyPlaying.item.Id;
|
||||
const trickplayData = currentlyPlaying.item.Trickplay[mediaSourceId];
|
||||
|
||||
if (!trickplayData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the first available resolution
|
||||
const firstResolution = Object.keys(trickplayData)[0];
|
||||
return firstResolution
|
||||
? {
|
||||
resolution: firstResolution,
|
||||
aspectRatio:
|
||||
trickplayData[firstResolution].Width! /
|
||||
trickplayData[firstResolution].Height!,
|
||||
data: trickplayData[firstResolution],
|
||||
}
|
||||
: null;
|
||||
}, [currentlyPlaying]);
|
||||
|
||||
const calculateTrickplayUrl = useCallback(
|
||||
(progress: SharedValue<number>) => {
|
||||
if (!trickplayInfo || !api || !currentlyPlaying?.item.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, resolution } = trickplayInfo;
|
||||
const { Interval, TileWidth, TileHeight } = data;
|
||||
|
||||
if (!Interval || !TileWidth || !TileHeight || !resolution) {
|
||||
throw new Error("Invalid trickplay data");
|
||||
}
|
||||
|
||||
const currentSecond = Math.max(0, Math.floor(progress.value / 10000000));
|
||||
|
||||
const cols = TileWidth;
|
||||
const rows = TileHeight;
|
||||
const imagesPerTile = cols * rows;
|
||||
const imageIndex = Math.floor(currentSecond / (Interval / 1000));
|
||||
const tileIndex = Math.floor(imageIndex / imagesPerTile);
|
||||
|
||||
const positionInTile = imageIndex % imagesPerTile;
|
||||
const rowInTile = Math.floor(positionInTile / cols);
|
||||
const colInTile = positionInTile % cols;
|
||||
|
||||
const newTrickPlayUrl = {
|
||||
x: rowInTile,
|
||||
y: colInTile,
|
||||
url: `${api.basePath}/Videos/${currentlyPlaying.item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
|
||||
};
|
||||
|
||||
setTrickPlayUrl(newTrickPlayUrl);
|
||||
return newTrickPlayUrl;
|
||||
},
|
||||
[trickplayInfo, currentlyPlaying, api]
|
||||
);
|
||||
|
||||
return { trickPlayUrl, calculateTrickplayUrl, trickplayInfo };
|
||||
};
|
||||
11
package.json
11
package.json
@@ -20,17 +20,16 @@
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@gorhom/bottom-sheet": "^4",
|
||||
"@jellyfin/sdk": "^0.10.0",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/netinfo": "11.3.1",
|
||||
"@react-native-menu/menu": "^1.1.2",
|
||||
"@react-navigation/native": "^6.0.2",
|
||||
"@shopify/flash-list": "1.6.4",
|
||||
"@tanstack/react-query": "^5.54.1",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"expo": "~51.0.32",
|
||||
"expo": "^51.0.32",
|
||||
"expo-blur": "~13.0.2",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
"expo-constants": "~16.0.2",
|
||||
@@ -59,16 +58,17 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-awesome-slider": "^2.5.3",
|
||||
"react-native-circular-progress": "^1.4.0",
|
||||
"react-native-compressor": "^1.8.25",
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-google-cast": "^4.8.2",
|
||||
"react-native-google-cast": "^4.8.3",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^2.5.1",
|
||||
"react-native-ios-utilities": "^4.4.5",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
"react-native-reanimated-carousel": "4.0.0-alpha.12",
|
||||
"react-native-reanimated-carousel": "4.0.0-canary.15",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
"react-native-screens": "3.31.1",
|
||||
"react-native-svg": "15.2.0",
|
||||
@@ -76,6 +76,7 @@
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"react-native-video": "^6.5.0",
|
||||
"react-native-web": "~0.19.10",
|
||||
"sonner-native": "^0.9.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.12.0" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.14.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.12.0"`,
|
||||
}, DeviceId="${deviceId}", Version="0.14.0"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
@@ -25,8 +25,12 @@ import { debounce } from "lodash";
|
||||
import { Alert } from "react-native";
|
||||
import { OnProgressData, type VideoRef } from "react-native-video";
|
||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||
import {
|
||||
parseM3U8ForSubtitles,
|
||||
SubtitleTrack,
|
||||
} from "@/utils/hls/parseM3U8ForSubtitles";
|
||||
|
||||
type CurrentlyPlayingState = {
|
||||
export type CurrentlyPlayingState = {
|
||||
url: string;
|
||||
item: BaseItemDto;
|
||||
};
|
||||
@@ -45,6 +49,8 @@ interface PlaybackContextType {
|
||||
dismissFullscreenPlayer: () => void;
|
||||
setIsFullscreen: (isFullscreen: boolean) => void;
|
||||
setIsPlaying: (isPlaying: boolean) => void;
|
||||
isBuffering: boolean;
|
||||
setIsBuffering: (val: boolean) => void;
|
||||
onProgress: (data: OnProgressData) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
setCurrentlyPlayingState: (
|
||||
@@ -53,6 +59,7 @@ interface PlaybackContextType {
|
||||
startDownloadedFilePlayback: (
|
||||
currentlyPlaying: CurrentlyPlayingState | null
|
||||
) => void;
|
||||
subtitles: SubtitleTrack[];
|
||||
}
|
||||
|
||||
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
||||
@@ -70,10 +77,12 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const previousVolume = useRef<number | null>(null);
|
||||
|
||||
const [isPlaying, _setIsPlaying] = useState<boolean>(false);
|
||||
const [isBuffering, setIsBuffering] = useState<boolean>(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||
const [progressTicks, setProgressTicks] = useState<number | null>(0);
|
||||
const [volume, _setVolume] = useState<number | null>(null);
|
||||
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
|
||||
const [subtitles, setSubtitles] = useState<SubtitleTrack[]>([]);
|
||||
const [currentlyPlaying, setCurrentlyPlaying] =
|
||||
useState<CurrentlyPlayingState | null>(null);
|
||||
|
||||
@@ -105,13 +114,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
setCurrentlyPlaying(state);
|
||||
setIsPlaying(true);
|
||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||
setTimeout(() => {
|
||||
presentFullscreenPlayer();
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
[settings?.openFullScreenVideoPlayerByDefault]
|
||||
[]
|
||||
);
|
||||
|
||||
const setCurrentlyPlayingState = useCallback(
|
||||
@@ -138,12 +142,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setSession(res.data);
|
||||
setCurrentlyPlaying(state);
|
||||
setIsPlaying(true);
|
||||
|
||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||
setTimeout(() => {
|
||||
presentFullscreenPlayer();
|
||||
}, 300);
|
||||
}
|
||||
} else {
|
||||
setCurrentlyPlaying(null);
|
||||
setIsFullscreen(false);
|
||||
@@ -161,11 +159,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
onPress: () => {
|
||||
setCurrentlyPlaying(state);
|
||||
setIsPlaying(true);
|
||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||
setTimeout(() => {
|
||||
presentFullscreenPlayer();
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -214,13 +207,15 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
);
|
||||
|
||||
const stopPlayback = useCallback(async () => {
|
||||
const id = currentlyPlaying?.item?.Id;
|
||||
setCurrentlyPlayingState(null);
|
||||
|
||||
await reportPlaybackStopped({
|
||||
api,
|
||||
itemId: currentlyPlaying?.item?.Id,
|
||||
itemId: id,
|
||||
sessionId: session?.PlaySessionId,
|
||||
positionTicks: progressTicks ? progressTicks : 0,
|
||||
});
|
||||
setCurrentlyPlayingState(null);
|
||||
}, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]);
|
||||
|
||||
const setIsPlaying = useCallback(
|
||||
@@ -254,7 +249,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const onProgress = useCallback(
|
||||
debounce((e: OnProgressData) => {
|
||||
_onProgress(e);
|
||||
}, 1000),
|
||||
}, 500),
|
||||
[_onProgress]
|
||||
);
|
||||
|
||||
@@ -352,6 +347,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
<PlaybackContext.Provider
|
||||
value={{
|
||||
onProgress,
|
||||
isBuffering,
|
||||
setIsBuffering,
|
||||
progressTicks,
|
||||
setVolume,
|
||||
setIsPlaying,
|
||||
@@ -368,6 +365,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
presentFullscreenPlayer,
|
||||
dismissFullscreenPlayer,
|
||||
startDownloadedFilePlayback,
|
||||
subtitles,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -48,6 +48,19 @@ const calculateRelativeLuminance = (rgb: number[]): number => {
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
|
||||
const isCloseToBlack = (color: string): boolean => {
|
||||
const r = parseInt(color.slice(1, 3), 16);
|
||||
const g = parseInt(color.slice(3, 5), 16);
|
||||
const b = parseInt(color.slice(5, 7), 16);
|
||||
|
||||
// Check if the color is very dark (close to black)
|
||||
return r < 20 && g < 20 && b < 20;
|
||||
};
|
||||
|
||||
const adjustToNearBlack = (color: string): string => {
|
||||
return "#212121"; // A very dark gray, almost black
|
||||
};
|
||||
|
||||
const baseThemeColorAtom = atom<ThemeColors>({
|
||||
primary: "#FFFFFF",
|
||||
secondary: "#000000",
|
||||
@@ -59,12 +72,15 @@ export const itemThemeColorAtom = atom(
|
||||
(get) => get(baseThemeColorAtom),
|
||||
(get, set, update: Partial<ThemeColors>) => {
|
||||
const currentColors = get(baseThemeColorAtom);
|
||||
const newColors = { ...currentColors, ...update };
|
||||
let newColors = { ...currentColors, ...update };
|
||||
|
||||
// Adjust primary color if it's too close to black
|
||||
if (newColors.primary && isCloseToBlack(newColors.primary)) {
|
||||
newColors.primary = adjustToNearBlack(newColors.primary);
|
||||
}
|
||||
|
||||
// Recalculate text color if primary color changes
|
||||
if (update.average) {
|
||||
newColors.text = calculateTextColor(update.average);
|
||||
}
|
||||
if (update.primary) newColors.text = calculateTextColor(newColors.primary);
|
||||
|
||||
set(baseThemeColorAtom, newColors);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useEffect } from "react";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
|
||||
export type DownloadQuality = "original" | "high" | "low";
|
||||
|
||||
@@ -9,6 +10,22 @@ export type DownloadOption = {
|
||||
value: DownloadQuality;
|
||||
};
|
||||
|
||||
export const ScreenOrientationEnum: Record<
|
||||
ScreenOrientation.OrientationLock,
|
||||
string
|
||||
> = {
|
||||
[ScreenOrientation.OrientationLock.DEFAULT]: "Default",
|
||||
[ScreenOrientation.OrientationLock.ALL]: "All",
|
||||
[ScreenOrientation.OrientationLock.PORTRAIT]: "Portrait",
|
||||
[ScreenOrientation.OrientationLock.PORTRAIT_UP]: "Portrait Up",
|
||||
[ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "Portrait Down",
|
||||
[ScreenOrientation.OrientationLock.LANDSCAPE]: "Landscape",
|
||||
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "Landscape Left",
|
||||
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "Landscape Right",
|
||||
[ScreenOrientation.OrientationLock.OTHER]: "Other",
|
||||
[ScreenOrientation.OrientationLock.UNKNOWN]: "Unknown",
|
||||
};
|
||||
|
||||
export const DownloadOptions: DownloadOption[] = [
|
||||
{
|
||||
label: "Original quality",
|
||||
@@ -40,7 +57,6 @@ export type DefaultLanguageOption = {
|
||||
type Settings = {
|
||||
autoRotate?: boolean;
|
||||
forceLandscapeInVideoPlayer?: boolean;
|
||||
openFullScreenVideoPlayerByDefault?: boolean;
|
||||
usePopularPlugin?: boolean;
|
||||
deviceProfile?: "Expo" | "Native" | "Old";
|
||||
forceDirectPlay?: boolean;
|
||||
@@ -53,6 +69,7 @@ type Settings = {
|
||||
defaultSubtitleLanguage: DefaultLanguageOption | null;
|
||||
defaultAudioLanguage: DefaultLanguageOption | null;
|
||||
showHomeTitles: boolean;
|
||||
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -67,7 +84,6 @@ const loadSettings = async (): Promise<Settings> => {
|
||||
const defaultValues: Settings = {
|
||||
autoRotate: true,
|
||||
forceLandscapeInVideoPlayer: false,
|
||||
openFullScreenVideoPlayerByDefault: false,
|
||||
usePopularPlugin: false,
|
||||
deviceProfile: "Expo",
|
||||
forceDirectPlay: false,
|
||||
@@ -86,6 +102,7 @@ const loadSettings = async (): Promise<Settings> => {
|
||||
defaultAudioLanguage: null,
|
||||
defaultSubtitleLanguage: null,
|
||||
showHomeTitles: true,
|
||||
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
55
utils/hls/parseM3U8ForSubtitles.ts
Normal file
55
utils/hls/parseM3U8ForSubtitles.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface SubtitleTrack {
|
||||
index: number;
|
||||
name: string;
|
||||
uri: string;
|
||||
language: string;
|
||||
default: boolean;
|
||||
forced: boolean;
|
||||
autoSelect: boolean;
|
||||
}
|
||||
|
||||
export async function parseM3U8ForSubtitles(
|
||||
url: string
|
||||
): Promise<SubtitleTrack[]> {
|
||||
try {
|
||||
const response = await axios.get(url, { responseType: "text" });
|
||||
const lines = response.data.split(/\r?\n/);
|
||||
const subtitleTracks: SubtitleTrack[] = [];
|
||||
let index = 0;
|
||||
|
||||
lines.forEach((line: string) => {
|
||||
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
|
||||
const attributes = parseAttributes(line);
|
||||
const track: SubtitleTrack = {
|
||||
index: index++,
|
||||
name: attributes["NAME"] || "",
|
||||
uri: attributes["URI"] || "",
|
||||
language: attributes["LANGUAGE"] || "",
|
||||
default: attributes["DEFAULT"] === "YES",
|
||||
forced: attributes["FORCED"] === "YES",
|
||||
autoSelect: attributes["AUTOSELECT"] === "YES",
|
||||
};
|
||||
subtitleTracks.push(track);
|
||||
}
|
||||
});
|
||||
|
||||
return subtitleTracks;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch or parse the M3U8 file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAttributes(line: string): { [key: string]: string } {
|
||||
const attributes: { [key: string]: string } = {};
|
||||
const parts = line.split(",");
|
||||
parts.forEach((part) => {
|
||||
const [key, value] = part.split("=");
|
||||
if (key && value) {
|
||||
attributes[key.trim()] = value.replace(/"/g, "").trim();
|
||||
}
|
||||
});
|
||||
return attributes;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
MediaSourceInfo,
|
||||
PlaybackInfoResponse,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
|
||||
export const getStreamUrl = async ({
|
||||
api,
|
||||
@@ -15,7 +16,7 @@ export const getStreamUrl = async ({
|
||||
sessionData,
|
||||
deviceProfile = ios,
|
||||
audioStreamIndex = 0,
|
||||
subtitleStreamIndex = 0,
|
||||
subtitleStreamIndex = undefined,
|
||||
forceDirectPlay = false,
|
||||
height,
|
||||
mediaSourceId,
|
||||
@@ -39,6 +40,9 @@ export const getStreamUrl = async ({
|
||||
|
||||
const itemId = item.Id;
|
||||
|
||||
/**
|
||||
* Build the stream URL for videos
|
||||
*/
|
||||
const response = await api.axiosInstance.post(
|
||||
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
|
||||
{
|
||||
@@ -58,9 +62,7 @@ export const getStreamUrl = async ({
|
||||
EnableMpegtsM2TsMode: false,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
headers: getAuthHeaders(api),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -80,10 +82,8 @@ export const getStreamUrl = async ({
|
||||
|
||||
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
||||
if (item.MediaType === "Video") {
|
||||
console.log("Using direct stream for video!");
|
||||
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||
} else if (item.MediaType === "Audio") {
|
||||
console.log("Using direct stream for audio!");
|
||||
const searchParams = new URLSearchParams({
|
||||
UserId: userId,
|
||||
DeviceId: api.deviceInfo.id,
|
||||
@@ -104,7 +104,6 @@ export const getStreamUrl = async ({
|
||||
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
||||
}
|
||||
} else if (mediaSource.TranscodingUrl) {
|
||||
console.log("Using transcoded stream!");
|
||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
}
|
||||
|
||||
|
||||
6
utils/secondsToTicks.ts
Normal file
6
utils/secondsToTicks.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// seconds to ticks util
|
||||
|
||||
export function secondsToTicks(seconds: number): number {
|
||||
"worklet";
|
||||
return seconds * 10000000;
|
||||
}
|
||||
Reference in New Issue
Block a user