Compare commits

..

43 Commits

Author SHA1 Message Date
Fredrik Burmester
7c10c467f3 chore 2024-09-25 07:42:41 +02:00
Fredrik Burmester
eff12b7350 fix: pip 2024-09-25 07:42:36 +02:00
Fredrik Burmester
94b6de6066 fix: open new full screen player 2024-09-25 07:42:32 +02:00
Fredrik Burmester
e82b154032 feat: skip credits 2024-09-24 20:05:04 +02:00
Fredrik Burmester
dd2a869929 chore: refactor 2024-09-24 20:04:58 +02:00
Fredrik Burmester
cd6158e141 chore 2024-09-24 20:04:50 +02:00
Fredrik Burmester
7fd232614b fix: refactor 2024-09-24 18:16:54 +02:00
Fredrik Burmester
af0a5f54d8 fix: design 2024-09-24 18:16:45 +02:00
Fredrik Burmester
3151812325 chore 2024-09-24 18:16:38 +02:00
Fredrik Burmester
9aa0dc0a3d wip: full screen player 2024-09-24 18:16:26 +02:00
Fredrik Burmester
9fcff04c0d chore: hide button 2024-09-24 18:16:00 +02:00
Fredrik Burmester
d1b6a265a1 fix: #135 2024-09-24 18:15:52 +02:00
Fredrik Burmester
ff1decfe2c feat: select skip/rewind time + refactor video player 2024-09-22 23:05:13 +02:00
Fredrik Burmester
a023c91877 feat: add genres to movies and episodes 2024-09-20 07:13:47 +02:00
Fredrik Burmester
27acd5287f fix: smaller card 2024-09-19 22:21:01 +02:00
Fredrik Burmester
ae73dab46d fix: design improvements 2024-09-19 22:17:40 +02:00
Fredrik Burmester
11f53630b5 feat: more from this actor 2024-09-19 22:17:29 +02:00
Fredrik Burmester
b7465a94e9 chore 2024-09-19 21:23:38 +02:00
Fredrik Burmester
cc97acbd4f feat: title selectable 2024-09-19 21:23:32 +02:00
Fredrik Burmester
abf7ec7d69 feat: include size 2024-09-19 21:23:22 +02:00
Fredrik Burmester
4dc9a6a0aa fix: initial load should be true 2024-09-19 21:23:04 +02:00
Fredrik Burmester
dd05ae89c3 fix: small changes 2024-09-19 16:51:13 +02:00
Fredrik Burmester
8a60adc6b2 fix: header size 2024-09-19 15:52:37 +02:00
Fredrik Burmester
51376cc8c1 fix: remove auto-hide controls for now 2024-09-19 13:00:20 +02:00
Fredrik Burmester
4eab1ebff6 chore 2024-09-18 08:42:26 +02:00
Fredrik Burmester
76388a408c fix: throttle 2024-09-18 08:42:23 +02:00
Fredrik Burmester
d3560c287c fix: more languages 2024-09-18 08:42:18 +02:00
Fredrik Burmester
da78ce898c fix: design 2024-09-18 08:42:09 +02:00
Fredrik Burmester
8a999a56a1 chore: component 2024-09-18 08:41:58 +02:00
Fredrik Burmester
669f8d7d1a fix: design 2024-09-18 08:41:48 +02:00
Fredrik Burmester
83bb5db335 fix: routing 2024-09-18 08:41:44 +02:00
Fredrik Burmester
7a5427099c fix: smaller titles 2024-09-18 08:41:28 +02:00
Fredrik Burmester
ee9b3de7d4 feat: set the server name as title 2024-09-17 19:34:32 +02:00
Fredrik Burmester
bcc28d7513 fix: accidental press when scrolling through the carousel 2024-09-17 19:34:18 +02:00
Fredrik Burmester
d093c028d2 chore 2024-09-17 19:33:46 +02:00
Fredrik Burmester
3032813234 Merge branch 'pr/129' 2024-09-17 18:55:06 +02:00
Fredrik Burmester
3577aae7cc fix: improved server check
Included timeout, check url even if http is included, doc strings
2024-09-16 21:15:57 +02:00
simon
5e141f27c4 /System/Info/Public instead of /web 2024-09-16 16:37:45 +02:00
simon
b67a4f1843 fixed some inconsistencies 2024-09-15 18:48:16 +02:00
simon
19a53da8a7 remove need for http/https 2024-09-15 18:45:26 +02:00
simon
25656cb7f1 remove need for http/https 2024-09-15 18:13:38 +02:00
simon
7f9c893560 remove need for http/https 2024-09-15 18:10:22 +02:00
simon
39880a6214 possible a good fix 2024-09-15 12:49:47 +02:00
44 changed files with 1428 additions and 785 deletions

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.14.0",
"version": "0.15.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -33,7 +33,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 40,
"versionCode": 41,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
},

