Compare commits

..

1 Commits

Author SHA1 Message Date
Fredrik Burmester
6752888bb0 wip 2024-10-11 16:42:13 +02:00
21 changed files with 384 additions and 189 deletions

View File

@@ -66,6 +66,13 @@
} }
} }
], ],
[
"./plugins/withAndroidMainActivityAttributes",
{
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
}
],
["./plugins/withExpandedController.js"],
[ [
"expo-build-properties", "expo-build-properties",
{ {

View File

@@ -2,7 +2,7 @@ import { Chromecast } from "@/components/Chromecast";
import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useDownload } from "@/providers/DownloadProvider"; 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 { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
@@ -45,6 +45,18 @@ export default function IndexLayout() {
name="settings" name="settings"
options={{ options={{
title: "Settings", 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]) => ( {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (

View File

@@ -25,10 +25,11 @@ import {
import NetInfo from "@react-native-community/netinfo"; import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query"; import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
RefreshControl, RefreshControl,
ScrollView, ScrollView,
TouchableOpacity, TouchableOpacity,

View File

@@ -20,12 +20,6 @@ export default function settings() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => readFromLog(),
refetchInterval: 1000,
});
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const openQuickConnectAuthCodeInput = () => { const openQuickConnectAuthCodeInput = () => {
@@ -129,30 +123,6 @@ export default function settings() {
</Button> </Button>
</View> </View>
</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> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -1,8 +1,12 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 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 * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai"; 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 { FlatList, useWindowDimensions, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -12,7 +16,6 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
genreFilterAtom, genreFilterAtom,
@@ -40,6 +43,7 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useOrientation } from "@/hooks/useOrientation";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter); const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);

View File

@@ -7,13 +7,25 @@ import {
PlaybackType, PlaybackType,
usePlaySettings, usePlaySettings,
} from "@/providers/PlaySettingsProvider"; } 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 { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk"; import { Api } from "@jellyfin/sdk";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import * as NavigationBar from "expo-navigation-bar";
import { useFocusEffect } from "expo-router"; import { useFocusEffect } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react"; import React, {
import { Pressable, StatusBar, useWindowDimensions, View } from "react-native"; useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video"; import Video, { OnProgressData, VideoRef } from "react-native-video";
@@ -25,10 +37,7 @@ export default function page() {
const videoSource = useVideoSource(playSettings, api, playUrl); const videoSource = useVideoSource(playSettings, api, playUrl);
const firstTime = useRef(true); const firstTime = useRef(true);
const dimensions = useWindowDimensions(); const screenDimensions = Dimensions.get("screen");
useOrientation();
useOrientationSettings();
useAndroidNavigationBar();
const [showControls, setShowControls] = useState(true); const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
@@ -68,6 +77,10 @@ export default function page() {
}, [play, stop]) }, [play, stop])
); );
const { orientation } = useOrientation();
useOrientationSettings();
useAndroidNavigationBar();
const onProgress = useCallback(async (data: OnProgressData) => { const onProgress = useCallback(async (data: OnProgressData) => {
if (isSeeking.value === true) return; if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime); progress.value = secondsToTicks(data.currentTime);
@@ -81,8 +94,8 @@ export default function page() {
return ( return (
<View <View
style={{ style={{
width: dimensions.width, width: screenDimensions.width,
height: dimensions.height, height: screenDimensions.height,
position: "relative", position: "relative",
}} }}
className="flex flex-col items-center justify-center" className="flex flex-col items-center justify-center"

View File

@@ -18,7 +18,7 @@ import * as Haptics from "expo-haptics";
import { useFocusEffect } from "expo-router"; import { useFocusEffect } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react"; 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 { useSharedValue } from "react-native-reanimated";
import Video, { import Video, {
OnProgressData, OnProgressData,
@@ -34,7 +34,8 @@ export default function page() {
const poster = usePoster(playSettings, api); const poster = usePoster(playSettings, api);
const videoSource = useVideoSource(playSettings, api, poster, playUrl); const videoSource = useVideoSource(playSettings, api, poster, playUrl);
const firstTime = useRef(true); const firstTime = useRef(true);
const dimensions = useWindowDimensions();
const screenDimensions = Dimensions.get("screen");
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, setShowControls] = useState(true); const [showControls, setShowControls] = useState(true);
@@ -169,7 +170,7 @@ export default function page() {
}, [play, stop]) }, [play, stop])
); );
useOrientation(); const { orientation } = useOrientation();
useOrientationSettings(); useOrientationSettings();
useAndroidNavigationBar(); useAndroidNavigationBar();
@@ -217,8 +218,8 @@ export default function page() {
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
width: dimensions.width, width: screenDimensions.width,
height: dimensions.height, height: screenDimensions.height,
position: "relative", position: "relative",
}} }}
> >
@@ -231,18 +232,15 @@ export default function page() {
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
width: dimensions.width, width: screenDimensions.width,
height: dimensions.height, height: screenDimensions.height,
zIndex: 0, zIndex: 0,
}} }}
> >
<Video <Video
ref={videoRef} ref={videoRef}
source={videoSource} source={videoSource}
style={{ style={{ width: "100%", height: "100%" }}
width: dimensions.width,
height: dimensions.height,
}}
resizeMode={ignoreSafeAreas ? "cover" : "contain"} resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress} onProgress={onProgress}
onError={() => {}} onError={() => {}}

View File

@@ -345,6 +345,13 @@ function Layout() {
animation: "fade", animation: "fade",
}} }}
/> />
<Stack.Screen
name="logs"
options={{
presentation: "modal",
title: "Logs",
}}
/>
<Stack.Screen <Stack.Screen
name="(auth)/play-offline-video" name="(auth)/play-offline-video"
options={{ options={{

View File

@@ -2,11 +2,12 @@ import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api"; import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { import {
@@ -27,6 +28,7 @@ const Login: React.FC = () => {
const { setServer, login, removeServer, initiateQuickConnect } = const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin(); useJellyfin();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const router = useRouter();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { const {
@@ -72,7 +74,17 @@ const Login: React.FC = () => {
try { try {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
if (result.success) { 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) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
@@ -105,37 +117,72 @@ const Login: React.FC = () => {
async function checkUrl(url: string) { async function checkUrl(url: string) {
url = url.endsWith("/") ? url.slice(0, -1) : url; url = url.endsWith("/") ? url.slice(0, -1) : url;
setLoadingServerCheck(true); setLoadingServerCheck(true);
writeToLog("INFO", `Checking URL: ${url}`);
const protocols = ["https://", "http://"]; const timeout = 5000; // 5 seconds timeout
const timeout = 2000; // 2 seconds timeout for long 404 responses const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try { try {
for (const protocol of protocols) { // Try HTTPS first
const controller = new AbortController(); const httpsUrl = `https://${url}/System/Info/Public`;
const timeoutId = setTimeout(() => controller.abort(), timeout); try {
const response = await fetch(httpsUrl, {
try { mode: "cors",
const response = await fetch(`${protocol}${url}/System/Info/Public`, { signal: controller.signal,
mode: "cors", });
signal: controller.signal, if (response.ok) {
}); const data = (await response.json()) as PublicSystemInfo;
clearTimeout(timeoutId); setServerName(data.ServerName || "");
if (response.ok) { return `https://${url}`;
const data = (await response.json()) as PublicSystemInfo; } else {
setServerName(data.ServerName || ""); writeToLog(
return `${protocol}${url}`; "WARN",
} `HTTPS connection failed with status: ${response.status}`
} 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);
}
} }
} 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; return undefined;
} finally { } finally {
clearTimeout(timeoutId);
setLoadingServerCheck(false); setLoadingServerCheck(false);
} }
} }
@@ -197,6 +244,16 @@ const Login: React.FC = () => {
style={{ flex: 1, height: "100%" }} style={{ flex: 1, height: "100%" }}
> >
<View className="flex flex-col w-full h-full relative items-center justify-center"> <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="px-4 -mt-20">
<View className="mb-4"> <View className="mb-4">
<Text className="text-3xl font-bold mb-1"> <Text className="text-3xl font-bold mb-1">

58
app/logs.tsx Normal file
View 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;

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,9 +1,8 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import React, { useCallback, useEffect } from "react"; import React, { useEffect } from "react";
import { Platform, TouchableOpacity, ViewProps } from "react-native"; import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, { import GoogleCast, {
CastButton,
CastContext, CastContext,
useCastDevice, useCastDevice,
useDevices, useDevices,
@@ -40,32 +39,18 @@ export const Chromecast: React.FC<Props> = ({
})(); })();
}, [client, devices, castDevice, sessionManager, discoveryManager]); }, [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") if (background === "transparent")
return ( return (
<> <TouchableOpacity
<TouchableOpacity onPress={() => {
onPress={() => { if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); else CastContext.showCastDialog();
else CastContext.showCastDialog(); }}
}} className="rounded-full h-10 w-10 flex items-center justify-center b"
className="rounded-full h-10 w-10 flex items-center justify-center b" {...props}
{...props} >
> <Feather name="cast" size={22} color={"white"} />
<Feather name="cast" size={22} color={"white"} /> </TouchableOpacity>
</TouchableOpacity>
<AndroidCastButton />
</>
); );
if (Platform.OS === "android") if (Platform.OS === "android")
@@ -97,7 +82,6 @@ export const Chromecast: React.FC<Props> = ({
> >
<Feather name="cast" size={22} color={"white"} /> <Feather name="cast" size={22} color={"white"} />
</BlurView> </BlurView>
<AndroidCastButton />
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -246,7 +246,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View> </View>
)} )}
<PlayButton className="grow" /> <PlayButton item={item} url={playUrl} className="grow" />
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (

View File

@@ -1,4 +1,4 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; 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 { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom, useAtomValue } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Alert, Linking, TouchableOpacity, View } from "react-native"; import { Linking, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton,
PlayServicesState, PlayServicesState,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
@@ -29,31 +28,32 @@ import { Button } from "./Button";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useSettings } from "@/utils/atoms/settings"; 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 ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15; const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ ...props }) => { export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const { playSettings, playUrl: url } = usePlaySettings();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const [colorAtom] = useAtom(itemThemeColorAtom); const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom); const [api] = useAtom(apiAtom);
const user = useAtomValue(userAtom);
const router = useRouter(); 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 startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0); const targetWidth = useSharedValue(0);
const endColor = useSharedValue(colorAtom); const endColor = useSharedValue(memoizedColor);
const startColor = useSharedValue(colorAtom); const startColor = useSharedValue(memoizedColor);
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings(); const [settings] = useSettings();
@@ -62,11 +62,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
return !url?.includes("m3u8"); return !url?.includes("m3u8");
}, [url]); }, [url]);
const item = useMemo(() => { const onPress = async () => {
return playSettings?.item;
}, [playSettings?.item]);
const onPress = useCallback(async () => {
if (!url || !item) { if (!url || !item) {
console.warn( console.warn(
"No URL or item provided to PlayButton", "No URL or item provided to PlayButton",
@@ -102,7 +98,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
await CastContext.getPlayServicesState().then(async (state) => { await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS) if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state); CastContext.showPlayServicesErrorDialog(state);
else { else {
@@ -112,34 +108,10 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
CastContext.showExpandedControls(); CastContext.showExpandedControls();
return; 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 client
.loadMedia({ .loadMedia({
mediaInfo: { mediaInfo: {
contentUrl: data?.url, contentUrl: url,
contentType: "video/mp4", contentType: "video/mp4",
metadata: metadata:
item.Type === "Episode" 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(() => { const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0; if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
const userData = item.UserData; const userData = memoizedItem.UserData;
if (userData && userData.PlaybackPositionTicks) { if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0 return userData.PlaybackPositionTicks > 0
? Math.max( ? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, (userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH MIN_PLAYBACK_WIDTH
) )
: 0; : 0;
} }
return 0; return 0;
}, [item]); }, [memoizedItem]);
useAnimatedReaction( useAnimatedReaction(
() => derivedTargetWidth.value, () => derivedTargetWidth.value,
@@ -253,7 +214,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
); );
useAnimatedReaction( useAnimatedReaction(
() => colorAtom, () => memoizedColor,
(newColor) => { (newColor) => {
endColor.value = newColor; endColor.value = newColor;
colorChangeProgress.value = 0; colorChangeProgress.value = 0;
@@ -262,19 +223,19 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
easing: Easing.bezier(0.9, 0, 0.31, 0.99), easing: Easing.bezier(0.9, 0, 0.31, 0.99),
}); });
}, },
[colorAtom] [memoizedColor]
); );
useEffect(() => { useEffect(() => {
const timeout_2 = setTimeout(() => { const timeout_2 = setTimeout(() => {
startColor.value = colorAtom; startColor.value = memoizedColor;
startWidth.value = targetWidth.value; startWidth.value = targetWidth.value;
}, ANIMATION_DURATION); }, ANIMATION_DURATION);
return () => { return () => {
clearTimeout(timeout_2); clearTimeout(timeout_2);
}; };
}, [colorAtom, item]); }, [memoizedColor, memoizedItem]);
/** /**
* ANIMATED STYLES * ANIMATED STYLES
@@ -357,7 +318,6 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
{client && ( {client && (
<Animated.Text style={animatedTextStyle}> <Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} /> <Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text> </Animated.Text>
)} )}
{!client && settings?.openInVLC && ( {!client && settings?.openInVLC && (

View File

@@ -11,9 +11,12 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { TouchableOpacityProps, View } from "react-native"; import { TouchableOpacityProps, View } from "react-native";
import { getColors } from "react-native-image-colors";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { useImageColors } from "@/hooks/useImageColors";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
library: BaseItemDto; library: BaseItemDto;
@@ -50,6 +53,10 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
[library] [library]
); );
// If we want to use image colors for library cards
// const [color] = useAtom(itemThemeColorAtom)
// useImageColors({ url });
const { data: itemsCount } = useQuery({ const { data: itemsCount } = useQuery({
queryKey: ["library-count", library.Id], queryKey: ["library-count", library.Id],
queryFn: async () => { queryFn: async () => {
@@ -61,7 +68,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
}); });
return response.data.TotalRecordCount; return response.data.TotalRecordCount;
}, },
staleTime: 1000 * 60 * 60,
}); });
if (!url) return null; if (!url) return null;

View File

@@ -71,6 +71,44 @@ export const Controls: React.FC<Props> = ({
const windowDimensions = Dimensions.get("window"); 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 { previousItem, nextItem } = useAdjacentItems({ item });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
item, item,
@@ -284,7 +322,7 @@ export const Controls: React.FC<Props> = ({
toggleControls(); toggleControls();
}} }}
> >
<View <Animated.View
style={[ style={[
{ {
position: "absolute", position: "absolute",
@@ -292,11 +330,11 @@ export const Controls: React.FC<Props> = ({
left: 0, left: 0,
width: windowDimensions.width + 100, width: windowDimensions.width + 100,
height: windowDimensions.height + 100, height: windowDimensions.height + 100,
opacity: showControls ? 1 : 0,
}, },
animatedStyles,
]} ]}
className={`bg-black/50 z-0`} className={`bg-black/50 z-0`}
></View> ></Animated.View>
</Pressable> </Pressable>
<View <View
@@ -315,14 +353,14 @@ export const Controls: React.FC<Props> = ({
<Loader /> <Loader />
</View> </View>
<View <Animated.View
style={[ style={[
{ {
position: "absolute", position: "absolute",
top: insets.top, top: insets.top,
right: insets.right, right: insets.right,
opacity: showControls ? 1 : 0,
}, },
animatedTopStyles,
]} ]}
pointerEvents={showControls ? "auto" : "none"} pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4`} 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" /> <Ionicons name="close" size={24} color="white" />
</TouchableOpacity> </TouchableOpacity>
</View> </Animated.View>
<View <Animated.View
style={[ style={[
{ {
position: "absolute", position: "absolute",
@@ -355,8 +393,8 @@ export const Controls: React.FC<Props> = ({
maxHeight: windowDimensions.height, maxHeight: windowDimensions.height,
left: insets.left, left: insets.left,
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom, bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
opacity: showControls ? 1 : 0,
}, },
animatedBottomStyles,
]} ]}
pointerEvents={showControls ? "auto" : "none"} pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-col p-4 `} className={`flex flex-col p-4 `}
@@ -491,7 +529,7 @@ export const Controls: React.FC<Props> = ({
</View> </View>
</View> </View>
</View> </View>
</View> </Animated.View>
</View> </View>
); );
}; };

View 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;
});
};

View 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;

View File

@@ -1,4 +1,5 @@
import { useInterval } from "@/hooks/useInterval"; import { useInterval } from "@/hooks/useInterval";
import { writeToLog } from "@/utils/log";
import { Api, Jellyfin } from "@jellyfin/sdk"; import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api"; import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
@@ -212,20 +213,35 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
switch (error.response?.status) { switch (error.response?.status) {
case 401: case 401:
writeToLog("ERROR", "Invalid username or password");
throw new Error("Invalid username or password"); throw new Error("Invalid username or password");
case 403: case 403:
writeToLog("ERROR", "User does not have permission to log in");
throw new Error("User does not have permission to log in"); throw new Error("User does not have permission to log in");
case 408: case 408:
writeToLog(
"WARN",
"Server is taking too long to respond, try again later"
);
throw new Error( throw new Error(
"Server is taking too long to respond, try again later" "Server is taking too long to respond, try again later"
); );
case 429: case 429:
writeToLog(
"WARN",
"Server received too many requests, try again later"
);
throw new Error( throw new Error(
"Server received too many requests, try again later" "Server received too many requests, try again later"
); );
case 500: case 500:
writeToLog("ERROR", "There is a server error");
throw new Error("There is a server error"); throw new Error("There is a server error");
default: default:
writeToLog(
"ERROR",
"An unexpected error occurred. Did you enter the server URL correctly?"
);
throw new Error( throw new Error(
"An unexpected error occurred. Did you enter the server URL correctly?" "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; if (loading) return;
const inAuthGroup = segments[0] === "(auth)"; const inAuthGroup = segments[0] === "(auth)";
const inLogs = segments[0] === "logs";
if (inLogs) return;
if (!user?.Id && inAuthGroup) { if (!user?.Id && inAuthGroup) {
router.replace("/login"); router.replace("/login");

View File

@@ -29,7 +29,7 @@ export const writeToLog = async (
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : []; const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
logs.push(newEntry); logs.push(newEntry);
const maxLogs = 100; const maxLogs = 1000;
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0)); const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
await AsyncStorage.setItem("logs", JSON.stringify(recentLogs)); await AsyncStorage.setItem("logs", JSON.stringify(recentLogs));

View File

@@ -16,8 +16,7 @@ export const runtimeTicksToMinutes = (
const hours = Math.floor(ticks / ticksPerHour); const hours = Math.floor(ticks / ticksPerHour);
const minutes = Math.floor((ticks % ticksPerHour) / ticksPerMinute); const minutes = Math.floor((ticks % ticksPerHour) / ticksPerMinute);
if (hours > 0) return `${hours}h ${minutes}m`; return `${hours}h ${minutes}m`;
else return `${minutes}m`;
}; };
export const runtimeTicksToSeconds = ( export const runtimeTicksToSeconds = (