Compare commits

..

23 Commits

Author SHA1 Message Date
Fredrik Burmester
1c4bc68566 wip 2024-09-19 21:14:03 +02:00
Fredrik Burmester
dd05ae89c3 fix: small changes 2024-09-19 16:51:13 +02:00
Fredrik Burmester
8a60adc6b2 fix: header size 2024-09-19 15:52:37 +02:00
Fredrik Burmester
51376cc8c1 fix: remove auto-hide controls for now 2024-09-19 13:00:20 +02:00
Fredrik Burmester
4eab1ebff6 chore 2024-09-18 08:42:26 +02:00
Fredrik Burmester
76388a408c fix: throttle 2024-09-18 08:42:23 +02:00
Fredrik Burmester
d3560c287c fix: more languages 2024-09-18 08:42:18 +02:00
Fredrik Burmester
da78ce898c fix: design 2024-09-18 08:42:09 +02:00
Fredrik Burmester
8a999a56a1 chore: component 2024-09-18 08:41:58 +02:00
Fredrik Burmester
669f8d7d1a fix: design 2024-09-18 08:41:48 +02:00
Fredrik Burmester
83bb5db335 fix: routing 2024-09-18 08:41:44 +02:00
Fredrik Burmester
7a5427099c fix: smaller titles 2024-09-18 08:41:28 +02:00
Fredrik Burmester
ee9b3de7d4 feat: set the server name as title 2024-09-17 19:34:32 +02:00
Fredrik Burmester
bcc28d7513 fix: accidental press when scrolling through the carousel 2024-09-17 19:34:18 +02:00
Fredrik Burmester
d093c028d2 chore 2024-09-17 19:33:46 +02:00
Fredrik Burmester
3032813234 Merge branch 'pr/129' 2024-09-17 18:55:06 +02:00
Fredrik Burmester
3577aae7cc fix: improved server check
Included timeout, check url even if http is included, doc strings
2024-09-16 21:15:57 +02:00
simon
5e141f27c4 /System/Info/Public instead of /web 2024-09-16 16:37:45 +02:00
simon
b67a4f1843 fixed some inconsistencies 2024-09-15 18:48:16 +02:00
simon
19a53da8a7 remove need for http/https 2024-09-15 18:45:26 +02:00
simon
25656cb7f1 remove need for http/https 2024-09-15 18:13:38 +02:00
simon
7f9c893560 remove need for http/https 2024-09-15 18:10:22 +02:00
simon
39880a6214 possible a good fix 2024-09-15 12:49:47 +02:00
18 changed files with 517 additions and 218 deletions

View File

@@ -82,9 +82,11 @@
"expo-build-properties",
{
"ios": {
"newArchEnabled": true,
"deploymentTarget": "14.0"
},
"android": {
"newArchEnabled": true,
"android": {
"compileSdkVersion": 34,
"targetSdkVersion": 34,
@@ -111,7 +113,8 @@
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
]
],
"expo-video"
],
"experiments": {
"typedRoutes": true

View File

@@ -347,7 +347,7 @@ export default function index() {
paddingLeft: insets.left,
paddingRight: insets.right,
}}
className="flex flex-col pt-4 pb-24 gap-y-4"
className="flex flex-col pt-4 pb-24 gap-y-2"
>
<LargeMovieCarousel />

View File