View File

@@ -26,6 +26,7 @@ export default function IndexLayout() {
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather name="download" color={"white"} size={22} />
</TouchableOpacity>
@@ -37,10 +38,9 @@ export default function IndexLayout() {
onPress={() => {
router.push("/(auth)/settings");
}}
className="p-2 "
>
<View className="h-10 aspect-square flex items-center justify-center rounded">
<Feather name="settings" color={"white"} size={22} />
</View>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
</View>
),

View File

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

View File

@@ -91,9 +91,7 @@ export default function settings() {
<Text className="font-bold text-lg mb-2">Tests</Text>
<Button
onPress={() => {
toast.success("Download started", {
invert: true,
});
toast.success("Download started");
}}
color="black"
>

View File

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

48
app/(auth)/play.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer";
import { useSettings } from "@/utils/atoms/settings";
import * as NavigationBar from "expo-navigation-bar";
import { StatusBar } from "expo-status-bar";
import { useEffect, useState } from "react";
import { Platform, View, ViewProps } from "react-native";
import * as ScreenOrientation from "expo-screen-orientation";
interface Props extends ViewProps {}
export default function page() {
const [settings] = useSettings();
useEffect(() => {
if (settings?.autoRotate) {
// Don't need to do anything
} else if (settings?.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("hidden");
NavigationBar.setBehaviorAsync("overlay-swipe");
}
return () => {
if (settings?.autoRotate) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("visible");
NavigationBar.setBehaviorAsync("inset-swipe");
}
};
}, [settings]);
return (
<View className="">
<StatusBar hidden />
<FullScreenVideoPlayer />
</View>
);
}

View File

@@ -120,14 +120,30 @@ function Layout() {
title: "",
}}
/>
<Stack.Screen
name="(auth)/play"
options={{ headerShown: false, title: "" }}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<FullScreenVideoPlayer />
<Toaster />
{/* <FullScreenVideoPlayer /> */}
<Toaster
duration={2000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
/>
</ThemeProvider>
</PlaybackProvider>
</JellyfinProvider>

View File

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

BIN
bun.lockb

Binary file not shown.

File diff suppressed because it is too large Load Diff

25
components/GenreTags.tsx Normal file
View File

@@ -0,0 +1,25 @@
// GenreTags.tsx
import React from "react";
import { View } from "react-native";
import { Text } from "./common/Text";
interface GenreTagsProps {
genres?: string[];
}
export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
if (!genres || genres.length === 0) return null;
return (
<View className="flex flex-row flex-wrap mt-2">
{genres.map((genre) => (
<View
key={genre}
className="bg-neutral-800 rounded-full px-2 py-1 mr-1"
>
<Text className="text-xs">{genre}</Text>
</View>
))}
</View>
);
};

View File

