Compare commits

..

1 Commits

Author SHA1 Message Date
Fredrik Burmester
6c051f6f61 first commit 2024-10-09 07:49:22 +02:00
23 changed files with 429 additions and 289 deletions

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.18.0", "version": "0.17.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -33,7 +33,7 @@
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 46, "versionCode": 43,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png" "foregroundImage": "./assets/images/adaptive_icon.png"
}, },

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, Ionicons } from "@expo/vector-icons"; import { Feather } 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";
@@ -42,21 +42,15 @@ export default function IndexLayout() {
}} }}
/> />
<Stack.Screen <Stack.Screen
name="settings" name="settings/index"
options={{ options={{
title: "Settings", title: "Settings",
headerRight: () => ( }}
<View className=""> />
<Ionicons <Stack.Screen
name="file-tray-full-outline" name="settings/audio-language"
size={22} options={{
color="white" title: "Audio Language",
onPress={() => {
router.push("/logs");
}}
/>
</View>
),
}} }}
/> />
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (

View File

@@ -0,0 +1,61 @@
import { ScrollView, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { LANGUAGES } from "@/constants/Languages";
import { ListItem } from "@/components/list/ListItem";
import { ListSection } from "@/components/list/ListSection";
import { TAB_HEIGHT } from "@/constants/Values";
import { DefaultLanguageOption, useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { Colors } from "@/constants/Colors";
interface Props extends ViewProps {}
export default function page() {
const insets = useSafeAreaInsets();
const [settings, updateSettings] = useSettings();
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="py-4 px-4">
<ListSection title="LANGUAGES">
{LANGUAGES.sort(sortByName).map((l) => (
<ListItem
key={l.value}
title={l.label}
onPress={() => {
updateSettings({
...settings,
defaultAudioLanguage: l,
});
}}
iconAfter={
settings?.defaultAudioLanguage?.value === l.value ? (
<Ionicons name="checkmark" size={24} color={Colors.primary} />
) : null
}
/>
))}
</ListSection>
</View>
</ScrollView>
);
}
const sortByName = (a: DefaultLanguageOption, b: DefaultLanguageOption) => {
if (a.label < b.label) {
return -1;
}
if (a.label > b.label) {
return 1;
}
return 0;
};

View File

@@ -1,13 +1,18 @@
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem"; import { ListInputItem } from "@/components/list/ListInputItem";
import { ListItem } from "@/components/list/ListItem";
import { ListSection } from "@/components/list/ListSection";
import { SettingToggles } from "@/components/settings/SettingToggles"; import { SettingToggles } from "@/components/settings/SettingToggles";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { clearLogs, readFromLog } from "@/utils/log"; import { clearLogs, readFromLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { Alert, ScrollView, View } from "react-native"; import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -16,10 +21,17 @@ import { toast } from "sonner-native";
export default function settings() { export default function settings() {
const { logout } = useJellyfin(); const { logout } = useJellyfin();
const { deleteAllFiles } = useDownload(); const { deleteAllFiles } = useDownload();
const [settings, updateSettings] = useSettings();
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 = () => {
@@ -51,6 +63,8 @@ export default function settings() {
); );
}; };
const router = useRouter();
return ( return (
<ScrollView <ScrollView
contentContainerStyle={{ contentContainerStyle={{
@@ -67,15 +81,46 @@ export default function settings() {
> >
registerBackgroundFetchAsync registerBackgroundFetchAsync
</Button> */} </Button> */}
<View> <ListSection title="USER INFO">
<Text className="font-bold text-lg mb-2">User Info</Text> <ListItem title="User" text={user?.Name} />
<ListItem title="Server" text={api?.basePath} />
<ListItem title="Token" text={api?.accessToken} />
</ListSection>
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 "> <ListSection title="MEDIA">
<ListItem title="User" subTitle={user?.Name} /> <ListItem
<ListItem title="Server" subTitle={api?.basePath} /> title="Audio language"
<ListItem title="Token" subTitle={api?.accessToken} /> iconAfter={
</View> <Ionicons name="chevron-forward" size={20} color="white" />
</View> }
onPress={() => router.push("/settings/audio-language")}
/>
<ListItem
title="Subtitle language"
iconAfter={
<Ionicons name="chevron-forward" size={20} color="white" />
}
onPress={() => router.push("/settings/subtitle-language")}
/>
<ListInputItem
textInputProps={{
placeholder: "30",
clearButtonMode: "never",
returnKeyType: "done",
}}
defaultValue={(settings?.forwardSkipTime || "").toString()}
title={"Forward skip"}
onChange={(val) => {
// 1. validate positive number
// 2. save settings
if (val.length === 0) return;
if (val.match(/^\d+$/)) {
} else {
toast.error("Invalid number");
}
}}
/>
</ListSection>
<View> <View>
<Text className="font-bold text-lg mb-2">Quick connect</Text> <Text className="font-bold text-lg mb-2">Quick connect</Text>
@@ -123,6 +168,30 @@ 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

@@ -0,0 +1,61 @@
import { ScrollView, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { LANGUAGES } from "@/constants/Languages";
import { ListItem } from "@/components/list/ListItem";
import { ListSection } from "@/components/list/ListSection";
import { TAB_HEIGHT } from "@/constants/Values";
import { DefaultLanguageOption, useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { Colors } from "@/constants/Colors";
interface Props extends ViewProps {}
export default function page() {
const insets = useSafeAreaInsets();
const [settings, updateSettings] = useSettings();
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="py-4 px-4">
<ListSection title="LANGUAGES">
{LANGUAGES.sort(sortByName).map((l) => (
<ListItem
key={l.value}
title={l.label}
onPress={() => {
updateSettings({
...settings,
defaultSubtitleLanguage: l,
});
}}
iconAfter={
settings?.defaultSubtitleLanguage?.value === l.value ? (
<Ionicons name="checkmark" size={24} color={Colors.primary} />
) : null
}
/>
))}
</ListSection>
</View>
</ScrollView>
);
}
const sortByName = (a: DefaultLanguageOption, b: DefaultLanguageOption) => {
if (a.label < b.label) {
return -1;
}
if (a.label > b.label) {
return 1;
}
return 0;
};

View File

@@ -32,6 +32,7 @@ import {
tagsFilterAtom, tagsFilterAtom,
yearFilterAtom, yearFilterAtom,
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import { orientationAtom } from "@/utils/atoms/orientation";
import { import {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
@@ -43,7 +44,6 @@ 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);
@@ -60,13 +60,12 @@ const Page = () => {
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, _setSortBy] = useAtom(sortByAtom); const [sortBy, _setSortBy] = useAtom(sortByAtom);
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom); const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [orientation] = useAtom(orientationAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom); const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [sortOrderPreference, setOderByPreference] = useAtom( const [sortOrderPreference, setOderByPreference] = useAtom(
sortOrderPreferenceAtom sortOrderPreferenceAtom
); );
const { orientation } = useOrientation();
useEffect(() => { useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference); const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) { if (sop) {
@@ -107,12 +106,11 @@ const Page = () => {
[libraryId, sortOrderPreference] [libraryId, sortOrderPreference]
); );
const nrOfCols = useMemo(() => { const getNumberOfColumns = useCallback(() => {
if (screenWidth < 300) return 2; if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
if (screenWidth < 500) return 3; if (screenWidth < 600) return 5;
if (screenWidth < 800) return 5; if (screenWidth < 960) return 6;
if (screenWidth < 1000) return 6; if (screenWidth < 1280) return 7;
if (screenWidth < 1500) return 7;
return 6; return 6;
}, [screenWidth, orientation]); }, [screenWidth, orientation]);
@@ -221,7 +219,7 @@ const Page = () => {
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => ( ({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter <MemoizedTouchableItemRouter
key={item.Id} key={item.Id}
style={{ style={{
width: "100%", width: "100%",
@@ -232,10 +230,10 @@ const Page = () => {
<View <View
style={{ style={{
alignSelf: alignSelf:
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP orientation === ScreenOrientation.Orientation.PORTRAIT_UP
? index % nrOfCols === 0 ? index % 3 === 0
? "flex-end" ? "flex-end"
: (index + 1) % nrOfCols === 0 : (index + 1) % 3 === 0
? "flex-start" ? "flex-start"
: "center" : "center"
: "center", : "center",
@@ -246,7 +244,7 @@ const Page = () => {
<ItemPoster item={item} /> <ItemPoster item={item} />
<ItemCardText item={item} /> <ItemCardText item={item} />
</View> </View>
</TouchableItemRouter> </MemoizedTouchableItemRouter>
), ),
[orientation] [orientation]
); );
@@ -431,7 +429,6 @@ const Page = () => {
return ( return (
<FlashList <FlashList
key={orientation}
ListEmptyComponent={ ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full"> <View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text> <Text className="font-bold text-xl text-neutral-500">No results</Text>
@@ -440,10 +437,10 @@ const Page = () => {
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
data={flatData} data={flatData}
renderItem={renderItem} renderItem={renderItem}
extraData={[orientation, nrOfCols]} extraData={orientation}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
estimatedItemSize={244} estimatedItemSize={244}
numColumns={nrOfCols} numColumns={getNumberOfColumns()}
onEndReached={() => { onEndReached={() => {
if (hasNextPage) { if (hasNextPage) {
fetchNextPage(); fetchNextPage();

View File

@@ -22,9 +22,11 @@ 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,
SelectedTrackType,
VideoRef, VideoRef,
SelectedTrack,
SelectedTrackType,
} from "react-native-video"; } from "react-native-video";
import { WithDefault } from "react-native/Libraries/Types/CodegenTypes";
export default function page() { export default function page() {
const { playSettings, playUrl, playSessionId } = usePlaySettings(); const { playSettings, playUrl, playSessionId } = usePlaySettings();
@@ -214,28 +216,18 @@ export default function page() {
return ( return (
<View <View
style={{ style={{
flex: 1,
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
width: screenDimensions.width, width: screenDimensions.width,
height: screenDimensions.height, height: screenDimensions.height,
position: "relative", position: "relative",
}} }}
className="flex flex-col items-center justify-center"
> >
<StatusBar hidden /> <StatusBar hidden />
<Pressable <Pressable
onPress={() => { onPress={() => {
setShowControls(!showControls); setShowControls(!showControls);
}} }}
style={{ className="absolute z-0 h-full w-full"
position: "absolute",
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
zIndex: 0,
}}
> >
<Video <Video
ref={videoRef} ref={videoRef}

View File

@@ -345,13 +345,6 @@ 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,12 +2,11 @@ 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, useRouter } from "expo-router"; import { useLocalSearchParams } 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 {
@@ -28,7 +27,6 @@ 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 {
@@ -74,17 +72,7 @@ const Login: React.FC = () => {
try { try {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
if (result.success) { if (result.success) {
try { await login(credentials.username, credentials.password);
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) {
@@ -117,72 +105,37 @@ 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 timeout = 5000; // 5 seconds timeout const protocols = ["https://", "http://"];
const controller = new AbortController(); const timeout = 2000; // 2 seconds timeout for long 404 responses
const timeoutId = setTimeout(() => controller.abort(), timeout);
try { try {
// Try HTTPS first for (const protocol of protocols) {
const httpsUrl = `https://${url}/System/Info/Public`; const controller = new AbortController();
try { const timeoutId = setTimeout(() => controller.abort(), timeout);
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 try {
const httpUrl = `http://${url}/System/Info/Public`; const response = await fetch(`${protocol}${url}/System/Info/Public`, {
try { mode: "cors",
const response = await fetch(httpUrl, { signal: controller.signal,
mode: "cors", });
signal: controller.signal, clearTimeout(timeoutId);
}); if (response.ok) {
writeToLog("INFO", `HTTP response status: ${response.status}`); const data = (await response.json()) as PublicSystemInfo;
if (response.ok) { setServerName(data.ServerName || "");
const data = (await response.json()) as PublicSystemInfo; return `${protocol}${url}`;
setServerName(data.ServerName || ""); }
return `http://${url}`; } catch (e) {
} else { const error = e as Error;
writeToLog( if (error.name === "AbortError") {
"WARN", console.log(`Request to ${protocol}${url} timed out`);
`HTTP connection failed with status: ${response.status}` } else {
); console.log(`Error checking ${protocol}${url}:`, error);
}
} }
} 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);
} }
} }
@@ -244,16 +197,6 @@ 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">

View File

@@ -1,58 +0,0 @@
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

@@ -26,7 +26,7 @@ import { useFocusEffect, 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, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, View } from "react-native"; import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast"; import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
@@ -59,11 +59,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
}); });
if (!mediaSource) {
Alert.alert("Error", "No media source found for this item.");
navigation.goBack();
}
}, [item, settings]) }, [item, settings])
); );

View File

@@ -1,35 +0,0 @@
import { PropsWithChildren, ReactNode } from "react";
import { View, ViewProps } from "react-native";
import { Text } from "./common/Text";
interface Props extends ViewProps {
title?: string | null | undefined;
subTitle?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
}
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
title,
subTitle,
iconAfter,
children,
...props
}) => {
return (
<View
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
{...props}
>
<View className="flex flex-col overflow-visible">
<Text className="font-bold ">{title}</Text>
{subTitle && (
<Text uiTextView selectable className="text-xs">
{subTitle}
</Text>
)}
</View>
{iconAfter}
</View>
);
};

View File

@@ -0,0 +1,22 @@
import { ScrollView, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TAB_HEIGHT } from "@/constants/Values";
interface Props extends ViewProps {}
export default function page() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
></ScrollView>
);
}

View File

@@ -0,0 +1,64 @@
import { PropsWithChildren, ReactNode, useEffect, useState } from "react";
import {
Pressable,
TextInput,
TextInputProps,
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "../common/Text";
interface Props extends ViewProps {
title?: string | null | undefined;
text?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
iconBefore?: ReactNode;
textInputProps?: TextInputProps;
defaultValue?: string;
onChange: (text: string) => void;
}
export const ListInputItem: React.FC<PropsWithChildren<Props>> = ({
title,
text,
iconAfter,
iconBefore,
children,
onChange,
textInputProps,
defaultValue,
...props
}) => {
const [value, setValue] = useState<string>(defaultValue || "");
useEffect(() => {
onChange(value);
}, [value]);
return (
<View
className={`flex flex-row items-center justify-between px-4 h-12 bg-neutral-900`}
{...props}
>
{iconBefore && <View className="mr-2">{iconBefore}</View>}
<View>
<Text className="">{title}</Text>
</View>
<View className="ml-auto">
<TextInput
inputMode="numeric"
keyboardType="decimal-pad"
style={{ color: "white" }}
value={value}
onChangeText={setValue}
className=""
{...textInputProps}
/>
</View>
{iconAfter && <View className="ml-2">{iconAfter}</View>}
</View>
);
};

View File

@@ -0,0 +1,44 @@
import { PropsWithChildren, ReactNode, useState } from "react";
import {
Pressable,
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "../common/Text";
interface Props extends TouchableOpacityProps {
title?: string | null | undefined;
text?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
iconBefore?: ReactNode;
}
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
title,
text,
iconAfter,
iconBefore,
children,
...props
}) => {
return (
<TouchableOpacity
className={`flex flex-row items-center justify-between px-4 h-12 bg-neutral-900`}
{...props}
>
{iconBefore && <View className="mr-2">{iconBefore}</View>}
<View>
<Text className="">{title}</Text>
</View>
<View className="ml-auto">
<Text selectable className="">
{text}
</Text>
</View>
{iconAfter && <View className="ml-2">{iconAfter}</View>}
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,24 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { Children, PropsWithChildren } from "react";
interface Props extends ViewProps {
title: string;
}
export const ListSection: React.FC<PropsWithChildren<Props>> = ({
children,
title,
...props
}) => {
return (
<View {...props}>
<Text className="ml-4 mb-1 text-xs text-neutral-500 uppercase">
{title}
</Text>
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800">
{children}
</View>
</View>
);
};

View File

@@ -10,6 +10,7 @@ import {
registerBackgroundFetchAsync, registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync, unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks"; } from "@/utils/background-tasks";
import { getStatistics } from "@/utils/optimize-server";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch"; import * as BackgroundFetch from "expo-background-fetch";
@@ -18,7 +19,6 @@ import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
ActivityIndicator,
Linking, Linking,
Switch, Switch,
TouchableOpacity, TouchableOpacity,
@@ -32,8 +32,6 @@ import { Input } from "../common/Input";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { MediaToggles } from "./MediaToggles"; import { MediaToggles } from "./MediaToggles";
import axios from "axios";
import { getStatistics } from "@/utils/optimize-server";
interface Props extends ViewProps {} interface Props extends ViewProps {}

View File

@@ -123,6 +123,17 @@ export const Controls: React.FC<Props> = ({
const wasPlayingRef = useRef(false); const wasPlayingRef = useRef(false);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - currentProgress);
setCurrentTime(current);
setRemainingTime(remaining);
},
[]
);
const { showSkipButton, skipIntro } = useIntroSkipper( const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id, item.Id,
currentTime, currentTime,
@@ -169,23 +180,6 @@ export const Controls: React.FC<Props> = ({
router.replace("/play-video"); router.replace("/play-video");
}, [nextItem, settings]); }, [nextItem, settings]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - currentProgress);
setCurrentTime(current);
setRemainingTime(remaining);
if (currentProgress === maxValue) {
setShowControls(true);
// Automatically play the next item if it exists
goToNextItem();
}
},
[goToNextItem]
);
useAnimatedReaction( useAnimatedReaction(
() => ({ () => ({
progress: progress.value, progress: progress.value,

View File

@@ -22,13 +22,13 @@
} }
}, },
"production": { "production": {
"channel": "0.18.0", "channel": "0.17.0",
"android": { "android": {
"image": "latest" "image": "latest"
} }
}, },
"production-apk": { "production-apk": {
"channel": "0.18.0", "channel": "0.17.0",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"

View File

@@ -1,5 +1,4 @@
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";
@@ -53,7 +52,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin( setJellyfin(
() => () =>
new Jellyfin({ new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.18.0" }, clientInfo: { name: "Streamyfin", version: "0.17.0" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
}) })
); );
@@ -87,7 +86,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return { return {
authorization: `MediaBrowser Client="Streamyfin", Device=${ authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS" Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.18.0"`, }, DeviceId="${deviceId}", Version="0.17.0"`,
}; };
}, [deviceId]); }, [deviceId]);
@@ -213,35 +212,20 @@ 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?"
); );
@@ -328,9 +312,6 @@ 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

@@ -9,7 +9,7 @@ import { Settings } from "../atoms/settings";
interface PlaySettings { interface PlaySettings {
item: BaseItemDto; item: BaseItemDto;
bitrate: (typeof BITRATES)[0]; bitrate: (typeof BITRATES)[0];
mediaSource?: MediaSourceInfo | null; mediaSource: MediaSourceInfo | undefined;
audioIndex?: number | null; audioIndex?: number | null;
subtitleIndex?: number | null; subtitleIndex?: number | null;
} }
@@ -29,9 +29,10 @@ export function getDefaultPlaySettings(
} }
// 1. Get first media source // 1. Get first media source
const mediaSource = item.MediaSources?.[0]; const mediaSource = item.MediaSources?.[0];
if (!mediaSource) throw new Error("No media source found");
// 2. Get default or preferred audio // 2. Get default or preferred audio
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex; const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
const preferedAudioIndex = mediaSource?.MediaStreams?.find( const preferedAudioIndex = mediaSource?.MediaStreams?.find(

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 = 1000; const maxLogs = 100;
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));