forked from Ninjalama/streamyfin_mirror
Compare commits
1 Commits
feat/bette
...
refactor/b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6752888bb0 |
7
app.json
7
app.json
@@ -66,6 +66,13 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"./plugins/withAndroidMainActivityAttributes",
|
||||
{
|
||||
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
|
||||
}
|
||||
],
|
||||
["./plugins/withExpandedController.js"],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Chromecast } from "@/components/Chromecast";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
@@ -45,6 +45,18 @@ export default function IndexLayout() {
|
||||
name="settings"
|
||||
options={{
|
||||
title: "Settings",
|
||||
headerRight: () => (
|
||||
<View className="">
|
||||
<Ionicons
|
||||
name="file-tray-full-outline"
|
||||
size={22}
|
||||
color="white"
|
||||
onPress={() => {
|
||||
router.push("/logs");
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
|
||||
@@ -25,10 +25,11 @@ import {
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
|
||||
@@ -20,12 +20,6 @@ export default function settings() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: logs } = useQuery({
|
||||
queryKey: ["logs"],
|
||||
queryFn: async () => readFromLog(),
|
||||
refetchInterval: 1000,
|
||||
});
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const openQuickConnectAuthCodeInput = () => {
|
||||
@@ -129,30 +123,6 @@ export default function settings() {
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{logs?.map((log, index) => (
|
||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||
<Text
|
||||
className={`
|
||||
mb-1
|
||||
${log.level === "INFO" && "text-blue-500"}
|
||||
${log.level === "ERROR" && "text-red-500"}
|
||||
`}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
<Text uiTextView selectable className="text-xs">
|
||||
{log.message}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
<Text className="opacity-50">No logs available</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } 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, useMemo } from "react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -12,7 +16,6 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
@@ -40,6 +43,7 @@ import {
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
|
||||
|
||||
@@ -7,13 +7,25 @@ import {
|
||||
PlaybackType,
|
||||
usePlaySettings,
|
||||
} from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, StatusBar, useWindowDimensions, View } from "react-native";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
|
||||
@@ -25,10 +37,7 @@ export default function page() {
|
||||
const videoSource = useVideoSource(playSettings, api, playUrl);
|
||||
const firstTime = useRef(true);
|
||||
|
||||
const dimensions = useWindowDimensions();
|
||||
useOrientation();
|
||||
useOrientationSettings();
|
||||
useAndroidNavigationBar();
|
||||
const screenDimensions = Dimensions.get("screen");
|
||||
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
@@ -68,6 +77,10 @@ export default function page() {
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
useOrientationSettings();
|
||||
useAndroidNavigationBar();
|
||||
|
||||
const onProgress = useCallback(async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
progress.value = secondsToTicks(data.currentTime);
|
||||
@@ -81,8 +94,8 @@ export default function page() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
position: "relative",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
|
||||
@@ -18,7 +18,7 @@ import * as Haptics from "expo-haptics";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, StatusBar, useWindowDimensions, View } from "react-native";
|
||||
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, {
|
||||
OnProgressData,
|
||||
@@ -34,7 +34,8 @@ export default function page() {
|
||||
const poster = usePoster(playSettings, api);
|
||||
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
|
||||
const firstTime = useRef(true);
|
||||
const dimensions = useWindowDimensions();
|
||||
|
||||
const screenDimensions = Dimensions.get("screen");
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
@@ -169,7 +170,7 @@ export default function page() {
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
useOrientation();
|
||||
const { orientation } = useOrientation();
|
||||
useOrientationSettings();
|
||||
useAndroidNavigationBar();
|
||||
|
||||
@@ -217,8 +218,8 @@ export default function page() {
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
@@ -231,18 +232,15 @@ export default function page() {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
onError={() => {}}
|
||||
|
||||
@@ -345,6 +345,13 @@ function Layout() {
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="logs"
|
||||
options={{
|
||||
presentation: "modal",
|
||||
title: "Logs",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/play-offline-video"
|
||||
options={{
|
||||
|
||||
109
app/login.tsx
109
app/login.tsx
@@ -2,11 +2,12 @@ import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ const Login: React.FC = () => {
|
||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||
useJellyfin();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
const {
|
||||
@@ -72,7 +74,17 @@ const Login: React.FC = () => {
|
||||
try {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (result.success) {
|
||||
await login(credentials.username, credentials.password);
|
||||
try {
|
||||
await login(credentials.username, credentials.password);
|
||||
} catch (loginError) {
|
||||
if (loginError instanceof Error) {
|
||||
setError(loginError.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred during login");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError("Invalid credentials format");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
@@ -105,37 +117,72 @@ const Login: React.FC = () => {
|
||||
async function checkUrl(url: string) {
|
||||
url = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
setLoadingServerCheck(true);
|
||||
writeToLog("INFO", `Checking URL: ${url}`);
|
||||
|
||||
const protocols = ["https://", "http://"];
|
||||
const timeout = 2000; // 2 seconds timeout for long 404 responses
|
||||
const timeout = 5000; // 5 seconds timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
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.log(`Error checking ${protocol}${url}:`, error);
|
||||
}
|
||||
// Try HTTPS first
|
||||
const httpsUrl = `https://${url}/System/Info/Public`;
|
||||
try {
|
||||
const response = await fetch(httpsUrl, {
|
||||
mode: "cors",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
setServerName(data.ServerName || "");
|
||||
return `https://${url}`;
|
||||
} else {
|
||||
writeToLog(
|
||||
"WARN",
|
||||
`HTTPS connection failed with status: ${response.status}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
writeToLog("WARN", "HTTPS connection failed - trying HTTP", e);
|
||||
}
|
||||
|
||||
// If HTTPS didn't work, try HTTP
|
||||
const httpUrl = `http://${url}/System/Info/Public`;
|
||||
try {
|
||||
const response = await fetch(httpUrl, {
|
||||
mode: "cors",
|
||||
signal: controller.signal,
|
||||
});
|
||||
writeToLog("INFO", `HTTP response status: ${response.status}`);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
setServerName(data.ServerName || "");
|
||||
return `http://${url}`;
|
||||
} else {
|
||||
writeToLog(
|
||||
"WARN",
|
||||
`HTTP connection failed with status: ${response.status}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
writeToLog("ERROR", "HTTP connection failed", e);
|
||||
}
|
||||
|
||||
// If neither worked, return undefined
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`Failed to connect to ${url} using both HTTPS and HTTP`
|
||||
);
|
||||
return undefined;
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
if (error.name === "AbortError") {
|
||||
writeToLog("ERROR", `Request to ${url} timed out`, error);
|
||||
} else {
|
||||
writeToLog("ERROR", `Unexpected error checking ${url}`, error);
|
||||
}
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
setLoadingServerCheck(false);
|
||||
}
|
||||
}
|
||||
@@ -197,6 +244,16 @@ const Login: React.FC = () => {
|
||||
style={{ flex: 1, height: "100%" }}
|
||||
>
|
||||
<View className="flex flex-col w-full h-full relative items-center justify-center">
|
||||
<View className="absolute top-4 right-4">
|
||||
<Ionicons
|
||||
name="file-tray-full-outline"
|
||||
size={22}
|
||||
color="white"
|
||||
onPress={() => {
|
||||
router.push("/logs");
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className="px-4 -mt-20">
|
||||
<View className="mb-4">
|
||||
<Text className="text-3xl font-bold mb-1">
|
||||
|
||||
58
app/logs.tsx
Normal file
58
app/logs.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { readFromLog } from "@/utils/log";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const Logs: React.FC = () => {
|
||||
const { data: logs } = useQuery({
|
||||
queryKey: ["logs"],
|
||||
queryFn: async () => (await readFromLog()).reverse(),
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 p-4"
|
||||
contentContainerStyle={{ gap: 10, paddingBottom: insets.top }}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
{logs?.map((log, index) => (
|
||||
<View key={index} className="border-b-neutral-800 border py-3">
|
||||
<View className="flex flex-row justify-between items-center mb-2">
|
||||
<Text
|
||||
className={`
|
||||
text-xs
|
||||
${log.level === "INFO" && "text-blue-500"}
|
||||
${log.level === "ERROR" && "text-red-500"}
|
||||
`}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text uiTextView selectable className="text-xs mb-1">
|
||||
{log.message}
|
||||
</Text>
|
||||
{log.data && (
|
||||
<Text uiTextView selectable className="text-xs">
|
||||
{log.data}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
<Text className="opacity-50">No logs available</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
useCastDevice,
|
||||
useDevices,
|
||||
@@ -40,32 +39,18 @@ export const Chromecast: React.FC<Props> = ({
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
|
||||
// Android requires the cast button to be present for startDiscovery to work
|
||||
const AndroidCastButton = useCallback(
|
||||
() =>
|
||||
Platform.OS === "android" ? (
|
||||
<CastButton tintColor="transparent" />
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
[Platform.OS]
|
||||
);
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
<AndroidCastButton />
|
||||
</>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (Platform.OS === "android")
|
||||
@@ -97,7 +82,6 @@ export const Chromecast: React.FC<Props> = ({
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</BlurView>
|
||||
<AndroidCastButton />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -246,7 +246,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
</View>
|
||||
)}
|
||||
|
||||
<PlayButton className="grow" />
|
||||
<PlayButton item={item} url={playUrl} className="grow" />
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
@@ -6,11 +6,10 @@ import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { Alert, Linking, TouchableOpacity, View } from "react-native";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Linking, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
PlayServicesState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
@@ -29,31 +28,32 @@ import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {}
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item?: BaseItemDto | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
const MIN_PLAYBACK_WIDTH = 15;
|
||||
|
||||
export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
||||
const { playSettings, playUrl: url } = usePlaySettings();
|
||||
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
|
||||
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
|
||||
|
||||
const startWidth = useSharedValue(0);
|
||||
const targetWidth = useSharedValue(0);
|
||||
const endColor = useSharedValue(colorAtom);
|
||||
const startColor = useSharedValue(colorAtom);
|
||||
const endColor = useSharedValue(memoizedColor);
|
||||
const startColor = useSharedValue(memoizedColor);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const [settings] = useSettings();
|
||||
@@ -62,11 +62,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
||||
return !url?.includes("m3u8");
|
||||
}, [url]);
|
||||
|
||||
const item = useMemo(() => {
|
||||
return playSettings?.item;
|
||||
}, [playSettings?.item]);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
const onPress = async () => {
|
||||
if (!url || !item) {
|
||||
console.warn(
|
||||
"No URL or item provided to PlayButton",
|
||||
@@ -102,7 +98,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
||||
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
await CastContext.getPlayServicesState().then(async (state) => {
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
@@ -112,34 +108,10 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
||||
CastContext.showExpandedControls();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get a new URL with the Chromecast device profile:
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
deviceProfile: chromecastProfile,
|
||||
item,
|
||||
mediaSourceId: playSettings?.mediaSource?.Id,
|
||||
startTimeTicks: 0,
|
||||
maxStreamingBitrate: playSettings?.bitrate?.value,
|
||||
audioStreamIndex: playSettings?.audioIndex ?? 0,
|
||||
subtitleStreamIndex: playSettings?.subtitleIndex ?? -1,
|
||||
userId: user?.Id,
|
||||
forceDirectPlay: settings?.forceDirectPlay,
|
||||
});
|
||||
|
||||
if (!data?.url) {
|
||||
console.warn("No URL returned from getStreamUrl", data);
|
||||
Alert.alert(
|
||||
"Client error",
|
||||
"Could not create stream for Chromecast"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
client
|
||||
.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: data?.url,
|
||||
contentUrl: url,
|
||||
contentType: "video/mp4",
|
||||
metadata:
|
||||
item.Type === "Episode"
|
||||
@@ -212,32 +184,21 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [
|
||||
url,
|
||||
item,
|
||||
client,
|
||||
settings,
|
||||
api,
|
||||
user,
|
||||
playSettings,
|
||||
router,
|
||||
showActionSheetWithOptions,
|
||||
mediaStatus,
|
||||
]);
|
||||
};
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
const userData = item.UserData;
|
||||
if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
|
||||
const userData = memoizedItem.UserData;
|
||||
if (userData && userData.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
? Math.max(
|
||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||
(userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
|
||||
MIN_PLAYBACK_WIDTH
|
||||
)
|
||||
: 0;
|
||||
}
|
||||
return 0;
|
||||
}, [item]);
|
||||
}, [memoizedItem]);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => derivedTargetWidth.value,
|
||||
@@ -253,7 +214,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => colorAtom,
|
||||
() => memoizedColor,
|
||||
(newColor) => {
|
||||
endColor.value = newColor;
|
||||
colorChangeProgress.value = 0;
|
||||
@@ -262,19 +223,19 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||
});
|
||||
},
|
||||
[colorAtom]
|
||||
[memoizedColor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout_2 = setTimeout(() => {
|
||||
startColor.value = colorAtom;
|
||||
startColor.value = memoizedColor;
|
||||
startWidth.value = targetWidth.value;
|
||||
}, ANIMATION_DURATION);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout_2);
|
||||
};
|
||||
}, [colorAtom, item]);
|
||||
}, [memoizedColor, memoizedItem]);
|
||||
|
||||
/**
|
||||
* ANIMATED STYLES
|
||||
@@ -357,7 +318,6 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Feather name="cast" size={22} />
|
||||
<CastButton tintColor="transparent" />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
|
||||
@@ -11,9 +11,12 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TouchableOpacityProps, View } from "react-native";
|
||||
import { getColors } from "react-native-image-colors";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
library: BaseItemDto;
|
||||
@@ -50,6 +53,10 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
[library]
|
||||
);
|
||||
|
||||
// If we want to use image colors for library cards
|
||||
// const [color] = useAtom(itemThemeColorAtom)
|
||||
// useImageColors({ url });
|
||||
|
||||
const { data: itemsCount } = useQuery({
|
||||
queryKey: ["library-count", library.Id],
|
||||
queryFn: async () => {
|
||||
@@ -61,7 +68,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
});
|
||||
return response.data.TotalRecordCount;
|
||||
},
|
||||
staleTime: 1000 * 60 * 60,
|
||||
});
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
@@ -71,6 +71,44 @@ export const Controls: React.FC<Props> = ({
|
||||
|
||||
const windowDimensions = Dimensions.get("window");
|
||||
|
||||
const op = useSharedValue<number>(1);
|
||||
const tr = useSharedValue<number>(10);
|
||||
const animatedStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: op.value,
|
||||
};
|
||||
});
|
||||
const animatedTopStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: op.value,
|
||||
transform: [
|
||||
{
|
||||
translateY: -tr.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
const animatedBottomStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: op.value,
|
||||
transform: [
|
||||
{
|
||||
translateY: tr.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (showControls || isBuffering) {
|
||||
op.value = withTiming(1, { duration: 200 });
|
||||
tr.value = withTiming(0, { duration: 200 });
|
||||
} else {
|
||||
op.value = withTiming(0, { duration: 200 });
|
||||
tr.value = withTiming(10, { duration: 200 });
|
||||
}
|
||||
}, [showControls, isBuffering]);
|
||||
|
||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
item,
|
||||
@@ -284,7 +322,7 @@ export const Controls: React.FC<Props> = ({
|
||||
toggleControls();
|
||||
}}
|
||||
>
|
||||
<View
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
@@ -292,11 +330,11 @@ export const Controls: React.FC<Props> = ({
|
||||
left: 0,
|
||||
width: windowDimensions.width + 100,
|
||||
height: windowDimensions.height + 100,
|
||||
opacity: showControls ? 1 : 0,
|
||||
},
|
||||
animatedStyles,
|
||||
]}
|
||||
className={`bg-black/50 z-0`}
|
||||
></View>
|
||||
></Animated.View>
|
||||
</Pressable>
|
||||
|
||||
<View
|
||||
@@ -315,14 +353,14 @@ export const Controls: React.FC<Props> = ({
|
||||
<Loader />
|
||||
</View>
|
||||
|
||||
<View
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: insets.top,
|
||||
right: insets.right,
|
||||
opacity: showControls ? 1 : 0,
|
||||
},
|
||||
animatedTopStyles,
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-row items-center space-x-2 z-10 p-4`}
|
||||
@@ -345,9 +383,9 @@ export const Controls: React.FC<Props> = ({
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<View
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
@@ -355,8 +393,8 @@ export const Controls: React.FC<Props> = ({
|
||||
maxHeight: windowDimensions.height,
|
||||
left: insets.left,
|
||||
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
|
||||
opacity: showControls ? 1 : 0,
|
||||
},
|
||||
animatedBottomStyles,
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-col p-4 `}
|
||||
@@ -491,7 +529,7 @@ export const Controls: React.FC<Props> = ({
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
42
plugins/withAndroidMainActivityAttributes.js
Normal file
42
plugins/withAndroidMainActivityAttributes.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const { withAndroidManifest } = require("@expo/config-plugins");
|
||||
|
||||
function addAttributesToMainActivity(androidManifest, attributes) {
|
||||
const { manifest } = androidManifest;
|
||||
|
||||
if (!Array.isArray(manifest["application"])) {
|
||||
console.warn("withAndroidMainActivityAttributes: No application array in manifest?");
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
const application = manifest["application"].find(
|
||||
(item) => item.$["android:name"] === ".MainApplication"
|
||||
);
|
||||
if (!application) {
|
||||
console.warn("withAndroidMainActivityAttributes: No .MainApplication?");
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
if (!Array.isArray(application["activity"])) {
|
||||
console.warn("withAndroidMainActivityAttributes: No activity array in .MainApplication?");
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
const activity = application["activity"].find(
|
||||
(item) => item.$["android:name"] === ".MainActivity"
|
||||
);
|
||||
if (!activity) {
|
||||
console.warn("withAndroidMainActivityAttributes: No .MainActivity?");
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
activity.$ = { ...activity.$, ...attributes };
|
||||
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
module.exports = function withAndroidMainActivityAttributes(config, attributes) {
|
||||
return withAndroidManifest(config, (config) => {
|
||||
config.modResults = addAttributesToMainActivity(config.modResults, attributes);
|
||||
return config;
|
||||
});
|
||||
};
|
||||
20
plugins/withExpandedController.js
Normal file
20
plugins/withExpandedController.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const { withAppDelegate } = require("@expo/config-plugins");
|
||||
|
||||
const withExpandedController = (config) => {
|
||||
return withAppDelegate(config, async (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
// Looking for the initialProps string inside didFinishLaunchingWithOptions,
|
||||
// and injecting expanded controller config.
|
||||
// Should be updated once there is an expo config option - see https://github.com/react-native-google-cast/react-native-google-cast/discussions/537
|
||||
const injectionIndex = contents.indexOf("self.initialProps = @{};");
|
||||
config.modResults.contents =
|
||||
contents.substring(0, injectionIndex) +
|
||||
`\n [GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true; \n` +
|
||||
contents.substring(injectionIndex);
|
||||
|
||||
return config;
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = withExpandedController;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
@@ -212,20 +213,35 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
if (axios.isAxiosError(error)) {
|
||||
switch (error.response?.status) {
|
||||
case 401:
|
||||
writeToLog("ERROR", "Invalid username or password");
|
||||
throw new Error("Invalid username or password");
|
||||
case 403:
|
||||
writeToLog("ERROR", "User does not have permission to log in");
|
||||
throw new Error("User does not have permission to log in");
|
||||
case 408:
|
||||
writeToLog(
|
||||
"WARN",
|
||||
"Server is taking too long to respond, try again later"
|
||||
);
|
||||
throw new Error(
|
||||
"Server is taking too long to respond, try again later"
|
||||
);
|
||||
case 429:
|
||||
writeToLog(
|
||||
"WARN",
|
||||
"Server received too many requests, try again later"
|
||||
);
|
||||
throw new Error(
|
||||
"Server received too many requests, try again later"
|
||||
);
|
||||
case 500:
|
||||
writeToLog("ERROR", "There is a server error");
|
||||
throw new Error("There is a server error");
|
||||
default:
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"An unexpected error occurred. Did you enter the server URL correctly?"
|
||||
);
|
||||
throw new Error(
|
||||
"An unexpected error occurred. Did you enter the server URL correctly?"
|
||||
);
|
||||
@@ -312,6 +328,9 @@ function useProtectedRoute(user: UserDto | null, loading = false) {
|
||||
if (loading) return;
|
||||
|
||||
const inAuthGroup = segments[0] === "(auth)";
|
||||
const inLogs = segments[0] === "logs";
|
||||
|
||||
if (inLogs) return;
|
||||
|
||||
if (!user?.Id && inAuthGroup) {
|
||||
router.replace("/login");
|
||||
|
||||
@@ -29,7 +29,7 @@ export const writeToLog = async (
|
||||
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
|
||||
logs.push(newEntry);
|
||||
|
||||
const maxLogs = 100;
|
||||
const maxLogs = 1000;
|
||||
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
|
||||
|
||||
await AsyncStorage.setItem("logs", JSON.stringify(recentLogs));
|
||||
|
||||
@@ -16,8 +16,7 @@ export const runtimeTicksToMinutes = (
|
||||
const hours = Math.floor(ticks / ticksPerHour);
|
||||
const minutes = Math.floor((ticks % ticksPerHour) / ticksPerMinute);
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
else return `${minutes}m`;
|
||||
return `${hours}h ${minutes}m`;
|
||||
};
|
||||
|
||||
export const runtimeTicksToSeconds = (
|
||||
|
||||
Reference in New Issue
Block a user