@@ -43,6 +43,7 @@ import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
const [api] = useAtom(apiAtom);
@@ -362,6 +363,19 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
<CastAndCrew item={item} className="mb-4" loading={loading} />
{item?.People && item.People.length > 0 && (
<View className="mb-4">
{item.People.slice(0, 3).map((person) => (
<MoreMoviesWithActor
currentItem={item}
key={person.Id}
actorId={person.Id!}
className="mb-4"
/>
))}
</View>
)}
{item?.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}

View File

@@ -3,6 +3,7 @@ import { View, ViewProps } from "react-native";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { GenreTags } from "./GenreTags";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -12,7 +13,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
if (!item)
return (
<View
className="flex flex-col space-y-1.5 w-full items-start h-24"
className="flex flex-col space-y-1.5 w-full items-start h-32"
{...props}
>
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
@@ -23,16 +24,22 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
);
return (
<View
style={{
minHeight: 96,
}}
className="flex flex-col"
{...props}
>
<Ratings item={item} className="mb-2" />
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}
<View className="flex flex-col" {...props}>
<View className="flex flex-col" {...props}>
<Ratings item={item} className="mb-2" />
{item.Type === "Episode" && (
<>
<EpisodeTitleHeader item={item} />
<GenreTags genres={item.Genres!} />
</>
)}
{item.Type === "Movie" && (
<>
<MoviesTitleHeader item={item} />
<GenreTags genres={item.Genres!} />
</>
)}
</View>
</View>
);
};

View File

@@ -7,6 +7,7 @@ import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
@@ -78,7 +79,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
}}
>
<DropdownMenu.ItemTitle>
{name(source.Name)}
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
source.Size
)}`}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}

View File

@@ -0,0 +1,100 @@
import React from "react";
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster";
import { ItemCardText } from "@/components/ItemCardText";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
interface Props extends ViewProps {
actorId: string;
currentItem: BaseItemDto;
}
export const MoreMoviesWithActor: React.FC<Props> = ({
actorId,
currentItem,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: actor } = useQuery({
queryKey: ["actor", actorId],
queryFn: async () => {
if (!api || !user?.Id) return null;
return await getUserItemData({
api,
userId: user.Id,
itemId: actorId,
});
},
enabled: !!api && !!user?.Id && !!actorId,
});
const { data: items, isLoading } = useQuery({
queryKey: ["actor", "movies", actorId, currentItem.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
personIds: [actorId],
limit: 20,
sortOrder: ["Descending"],
includeItemTypes: ["Movie", "Series"],
recursive: true,
fields: ["ParentId", "PrimaryImageAspectRatio"],
sortBy: ["PremiereDate"],
collapseBoxSetItems: false,
excludeItemIds: [currentItem.SeriesId || "", currentItem.Id || ""],
});
// Remove duplicates based on item ID
const uniqueItems =
response.data.Items?.reduce((acc, current) => {
const x = acc.find((item) => item.Id === current.Id);
if (!x) {
return acc.concat([current]);
} else {
return acc;
}
}, [] as BaseItemDto[]) || [];
return uniqueItems;
},
enabled: !!api && !!user?.Id && !!actorId,
});
if (items?.length === 0) return null;
return (
<View {...props}>
<Text className="text-lg font-bold mb-2 px-4">
More with {actor?.Name}
</Text>
<HorizontalScroll
data={items}
loading={isLoading}
height={247}
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={idx}
item={item}
className="flex flex-col w-28"
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}
/>
</View>
);
};

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ import Animated, {
} from "react-native-reanimated";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { useRouter } from "expo-router";
interface Props extends React.ComponentProps<typeof Button> {
item?: BaseItemDto | null;
@@ -45,6 +46,8 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const [colorAtom] = useAtom(itemThemeColorAtom);
const [api] = useAtom(apiAtom);
const router = useRouter();
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
@@ -63,6 +66,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
if (!url || !item) return;
if (!client) {
setCurrentlyPlayingState({ item, url });
router.push("/play");
return;
}
const options = ["Chromecast", "Device", "Cancel"];
@@ -159,7 +163,9 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
});
break;
case 1:
console.log("Device");
setCurrentlyPlayingState({ item, url });
router.push("/play");
break;
case cancelButtonIndex:
break;

View File

@@ -10,6 +10,8 @@ import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "./common/Text";
import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader";
import { HorizontalScroll } from "./common/HorrizontalScroll";
import { TouchableItemRouter } from "./common/TouchableItemRouter";
interface SimilarItemsProps extends ViewProps {
itemId?: string | null;
@@ -46,29 +48,24 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
return (
<View {...props}>
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
{isLoading ? (
<View className="my-12">
<Loader />
</View>
) : (
<ScrollView horizontal>
<View className="px-4 flex flex-row gap-x-2">
{movies.map((item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/page?id=${item.Id}`)}
className="flex flex-col w-32"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
))}
</View>
</ScrollView>
)}
{movies.length === 0 && (
<Text className="px-4 text-neutral-500">No similar items</Text>
)}
<HorizontalScroll
data={movies}
loading={isLoading}
height={247}
noItemsText="No similar items found"
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={idx}
item={item}
className="flex flex-col w-28"
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}
/>
</View>
);
};