@@ -34,8 +34,6 @@ import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FlatList, View } from "react-native";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
@@ -169,7 +167,7 @@ const page: React.FC = () => {
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<MemoizedTouchableItemRouter
<TouchableItemRouter
key={item.Id}
style={{
width: "100%",
@@ -193,7 +191,7 @@ const page: React.FC = () => {
{/* <MoviePoster item={item} /> */}
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
</TouchableItemRouter>
),
[orientation]
);

View File

@@ -3,6 +3,8 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useState } from "react";
@@ -33,6 +35,7 @@ const Login: React.FC = () => {
} = params as { apiUrl: string; username: string; password: string };
const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>("");
const [error, setError] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
@@ -44,6 +47,8 @@ const Login: React.FC = () => {
useEffect(() => {
(async () => {
// we might re-use the checkUrl function here to check the url as well
// however, I don't think it should be necessary for now
if (_apiUrl) {
setServer({
address: _apiUrl,
@@ -79,12 +84,93 @@ const Login: React.FC = () => {
}
};
const handleConnect = (url: string) => {
if (!url.startsWith("http")) {
Alert.alert("Error", "URL needs to start with http or https.");
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
/**
* Checks the availability and validity of a Jellyfin server URL.
*
* This function attempts to connect to a Jellyfin server using the provided URL.
* It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
*
* @param {string} url - The base URL of the Jellyfin server to check.
* @returns {Promise<string | undefined>} A Promise that resolves to:
* - The full URL (including protocol) if a valid Jellyfin server is found.
* - undefined if no valid server is found at the given URL.
*
* Side effects:
* - Sets loadingServerCheck state to true at the beginning and false at the end.
* - Logs errors and timeout information to the console.
*/
async function checkUrl(url: string) {
url = url.endsWith("/") ? url.slice(0, -1) : url;
setLoadingServerCheck(true);
const protocols = ["https://", "http://"];
const timeout = 2000; // 2 seconds timeout for long 404 responses
try {
for (const protocol of protocols) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(`${protocol}${url}/System/Info/Public`, {
mode: "cors",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
setServerName(data.ServerName || "");
return `${protocol}${url}`;
}
} catch (e) {
const error = e as Error;
if (error.name === "AbortError") {
console.log(`Request to ${protocol}${url} timed out`);
} else {
console.error(`Error checking ${protocol}${url}:`, error);
}
}
}
return undefined;
} finally {
setLoadingServerCheck(false);
}
}
/**
* Handles the connection attempt to a Jellyfin server.
*
* This function trims the input URL, checks its validity using the `checkUrl` function,
* and sets the server address if a valid connection is established.
*
* @param {string} url - The URL of the Jellyfin server to connect to.
*
* @returns {Promise<void>}
*
* Side effects:
* - Calls `checkUrl` to validate the server URL.
* - Shows an alert if the connection fails.
* - Sets the server address using `setServer` if the connection is successful.
*
*/
const handleConnect = async (url: string) => {
url = url.trim();
const result = await checkUrl(
url.startsWith("http") ? new URL(url).host : url
);
if (result === undefined) {
Alert.alert(
"Connection failed",
"Could not connect to the server. Please check the URL and your network connection."
);
return;
}
setServer({ address: url.trim() });
setServer({ address: result });
};
const handleQuickConnect = async () => {
@@ -113,7 +199,9 @@ const Login: React.FC = () => {
<View></View>
<View>
<View className="mb-4">
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
<Text className="text-3xl font-bold mb-1">
{serverName || "Streamyfin"}
</Text>
<Text className="text-neutral-500 mb-2">
Server: {api.basePath}
</Text>
@@ -121,7 +209,6 @@ const Login: React.FC = () => {
color="black"
onPress={() => {
removeServer();
setServerURL("");
}}
justify="between"
iconLeft={
@@ -138,9 +225,6 @@ const Login: React.FC = () => {
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Text className="text-neutral-500">
Log in to any user account
</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
@@ -218,11 +302,13 @@ const Login: React.FC = () => {
textContentType="URL"
maxLength={500}
/>
<Text className="opacity-30">
Server URL requires http or https
</Text>
</View>
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="mb-2"
>
Connect
</Button>
</View>

BIN
bun.lockb

Binary file not shown.

View File

@@ -5,8 +5,9 @@ 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 { getAuthHeaders, isBaseItemDto } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { runtimeTicksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
@@ -14,21 +15,24 @@ 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 { setStatusBarHidden, StatusBar } from "expo-status-bar";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
AppState,
AppStateStatus,
BackHandler,
Dimensions,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import "react-native-gesture-handler";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
@@ -38,9 +42,9 @@ 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";
import { useVideoPlayer, VideoView } from "expo-video";
async function setOrientation(orientation: ScreenOrientation.OrientationLock) {
async function lockOrientation(orientation: ScreenOrientation.OrientationLock) {
await ScreenOrientation.lockAsync(orientation);
}
@@ -57,10 +61,10 @@ export const FullScreenVideoPlayer: React.FC = () => {
setVolume,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
isBuffering: _isBuffering,
isBuffering,
setIsBuffering,
player,
} = usePlayback();
const [settings] = useSettings();
@@ -69,11 +73,16 @@ export const FullScreenVideoPlayer: React.FC = () => {
const segments = useSegments();
const router = useRouter();
const firstLoad = useRef(true);
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
useTrickplay(currentlyPlaying);
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
const { showControls, hideControls, opacity } = useControlsVisibility(3000);
const [isInteractive, setIsInteractive] = useState(true);
const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
);
const opacity = useSharedValue(1);
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
const from = useMemo(() => segments[2], [segments]);
@@ -82,8 +91,62 @@ export const FullScreenVideoPlayer: React.FC = () => {
const min = useSharedValue(0);
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
const sliding = useRef(false);
const localIsBuffering = useSharedValue(false);
const localIsBuffering = useSharedValue(true);
const cacheProgress = useSharedValue(0);
const [isStatusBarHidden, setIsStatusBarHidden] = useState(false);
const [progressState, _setProgressState] = useState(0);
const setProgressState = useCallback(
(value: number) => {
if (sliding.current === true) return;
_setProgressState(value);
},
[sliding.current]
);
useAnimatedReaction(
() => {
return progress.value;
},
(progress) => {
runOnJS(setProgressState)(progress);
}
);
const hideControls = useCallback(() => {
"worklet";
opacity.value = 0;
}, [opacity]);
const showControls = useCallback(() => {
"worklet";
opacity.value = 1;
}, [opacity]);
useEffect(() => {
const backAction = () => {
if (currentlyPlaying) {
Alert.alert("Hold on!", "Are you sure you want to exit?", [
{
text: "Cancel",
onPress: () => null,
style: "cancel",
},
{ text: "Yes", onPress: () => stopPlayback() },
]);
return true;
}
return false;
};
const backHandler = BackHandler.addEventListener(
"hardwareBackPress",
backAction
);
return () => backHandler.remove();
}, [currentlyPlaying]);
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
@@ -120,29 +183,61 @@ export const FullScreenVideoPlayer: React.FC = () => {
}, [currentlyPlaying, api, poster]);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === "active") {
setIsInteractive(true);
showControls();
} else {
setIsInteractive(false);
}
};
const subscription = player.addListener("playingChange", (isPlaying) => {
setIsPlaying(isPlaying);
});
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
const subscription2 = player.addListener("statusChange", (status) => {
if (status === "error") {
console.log("player.addListener ~ error");
Alert.alert("Error", "An error occurred while playing the video.");
}
if (status === "readyToPlay") {
console.log("player.addListener ~ readyToPlay");
localIsBuffering.value = false;
setIsBuffering(false);
if (firstLoad.current === true) {
playVideo();
firstLoad.current = false;
}
}
if (status === "loading") {
localIsBuffering.value = true;
setIsBuffering(true);
}
if (status === "idle") {
console.log("player.addListener ~ idle");
}
});
return () => {
subscription.remove();
subscription2.remove();
};
}, [showControls]);
}, [player, setIsBuffering]);
useEffect(() => {
max.value = currentlyPlaying?.item.RunTimeTicks || 0;
}, [currentlyPlaying?.item.RunTimeTicks]);
useEffect(() => {
if (!player) return;
const interval = setInterval(async () => {
try {
if (sliding.current === true) return;
if (player.playing === true) {
const time = secondsToTicks(player.currentTime);
progress.value = time;
}
} catch (error) {
console.error("Error getting current time:", error);
}
}, 500);
return () => clearInterval(interval);
}, [player, sliding.current]);
useEffect(() => {
if (!currentlyPlaying) {
resetOrientation();
@@ -150,11 +245,12 @@ export const FullScreenVideoPlayer: React.FC = () => {
min.value = 0;
max.value = 0;
cacheProgress.value = 0;
localIsBuffering.value = false;
sliding.current = false;
hideControls();
setStatusBarHidden(false);
} else {
setOrientation(
setStatusBarHidden(true);
lockOrientation(
settings?.defaultVideoOrientation ||
ScreenOrientation.OrientationLock.DEFAULT
);
@@ -165,6 +261,30 @@ export const FullScreenVideoPlayer: React.FC = () => {
}
}, [currentlyPlaying, settings]);
/**
* Event listener for orientation
*/
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(
orientationToOrientationLock(event.orientationInfo.orientation)
);
}
);
return () => {
subscription.remove();
};
}, []);
const isLandscape = useMemo(() => {
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
? true
: false;
}, [orientation]);
const animatedStyles = {
controls: useAnimatedStyle(() => ({
opacity: withTiming(opacity.value, { duration: 300 }),
@@ -178,7 +298,9 @@ export const FullScreenVideoPlayer: React.FC = () => {
),
})),
loader: useAnimatedStyle(() => ({
opacity: withTiming(localIsBuffering.value ? 1 : 0, { duration: 300 }),
opacity: withTiming(localIsBuffering.value === true ? 1 : 0, {
duration: 300,
}),
})),
};
@@ -220,13 +342,19 @@ export const FullScreenVideoPlayer: React.FC = () => {
progress.value > showButtonAt && progress.value < hideButtonAt;
return {
opacity: withTiming(
localIsBuffering.value === false && opacity.value === 1 && showButton
localIsBuffering.value === false && showButton && progress.value !== 0
? 1
: 0,
{
duration: 300,
}
),
bottom: withTiming(
opacity.value === 0 ? insets.bottom + 8 : isLandscape ? 85 : 140,
{
duration: 300,
}
),
};
});
@@ -243,32 +371,14 @@ export const FullScreenVideoPlayer: React.FC = () => {
}, [opacity.value, hideControls, showControls]);
const skipIntro = useCallback(async () => {
if (!introTimestamps || !videoRef.current) return;
if (!introTimestamps || !player) return;
try {
videoRef.current.seek(introTimestamps.IntroEnd);
player.currentTime = 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);
@@ -281,53 +391,57 @@ export const FullScreenVideoPlayer: React.FC = () => {
const handleSkipBackward = useCallback(async () => {
try {
const curr = await videoRef.current?.getCurrentPosition();
const curr = player.currentTime;
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - 15));
player.currentTime = Math.max(0, curr - 15);
showControls();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [videoRef, showControls]);
}, [player, showControls]);
const handleSkipForward = useCallback(async () => {
try {
const curr = await videoRef.current?.getCurrentPosition();
const curr = player.currentTime;
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + 15));
player.currentTime = Math.max(0, curr + 15);
showControls();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [videoRef, showControls]);
}, [player, showControls]);
const handlePlayPause = useCallback(() => {
console.log("handlePlayPause");
if (isPlaying) pauseVideo();
else playVideo();
showControls();
}, [isPlaying, pauseVideo, playVideo, showControls]);
const handleSliderStart = useCallback(() => {
if (opacity.value === 0) return;
sliding.current = true;
}, []);
const handleSliderComplete = useCallback(
(val: number) => {
if (opacity.value === 0) return;
const tick = Math.floor(val);
videoRef.current?.seek(tick / 10000000);
player.currentTime = tick / 10000000;
sliding.current = false;
},
[videoRef]
[player]
);
const handleSliderChange = useCallback(
(val: number) => {
if (opacity.value === 0) return;
sliding.current = true;
const tick = Math.floor(val);
progress.value = tick;
calculateTrickplayUrl(progress);
showControls();
},
[progress, calculateTrickplayUrl, showControls]
);
@@ -359,9 +473,14 @@ export const FullScreenVideoPlayer: React.FC = () => {
});
const playPauseGesture = Gesture.Tap()
.enabled(opacity.value !== 0)
.onBegin(() => {
console.log("playPauseGesture ~", opacity.value);
})
.onStart(() => {
runOnJS(handlePlayPause)();
})
.onFinalize(() => {
if (opacity.value === 0) opacity.value = 1;
});
const goToPreviouItemGesture = Gesture.Tap()
@@ -388,11 +507,9 @@ export const FullScreenVideoPlayer: React.FC = () => {
runOnJS(handleSkipForward)();
});
const skipIntroGesture = Gesture.Tap()
.enabled(opacity.value !== 0)
.onStart(() => {
runOnJS(skipIntro)();
});
const skipIntroGesture = Gesture.Tap().onStart(() => {
runOnJS(skipIntro)();
});
if (!api || !currentlyPlaying) return null;
@@ -404,6 +521,7 @@ export const FullScreenVideoPlayer: React.FC = () => {
backgroundColor: "black",
}}
>
<StatusBar hidden={isStatusBarHidden} />
<GestureDetector gesture={videoTap}>
<Animated.View
style={[
@@ -427,46 +545,16 @@ export const FullScreenVideoPlayer: React.FC = () => {
}}
>
{videoSource && (
<Video
ref={videoRef}
allowsExternalPlayback
<VideoView
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}
player={player}
allowsFullscreen
nativeControls={false}
allowsPictureInPicture
contentFit={ignoreSafeArea ? "cover" : "contain"}
/>
)}
</View>
@@ -499,35 +587,36 @@ export const FullScreenVideoPlayer: React.FC = () => {
{
position: "absolute",
bottom: insets.bottom + 8 * 8,
right: insets.right + 32,
right: isLandscape ? insets.right + 32 : insets.right + 16,
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">
<TouchableOpacity className="flex flex-col items-center justify-center px-3 py-2 bg-purple-600 rounded-full">
<GestureDetector gesture={skipIntroGesture}>
<Text>Skip intro</Text>
<Text className="font-semibold">Skip intro</Text>
</GestureDetector>
</TouchableOpacity>
</View>
</Animated.View>
<Animated.View
pointerEvents={opacity.value === 0 ? "none" : "auto"}
style={[
{
position: "absolute",
top: insets.top,
right: insets.right + 20,
right: isLandscape ? insets.right + 32 : insets.right + 8,
height: 70,
},
animatedStyles.controls,
]}
>
<View className="flex flex-row items-center h-full">
<View className="flex flex-row items-center h-full space-x-2 z-10">
<GestureDetector gesture={toggleIgnoreSafeAreaGesture}>
<TouchableOpacity className="aspect-square rounded flex flex-col items-center justify-center p-2">
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2">
<Ionicons
name={ignoreSafeArea ? "contract-outline" : "expand"}
size={24}
@@ -535,12 +624,11 @@ export const FullScreenVideoPlayer: React.FC = () => {
/>
</TouchableOpacity>
</GestureDetector>
<TouchableOpacity
onPress={() => {
stopPlayback();
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
className="aspect-square bg-neutral-800 rounded-xl flex flex-col items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
@@ -552,9 +640,10 @@ export const FullScreenVideoPlayer: React.FC = () => {
{
position: "absolute",
bottom: insets.bottom + 8,
left: insets.left + 32,
width: screenWidth - insets.left - insets.right - 64,
borderRadius: 100,
left: isLandscape ? insets.left + 32 : insets.left + 16,
width: isLandscape
? screenWidth - insets.left - insets.right - 64
: screenWidth - insets.left - insets.right - 32,
},
animatedStyles.controls,
]}
@@ -577,22 +666,29 @@ export const FullScreenVideoPlayer: React.FC = () => {
</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">
<View
className={`flex ${
isLandscape
? "flex-row space-x-6 py-2 px-4 rounded-full"
: "flex-col-reverse py-4 px-4 rounded-2xl"
}
items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
>
<GestureDetector gesture={goToPreviouItemGesture}>
<Ionicons name="play-skip-back" size={20} color="white" />
<Ionicons name="play-skip-back" size={24} color="white" />
</GestureDetector>
</TouchableOpacity>
<TouchableOpacity>
<GestureDetector gesture={skipBackwardGesture}>
<Ionicons
name="refresh-outline"
size={24}
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
@@ -604,14 +700,14 @@ export const FullScreenVideoPlayer: React.FC = () => {
<GestureDetector gesture={playPauseGesture}>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={26}
size={30}
color="white"
/>
</GestureDetector>
</TouchableOpacity>
<TouchableOpacity>
<GestureDetector gesture={skipForwardGesture}>
<Ionicons name="refresh-outline" size={24} color="white" />
<Ionicons name="refresh-outline" size={26} color="white" />
</GestureDetector>
</TouchableOpacity>
<TouchableOpacity
@@ -620,13 +716,16 @@ export const FullScreenVideoPlayer: React.FC = () => {
}}
>
<GestureDetector gesture={goToNextItemGesture}>
<Ionicons name="play-skip-forward" size={20} color="white" />
<Ionicons name="play-skip-forward" size={24} color="white" />
</GestureDetector>
</TouchableOpacity>
</View>
<View className="flex flex-col w-full shrink">
<View
className={`flex flex-col w-full shrink
${""}
`}
>
<Slider
disable={opacity.value === 0}
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
@@ -653,15 +752,19 @@ export const FullScreenVideoPlayer: React.FC = () => {
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
marginBottom: 10,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
@@ -684,12 +787,15 @@ export const FullScreenVideoPlayer: React.FC = () => {
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between -mb-0.5">
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{runtimeTicksToSeconds(progress.value)}
{runtimeTicksToSeconds(progressState)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{runtimeTicksToSeconds(max.value - progress.value)}
-
{runtimeTicksToSeconds(
(currentlyPlaying?.item.RunTimeTicks || 0) - progressState
)}
</Text>
</View>
</View>

View File

@@ -19,7 +19,7 @@ export const OverviewText: React.FC<Props> = ({
return (
<View className="flex flex-col" {...props}>
<Text className="text-xl font-bold mb-2">Overview</Text>
<Text className="text-lg font-bold mb-2">Overview</Text>
<TouchableOpacity
onPress={() =>
setLimit((prev) =>

View File

@@ -0,0 +1,35 @@
import { BlurView } from "expo-blur";
import React from "react";
import { Platform, View, ViewProps } from "react-native";
interface Props extends ViewProps {
blurAmount?: number;
blurType?: "light" | "dark" | "xlight";
}
/**
* BlurView for iOS and simple View for Android
*/
export const PlatformBlurView: React.FC<Props> = ({
blurAmount = 100,
blurType = "light",
style,
children,
...props
}) => {
if (Platform.OS === "ios") {
return (
<BlurView style={style} intensity={blurAmount} {...props}>
{children}
</BlurView>
);
}
return (
<View
style={[{ backgroundColor: "rgba(50, 50, 50, 0.9)" }, style]}
{...props}
>
{children}
</View>
);
};

View File

@@ -4,25 +4,29 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, { useMemo } from "react";
import { Dimensions, View, ViewProps } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import React, { useCallback, useMemo } from "react";
import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native";
import Animated, {
runOnJS,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import Carousel, {
ICarouselInstance,
Pagination,
} from "react-native-reanimated-carousel";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useRouter, useSegments } from "expo-router";
import * as Haptics from "expo-haptics";
interface Props extends ViewProps {}
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const router = useRouter();
const queryClient = useQueryClient();
const [settings] = useSettings();
const ref = React.useRef<ICarouselInstance>(null);
@@ -122,7 +126,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
const router = useRouter();
const screenWidth = Dimensions.get("screen").width;
const uri = useMemo(() => {
@@ -141,11 +145,41 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return getLogoImageUrlById({ api, item, height: 100 });
}, [item]);
const segments = useSegments();
const from = segments[2];
const opacity = useSharedValue(1);
const handleRoute = useCallback(() => {
if (!from) return;
const url = itemRouter(item, from);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// @ts-ignore
if (url) router.push(url);
}, [item, from]);
const tap = Gesture.Tap()
.maxDuration(2000)
.onBegin(() => {
opacity.value = withTiming(0.5, { duration: 100 });
})
.onEnd(() => {
runOnJS(handleRoute)();
})
.onFinalize(() => {
opacity.value = withTiming(1, { duration: 100 });
});
if (!uri || !logoUri) return null;
return (
<TouchableItemRouter item={item}>
<View className="px-4">
<GestureDetector gesture={tap}>
<Animated.View
style={{
opacity: opacity,
}}
className="px-4"
>
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
<Image
source={{
@@ -171,7 +205,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
/>
</View>
</View>
</View>
</TouchableItemRouter>
</Animated.View>
</GestureDetector>
);
};

View File

@@ -44,7 +44,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
return (
<View {...props}>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
{title}
</Text>
<HorizontalScroll

View File

@@ -10,6 +10,7 @@ import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
const [user] = useAtom(userAtom);
@@ -46,16 +47,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
<HorizontalScroll
data={items}
renderItem={(item, index) => (
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/items/page?id=${item.Id}`);
}}
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-44"
>
<ContinueWatchingPoster item={item} useEpisodePoster />
<ItemCardText item={item} />
</TouchableOpacity>
</TouchableItemRouter>
)}
/>
</View>

View File

@@ -15,6 +15,7 @@ import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Image } from "expo-image";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
type Props = {
item: BaseItemDto;
@@ -192,11 +193,9 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
</View>
) : (
episodes?.map((e: BaseItemDto) => (
<TouchableOpacity
<TouchableItemRouter
item={e}
key={e.Id}
onPress={() => {
router.push(`/(auth)/items/page?id=${e.Id}`);
}}
className="flex flex-col mb-4"
>
<View className="flex flex-row items-center mb-2">
@@ -229,7 +228,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
>
{e.Overview}
</Text>
</TouchableOpacity>
</TouchableItemRouter>
))
)}
</View>

View File

@@ -1,33 +1,8 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
DefaultLanguageOption,
DownloadOptions,
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,
ViewProps,
} from "react-native";
import { useSettings } from "@/utils/atoms/settings";
import { TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { Input } from "../common/Input";
import { useState } from "react";
import { Button } from "../Button";
const LANGUAGES: DefaultLanguageOption[] = [
{ label: "eng", value: "eng" },
{
label: "sv",
value: "sv",
},
];
import { LANGUAGES } from "@/constants/Languages";
interface Props extends ViewProps {}

39
constants/Languages.ts Normal file
View File

@@ -0,0 +1,39 @@
import { DefaultLanguageOption } from "@/utils/atoms/settings";
export const LANGUAGES: DefaultLanguageOption[] = [
{ label: "English", value: "eng" },
{ label: "Spanish", value: "es" },
{ label: "Chinese (Mandarin)", value: "zh" },
{ label: "Hindi", value: "hi" },
{ label: "Arabic", value: "ar" },
{ label: "French", value: "fr" },
{ label: "Russian", value: "ru" },
{ label: "Portuguese", value: "pt" },
{ label: "Japanese", value: "ja" },
{ label: "German", value: "de" },
{ label: "Italian", value: "it" },
{ label: "Korean", value: "ko" },
{ label: "Turkish", value: "tr" },
{ label: "Dutch", value: "nl" },
{ label: "Polish", value: "pl" },
{ label: "Vietnamese", value: "vi" },
{ label: "Thai", value: "th" },
{ label: "Indonesian", value: "id" },
{ label: "Greek", value: "el" },
{ label: "Swedish", value: "sv" },
{ label: "Danish", value: "da" },
{ label: "Norwegian", value: "no" },
{ label: "Finnish", value: "fi" },
{ label: "Czech", value: "cs" },
{ label: "Hungarian", value: "hu" },
{ label: "Romanian", value: "ro" },
{ label: "Ukrainian", value: "uk" },
{ label: "Hebrew", value: "he" },
{ label: "Bengali", value: "bn" },
{ label: "Punjabi", value: "pa" },
{ label: "Tagalog", value: "tl" },
{ label: "Swahili", value: "sw" },
{ label: "Malay", value: "ms" },
{ label: "Persian", value: "fa" },
{ label: "Urdu", value: "ur" },
];

View File

@@ -1,6 +1,6 @@
// hooks/useTrickplay.ts
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, useRef } from "react";
import { Api } from "@jellyfin/sdk";
import { SharedValue } from "react-native-reanimated";
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
@@ -33,6 +33,8 @@ export const useTrickplay = (
) => {
const [api] = useAtom(apiAtom);
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
const lastCalculationTime = useRef(0);
const throttleDelay = 100; // 200ms throttle
const trickplayInfo = useMemo(() => {
if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) {
@@ -61,6 +63,12 @@ export const useTrickplay = (
const calculateTrickplayUrl = useCallback(
(progress: SharedValue<number>) => {
const now = Date.now();
if (now - lastCalculationTime.current < throttleDelay) {
return null;
}
lastCalculationTime.current = now;
if (!trickplayInfo || !api || !currentlyPlaying?.item.Id) {
return null;
}

View File

@@ -50,6 +50,7 @@
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.24",
"expo-video": "^1.2.6",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.9.3",
@@ -93,5 +94,12 @@
"react-test-renderer": "18.2.0",
"typescript": "~5.3.3"
},
"private": true
"private": true,
"expo": {
"doctor": {
"reactNativeDirectoryCheck": {
"enabled": true
}
}
}
}

View File

@@ -29,6 +29,7 @@ import {
parseM3U8ForSubtitles,
SubtitleTrack,
} from "@/utils/hls/parseM3U8ForSubtitles";
import { useVideoPlayer, VideoPlayer } from "expo-video";
export type CurrentlyPlayingState = {
url: string;
@@ -40,14 +41,10 @@ interface PlaybackContextType {
currentlyPlaying: CurrentlyPlayingState | null;
videoRef: React.MutableRefObject<VideoRef | null>;
isPlaying: boolean;
isFullscreen: boolean;
progressTicks: number | null;
playVideo: (triggerRef?: boolean) => void;
pauseVideo: (triggerRef?: boolean) => void;
stopPlayback: () => void;
presentFullscreenPlayer: () => void;
dismissFullscreenPlayer: () => void;
setIsFullscreen: (isFullscreen: boolean) => void;
setIsPlaying: (isPlaying: boolean) => void;
isBuffering: boolean;
setIsBuffering: (val: boolean) => void;
@@ -60,6 +57,7 @@ interface PlaybackContextType {
currentlyPlaying: CurrentlyPlayingState | null
) => void;
subtitles: SubtitleTrack[];
player: VideoPlayer;
}
const PlaybackContext = createContext<PlaybackContextType | null>(null);
@@ -71,13 +69,12 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const [user] = useAtom(userAtom);
const videoRef = useRef<VideoRef | null>(null);
const previousVolume = useRef<number | null>(null);
const [settings] = useSettings();
const previousVolume = useRef<number | null>(null);
const [isPlaying, _setIsPlaying] = useState<boolean>(false);
const [isBuffering, setIsBuffering] = useState<boolean>(false);
const [isBuffering, setIsBuffering] = useState<boolean>(true);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [progressTicks, setProgressTicks] = useState<number | null>(0);
const [volume, _setVolume] = useState<number | null>(null);
@@ -99,6 +96,10 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
[_setVolume]
);
const player = useVideoPlayer(currentlyPlaying?.url || null, () => {
if (player) player.play();
});
const { data: deviceId } = useQuery({
queryKey: ["deviceId", api],
queryFn: getDeviceId,
@@ -175,7 +176,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const playVideo = useCallback(
(triggerRef: boolean = true) => {
if (triggerRef === true) {
videoRef.current?.resume();
player.play();
}
_setIsPlaying(true);
reportPlaybackProgress({
@@ -192,7 +193,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const pauseVideo = useCallback(
(triggerRef: boolean = true) => {
if (triggerRef === true) {
videoRef.current?.pause();
player.pause();
}
_setIsPlaying(false);
reportPlaybackProgress({
@@ -253,16 +254,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
[_onProgress]
);
const presentFullscreenPlayer = useCallback(() => {
videoRef.current?.presentFullscreenPlayer();
setIsFullscreen(true);
}, []);
const dismissFullscreenPlayer = useCallback(() => {
videoRef.current?.dismissFullscreenPlayer();
setIsFullscreen(false);
}, []);
useEffect(() => {
if (!deviceId || !api?.accessToken) return;
@@ -346,14 +337,13 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
return (
<PlaybackContext.Provider
value={{
player,
onProgress,
isBuffering,
setIsBuffering,
progressTicks,
setVolume,
setIsPlaying,
setIsFullscreen,
isFullscreen,
isPlaying,
currentlyPlaying,
sessionData: session,
@@ -362,8 +352,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
setCurrentlyPlayingState,
pauseVideo,
stopPlayback,
presentFullscreenPlayer,
dismissFullscreenPlayer,
startDownloadedFilePlayback,
subtitles,
}}

View File

@@ -0,0 +1,21 @@
import { Orientation, OrientationLock } from "expo-screen-orientation";
function orientationToOrientationLock(
orientation: Orientation
): OrientationLock {
switch (orientation) {
case Orientation.PORTRAIT_UP:
return OrientationLock.PORTRAIT_UP;
case Orientation.PORTRAIT_DOWN:
return OrientationLock.PORTRAIT_DOWN;
case Orientation.LANDSCAPE_LEFT:
return OrientationLock.LANDSCAPE_LEFT;
case Orientation.LANDSCAPE_RIGHT:
return OrientationLock.LANDSCAPE_RIGHT;
case Orientation.UNKNOWN:
default:
return OrientationLock.DEFAULT;
}
}
export default orientationToOrientationLock;