View File

@@ -22,6 +22,7 @@ interface HorizontalScrollProps<T>
height?: number;
loading?: boolean;
extraData?: any;
noItemsText?: string;
}
export const HorizontalScroll = forwardRef<
@@ -38,6 +39,7 @@ export const HorizontalScroll = forwardRef<
loading = false,
height = 164,
extraData,
noItemsText,
...props
}: HorizontalScrollProps<T>,
ref: React.ForwardedRef<HorizontalScrollRef>
@@ -91,7 +93,9 @@ export const HorizontalScroll = forwardRef<
}}
ListEmptyComponent={() => (
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">No data available</Text>
<Text className="text-center text-gray-500">
{noItemsText || "No data available"}
</Text>
</View>
)}
{...props}

View File

@@ -10,6 +10,7 @@ import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { useSettings } from "@/utils/atoms/settings";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useRouter } from "expo-router";
interface EpisodeCardProps {
item: BaseItemDto;
@@ -22,6 +23,7 @@ interface EpisodeCardProps {
*/
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useFiles();
const router = useRouter();
const { startDownloadedFilePlayback } = usePlayback();
@@ -30,6 +32,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
router.push("/play");
}, [item, startDownloadedFilePlayback]);
/**

View File

@@ -1,17 +1,16 @@
import React, { useCallback } from "react";
import { TouchableOpacity, View } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as ContextMenu from "zeego/context-menu";
import * as FileSystem from "expo-file-system";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import React, { useCallback } from "react";
import { TouchableOpacity, View } from "react-native";
import * as ContextMenu from "zeego/context-menu";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Text } from "../common/Text";
import { useSettings } from "@/utils/atoms/settings";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useRouter } from "expo-router";
interface MovieCardProps {
item: BaseItemDto;
@@ -24,8 +23,7 @@ interface MovieCardProps {
*/
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useFiles();
const [settings] = useSettings();
const router = useRouter();
const { startDownloadedFilePlayback } = usePlayback();
const handleOpenFile = useCallback(() => {
@@ -33,6 +31,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
router.push("/play");
}, [item, startDownloadedFilePlayback]);
/**

View File

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

View File

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

View File

@@ -9,7 +9,9 @@ interface Props extends ViewProps {
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text className=" font-bold text-2xl mb-1">{item?.Name}</Text>
<Text className=" font-bold text-2xl mb-1" selectable>
{item?.Name}
</Text>
<Text className=" opacity-50">{item?.ProductionYear}</Text>
</View>
);

View File

@@ -25,6 +25,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
<HorizontalScroll
loading={loading}
height={247}
data={item?.People || []}
renderItem={(item, index) => (
<TouchableOpacity
@@ -32,7 +33,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
router.push(`/actors/${item.Id}`);
}}
key={item.Id}
className="flex flex-col w-32"
className="flex flex-col w-28"
>
<Poster item={item} url={getPrimaryImageUrl({ api, item })} />
<Text className="mt-2">{item.Name}</Text>

View File

@@ -21,11 +21,12 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
<HorizontalScroll
data={[item]}
height={247}
renderItem={(item, index) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.SeriesId}`)}
className="flex flex-col space-y-2 w-32"
className="flex flex-col space-y-2 w-28"
>
<Poster
item={item}

View File

@@ -12,7 +12,9 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text className="font-bold text-2xl">{item?.Name}</Text>
<Text className="font-bold text-2xl" selectable>
{item?.Name}
</Text>
<View className="flex flex-row items-center mb-1">
<TouchableOpacity
onPress={() => {

View File

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

View File

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

View File

@@ -1,39 +1,16 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
DefaultLanguageOption,
DownloadOptions,
useSettings,
} from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import {
Linking,
Switch,
TouchableOpacity,
View,
ViewProps,
} from "react-native";
import { useSettings } from "@/utils/atoms/settings";
import { TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { Input } from "../common/Input";
import { useState } from "react";
import { Button } from "../Button";
const LANGUAGES: DefaultLanguageOption[] = [
{ label: "eng", value: "eng" },
{
label: "sv",
value: "sv",
},
];
import { LANGUAGES } from "@/constants/Languages";
interface Props extends ViewProps {}
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
if (!settings) return null;
return (
<View>
<Text className="text-lg font-bold mb-2">Media</Text>
@@ -144,6 +121,82 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Forward skip length</Text>
<Text className="text-xs opacity-50">
Choose length in seconds when skipping in video playback.
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{settings.forwardSkipTime}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Rewind length</Text>
<Text className="text-xs opacity-50">
Choose length in seconds when skipping in video playback.
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{settings.rewindSkipTime}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
</View>
</View>
);

View File

@@ -97,6 +97,113 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
/>
</View>
<View
pointerEvents={settings.autoRotate ? "none" : "auto"}
className={`
${
settings.autoRotate
? "opacity-50 pointer-events-none"
: "opacity-100"
}
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Video orientation</Text>
<Text className="text-xs opacity-50">
Set the full screen video player orientation.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.DEFAULT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.DEFAULT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.PORTRAIT_UP,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.PORTRAIT_UP
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="4"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Use external player (VLC)</Text>
@@ -256,106 +363,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Root>
</View>
<View className="flex flex-col">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Video orientation</Text>
<Text className="text-xs opacity-50">
Set the full screen video player orientation.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.DEFAULT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.DEFAULT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.PORTRAIT_UP,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.PORTRAIT_UP
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="4"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4

39
constants/Languages.ts Normal file
View File

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

View File

@@ -21,13 +21,13 @@
}
},
"production": {
"channel": "0.14.0",
"channel": "0.15.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.14.0",
"channel": "0.15.0",
"android": {
"buildType": "apk",
"image": "latest"

72
hooks/useCreditSkipper.ts Normal file
View File

@@ -0,0 +1,72 @@
import { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
interface CreditTimestamps {
Introduction: {
Start: number;
End: number;
Valid: boolean;
};
Credits: {
Start: number;
End: number;
Valid: boolean;
};
}
export const useCreditSkipper = (
itemId: string | undefined,
currentTime: number,
videoRef: React.RefObject<any>
) => {
const [api] = useAtom(apiAtom);
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
queryKey: ["creditTimestamps", itemId],
queryFn: async () => {
if (!itemId) {
console.log("No item id");
return null;
}
const res = await api?.axiosInstance.get(
`${api.basePath}/Episode/${itemId}/Timestamps`,
{
headers: getAuthHeaders(api),
}
);
if (res?.status !== 200) {
return null;
}
return res?.data;
},
enabled: !!itemId,
});
useEffect(() => {
if (creditTimestamps) {
setShowSkipCreditButton(
currentTime > creditTimestamps.Credits.Start &&
currentTime < creditTimestamps.Credits.End
);
}
}, [creditTimestamps, currentTime]);
const skipCredit = useCallback(() => {
if (!creditTimestamps || !videoRef.current) return;
try {
videoRef.current.seek(creditTimestamps.Credits.End);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}
}, [creditTimestamps, videoRef]);
return { showSkipCreditButton, skipCredit };
};

68
hooks/useIntroSkipper.ts Normal file
View File

@@ -0,0 +1,68 @@
import { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
interface IntroTimestamps {
EpisodeId: string;
HideSkipPromptAt: number;
IntroEnd: number;
IntroStart: number;
ShowSkipPromptAt: number;
Valid: boolean;
}
export const useIntroSkipper = (
itemId: string | undefined,
currentTime: number,
videoRef: React.RefObject<any>
) => {
const [api] = useAtom(apiAtom);
const [showSkipButton, setShowSkipButton] = useState(false);
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
queryKey: ["introTimestamps", itemId],
queryFn: async () => {
if (!itemId) {
console.log("No item id");
return null;
}
const res = await api?.axiosInstance.get(
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
{
headers: getAuthHeaders(api),
}
);
if (res?.status !== 200) {
return null;
}
return res?.data;
},
enabled: !!itemId,
});
useEffect(() => {
if (introTimestamps) {
setShowSkipButton(
currentTime > introTimestamps.ShowSkipPromptAt &&
currentTime < introTimestamps.HideSkipPromptAt
);
}
}, [introTimestamps, currentTime]);
const skipIntro = useCallback(() => {
if (!introTimestamps || !videoRef.current) return;
try {
videoRef.current.seek(introTimestamps.IntroEnd);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}
}, [introTimestamps, videoRef]);
return { showSkipButton, skipIntro };
};

View File

@@ -1,6 +1,6 @@
// hooks/useTrickplay.ts
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, useRef } from "react";
import { Api } from "@jellyfin/sdk";
import { SharedValue } from "react-native-reanimated";
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
@@ -33,6 +33,8 @@ export const useTrickplay = (
) => {
const [api] = useAtom(apiAtom);
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
const lastCalculationTime = useRef(0);
const throttleDelay = 200; // 200ms throttle
const trickplayInfo = useMemo(() => {
if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) {
@@ -60,7 +62,13 @@ export const useTrickplay = (
}, [currentlyPlaying]);
const calculateTrickplayUrl = useCallback(
(progress: SharedValue<number>) => {
(progress: number) => {
const now = Date.now();
if (now - lastCalculationTime.current < throttleDelay) {
return null;
}
lastCalculationTime.current = now;
if (!trickplayInfo || !api || !currentlyPlaying?.item.Id) {
return null;
}
@@ -72,7 +80,7 @@ export const useTrickplay = (
throw new Error("Invalid trickplay data");
}
const currentSecond = Math.max(0, Math.floor(progress.value / 10000000));
const currentSecond = Math.max(0, Math.floor(progress / 10000000));
const cols = TileWidth;
const rows = TileHeight;

View File

@@ -17,27 +17,27 @@
"dependencies": {
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.2",
"@expo/vector-icons": "^14.0.3",
"@gorhom/bottom-sheet": "^4",
"@jellyfin/sdk": "^0.10.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.3.1",
"@react-native-menu/menu": "^1.1.2",
"@react-native-menu/menu": "^1.1.3",
"@react-navigation/native": "^6.0.2",
"@shopify/flash-list": "1.6.4",
"@tanstack/react-query": "^5.56.2",
"@types/lodash": "^4.17.7",
"@types/lodash": "^4.17.9",
"@types/uuid": "^10.0.0",
"axios": "^1.7.7",
"expo": "^51.0.32",
"expo": "~51.0.34",
"expo-blur": "~13.0.2",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.26",
"expo-dev-client": "~4.0.27",
"expo-device": "~6.0.2",
"expo-font": "~12.0.10",
"expo-haptics": "~13.0.1",
"expo-image": "~1.12.15",
"expo-image": "~1.13.0",
"expo-keep-awake": "~13.0.2",
"expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1",
@@ -46,13 +46,13 @@
"expo-router": "~3.5.23",
"expo-screen-orientation": "~7.0.5",
"expo-sensors": "~13.0.9",
"expo-splash-screen": "~0.27.5",
"expo-splash-screen": "~0.27.6",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.24",
"expo-updates": "~0.25.25",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.9.3",
"jotai": "^2.10.0",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"react": "18.2.0",
@@ -74,9 +74,9 @@
"react-native-svg": "15.2.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
"react-native-video": "^6.5.0",
"react-native-video": "^6.6.2",
"react-native-web": "~0.19.10",
"sonner-native": "^0.9.2",
"sonner-native": "^0.14.2",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.3",
"uuid": "^10.0.0",

View File

@@ -63,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.14.0" },
clientInfo: { name: "Streamyfin", version: "0.15.0" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
})
);
@@ -97,7 +97,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.14.0"`,
}, DeviceId="${deviceId}", Version="0.15.0"`,
};
}, [deviceId]);

View File

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

View File

@@ -70,8 +70,9 @@ type Settings = {
defaultAudioLanguage: DefaultLanguageOption | null;
showHomeTitles: boolean;
defaultVideoOrientation: ScreenOrientation.OrientationLock;
forwardSkipTime: number;
rewindSkipTime: number;
};
/**
*
* The settings atom is a Jotai atom that stores the user's settings.
@@ -103,6 +104,8 @@ const loadSettings = async (): Promise<Settings> => {
defaultSubtitleLanguage: null,
showHomeTitles: true,
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
forwardSkipTime: 30,
rewindSkipTime: 10,
};
try {

19
utils/bToMb.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Convert bits to megabits or gigabits
*
* Return nice looking string
* If under 1000Mb, return XXXMB, else return X.XGB
*/
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
if (!bits) return "0MB";
const megabits = bits / 1000000;
if (megabits < 1000) {
return Math.round(megabits) + "MB";
} else {
const gigabits = megabits / 1000;
return gigabits.toFixed(1) + "GB";
}
}

View File

@@ -1,6 +1,5 @@
// seconds to ticks util
export function secondsToTicks(seconds: number): number {
"worklet";
return seconds * 10000000;
}

View File

@@ -6,7 +6,7 @@
* @returns A string formatted as "Xh Ym" where X is hours and Y is minutes.
*/
export const runtimeTicksToMinutes = (
ticks: number | null | undefined,
ticks: number | null | undefined
): string => {
if (!ticks) return "0h 0m";
@@ -20,7 +20,7 @@ export const runtimeTicksToMinutes = (
};
export const runtimeTicksToSeconds = (
ticks: number | null | undefined,
ticks: number | null | undefined
): string => {
if (!ticks) return "0h 0m";
@@ -34,3 +34,37 @@ export const runtimeTicksToSeconds = (
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
else return `${minutes}m ${seconds}s`;
};
export const formatTimeString = (
t: number | null | undefined,
tick = false
): string => {
if (t === null || t === undefined) return "0:00";
let seconds = t;
if (tick) {
seconds = Math.floor(t / 10000000); // Convert ticks to seconds
}
if (seconds < 0) return "0:00";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}h ${minutes}m ${remainingSeconds}s`;
} else {
return `${minutes}m ${remainingSeconds}s`;
}
};
export const secondsToTicks = (seconds?: number | undefined) => {
if (!seconds) return 0;
return seconds * 10000000;
};
export const ticksToSeconds = (ticks?: number | undefined) => {
if (!ticks) return 0;
return ticks / 10000000;
};