Compare commits

...

93 Commits

Author SHA1 Message Date
Fredrik Burmester
87092fed48 fix: working material3 theme 2024-11-10 09:21:51 +01:00
Fredrik Burmester
65a6ab9972 wip 2024-11-04 22:08:01 +00:00
Fredrik Burmester
8943708ff5 fix: update versions and use native-edge-to-edge 2024-11-04 21:35:26 +00:00
Fredrik Burmester
120fd4a32b wip 2024-10-27 15:21:59 +01:00
Fredrik Burmester
06e657dc4d wip 2024-10-27 14:14:27 +01:00
Fredrik Burmester
f5857e2162 fix: native pill look on android 2024-10-27 12:37:46 +01:00
Fredrik Burmester
d1221dae83 chore 2024-10-27 11:24:49 +01:00
Fredrik Burmester
b99c18c5ac chore 2024-10-27 11:23:24 +01:00
Fredrik Burmester
25544eb157 chore 2024-10-27 11:22:58 +01:00
Fredrik Burmester
0c436408e7 wip 2024-10-13 13:59:27 +02:00
Fredrik Burmester
ebc77f4ee2 Merge branch 'master' into feat/native-tabbar 2024-10-13 11:54:22 +02:00
Fredrik Burmester
d6ee1807f3 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-13 11:39:54 +02:00
Fredrik Burmester
0d7c3cb9da fix: again remove animations because it causes no-tap-register bug 2024-10-13 11:39:51 +02:00
Fredrik Burmester
fd252247aa fix: screen rotation issues 2024-10-13 11:39:25 +02:00
Fredrik Burmester
c12af2efe9 Merge pull request #183 from simoncaron/feat/hide-zero-hour
feat: Hide 0h on play button when media length is lower than an hour
2024-10-13 08:51:27 +02:00
Simon Caron
04b24ee86b feat: Hide 0h on play button when media length is lower than an hour 2024-10-12 14:27:51 -04:00
Fredrik Burmester
5c3da8b01a fix: don't clip bottom - allow blur 2024-10-12 15:14:52 +02:00
Fredrik Burmester
46c4b3e1d8 Merge branch 'master' into feat/native-tabbar 2024-10-12 15:03:31 +02:00
Fredrik Burmester
43d251fcda chore 2024-10-12 15:02:46 +02:00
Fredrik Burmester
fed3725733 fix: use better screen dimensions 2024-10-12 15:02:23 +02:00
Fredrik Burmester
f5be204ac8 fix: cache item count 2024-10-12 15:02:10 +02:00
Fredrik Burmester
093fdcda45 Merge pull request #177 from fredrikburmester/fix/video-rotation-bug
fix: video rotation bug by updating screen dimensions dynamically using an event listener
2024-10-11 19:03:35 +02:00
Fredrik Burmester
eeaa027579 fix: turn into hook 2024-10-11 19:03:15 +02:00
Fredrik Burmester
a4c20981cf Merge pull request #174 from jakequade/fix/issue-159-chromecast-button-android
fix: casting broken on 0.17.0
2024-10-11 19:01:41 +02:00
Fredrik Burmester
63965c9e64 fix: video rotation bug 2024-10-11 16:41:44 +02:00
Fredrik Burmester
c5f39f6f8a chore: no need for these props any more 2024-10-11 12:04:15 +02:00
Fredrik Burmester
eb841601f6 fix: use correct url for chromecast streaming 2024-10-11 12:04:15 +02:00
jakequade
3f5ce6dc43 use cast button rather than feather icon for casting 2024-10-11 20:34:01 +11:00
Fredrik Burmester
2ed29e5a18 wip 2024-10-10 23:11:40 +02:00
Fredrik Burmester
380172c5ac Merge branch 'master' into feat/native-tabbar 2024-10-10 23:11:35 +02:00
Fredrik Burmester
b73a33b05b chore 2024-10-10 17:27:21 +02:00
Fredrik Burmester
e3baa2f58b fix: rotation issues 2024-10-10 17:27:17 +02:00
Fredrik Burmester
ef7fbc985f chore 2024-10-10 10:10:24 +02:00
Fredrik Burmester
381c6701f2 chore: version bump 2024-10-10 07:56:23 +02:00
Fredrik Burmester
71da79ee6a chore 2024-10-09 20:23:53 +02:00
Fredrik Burmester
5cff323871 feat: go to next episode on end 2024-10-09 20:23:50 +02:00
Fredrik Burmester
39b7c66d34 fix: don't crash app when no media source found for unmatched items 2024-10-09 20:23:40 +02:00
Fredrik Burmester
57201f8606 wip 2024-10-09 20:06:30 +02:00
Fredrik Burmester
0a098bf26e chore 2024-10-09 07:28:53 +02:00
Fredrik Burmester
f6cb90e5dc feat: add logo to login 2024-10-09 07:28:49 +02:00
Fredrik Burmester
eba9163ce8 wip 2024-10-08 22:24:52 +02:00
Fredrik Burmester
4b166cf1d8 first commit 2024-10-08 21:21:12 +02:00
Fredrik Burmester
b878e93dec Merge pull request #163 from Alexk2309/master
Removed resumable items from next up
2024-10-08 19:55:28 +02:00
Fredrik Burmester
66cd36a899 feat: native selectable text for titles 2024-10-08 19:53:48 +02:00
Fredrik Burmester
91b926e6c2 fix: larger tap area 2024-10-08 19:53:38 +02:00
Fredrik Burmester
d4cc7499c0 Merge pull request #165 from fredrikburmester/refactor/player
Refactor: Update the player logic (video, music, live-tv)
2024-10-08 18:54:43 +02:00
Fredrik Burmester
317e719460 wip 2024-10-08 18:43:25 +02:00
Alex Kim
6012f8c8d2 Removed resumable items from next up 2024-10-09 02:40:11 +11:00
Fredrik Burmester
ec0843d737 wip 2024-10-08 15:39:44 +02:00
Fredrik Burmester
a5b4f6cc78 wip 2024-10-07 10:00:16 +02:00
Fredrik Burmester
4b60de4d43 fix: padding 2024-10-07 08:18:24 +02:00
Fredrik Burmester
aa56749402 wip 2024-10-06 22:48:31 +02:00
Fredrik Burmester
d6f02bd970 wip 2024-10-06 16:33:29 +02:00
Fredrik Burmester
862e783de1 wip 2024-10-06 15:11:06 +02:00
Fredrik Burmester
0233862fc1 wip 2024-10-06 13:03:16 +02:00
Fredrik Burmester
cc242a971f Merge branch 'master' into refactor/player 2024-10-06 09:48:42 +02:00
Fredrik Burmester
4fc3044838 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-05 21:28:11 +02:00
Fredrik Burmester
df6cd17099 chore 2024-10-05 21:28:07 +02:00
Fredrik Burmester
5e8a0a9fa9 Merge pull request #156 from fredrikburmester/feat/live-tv
feat: live-tv support
2024-10-05 20:01:49 +02:00
Fredrik Burmester
005938a421 chore 2024-10-05 20:01:34 +02:00
Fredrik Burmester
81aafa26d4 chore: small fixes 2024-10-05 19:19:34 +02:00
Fredrik Burmester
0080874213 fix: pages 2024-10-05 19:19:28 +02:00
Fredrik Burmester
aa89c66e6e Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-05 13:26:46 +02:00
Fredrik Burmester
b1e2020b43 feat: new icons 2024-10-05 13:26:44 +02:00
retardgerman
5ab53738d5 fix: international App Store link 2024-10-05 13:18:49 +02:00
Fredrik Burmester
95de03f8b1 chore 2024-10-05 13:15:33 +02:00
Fredrik Burmester
48570489d5 chore 2024-10-05 12:38:02 +02:00
Fredrik Burmester
2c14a18e53 chore 2024-10-05 12:22:35 +02:00
Fredrik Burmester
200ccc6070 feat: guide grid view 2024-10-05 12:18:47 +02:00
Fredrik Burmester
1c20a3453f fix: streaming live tv now works 2024-10-05 10:24:49 +02:00
Fredrik Burmester
bf1efd7ca2 chore 2024-10-05 09:29:42 +02:00
Fredrik Burmester
387add4c83 first commit 2024-10-05 09:17:54 +02:00
Fredrik Burmester
d064622055 fix: add key 2024-10-05 08:38:24 +02:00
Fredrik Burmester
a04296f395 fix: bottom overlapp 2024-10-05 08:38:10 +02:00
Fredrik Burmester
eb11b928af fix: update device profile 2024-10-04 16:51:26 +02:00
Fredrik Burmester
4fd67091ea fix: change to ISO 639-2 Code 2024-10-04 16:48:33 +02:00
Fredrik Burmester
57c911cc94 feat: add star history 2024-10-04 16:43:58 +02:00
Fredrik Burmester
61255e6dc4 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-04 13:29:19 +02:00
Fredrik Burmester
d9037e72f0 chore 2024-10-04 13:29:16 +02:00
Fredrik Burmester
b25cdce702 first commit 2024-10-04 12:08:45 +02:00
Fredrik Burmester
a85d5bbc92 Update README.md 2024-10-04 08:25:09 +02:00
Fredrik Burmester
097c428d41 chore 2024-10-03 21:44:54 +02:00
Fredrik Burmester
c55d498592 Merge pull request #151 from Alexk2309/master
Added support for more libraries to use the recently added section
2024-10-03 21:39:46 +02:00
Fredrik Burmester
1ce85a9d38 Merge branch 'master' into master 2024-10-03 21:39:27 +02:00
Fredrik Burmester
027c69bb7e fix: refactor 2024-10-03 21:37:20 +02:00
Fredrik Burmester
7a20b6db7d Merge pull request #142 from Simon-Eklundh/master
set downloads button to green if downloads exist
2024-10-03 20:04:23 +02:00
Fredrik Burmester
0e8f6dc0cc fix: use hook and purple color 2024-10-03 20:02:14 +02:00
Fredrik Burmester
bc3bdbf4c5 Merge branch 'master' into pr/142 2024-10-03 19:53:09 +02:00
Fredrik Burmester
2bea483f08 Merge pull request #140 from fredrikburmester/feat/background-downloads
[Feature] Background downloads
2024-10-03 19:27:53 +02:00
Alex Kim
f4bf0b2773 Fixed bug with useMemo changed size 2024-10-03 05:54:37 +10:00
Alex Kim
8138b37e7a Added support for more libaries to use the recently added section 2024-10-03 05:35:07 +10:00
Simon Eklundh
92ebb29808 Merge branch 'master' into master 2024-09-28 10:52:03 +02:00
Simon Eklundh
f46cb97e7f working prototype 2024-09-28 10:42:02 +02:00
98 changed files with 3654 additions and 2990 deletions

2
.gitignore vendored
View File

@@ -26,6 +26,8 @@ package-lock.json
/ios
/android
modules/vlc-player/android
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
*.apk

View File

@@ -12,6 +12,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
</div>
## 🌟 Features
- 🚀 **Skp intro / credits support**
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
@@ -61,7 +62,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
## Get it now
<div style="display: flex; gap: 5px;">
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
</div>
@@ -135,7 +136,7 @@ Key points of the MPL-2.0:
## 🌐 Connect with Us
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/aJvAYeycyY)
Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE)
If you have questions or need support, feel free to reach out:
@@ -153,3 +154,7 @@ I'd like to thank the following people and projects for their contributions to S
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
- The Jellyfin devs for always being helpful in the Discord.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=fredrikburmester/streamyfin&type=Date)](https://star-history.com/#fredrikburmester/streamyfin&Date)

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.16.0",
"version": "0.18.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -10,7 +10,7 @@
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#29164B"
"backgroundColor": "#2E2E2E"
},
"jsEngine": "hermes",
"assetBundlePatterns": ["**/*"],
@@ -33,9 +33,9 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 42,
"versionCode": 46,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
"foregroundImage": "./assets/images/adaptive_icon.png"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [
@@ -66,13 +66,6 @@
}
}
],
[
"./plugins/withAndroidMainActivityAttributes",
{
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
}
],
["./plugins/withExpandedController.js"],
[
"expo-build-properties",
{
@@ -106,7 +99,12 @@
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
]
],
[
"react-native-edge-to-edge",
{ "android": { "parentTheme": "Material3" } }
],
["react-native-bottom-tabs"]
],
"experiments": {
"typedRoutes": true

View File

@@ -1,12 +1,14 @@
import { Chromecast } from "@/components/Chromecast";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useDownload } from "@/providers/DownloadProvider";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native";
export default function IndexLayout() {
const router = useRouter();
return (
<Stack>
<Stack.Screen
@@ -18,19 +20,6 @@ export default function IndexLayout() {
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
style={{
marginRight: Platform.OS === "android" ? 17 : 0,
}}
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather name="download" color={"white"} size={22} />
</TouchableOpacity>
),
headerRight: () => (
<View className="flex flex-row items-center space-x-2">
<Chromecast />

View File

@@ -4,12 +4,16 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { TAB_HEIGHT } from "@/constants/Values";
import { Colors } from "@/constants/Colors";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
@@ -18,33 +22,31 @@ import {
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type BaseSection = {
title: string;
queryKey: (string | undefined)[];
};
type ScrollingCollectionListSection = BaseSection & {
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
queryFn: () => Promise<BaseItemDto[]>;
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = BaseSection & {
type MediaListSection = {
type: "MediaListSection";
queryFn: () => Promise<BaseItemDto>;
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
@@ -53,8 +55,8 @@ export default function index() {
const queryClient = useQueryClient();
const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [settings, _] = useSettings();
@@ -62,6 +64,29 @@ export default function index() {
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const { downloadedFiles } = useDownload();
const navigation = useNavigation();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather
name="download"
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [downloadedFiles, navigation, router]);
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
@@ -90,7 +115,7 @@ export default function index() {
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["userViews", user?.Id],
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
@@ -111,7 +136,7 @@ export default function index() {
isError: e2,
isLoading: l2,
} = useQuery({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
@@ -129,115 +154,134 @@ export default function index() {
staleTime: 60 * 1000,
});
const movieCollectionId = useMemo(() => {
return userViews?.find((c) => c.CollectionType === "movies")?.Id;
}, [userViews]);
const tvShowCollectionId = useMemo(() => {
return userViews?.find((c) => c.CollectionType === "tvshows")?.Id;
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const refetch = useCallback(async () => {
setLoading(true);
await queryClient.invalidateQueries();
// await queryClient.invalidateQueries({ queryKey: ["userViews"] });
// await queryClient.invalidateQueries({ queryKey: ["resumeItems"] });
// await queryClient.invalidateQueries({ queryKey: ["continueWatching"] });
// await queryClient.invalidateQueries({ queryKey: ["nextUp-all"] });
// await queryClient.invalidateQueries({
// queryKey: ["recentlyAddedInMovies"],
// });
// await queryClient.invalidateQueries({
// queryKey: ["recentlyAddedInTVShows"],
// });
// await queryClient.invalidateQueries({ queryKey: ["suggestions"] });
// await queryClient.invalidateQueries({
// queryKey: ["sf_promoted"],
// });
// await queryClient.invalidateQueries({
// queryKey: ["sf_carousel"],
// });
await queryClient.invalidateQueries({
queryKey: ["home"],
refetchType: "all",
type: "all",
exact: false,
});
await queryClient.invalidateQueries({
queryKey: ["home"],
refetchType: "all",
type: "all",
exact: false,
});
await queryClient.invalidateQueries({
queryKey: ["item"],
refetchType: "all",
type: "all",
exact: false,
});
setLoading(false);
}, [queryClient, user?.Id]);
}, [queryClient]);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 50,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
const sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = "Recently Added in " + c.Name;
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
// {
// title: "Continue Watching",
// queryKey: ["resumeItems", user.Id],
// queryFn: async () =>
// (
// await getItemsApi(api).getResumeItems({
// userId: user.Id,
// enableImageTypes: ["Primary", "Backdrop", "Thumb"],
// })
// ).data.Items || [],
// type: "ScrollingCollectionList",
// orientation: "horizontal",
// },
// {
// title: "Next Up",
// queryKey: ["nextUp-all", user?.Id],
// queryFn: async () =>
// (
// await getTvShowsApi(api).getNextUp({
// userId: user?.Id,
// fields: ["MediaSourceCount"],
// limit: 20,
// enableImageTypes: ["Primary", "Backdrop", "Thumb"],
// })
// ).data.Items || [],
// type: "ScrollingCollectionList",
// orientation: "horizontal",
// },
{
title: "Recently Added in Movies",
queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
title: "Continue Watching",
queryKey: ["home", "resumeItems", user.Id],
queryFn: async () =>
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 50,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie"],
parentId: movieCollectionId,
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data || [],
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: "Recently Added in TV-Shows",
queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
title: "Next Up",
queryKey: ["home", "nextUp-all", user?.Id],
queryFn: async () =>
(
await getUserLibraryApi(api).getLatestMedia({
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
limit: 50,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Series"],
parentId: tvShowCollectionId,
enableResumable: false,
})
).data || [],
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
...(mediaListCollections?.map(
(ml) =>
({
title: ml.Name || "",
queryKey: ["mediaList", ml.Id],
title: ml.Name,
queryKey: ["home", "mediaList", ml.Id!],
queryFn: async () => ml,
type: "MediaListSection",
} as MediaListSection)
orientation: "vertical",
} as Section)
) || []),
{
title: "Suggested Movies",
queryKey: ["suggestedMovies", user?.Id],
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
@@ -252,7 +296,7 @@ export default function index() {
},
{
title: "Suggested Episodes",
queryKey: ["suggestedEpisodes", user?.Id],
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
@@ -272,13 +316,7 @@ export default function index() {
},
];
return ss;
}, [
api,
user?.Id,
movieCollectionId,
tvShowCollectionId,
mediaListCollections,
]);
}, [api, user?.Id, collections, mediaListCollections]);
if (isConnected === false) {
return (
@@ -324,7 +362,7 @@ export default function index() {
const insets = useSafeAreaInsets();
if (e1 || e2 || !api)
if (e1 || e2)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
@@ -350,73 +388,37 @@ export default function index() {
}
key={"home"}
contentContainerStyle={{
flexDirection: "column",
paddingLeft: insets.left,
paddingRight: insets.right,
paddingTop: 8,
paddingBottom: 8,
rowGap: 8,
}}
style={{
marginBottom: TAB_HEIGHT,
paddingBottom: 16,
}}
>
<LargeMovieCarousel />
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
<ScrollingCollectionList
key="continueWatching"
title={"Continue Watching"}
queryKey={["continueWatching", user?.Id]}
queryFn={async () =>
(
await getItemsApi(api).getResumeItems({
userId: user?.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
})
).data.Items || []
}
orientation={"horizontal"}
/>
<ScrollingCollectionList
key="nextUp"
title={"Next Up"}
queryKey={["nextUp-all", user?.Id]}
queryFn={async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
})
).data.Items || []
}
orientation={"horizontal"}
/>
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
}

View File

@@ -74,7 +74,7 @@ export default function settings() {
registerBackgroundFetchAsync
</Button> */}
<View>
<Text className="font-bold text-lg mb-2">Information</Text>
<Text className="font-bold text-lg mb-2">User Info</Text>
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<ListItem title="User" subTitle={user?.Name} />
@@ -143,7 +143,9 @@ export default function settings() {
>
{log.level}
</Text>
<Text className="text-xs">{log.message}</Text>
<Text uiTextView selectable className="text-xs">
{log.message}
</Text>
</View>
))}
{logs?.length === 0 && (

View File

@@ -50,7 +50,7 @@ const page: React.FC = () => {
userId: user.Id,
personIds: [actorId],
startIndex: pageParam,
limit: 8,
limit: 16,
sortOrder: ["Descending", "Descending", "Ascending"],
includeItemTypes: ["Movie", "Series"],
recursive: true,

View File

@@ -1,15 +1,94 @@
import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { Stack, useLocalSearchParams } from "expo-router";
import React from "react";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect } from "react";
import { View } from "react-native";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
const Page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { id } = useLocalSearchParams() as { id: string };
const { data: item, isError } = useQuery({
queryKey: ["item", id],
queryFn: async () => {
const res = await getUserItemData({
api,
userId: user?.Id,
itemId: id,
});
return res;
},
enabled: !!id && !!api,
staleTime: 60 * 1000 * 5, // 5 minutes
});
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
const fadeOut = (callback: any) => {
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
};
const fadeIn = (callback: any) => {
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
};
useEffect(() => {
if (item) {
fadeOut(() => {});
} else {
fadeIn(() => {});
}
}, [item]);
if (isError)
return (
<View className="flex flex-col items-center justify-center h-screen w-screen">
<Text>Could not load item</Text>
</View>
);
return (
<>
<Stack.Screen options={{ autoHideHomeIndicator: true }} />
<ItemContent id={id} />
</>
<View className="flex flex-1 relative">
<Animated.View
pointerEvents={"none"}
style={[animatedStyle]}
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
>
<View className="h-[350px] bg-transparent rounded-lg mb-4 w-full"></View>
<View className="h-6 bg-neutral-900 rounded mb-1 w-12"></View>
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-1/2"></View>
<View className="h-12 bg-neutral-900 rounded-lg w-2/3 mb-10"></View>
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-1/4"></View>
</Animated.View>
{item && <ItemContent item={item} />}
</View>
);
};

View File

@@ -0,0 +1,49 @@
import type {
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
import { Stack, withLayoutContext } from "expo-router";
import React from "react";
const { Navigator } = createMaterialTopTabNavigator();
export const Tab = withLayoutContext<
MaterialTopTabNavigationOptions,
typeof Navigator,
TabNavigationState<ParamListBase>,
MaterialTopTabNavigationEventMap
>(Navigator);
const Layout = () => {
return (
<>
<Stack.Screen options={{ title: "Live TV" }} />
<Tab
initialRouteName="programs"
keyboardDismissMode="none"
screenOptions={{
tabBarBounces: true,
tabBarLabelStyle: { fontSize: 10 },
tabBarItemStyle: {
width: 100,
},
tabBarStyle: { backgroundColor: "black" },
animationEnabled: true,
lazy: true,
swipeEnabled: true,
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
tabBarScrollEnabled: true,
}}
>
<Tab.Screen name="programs" />
<Tab.Screen name="guide" />
<Tab.Screen name="channels" />
<Tab.Screen name="recordings" />
</Tab>
</>
);
};
export default Layout;

View File

@@ -0,0 +1,56 @@
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React from "react";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { data: channels } = useQuery({
queryKey: ["livetv", "channels"],
queryFn: async () => {
const res = await getLiveTvApi(api!).getLiveTvChannels({
startIndex: 0,
limit: 500,
enableFavoriteSorting: true,
userId: user?.Id,
addCurrentProgram: false,
enableUserData: false,
enableImageTypes: ["Primary"],
});
return res.data;
},
});
return (
<View className="flex flex-1">
<FlashList
data={channels?.Items}
estimatedItemSize={76}
renderItem={({ item }) => (
<View className="flex flex-row items-center px-4 mb-2">
<View className="w-22 mr-4 rounded-lg overflow-hidden">
<ItemImage
style={{
aspectRatio: "1/1",
width: 60,
borderRadius: 8,
}}
item={item}
/>
</View>
<Text className="font-bold">{item.Name}</Text>
</View>
)}
/>
</View>
);
}

View File

@@ -0,0 +1,168 @@
import { ItemImage } from "@/components/common/ItemImage";
import { HourHeader } from "@/components/livetv/HourHeader";
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useState } from "react";
import { Button, Dimensions, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const HOUR_HEIGHT = 30;
const ITEMS_PER_PAGE = 20;
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const [date, setDate] = useState<Date>(new Date());
const [currentPage, setCurrentPage] = useState(1);
const { data: guideInfo } = useQuery({
queryKey: ["livetv", "guideInfo"],
queryFn: async () => {
const res = await getLiveTvApi(api!).getGuideInfo();
return res.data;
},
});
const { data: channels } = useQuery({
queryKey: ["livetv", "channels", currentPage],
queryFn: async () => {
const res = await getLiveTvApi(api!).getLiveTvChannels({
startIndex: (currentPage - 1) * ITEMS_PER_PAGE,
limit: ITEMS_PER_PAGE,
enableFavoriteSorting: true,
userId: user?.Id,
addCurrentProgram: false,
enableUserData: false,
enableImageTypes: ["Primary"],
});
return res.data;
},
});
const { data: programs } = useQuery({
queryKey: ["livetv", "programs", date, currentPage],
queryFn: async () => {
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
const now = new Date();
const isToday = startOfDay.toDateString() === now.toDateString();
const res = await getLiveTvApi(api!).getPrograms({
getProgramsDto: {
MaxStartDate: endOfDay.toISOString(),
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
Boolean
) as string[],
ImageTypeLimit: 1,
EnableImages: false,
SortBy: ["StartDate"],
EnableTotalRecordCount: false,
EnableUserData: false,
},
});
return res.data;
},
enabled: !!channels,
});
const screenWidth = Dimensions.get("window").width;
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
const [scrollX, setScrollX] = useState(0);
const handleNextPage = useCallback(() => {
setCurrentPage((prev) => prev + 1);
}, []);
const handlePrevPage = useCallback(() => {
setCurrentPage((prev) => Math.max(1, prev - 1));
}, []);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-row bg-neutral-800 w-full items-end">
<Button
title="Previous"
onPress={handlePrevPage}
disabled={currentPage === 1}
/>
<Button
title="Next"
onPress={handleNextPage}
disabled={
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
}
/>
</View>
<View className="flex flex-row">
<View className="flex flex-col w-[64px]">
<View
style={{
height: HOUR_HEIGHT,
}}
className="bg-neutral-800"
></View>
{channels?.Items?.map((c, i) => (
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
<ItemImage
style={{
width: "100%",
height: "100%",
resizeMode: "contain",
}}
item={c}
/>
</View>
))}
</View>
<ScrollView
style={{
width: screenWidth - 64,
}}
horizontal
scrollEnabled
onScroll={(e) => {
setScrollX(e.nativeEvent.contentOffset.x);
}}
>
<View className="flex flex-col">
<HourHeader height={HOUR_HEIGHT} />
{channels?.Items?.map((c, i) => (
<MemoizedLiveTVGuideRow
channel={c}
programs={programs?.Items}
key={c.Id}
scrollX={scrollX}
/>
))}
</View>
</ScrollView>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,150 @@
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import React from "react";
import {
ScrollView,
View
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
paddingTop: 8,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-col space-y-2">
<ScrollingCollectionList
queryKey={["livetv", "recommended"]}
title={"On now"}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getRecommendedPrograms({
userId: user?.Id,
isAiring: true,
limit: 24,
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
enableTotalRecordCount: false,
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
});
return res.data.Items || [];
}}
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "shows"]}
title={"Shows"}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isMovie: false,
isSeries: true,
isSports: false,
isNews: false,
isKids: false,
enableTotalRecordCount: false,
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "movies"]}
title={"Movies"}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isMovie: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "sports"]}
title={"Sports"}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isSports: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "kids"]}
title={"For Kids"}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isKids: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "news"]}
title={"News"}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isNews: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation="horizontal"
/>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,11 @@
import { Text } from "@/components/common/Text";
import React from "react";
import { View } from "react-native";
export default function page() {
return (
<View className="flex items-center justify-center h-full -mt-12">
<Text>Coming soon</Text>
</View>
);
}

View File

@@ -1,12 +1,8 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import {
useFocusEffect,
useLocalSearchParams,
useNavigation,
} from "expo-router";
import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
import React, { useCallback, useEffect, useMemo } from "react";
import { FlatList, useWindowDimensions, View } from "react-native";
import { Text } from "@/components/common/Text";
@@ -16,6 +12,7 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
@@ -32,7 +29,6 @@ import {
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import { orientationAtom } from "@/utils/atoms/orientation";
import {
BaseItemDto,
BaseItemDtoQueryResult,
@@ -60,12 +56,13 @@ const Page = () => {
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, _setSortBy] = useAtom(sortByAtom);
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [orientation] = useAtom(orientationAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [sortOrderPreference, setOderByPreference] = useAtom(
sortOrderPreferenceAtom
);
const { orientation } = useOrientation();
useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) {
@@ -106,11 +103,12 @@ const Page = () => {
[libraryId, sortOrderPreference]
);
const getNumberOfColumns = useCallback(() => {
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
if (screenWidth < 600) return 5;
if (screenWidth < 960) return 6;
if (screenWidth < 1280) return 7;
const nrOfCols = useMemo(() => {
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
if (screenWidth < 1000) return 6;
if (screenWidth < 1500) return 7;
return 6;
}, [screenWidth, orientation]);
@@ -219,7 +217,7 @@ const Page = () => {
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<MemoizedTouchableItemRouter
<TouchableItemRouter
key={item.Id}
style={{
width: "100%",
@@ -230,10 +228,10 @@ const Page = () => {
<View
style={{
alignSelf:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
? index % 3 === 0
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
? index % nrOfCols === 0
? "flex-end"
: (index + 1) % 3 === 0
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center"
: "center",
@@ -244,7 +242,7 @@ const Page = () => {
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
</TouchableItemRouter>
),
[orientation]
);
@@ -429,6 +427,7 @@ const Page = () => {
return (
<FlashList
key={orientation}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text>
@@ -437,10 +436,10 @@ const Page = () => {
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}
extraData={orientation}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
estimatedItemSize={244}
numColumns={getNumberOfColumns()}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();

View File

@@ -1,4 +1,3 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
@@ -8,7 +7,6 @@ import { Loader } from "@/components/Loader";
import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
@@ -226,10 +224,6 @@ export default function search() {
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-col pt-2">
@@ -278,7 +272,7 @@ export default function search() {
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28"
className="flex flex-col w-28 mr-2"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
@@ -297,7 +291,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-44"
className="flex flex-col w-44 mr-2"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
@@ -311,7 +305,7 @@ export default function search() {
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28"
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
@@ -327,7 +321,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
@@ -341,7 +335,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
@@ -355,7 +349,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
@@ -369,7 +363,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />

View File

@@ -1,87 +1,77 @@
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import React from "react";
import { Platform } from "react-native";
import { withLayoutContext } from "expo-router";
import {
createNativeBottomTabNavigator,
NativeBottomTabNavigationEventMap,
} from "react-native-bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors";
import { BlurView } from "expo-blur";
import * as NavigationBar from "expo-navigation-bar";
import { Tabs } from "expo-router";
import React, { useEffect } from "react";
import { Platform, StyleSheet } from "react-native";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge";
export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions,
typeof Navigator,
TabNavigationState<ParamListBase>,
NativeBottomTabNavigationEventMap
>(Navigator);
export default function TabLayout() {
useEffect(() => {
if (Platform.OS === "android") {
NavigationBar.setBackgroundColorAsync("#121212");
NavigationBar.setBorderColorAsync("#121212");
}
}, []);
return (
<Tabs
initialRouteName="home"
screenOptions={{
tabBarActiveTintColor: Colors.tabIconSelected,
headerShown: false,
tabBarStyle: {
position: "absolute",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
borderTopWidth: 0,
paddingTop: 8,
paddingBottom: Platform.OS === "android" ? 8 : 26,
height: Platform.OS === "android" ? 58 : 74,
},
tabBarBackground: () =>
Platform.OS === "ios" ? (
<BlurView
experimentalBlurMethod="dimezisBlurView"
intensity={95}
style={{
...StyleSheet.absoluteFillObject,
overflow: "hidden",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
backgroundColor: "black",
}}
/>
) : undefined,
}}
>
<Tabs.Screen redirect name="index" />
<Tabs.Screen
name="(home)"
options={{
headerShown: false,
title: "Home",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "home" : "home-outline"}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="(search)"
options={{
headerShown: false,
title: "Search",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? "search" : "search"} color={color} />
),
}}
/>
<Tabs.Screen
name="(libraries)"
options={{
headerShown: false,
title: "Library",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "apps" : "apps-outline"}
color={color}
/>
),
}}
/>
</Tabs>
<>
<SystemBars hidden={false} style="light" />
<NativeTabs
sidebarAdaptable
ignoresTopSafeArea
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
tabBarActiveTintColor={Colors.primary}
scrollEdgeAppearance="default"
>
<NativeTabs.Screen redirect name="index" />
<NativeTabs.Screen
name="(home)"
options={{
title: "Home",
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/house.fill.png")
: () => ({ sfSymbol: "house" }),
}}
/>
<NativeTabs.Screen
name="(search)"
options={{
title: "Search",
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/magnifyingglass.png")
: () => ({ sfSymbol: "magnifyingglass" }),
}}
/>
<NativeTabs.Screen
name="(libraries)"
options={{
title: "Library",
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/server.rack.png")
: () => ({ sfSymbol: "rectangle.stack" }),
}}
/>
</NativeTabs>
</>
);
}

View File

@@ -1,14 +1,304 @@
import { FullScreenMusicPlayer } from "@/components/FullScreenMusicPlayer";
import { StatusBar } from "expo-status-bar";
import { View, ViewProps } from "react-native";
interface Props extends ViewProps {}
import { Controls } from "@/components/video-player/Controls";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
PlaybackType,
usePlaySettings,
} from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { useFocusEffect } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Dimensions, Pressable, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
export default function page() {
const { playSettings, playUrl, playSessionId } = usePlaySettings();
const api = useAtomValue(apiAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const poster = usePoster(playSettings, api);
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
const firstTime = useRef(true);
const screenDimensions = Dimensions.get("screen");
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
const togglePlay = useCallback(
async (ticks: number) => {
console.log("togglePlay");
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: true,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: false,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
}
},
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
);
const play = useCallback(() => {
console.log("play");
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
console.log("play");
videoRef.current?.pause();
}, [videoRef]);
const stop = useCallback(() => {
console.log("stop");
setIsPlaybackStopped(true);
videoRef.current?.pause();
reportPlaybackStopped();
}, [videoRef]);
const reportPlaybackStopped = async () => {
await getPlaystateApi(api).onPlaybackStopped({
itemId: playSettings?.item?.Id!,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(progress.value),
playSessionId: playSessionId ? playSessionId : undefined,
});
};
const reportPlaybackStart = async () => {
await getPlaystateApi(api).onPlaybackStart({
itemId: playSettings?.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
};
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const ticks = data.currentTime * 10000000;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0);
if (!playSettings?.item?.Id || data.currentTime === 0) return;
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item.Id,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
},
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
const { orientation } = useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
return (
<View className="">
<StatusBar hidden={false} />
<FullScreenMusicPlayer />
<View
style={{
width: screenDimensions.width,
height: screenDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<SystemBars hidden />
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
<Image
source={poster}
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
/>
</View>
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full opacity-0"
>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
setIsPlaying(state.isPlaying);
}}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
enableTrickplay={false}
/>
</View>
);
}
export function usePoster(
playSettings: PlaybackType | null,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!playSettings?.item || !api) return undefined;
return playSettings.item.Type === "Audio"
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: playSettings.item,
quality: 70,
width: 200,
});
}, [playSettings?.item, api]);
return poster ?? undefined;
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
poster: string | undefined,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: playUrl,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: playSettings.item?.AlbumArtist ?? undefined,
title: playSettings.item?.Name || "Unknown",
description: playSettings.item?.Overview ?? undefined,
imageUri: poster,
subtitle: playSettings.item?.Album ?? undefined,
},
};
}, [playSettings, api, poster]);
return videoSource;
}

View File

@@ -0,0 +1,165 @@
import { Controls } from "@/components/video-player/Controls";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
PlaybackType,
usePlaySettings,
} from "@/providers/PlaySettingsProvider";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import * as Haptics from "expo-haptics";
import { useFocusEffect } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Pressable, useWindowDimensions, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
export default function page() {
const { playSettings, playUrl } = usePlaySettings();
const api = useAtomValue(apiAtom);
const videoRef = useRef<VideoRef | null>(null);
const videoSource = useVideoSource(playSettings, api, playUrl);
const firstTime = useRef(true);
const dimensions = useWindowDimensions();
useOrientation();
useOrientationSettings();
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const togglePlay = useCallback(async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
} else {
videoRef.current?.resume();
}
}, [isPlaying]);
const play = useCallback(() => {
setIsPlaying(true);
videoRef.current?.resume();
}, [videoRef]);
const stop = useCallback(() => {
setIsPlaying(false);
videoRef.current?.pause();
}, [videoRef]);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
const onProgress = useCallback(async (data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0);
}, []);
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
return (
<View
style={{
width: dimensions.width,
height: dimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<SystemBars hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full"
>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>
</View>
);
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = 0;
return {
uri: playUrl,
isNetwork: false,
startPosition,
metadata: {
artist: playSettings.item?.AlbumArtist ?? undefined,
title: playSettings.item?.Name || "Unknown",
description: playSettings.item?.Overview ?? undefined,
subtitle: playSettings.item?.Album ?? undefined,
},
};
}, [playSettings, api]);
return videoSource;
}

341
app/(auth)/play-video.tsx Normal file
View File

@@ -0,0 +1,341 @@
import { Controls } from "@/components/video-player/Controls";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
PlaybackType,
usePlaySettings,
} from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
import { useFocusEffect } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Pressable, useWindowDimensions, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { useSharedValue } from "react-native-reanimated";
import Video, {
OnProgressData,
SelectedTrackType,
VideoRef,
} from "react-native-video";
export default function page() {
const { playSettings, playUrl, playSessionId } = usePlaySettings();
const api = useAtomValue(apiAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const poster = usePoster(playSettings, api);
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
const firstTime = useRef(true);
const dimensions = useWindowDimensions();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
const togglePlay = useCallback(
async (ticks: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: true,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: false,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
}
},
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
);
const play = useCallback(() => {
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
videoRef.current?.pause();
}, [videoRef]);
const stop = useCallback(() => {
setIsPlaybackStopped(true);
videoRef.current?.pause();
reportPlaybackStopped();
}, [videoRef]);
const reportPlaybackStopped = async () => {
await getPlaystateApi(api).onPlaybackStopped({
itemId: playSettings?.item?.Id!,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(progress.value),
playSessionId: playSessionId ? playSessionId : undefined,
});
};
const reportPlaybackStart = async () => {
await getPlaystateApi(api).onPlaybackStart({
itemId: playSettings?.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
};
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const ticks = data.currentTime * 10000000;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0);
if (!playSettings?.item?.Id || data.currentTime === 0) return;
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item.Id,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
},
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
const selectedSubtitleTrack = useMemo(() => {
const a = playSettings?.mediaSource?.MediaStreams?.find(
(s) => s.Index === playSettings.subtitleIndex
);
console.log(a);
return a;
}, [playSettings]);
const [hlsSubTracks, setHlsSubTracks] = useState<
{
index: number;
language?: string | undefined;
selected?: boolean | undefined;
title?: string | undefined;
type: any;
}[]
>([]);
const selectedTextTrack = useMemo(() => {
for (let st of hlsSubTracks) {
if (st.title === selectedSubtitleTrack?.DisplayTitle) {
return {
type: SelectedTrackType.TITLE,
value: selectedSubtitleTrack?.DisplayTitle ?? "",
};
}
}
return undefined;
}, [hlsSubTracks]);
return (
<View
style={{
flex: 1,
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
width: dimensions.width,
height: dimensions.height,
position: "relative",
}}
>
<SystemBars hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
style={{
position: "absolute",
top: 0,
left: 0,
width: dimensions.width,
height: dimensions.height,
zIndex: 0,
}}
>
<Video
ref={videoRef}
source={videoSource}
style={{
width: dimensions.width,
height: dimensions.height,
}}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
onTextTracks={(data) => {
console.log("onTextTracks ~", data);
setHlsSubTracks(data.textTracks as any);
}}
selectedTextTrack={selectedTextTrack}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>
</View>
);
}
export function usePoster(
playSettings: PlaybackType | null,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!playSettings?.item || !api) return undefined;
return playSettings.item.Type === "Audio"
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: playSettings.item,
quality: 70,
width: 200,
});
}, [playSettings?.item, api]);
return poster ?? undefined;
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
poster: string | undefined,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: playUrl,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: playSettings.item?.AlbumArtist ?? undefined,
title: playSettings.item?.Name || "Unknown",
description: playSettings.item?.Overview ?? undefined,
imageUri: poster,
subtitle: playSettings.item?.Album ?? undefined,
},
};
}, [playSettings, api, poster]);
return videoSource;
}

View File

@@ -1,48 +0,0 @@
import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer";
import { useSettings } from "@/utils/atoms/settings";
import * as NavigationBar from "expo-navigation-bar";
import * as ScreenOrientation from "expo-screen-orientation";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import { Platform, View, ViewProps } from "react-native";
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

@@ -5,10 +5,11 @@ import {
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaybackProvider } from "@/providers/PlaybackProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { writeToLog } from "@/utils/log";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
@@ -30,7 +31,6 @@ import * as Notifications from "expo-notifications";
import { router, Stack } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
import { useEffect, useRef } from "react";
@@ -38,6 +38,7 @@ import { AppState } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import { Toaster } from "sonner-native";
import { SystemBars } from "react-native-edge-to-edge";
SplashScreen.preventAutoHideAsync();
@@ -198,8 +199,10 @@ const checkAndRequestPermissions = async () => {
const { status } = await Notifications.requestPermissionsAsync();
if (status === "granted") {
writeToLog("INFO", "Notification permissions granted.");
console.log("Notification permissions granted.");
} else {
writeToLog("ERROR", "Notification permissions denied.");
console.log("Notification permissions denied.");
}
@@ -208,6 +211,11 @@ const checkAndRequestPermissions = async () => {
console.log("Already asked for notification permissions before.");
}
} catch (error) {
writeToLog(
"ERROR",
"Error checking/requesting notification permissions:",
error
);
console.error("Error checking/requesting notification permissions:", error);
}
};
@@ -312,31 +320,37 @@ function Layout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClientRef.current}>
<JobQueueProvider>
<DownloadProvider>
<ActionSheetProvider>
<BottomSheetModalProvider>
<JellyfinProvider>
<PlaybackProvider>
<StatusBar style="light" backgroundColor="#000" />
<ActionSheetProvider>
<JobQueueProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack
initialRouteName="/home"
screenOptions={{
autoHideHomeIndicator: true,
}}
>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/play"
name="(auth)/play-video"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/play-offline-video"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
@@ -345,6 +359,7 @@ function Layout() {
name="(auth)/play-music"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
@@ -370,12 +385,12 @@ function Layout() {
closeButton
/>
</ThemeProvider>
</PlaybackProvider>
</JellyfinProvider>
</BottomSheetModalProvider>
</ActionSheetProvider>
</DownloadProvider>
</JobQueueProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</JobQueueProvider>
</ActionSheetProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
@@ -397,6 +412,7 @@ async function saveDownloadedItemInfo(item: BaseItemDto) {
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
} catch (error) {
writeToLog("ERROR", "Failed to save downloaded item information:", error);
console.error("Failed to save downloaded item information:", error);
}
}

View File

@@ -5,6 +5,7 @@ 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 { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useState } from "react";
@@ -129,7 +130,7 @@ const Login: React.FC = () => {
if (error.name === "AbortError") {
console.log(`Request to ${protocol}${url} timed out`);
} else {
console.error(`Error checking ${protocol}${url}:`, error);
console.log(`Error checking ${protocol}${url}:`, error);
}
}
}
@@ -195,16 +196,18 @@ const Login: React.FC = () => {
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, height: "100%" }}
>
<View className="flex flex-col justify-between px-4 h-full gap-y-2">
<View></View>
<View>
<View className="flex flex-col w-full h-full relative items-center justify-center">
<View className="px-4 -mt-20">
<View className="mb-4">
<Text className="text-3xl font-bold mb-1">
{serverName || "Streamyfin"}
</Text>
<Text className="text-neutral-500 mb-2">
Server: {api.basePath}
</Text>
<View className="bg-neutral-900 rounded-xl p-4 mb-2 flex flex-row items-center justify-between">
<Text className="">URL</Text>
<Text numberOfLines={1} className="shrink">
{api.basePath}
</Text>
</View>
<Button
color="black"
onPress={() => {
@@ -261,11 +264,11 @@ const Login: React.FC = () => {
<Text className="text-red-600 mb-2">{error}</Text>
</View>
<View className="mt-auto mb-2">
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
<Button
color="black"
onPress={handleQuickConnect}
className="mb-2"
className="w-full mb-2"
>
Use Quick Connect
</Button>
@@ -285,9 +288,17 @@ const Login: React.FC = () => {
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<View className="flex flex-col px-4 justify-between h-full">
<View></View>
<View className="flex flex-col gap-y-2">
<View className="flex flex-col h-full relative items-center justify-center w-full">
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Connect to your Jellyfin server
@@ -303,14 +314,16 @@ const Login: React.FC = () => {
maxLength={500}
/>
</View>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="mb-2"
>
Connect
</Button>
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
>
Connect
</Button>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>

BIN
assets/icons/house.fill.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 49 KiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,20 +1,13 @@
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
onChange: (value: number) => void;
selected: number;
selected?: number | null;
}
export const AudioTrackSelector: React.FC<Props> = ({
@@ -23,8 +16,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
selected,
...props
}) => {
const [settings] = useSettings();
const audioStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
[source]
@@ -35,23 +26,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
[audioStreams, selected]
);
useEffect(() => {
const defaultAudioIndex = audioStreams?.find(
(x) => x.Language === settings?.defaultAudioLanguage
)?.Index;
if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
onChange(defaultAudioIndex);
return;
}
const index = source.DefaultAudioStreamIndex;
if (index !== undefined && index !== null) {
onChange(index);
return;
}
onChange(0);
}, [audioStreams, settings]);
return (
<View
className="flex shrink"

View File

@@ -9,7 +9,7 @@ export type Bitrate = {
height?: number;
};
const BITRATES: Bitrate[] = [
export const BITRATES: Bitrate[] = [
{
key: "Max",
value: undefined,
@@ -39,12 +39,12 @@ const BITRATES: Bitrate[] = [
value: 250000,
height: 480,
},
];
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
selected: Bitrate;
inverted?: boolean;
selected?: Bitrate | null;
inverted?: boolean | null;
}
export const BitrateSelector: React.FC<Props> = ({
@@ -77,7 +77,7 @@ export const BitrateSelector: React.FC<Props> = ({
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{BITRATES.find((b) => b.value === selected.value)?.key}
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
</View>

View File

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

View File

@@ -1,7 +1,7 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
@@ -18,7 +18,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
useEpisodePoster = false,
size = "normal",
}) => {
const [api] = useAtom(apiAtom);
const api = useAtomValue(apiAtom);
/**
* Get horrizontal poster for movie and episode, with failover to primary.
@@ -40,11 +40,26 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.Type === "Program") {
if (item.ImageTags?.["Thumb"])
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
}, [item]);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
);
const progress = useMemo(() => {
if (item.Type === "Program") {
const startDate = new Date(item.StartDate || "");
const endDate = new Date(item.EndDate || "");
const now = new Date();
const total = endDate.getTime() - startDate.getTime();
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
} else {
return item.UserData?.PlayedPercentage || 0;
}
}, [item]);
if (!url)
return (

View File

@@ -17,12 +17,12 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Bitrate, BITRATES, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
@@ -30,6 +30,8 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { toast } from "sonner-native";
import iosFmp4 from "@/utils/profiles/iosFmp4";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
@@ -41,10 +43,11 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { processes, startBackgroundDownload } = useDownload();
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item);
const { startRemuxing } = useRemuxHlsToMp4(item);
const [selectedMediaSource, setSelectedMediaSource] =
useState<MediaSourceInfo | null>(null);
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined
>(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
@@ -53,6 +56,20 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
value: undefined,
});
useFocusEffect(
useCallback(() => {
if (!settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(item, settings);
// 4. Set states
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [item, settings])
);
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
@@ -82,7 +99,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
);
}
let deviceProfile: any = ios;
let deviceProfile: any = iosFmp4;
if (settings?.deviceProfile === "Native") {
deviceProfile = native;

View File

@@ -1,544 +0,0 @@
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
BackHandler,
Dimensions,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Video, { OnProgressData } from "react-native-video";
import { Text } from "./common/Text";
import { itemRouter } from "./common/TouchableItemRouter";
import { Loader } from "./Loader";
const windowDimensions = Dimensions.get("window");
const screenDimensions = Dimensions.get("screen");
export const FullScreenMusicPlayer: React.FC = () => {
const {
currentlyPlaying,
pauseVideo,
playVideo,
stopPlayback,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
setIsBuffering,
} = usePlayback();
const [settings] = useSettings();
const [api] = useAtom(apiAtom);
const router = useRouter();
const segments = useSegments();
const insets = useSafeAreaInsets();
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBufferingState] = useState(true);
// Seconds
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const progress = useSharedValue(0);
const min = useSharedValue(0);
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
const [dimensions, setDimensions] = useState({
window: windowDimensions,
screen: screenDimensions,
});
useEffect(() => {
const subscription = Dimensions.addEventListener(
"change",
({ window, screen }) => {
setDimensions({ window, screen });
}
);
return () => subscription?.remove();
});
const from = useMemo(() => segments[2], [segments]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - current);
setCurrentTime(current);
setRemainingTime(remaining);
},
[]
);
const { showSkipButton, skipIntro } = useIntroSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => {
const backAction = () => {
if (currentlyPlaying) {
Alert.alert("Hold on!", "Are you sure you want to exit?", [
{
text: "Cancel",
onPress: () => null,
style: "cancel",
},
{
text: "Yes",
onPress: () => {
stopPlayback();
router.back();
},
},
]);
return true;
}
return false;
};
const backHandler = BackHandler.addEventListener(
"hardwareBackPress",
backAction
);
return () => backHandler.remove();
}, [currentlyPlaying, stopPlayback, router]);
const poster = useMemo(() => {
if (!currentlyPlaying?.item || !api) return "";
return currentlyPlaying.item.Type === "Audio"
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: currentlyPlaying.item,
quality: 70,
width: 200,
});
}, [currentlyPlaying?.item, api]);
const videoSource = useMemo(() => {
if (!api || !currentlyPlaying || !poster) return null;
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
title: currentlyPlaying.item?.Name || "Unknown",
description: currentlyPlaying.item?.Overview ?? undefined,
imageUri: poster,
subtitle: currentlyPlaying.item?.Album ?? undefined,
},
};
}, [currentlyPlaying, api, poster]);
useEffect(() => {
if (currentlyPlaying) {
progress.value =
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
max.value = currentlyPlaying.item.RunTimeTicks || 0;
setShowControls(true);
playVideo();
}
}, [currentlyPlaying]);
const toggleControls = () => setShowControls(!showControls);
const handleVideoProgress = useCallback(
(data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBufferingState(data.playableDuration === 0);
setIsBuffering(data.playableDuration === 0);
onProgress(data);
},
[onProgress, setIsBuffering, isSeeking]
);
const handleVideoError = useCallback(
(e: any) => {
console.log(e);
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
},
[setIsPlaying]
);
const handlePlayPause = useCallback(() => {
if (isPlaying) pauseVideo();
else playVideo();
}, [isPlaying, pauseVideo, playVideo]);
const handleSliderComplete = (value: number) => {
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(value / 10000000);
};
const handleSliderChange = (value: number) => {};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
isSeeking.value = true;
}, []);
const handleSkipBackward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings]);
const handleSkipForward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings]);
const handleGoToPreviousItem = useCallback(() => {
if (!previousItem || !from) return;
const url = itemRouter(previousItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [previousItem, from, stopPlayback, router]);
const handleGoToNextItem = useCallback(() => {
if (!nextItem || !from) return;
const url = itemRouter(nextItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [nextItem, from, stopPlayback, router]);
if (!currentlyPlaying) return null;
return (
<View
style={{
width: dimensions.window.width,
height: dimensions.window.height,
backgroundColor: "black",
}}
>
<Pressable
onPress={toggleControls}
style={[
{
position: "absolute",
top: 0,
bottom: 0,
left: insets.left,
right: insets.right,
width: dimensions.window.width - (insets.left + insets.right),
},
]}
>
{videoSource && (
<>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={"contain"}
onProgress={handleVideoProgress}
onLoad={(data) => (max.value = secondsToTicks(data.duration))}
onError={handleVideoError}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
/>
</>
)}
</Pressable>
<View pointerEvents="none" className="p-4">
<Image
source={poster ? { uri: poster } : undefined}
style={{
width: "100%",
height: "100%",
resizeMode: "contain",
}}
/>
</View>
{(showControls || isBuffering) && (
<View
pointerEvents="none"
style={[
{
top: 0,
left: 0,
position: "absolute",
width: dimensions.window.width,
height: dimensions.window.height,
},
]}
className=" bg-black/50 z-0"
></View>
)}
{isBuffering && (
<View
pointerEvents="none"
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
>
<Loader />
</View>
)}
{showSkipButton && (
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 70,
right: insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
)}
{showSkipCreditButton && (
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 70,
right: insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
)}
{showControls && (
<>
<View
style={[
{
position: "absolute",
top: insets.top,
right: insets.right + 16,
height: 70,
zIndex: 10,
},
]}
className="flex flex-row items-center space-x-2 z-10"
>
<TouchableOpacity
onPress={() => {
stopPlayback();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 8,
left: insets.left + 16,
width:
dimensions.window.width - insets.left - insets.right - 32,
},
]}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
{currentlyPlaying.item?.Type === "Episode" && (
<Text className="opacity-50">
{currentlyPlaying.item.SeriesName}
</Text>
)}
{currentlyPlaying.item?.Type === "Movie" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.ProductionYear}
</Text>
)}
{currentlyPlaying.item?.Type === "Audio" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
)}
</View>
<View
className={`flex ${"flex-col-reverse py-4 px-4 rounded-2xl"}
items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={handleGoToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handlePlayPause}>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={handleGoToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View
className={`flex flex-col w-full shrink
${""}
`}
>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
sliderHeight={10}
progress={progress}
thumbWidth={0}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</View>
</View>
</View>
</>
)}
</View>
);
};

View File

@@ -1,625 +0,0 @@
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
BackHandler,
Dimensions,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Video, { OnProgressData } from "react-native-video";
import { Text } from "./common/Text";
import { itemRouter } from "./common/TouchableItemRouter";
import { Loader } from "./Loader";
const windowDimensions = Dimensions.get("window");
const screenDimensions = Dimensions.get("screen");
export const FullScreenVideoPlayer: React.FC = () => {
const {
currentlyPlaying,
pauseVideo,
playVideo,
stopPlayback,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
setIsBuffering,
} = usePlayback();
const [settings] = useSettings();
const [api] = useAtom(apiAtom);
const router = useRouter();
const segments = useSegments();
const insets = useSafeAreaInsets();
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
useTrickplay(currentlyPlaying);
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBufferingState] = useState(true);
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
);
// Seconds
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const progress = useSharedValue(0);
const min = useSharedValue(0);
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
const [dimensions, setDimensions] = useState({
window: windowDimensions,
screen: screenDimensions,
});
useEffect(() => {
const dimensionsSubscription = Dimensions.addEventListener(
"change",
({ window, screen }) => {
setDimensions({ window, screen });
}
);
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {
setOrientation(
orientationToOrientationLock(event.orientationInfo.orientation)
);
});
ScreenOrientation.getOrientationAsync().then((orientation) => {
setOrientation(orientationToOrientationLock(orientation));
});
return () => {
dimensionsSubscription.remove();
orientationSubscription.remove();
};
}, []);
const from = useMemo(() => segments[2], [segments]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - current);
setCurrentTime(current);
setRemainingTime(remaining);
},
[]
);
const { showSkipButton, skipIntro } = useIntroSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => {
const backAction = () => {
if (currentlyPlaying) {
Alert.alert("Hold on!", "Are you sure you want to exit?", [
{
text: "Cancel",
onPress: () => null,
style: "cancel",
},
{
text: "Yes",
onPress: () => {
stopPlayback();
router.back();
},
},
]);
return true;
}
return false;
};
const backHandler = BackHandler.addEventListener(
"hardwareBackPress",
backAction
);
return () => backHandler.remove();
}, [currentlyPlaying, stopPlayback, router]);
const isLandscape = useMemo(() => {
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
? true
: false;
}, [orientation]);
const poster = useMemo(() => {
if (!currentlyPlaying?.item || !api) return "";
return currentlyPlaying.item.Type === "Audio"
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: currentlyPlaying.item,
quality: 70,
width: 200,
});
}, [currentlyPlaying?.item, api]);
const videoSource = useMemo(() => {
if (!api || !currentlyPlaying || !poster) return null;
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
title: currentlyPlaying.item?.Name || "Unknown",
description: currentlyPlaying.item?.Overview ?? undefined,
imageUri: poster,
subtitle: currentlyPlaying.item?.Album ?? undefined,
},
};
}, [currentlyPlaying, api, poster]);
useEffect(() => {
if (currentlyPlaying) {
progress.value =
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
max.value = currentlyPlaying.item.RunTimeTicks || 0;
setShowControls(true);
playVideo();
}
}, [currentlyPlaying]);
const toggleControls = () => setShowControls(!showControls);
const handleVideoProgress = useCallback(
(data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBufferingState(data.playableDuration === 0);
setIsBuffering(data.playableDuration === 0);
onProgress(data);
},
[onProgress, setIsBuffering, isSeeking]
);
const handleVideoError = useCallback(
(e: any) => {
console.log(e);
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
},
[setIsPlaying]
);
const handlePlayPause = useCallback(() => {
if (isPlaying) pauseVideo();
else playVideo();
}, [isPlaying, pauseVideo, playVideo]);
const handleSliderComplete = (value: number) => {
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(value / 10000000);
};
const handleSliderChange = (value: number) => {
calculateTrickplayUrl(value);
};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
isSeeking.value = true;
}, []);
const handleSkipBackward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings]);
const handleSkipForward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings]);
const handleGoToPreviousItem = useCallback(() => {
if (!previousItem || !from) return;
const url = itemRouter(previousItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [previousItem, from, stopPlayback, router]);
const handleGoToNextItem = useCallback(() => {
if (!nextItem || !from) return;
const url = itemRouter(nextItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [nextItem, from, stopPlayback, router]);
const toggleIgnoreSafeArea = useCallback(() => {
setIgnoreSafeArea((prev) => !prev);
}, []);
if (!currentlyPlaying) return null;
return (
<View
style={{
width: dimensions.window.width,
height: dimensions.window.height,
backgroundColor: "black",
}}
>
<Pressable
onPress={toggleControls}
style={[
{
position: "absolute",
top: 0,
bottom: 0,
left: ignoreSafeArea ? 0 : insets.left,
right: ignoreSafeArea ? 0 : insets.right,
width: ignoreSafeArea
? dimensions.window.width
: dimensions.window.width - (insets.left + insets.right),
},
]}
>
{videoSource && (
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeArea ? "cover" : "contain"}
onProgress={handleVideoProgress}
onLoad={(data) => (max.value = secondsToTicks(data.duration))}
onError={handleVideoError}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
/>
)}
</Pressable>
{(showControls || isBuffering) && (
<View
pointerEvents="none"
style={[
{
top: 0,
left: 0,
position: "absolute",
width: dimensions.window.width,
height: dimensions.window.height,
},
]}
className=" bg-black/50 z-0"
></View>
)}
{isBuffering && (
<View
pointerEvents="none"
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
>
<Loader />
</View>
)}
{showSkipButton && (
<View
style={[
{
position: "absolute",
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
)}
{showSkipCreditButton && (
<View
style={[
{
position: "absolute",
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
)}
{showControls && (
<>
<View
style={[
{
position: "absolute",
top: insets.top,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
zIndex: 10,
},
]}
className="flex flex-row items-center space-x-2 z-10"
>
<TouchableOpacity
onPress={toggleIgnoreSafeArea}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeArea ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
stopPlayback();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 8,
left: isLandscape ? insets.left + 32 : insets.left + 16,
width: isLandscape
? dimensions.window.width - insets.left - insets.right - 64
: dimensions.window.width - insets.left - insets.right - 32,
},
]}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
{currentlyPlaying.item?.Type === "Episode" && (
<Text className="opacity-50">
{currentlyPlaying.item.SeriesName}
</Text>
)}
{currentlyPlaying.item?.Type === "Movie" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.ProductionYear}
</Text>
)}
{currentlyPlaying.item?.Type === "Audio" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
)}
</View>
<View
className={`flex ${
isLandscape
? "flex-row space-x-6 py-2 px-4 rounded-full"
: "flex-col-reverse py-4 px-4 rounded-2xl"
}
items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={handleGoToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handlePlayPause}>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={handleGoToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View
className={`flex flex-col w-full shrink
${""}
`}
>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</View>
</View>
</View>
</>
)}
</View>
);
};

View File

@@ -12,296 +12,210 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getItemImage } from "@/utils/getItemImage";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { Stack, useNavigation } from "expo-router";
import { useFocusEffect, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
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);
const [user] = useAtom(userAtom);
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
const [api] = useAtom(apiAtom);
const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
const [settings] = useSettings();
const navigation = useNavigation();
const opacity = useSharedValue(0);
const castDevice = useCastDevice();
const navigation = useNavigation();
const [settings] = useSettings();
const [selectedMediaSource, setSelectedMediaSource] =
useState<MediaSourceInfo | null>(null);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(-1);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
const [loadingLogo, setLoadingLogo] = useState(true);
const [loadingLogo, setLoadingLogo] = useState(true);
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
useFocusEffect(
useCallback(() => {
if (!settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(item, settings);
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
const fadeIn = () => {
opacity.value = withTiming(1, { duration: 300 });
};
const fadeOut = (callback: any) => {
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
};
const headerHeightRef = useRef(400);
const {
data: item,
isLoading,
isFetching,
} = useQuery({
queryKey: ["item", id],
queryFn: async () => {
const res = await getUserItemData({
api,
userId: user?.Id,
itemId: id,
});
return res;
},
enabled: !!id && !!api,
staleTime: 60 * 1000 * 5,
});
const [localItem, setLocalItem] = useState(item);
useImageColors(item);
useEffect(() => {
if (item) {
if (localItem) {
// Fade out current item
fadeOut(() => {
// Update local item after fade out
setLocalItem(item);
// Then fade in
fadeIn();
setPlaySettings({
item,
bitrate,
mediaSource,
audioIndex,
subtitleIndex,
});
} else {
// If there's no current item, just set and fade in
setLocalItem(item);
fadeIn();
}
} else {
// If item is null, fade out and clear local item
fadeOut(() => setLocalItem(null));
}
}, [item]);
useEffect(() => {
navigation.setOptions({
headerRight: () =>
item && (
<View className="flex flex-row items-center space-x-2">
<Chromecast background="blur" width={22} height={22} />
<DownloadItem item={item} />
<PlayedStatus item={item} />
</View>
),
});
}, [item]);
if (!mediaSource) {
Alert.alert("Error", "No media source found for this item.");
navigation.goBack();
}
}, [item, settings])
);
useEffect(() => {
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
headerHeightRef.current = 230;
return;
}
if (item?.Type === "Episode") headerHeightRef.current = 400;
else if (item?.Type === "Movie") headerHeightRef.current = 500;
else headerHeightRef.current = 400;
}, [item, orientation]);
const selectedMediaSource = useMemo(() => {
return playSettings?.mediaSource || undefined;
}, [playSettings?.mediaSource]);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", item?.Id],
queryFn: async () => {
if (!api || !user?.Id || !item?.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
userId: user?.Id,
const setSelectedMediaSource = (mediaSource: MediaSourceInfo) => {
setPlaySettings((prev) => ({
...prev,
mediaSource,
}));
};
const selectedAudioStream = useMemo(() => {
return playSettings?.audioIndex;
}, [playSettings?.audioIndex]);
const setSelectedAudioStream = (audioIndex: number) => {
setPlaySettings((prev) => ({
...prev,
audioIndex,
}));
};
const selectedSubtitleStream = useMemo(() => {
return playSettings?.subtitleIndex;
}, [playSettings?.subtitleIndex]);
const setSelectedSubtitleStream = (subtitleIndex: number) => {
setPlaySettings((prev) => ({
...prev,
subtitleIndex,
}));
};
const maxBitrate = useMemo(() => {
return playSettings?.bitrate;
}, [playSettings?.bitrate]);
const setMaxBitrate = (bitrate: Bitrate | undefined) => {
console.log("setMaxBitrate", bitrate);
setPlaySettings((prev) => ({
...prev,
bitrate,
}));
};
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return playbackData.data;
},
enabled: !!item?.Id && !!api && !!user?.Id,
staleTime: 0,
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
const { data: playbackUrl } = useQuery({
queryKey: [
"playbackUrl",
item?.Id,
maxBitrate,
castDevice,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings,
],
queryFn: async () => {
if (!api || !user?.Id || !sessionData || !selectedMediaSource?.Id)
return null;
const [headerHeight, setHeaderHeight] = useState(350);
let deviceProfile: any = ios;
useImageColors({ item });
if (castDevice?.deviceId) {
deviceProfile = chromecastProfile;
} else if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
const url = await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate.value,
sessionData,
deviceProfile,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
forceDirectPlay: settings?.forceDirectPlay,
height: maxBitrate.height,
mediaSourceId: selectedMediaSource.Id,
});
console.info("Stream URL:", url);
return url;
},
enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
staleTime: 0,
});
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
const loading = useMemo(() => {
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
}, [isLoading, isFetching, loadingLogo, logoUrl]);
const insets = useSafeAreaInsets();
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
{loading && (
<View className="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex flex-col justify-center items-center z-50">
<Loader />
</View>
)}
<ParallaxScrollView
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
headerHeight={headerHeightRef.current}
headerImage={
<>
<Animated.View style={[animatedStyle, { flex: 1 }]}>
{localItem && (
<ItemImage
variant={
localItem.Type === "Movie" && logoUrl
? "Backdrop"
: "Primary"
}
item={localItem}
style={{
width: "100%",
height: "100%",
}}
/>
useEffect(() => {
navigation.setOptions({
headerRight: () =>
item && (
<View className="flex flex-row items-center space-x-2">
<Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<DownloadItem item={item} />
<PlayedStatus item={item} />
</View>
)}
</Animated.View>
</>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
) : null}
</>
}
</View>
),
});
}, [item]);
useEffect(() => {
// If landscape
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
setHeaderHeight(230);
return;
}
if (item.Type === "Movie") setHeaderHeight(500);
else setHeaderHeight(350);
}, [item.Type, orientation]);
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
const insets = useSafeAreaInsets();
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View className="flex flex-col bg-transparent shrink">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<Animated.View style={[animatedStyle, { flex: 1 }]}>
<ItemHeader item={localItem} className="mb-4" />
{localItem ? (
<ParallaxScrollView
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
headerHeight={headerHeight}
headerImage={
<View style={[{ flex: 1 }]}>
<ItemImage
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
) : null}
</>
}
>
<View className="flex flex-col bg-transparent shrink">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && (
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector
className="mr-1"
@@ -310,7 +224,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
/>
<MediaSourceSelector
className="mr-1"
item={localItem}
item={item}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
@@ -330,46 +244,45 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
</>
)}
</View>
) : (
<View className="h-16">
<View className="bg-neutral-900 h-4 w-2/4 rounded-md mb-1"></View>
<View className="bg-neutral-900 h-10 w-3/4 rounded-lg"></View>
</View>
)}
</Animated.View>
<PlayButton item={item} url={playbackUrl} className="grow" />
</View>
{item?.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
)}
<OverviewText text={item?.Overview} className="px-4 my-4" />
<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"
/>
))}
<PlayButton className="grow" />
</View>
)}
{item?.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<SimilarItems itemId={item?.Id} />
{item.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
)}
<View className="h-16"></View>
</View>
</ParallaxScrollView>
</View>
);
});
<OverviewText text={item.Overview} className="px-4 my-4" />
{item.Type !== "Program" && (
<>
<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" />
)}
<SimilarItems itemId={item.Id} />
</>
)}
<View className="h-16"></View>
</View>
</ParallaxScrollView>
</View>
);
}
);

View File

@@ -21,10 +21,10 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
{...props}
>
<View className="flex flex-col">
<View className="flex flex-col overflow-visible">
<Text className="font-bold ">{title}</Text>
{subTitle && (
<Text className="text-xs" selectable>
<Text uiTextView selectable className="text-xs">
{subTitle}
</Text>
)}

View File

@@ -12,7 +12,7 @@ import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
onChange: (value: MediaSourceInfo) => void;
selected: MediaSourceInfo | null;
selected?: MediaSourceInfo | null;
}
export const MediaSourceSelector: React.FC<Props> = ({
@@ -21,21 +21,19 @@ export const MediaSourceSelector: React.FC<Props> = ({
selected,
...props
}) => {
const mediaSources = useMemo(() => {
return item.MediaSources;
}, [item]);
const selectedMediaSource = useMemo(
const selectedName = useMemo(
() =>
mediaSources
?.find((x) => x.Id === selected?.Id)
?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "",
[mediaSources, selected]
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video"
)?.DisplayTitle || "",
[item.MediaSources, selected]
);
useEffect(() => {
if (mediaSources?.length) onChange(mediaSources[0]);
}, [mediaSources]);
if (!selected && item.MediaSources && item.MediaSources.length > 0) {
onChange(item.MediaSources[0]);
}
}, [item.MediaSources, selected]);
const name = (name?: string | null) => {
if (name && name.length > 40)
@@ -56,8 +54,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Video</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center ">
<Text numberOfLines={1}>{selectedMediaSource}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
@@ -71,7 +69,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
sideOffset={8}
>
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
{mediaSources?.map((source, idx: number) => (
{item.MediaSources?.map((source, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {

View File

@@ -1,37 +0,0 @@
import React, { useEffect, useRef } from "react";
import Video, { VideoRef } from "react-native-video";
type VideoPlayerProps = {
url: string;
};
export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
const videoRef = useRef<VideoRef | null>(null);
const onError = (error: any) => {
console.error("Video Error: ", error);
};
useEffect(() => {
if (videoRef.current) {
videoRef.current.resume();
}
setTimeout(() => {
if (videoRef.current) {
videoRef.current.presentFullscreenPlayer();
}
}, 500);
}, []);
return (
<Video
source={{
uri: url,
isNetwork: false,
}}
ref={videoRef}
onError={onError}
ignoreSilentSwitch="ignore"
/>
);
};

View File

@@ -1,16 +1,16 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
import { Alert, Linking, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -28,47 +28,65 @@ import Animated, {
import { Button } from "./Button";
import { Text } from "./common/Text";
import { useRouter } from "expo-router";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
interface Props extends React.ComponentProps<typeof Button> {
item?: BaseItemDto | null;
url?: string | null;
}
interface Props extends React.ComponentProps<typeof Button> {}
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
export const PlayButton: React.FC<Props> = ({ ...props }) => {
const { playSettings, playUrl: url } = usePlaySettings();
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const { setCurrentlyPlayingState } = usePlayback();
const mediaStatus = useMediaStatus();
const [colorAtom] = useAtom(itemThemeColorAtom);
const [api] = useAtom(apiAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const router = useRouter();
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
const endColor = useSharedValue(memoizedColor);
const startColor = useSharedValue(memoizedColor);
const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
const directStream = useMemo(() => {
return !url?.includes("m3u8");
}, [url]);
const onPress = async () => {
if (!url || !item) return;
if (!client) {
setCurrentlyPlayingState({ item, url });
router.push("/play");
const item = useMemo(() => {
return playSettings?.item;
}, [playSettings?.item]);
const onPress = useCallback(async () => {
if (!url || !item) {
console.warn(
"No URL or item provided to PlayButton",
url?.slice(0, 100),
item?.Id
);
return;
}
if (!client) {
const vlcLink = "vlc://" + url;
if (vlcLink && settings?.openInVLC) {
Linking.openURL(vlcLink);
return;
}
router.push("/play-video");
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
@@ -84,7 +102,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then((state) => {
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
@@ -94,10 +112,34 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
CastContext.showExpandedControls();
return;
}
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
api,
deviceProfile: chromecastProfile,
item,
mediaSourceId: playSettings?.mediaSource?.Id,
startTimeTicks: 0,
maxStreamingBitrate: playSettings?.bitrate?.value,
audioStreamIndex: playSettings?.audioIndex ?? 0,
subtitleStreamIndex: playSettings?.subtitleIndex ?? -1,
userId: user?.Id,
forceDirectPlay: settings?.forceDirectPlay,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
"Client error",
"Could not create stream for Chromecast"
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: url,
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
@@ -163,29 +205,39 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
});
break;
case 1:
setCurrentlyPlayingState({ item, url });
router.push("/play");
router.push("/play-video");
break;
case cancelButtonIndex:
break;
}
}
);
};
}, [
url,
item,
client,
settings,
api,
user,
playSettings,
router,
showActionSheetWithOptions,
mediaStatus,
]);
const derivedTargetWidth = useDerivedValue(() => {
if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
const userData = memoizedItem.UserData;
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH
)
: 0;
}
return 0;
}, [memoizedItem]);
}, [item]);
useAnimatedReaction(
() => derivedTargetWidth.value,
@@ -201,7 +253,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
);
useAnimatedReaction(
() => memoizedColor,
() => colorAtom,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -210,19 +262,19 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[memoizedColor]
[colorAtom]
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = memoizedColor;
startColor.value = colorAtom;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [memoizedColor, memoizedItem]);
}, [colorAtom, item]);
/**
* ANIMATED STYLES
@@ -305,6 +357,16 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>

View File

@@ -44,6 +44,9 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
});
queryClient.invalidateQueries({
queryKey: ["home"],
});
};
return (

View File

@@ -1,20 +1,14 @@
import { tc } from "@/utils/textTools";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
onChange: (value: number) => void;
selected: number;
selected?: number | null;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
@@ -23,8 +17,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
selected,
...props
}) => {
const [settings] = useSettings();
const subtitleStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
[source]
@@ -35,23 +27,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
[subtitleStreams, selected]
);
useEffect(() => {
// const index = source.DefaultAudioStreamIndex;
// if (index !== undefined && index !== null) {
// onChange(index);
// return;
// }
const defaultSubIndex = subtitleStreams?.find(
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
)?.Index;
if (defaultSubIndex !== undefined && defaultSubIndex !== null) {
onChange(defaultSubIndex);
return;
}
onChange(-1);
}, [subtitleStreams, settings]);
if (subtitleStreams.length === 0) return null;
return (

View File

@@ -1,4 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { View } from "react-native";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {

View File

@@ -24,14 +24,14 @@ export const HeaderBackButton: React.FC<Props> = ({
if (background === "transparent" && Platform.OS !== "android")
return (
<BlurView
{...props}
intensity={100}
className="overflow-hidden rounded-full p-2"
<TouchableOpacity
onPress={() => router.back()}
{...touchableOpacityProps}
>
<TouchableOpacity
onPress={() => router.back()}
{...touchableOpacityProps}
<BlurView
{...props}
intensity={100}
className="overflow-hidden rounded-full p-2"
>
<Ionicons
className="drop-shadow-2xl"
@@ -39,8 +39,8 @@ export const HeaderBackButton: React.FC<Props> = ({
size={24}
color="white"
/>
</TouchableOpacity>
</BlurView>
</BlurView>
</TouchableOpacity>
);
return (

View File

@@ -10,9 +10,9 @@ export function Input(props: TextInputProps) {
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
placeholderTextColor={"#9CA3AF"}
clearButtonMode="while-editing"
{...otherProps}
/>
);
}

View File

@@ -1,11 +1,16 @@
import React from "react";
import { TextProps } from "react-native";
import { Text as DefaultText } from "react-native";
export function Text(props: TextProps) {
import { UITextView } from "react-native-uitextview";
export function Text(
props: TextProps & {
uiTextView?: boolean;
}
) {
const { style, ...otherProps } = props;
return (
<DefaultText
<UITextView
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}

View File

@@ -9,6 +9,10 @@ interface Props extends TouchableOpacityProps {
}
export const itemRouter = (item: BaseItemDto, from: string) => {
if (item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
}
if (item.Type === "Series") {
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}

View File

@@ -76,10 +76,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
<TouchableOpacity
onPress={handleOpenFile}
onLongPress={showActionSheet}
className="flex flex-col"
className="flex flex-col w-44 mr-2"
>
{base64Image ? (
<View className="w-44 aspect-video rounded-lg overflow-hidden mr-2">
<View className="w-44 aspect-video rounded-lg overflow-hidden">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
@@ -92,7 +92,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
/>
</View>
) : (
<View className="w-44 aspect-video rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}

View File

@@ -44,7 +44,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{seasonItems.sort(sortByIndex)?.map((item, index) => (
<EpisodeCard item={item} />
<EpisodeCard item={item} key={index} />
))}
</View>
</ScrollView>

View File

@@ -9,10 +9,8 @@ import {
import { ScrollView, View, ViewProps } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import SeriesPoster from "../posters/SeriesPoster";
import { FlashList } from "@shopify/flash-list";
interface Props extends ViewProps {
title?: string | null;
@@ -34,7 +32,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryKey,
queryFn,
enabled: !disabled,
staleTime: 60 * 1000,
staleTime: 0,
});
if (disabled || !title) return null;
@@ -44,6 +42,11 @@ export const ScrollingCollectionList: React.FC<Props> = ({
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
{title}
</Text>
{isLoading === false && data?.length === 0 && (
<View className="px-4">
<Text className="text-neutral-500">No items</Text>
</View>
)}
{isLoading ? (
<View
className={`
@@ -98,6 +101,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
<MoviePoster item={item} />
)}
{item.Type === "Series" && <SeriesPoster item={item} />}
{item.Type === "Program" && (
<ContinueWatchingPoster item={item} />
)}
<ItemCardText item={item} />
</TouchableItemRouter>
))}

View File

@@ -11,21 +11,14 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { TouchableOpacityProps, View } from "react-native";
import { getColors } from "react-native-image-colors";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
interface Props extends TouchableOpacityProps {
library: BaseItemDto;
}
type LibraryColor = {
dominantColor: string;
averageColor: string;
secondary: string;
};
type IconName = React.ComponentProps<typeof Ionicons>["name"];
const icons: Record<CollectionType, IconName> = {
@@ -48,12 +41,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
const [user] = useAtom(userAtom);
const [settings] = useSettings();
const [imageInfo, setImageInfo] = useState<LibraryColor>({
dominantColor: "#fff",
averageColor: "#fff",
secondary: "#fff",
});
const url = useMemo(
() =>
getPrimaryImageUrl({
@@ -74,42 +61,9 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
});
return response.data.TotalRecordCount;
},
staleTime: 1000 * 60 * 60,
});
useEffect(() => {
if (url) {
getColors(url, {
fallback: "#fff",
cache: true,
key: url,
})
.then((colors) => {
let dominantColor: string = "#fff";
let averageColor: string = "#fff";
let secondary: string = "#fff";
if (colors.platform === "android") {
dominantColor = colors.dominant;
averageColor = colors.average;
secondary = colors.muted;
} else if (colors.platform === "ios") {
dominantColor = colors.primary;
averageColor = colors.background;
secondary = colors.detail;
}
setImageInfo({
dominantColor,
averageColor,
secondary,
});
})
.catch((error) => {
console.error("Error getting colors", error);
});
}
}, [url]);
if (!url) return null;
if (settings?.libraryOptions?.display === "row") {

View File

@@ -0,0 +1,43 @@
import React from "react";
import { View } from "react-native";
import { Text } from "../common/Text";
export const HourHeader = ({ height }: { height: number }) => {
const now = new Date();
const currentHour = now.getHours();
const hoursRemaining = 24 - currentHour;
const hours = generateHours(currentHour, hoursRemaining);
return (
<View
className="flex flex-row"
style={{
height,
}}
>
{hours.map((hour, index) => (
<HourCell key={index} hour={hour} />
))}
</View>
);
};
const HourCell = ({ hour }: { hour: Date }) => (
<View className="w-[200px] flex items-center justify-center bg-neutral-800">
<Text className="text-xs text-gray-600">
{hour.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</View>
);
const generateHours = (startHour: number, count: number): Date[] => {
const now = new Date();
return Array.from({ length: count }, (_, i) => {
const hour = new Date(now);
hour.setHours(startHour + i, 0, 0, 0);
return hour;
});
};

View File

@@ -0,0 +1,96 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useMemo, useRef } from "react";
import { Dimensions, View } from "react-native";
import { Text } from "../common/Text";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
export const LiveTVGuideRow = ({
channel,
programs,
scrollX = 0,
isVisible = true,
}: {
channel: BaseItemDto;
programs?: BaseItemDto[] | null;
scrollX?: number;
isVisible?: boolean;
}) => {
const positionRefs = useRef<{ [key: string]: number }>({});
const screenWidth = Dimensions.get("window").width;
const calculateWidth = (s?: string | null, e?: string | null) => {
if (!s || !e) return 0;
const start = new Date(s);
const end = new Date(e);
const duration = end.getTime() - start.getTime();
const minutes = duration / 60000;
const width = (minutes / 60) * 200;
return width;
};
const programsWithPositions = useMemo(() => {
let cumulativeWidth = 0;
return programs
?.filter((p) => p.ChannelId === channel.Id)
.map((p) => {
const width = calculateWidth(p.StartDate, p.EndDate);
const position = cumulativeWidth;
cumulativeWidth += width;
return { ...p, width, position };
});
}, [programs, channel.Id]);
const isCurrentlyLive = (program: BaseItemDto) => {
if (!program.StartDate || !program.EndDate) return false;
const now = new Date();
const start = new Date(program.StartDate);
const end = new Date(program.EndDate);
return now >= start && now <= end;
};
if (!isVisible) {
return <View style={{ height: 64 }} />;
}
return (
<View key={channel.ChannelNumber} className="flex flex-row h-16">
{programsWithPositions?.map((p) => (
<TouchableItemRouter item={p} key={p.Id}>
<View
style={{
width: p.width,
height: "100%",
position: "absolute",
left: p.position,
backgroundColor: isCurrentlyLive(p)
? "rgba(255, 255, 255, 0.1)"
: "transparent",
}}
className="flex flex-col items-center justify-center border border-neutral-800 overflow-hidden"
>
{(() => {
return (
<View
style={{
marginLeft:
p.width > screenWidth && scrollX > p.position
? scrollX - p.position
: 0,
}}
className="px-4 self-start"
>
<Text
numberOfLines={2}
className="text-xs text-start self-start"
>
{p.Name}
</Text>
</View>
);
})()}
</View>
</TouchableItemRouter>
))}
</View>
);
};

View File

@@ -31,10 +31,10 @@ export const MediaListSection: React.FC<Props> = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: collection, isLoading } = useQuery({
const { data: collection } = useQuery({
queryKey,
queryFn,
staleTime: 60 * 1000,
staleTime: 0,
});
const fetchItems = useCallback(

View File

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

View File

@@ -1,15 +1,12 @@
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { runtimeTicksToSeconds } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import CastContext, {
PlayServicesState,
@@ -40,7 +37,7 @@ export const SongsListItem: React.FC<Props> = ({
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
const { setCurrentlyPlayingState } = usePlayback();
const { setPlaySettings } = usePlaySettings();
const openSelect = () => {
if (!castDevice?.deviceId) {
@@ -71,32 +68,18 @@ export const SongsListItem: React.FC<Props> = ({
);
};
const play = async (type: "device" | "cast") => {
const play = useCallback(async (type: "device" | "cast") => {
if (!user?.Id || !api || !item.Id) {
console.warn("No user, api or item", user, api, item.Id);
return;
}
const response = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
userId: user?.Id,
});
const sessionData = response.data;
const url = await getStreamUrl({
api,
userId: user.Id,
const data = await setPlaySettings({
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
mediaSourceId: item.Id,
});
if (!url || !item) {
console.warn("No url or item", url, item.Id);
return;
if (!data?.url) {
throw new Error("play-music ~ No stream url");
}
if (type === "cast" && client) {
@@ -106,7 +89,7 @@ export const SongsListItem: React.FC<Props> = ({
else {
client.loadMedia({
mediaInfo: {
contentUrl: url,
contentUrl: data.url!,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
@@ -119,14 +102,10 @@ export const SongsListItem: React.FC<Props> = ({
}
});
} else {
console.log("Playing on device", url, item.Id);
setCurrentlyPlayingState({
item,
url,
});
console.log("Playing on device", data.url, item.Id);
router.push("/play-music");
}
};
}, []);
return (
<TouchableOpacity

View File

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

View File

@@ -13,7 +13,7 @@ interface Props extends React.ComponentProps<typeof Button> {
type?: "next" | "previous";
}
export const NextEpisodeButton: React.FC<Props> = ({
export const NextItemButton: React.FC<Props> = ({
item,
type = "next",
...props
@@ -23,8 +23,8 @@ export const NextEpisodeButton: React.FC<Props> = ({
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const { data: nextEpisode } = useQuery({
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
const { data: nextItem } = useQuery({
queryKey: ["nextItem", item.Id, item.ParentId, type],
queryFn: async () => {
if (
!api ||
@@ -47,16 +47,16 @@ export const NextEpisodeButton: React.FC<Props> = ({
});
const disabled = useMemo(() => {
if (!nextEpisode) return true;
if (nextEpisode.Id === item.Id) return true;
if (!nextItem) return true;
if (nextItem.Id === item.Id) return true;
return false;
}, [nextEpisode, type]);
}, [nextItem, type]);
if (item.Type !== "Episode") return null;
return (
<Button
onPress={() => router.setParams({ id: nextEpisode?.Id })}
onPress={() => router.setParams({ id: nextItem?.Id })}
className={`h-12 aspect-square`}
disabled={disabled}
{...props}

View File

@@ -85,7 +85,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
userId: user?.Id,
itemId: previousId,
}),
staleTime: 60 * 1000,
staleTime: 60 * 1000 * 5,
});
}
@@ -101,7 +101,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
userId: user?.Id,
itemId: nextId,
}),
staleTime: 60 * 1000,
staleTime: 60 * 1000 * 5,
});
}
}, [episodes, api, user?.Id, item]);

View File

@@ -1,5 +1,3 @@
import { Stack } from "expo-router";
import { Chromecast } from "../Chromecast";
import { HeaderBackButton } from "../common/HeaderBackButton";
const commonScreenOptions = {

View File

@@ -0,0 +1,496 @@
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { writeToLog } from "@/utils/log";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Dimensions,
Platform,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import Animated, {
runOnJS,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
interface Props {
item: BaseItemDto;
videoRef: React.MutableRefObject<VideoRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
progress: SharedValue<number>;
isBuffering: boolean;
showControls: boolean;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
enableTrickplay?: boolean;
togglePlay: (ticks: number) => void;
setShowControls: (shown: boolean) => void;
}
export const Controls: React.FC<Props> = ({
item,
videoRef,
togglePlay,
isPlaying,
isSeeking,
progress,
isBuffering,
cacheProgress,
showControls,
setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
enableTrickplay = true,
}) => {
const [settings] = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
const { setPlaySettings } = usePlaySettings();
const windowDimensions = Dimensions.get("window");
const { previousItem, nextItem } = useAdjacentItems({ item });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
item,
enableTrickplay
);
const [currentTime, setCurrentTime] = useState(0); // Seconds
const [remainingTime, setRemainingTime] = useState(0); // Seconds
const min = useSharedValue(0);
const max = useSharedValue(item.RunTimeTicks || 0);
const wasPlayingRef = useRef(false);
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id,
currentTime,
videoRef
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
item.Id,
currentTime,
videoRef
);
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(previousItem, settings);
setPlaySettings({
item: previousItem,
bitrate,
mediaSource,
audioIndex,
subtitleIndex,
});
router.replace("/play-video");
}, [previousItem, settings]);
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(nextItem, settings);
setPlaySettings({
item: nextItem,
bitrate,
mediaSource,
audioIndex,
subtitleIndex,
});
router.replace("/play-video");
}, [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(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => {
if (item) {
progress.value = item?.UserData?.PlaybackPositionTicks || 0;
max.value = item.RunTimeTicks || 0;
}
}, [item]);
const toggleControls = () => setShowControls(!showControls);
const handleSliderComplete = useCallback((value: number) => {
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(Math.max(0, Math.floor(value / 10000000)));
if (wasPlayingRef.current === true) videoRef.current?.resume();
}, []);
const handleSliderChange = (value: number) => {
calculateTrickplayUrl(value);
};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
wasPlayingRef.current = isPlaying;
videoRef.current?.pause();
isSeeking.value = true;
}, [showControls, isPlaying]);
const handleSkipBackward = useCallback(async () => {
console.log("handleSkipBackward");
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
setTimeout(() => {
if (wasPlayingRef.current === true) videoRef.current?.resume();
}, 10);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings, isPlaying]);
const handleSkipForward = useCallback(async () => {
console.log("handleSkipForward");
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
setTimeout(() => {
if (wasPlayingRef.current === true) videoRef.current?.resume();
}, 10);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying]);
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
}, []);
return (
<View
style={[
{
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width,
height: windowDimensions.height,
},
]}
>
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 97,
right: insets.right,
},
]}
className={`z-10 p-4
${showSkipButton ? "opacity-100" : "opacity-0"}
`}
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
<View
style={{
position: "absolute",
bottom: insets.bottom + 94,
right: insets.right,
height: 70,
}}
pointerEvents={showSkipCreditButton ? "auto" : "none"}
className={`z-10 p-4 ${
showSkipCreditButton ? "opacity-100" : "opacity-0"
}`}
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
<Pressable
onPress={() => {
toggleControls();
}}
>
<View
style={[
{
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width + 100,
height: windowDimensions.height + 100,
},
]}
className={`bg-black/50 z-0`}
></View>
</Pressable>
<View
style={{
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width,
height: windowDimensions.height,
}}
pointerEvents="none"
className={`flex flex-col items-center justify-center
${isBuffering ? "opacity-100" : "opacity-0"}
`}
>
<Loader />
</View>
<View
style={[
{
position: "absolute",
top: insets.top,
right: insets.right,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4`}
>
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
width: windowDimensions.width - insets.left - insets.right,
maxHeight: windowDimensions.height,
left: insets.left,
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-col p-4 `}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
<View
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800/90`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={goToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay(progress.value);
}}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={goToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</View>
</View>
</View>
</View>
);
};

View File

@@ -1,15 +1,8 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
const tintColorLight = "#0a7ea4";
const tintColorDark = "#fff";
export const Colors = {
primary: "#9334E9",
text: "#ECEDEE",
background: "#151718",
tint: tintColorDark,
tint: "#fff",
icon: "#9BA1A6",
tabIconDefault: "#9BA1A6",
tabIconSelected: "#9333ea",

View File

@@ -2,38 +2,38 @@ 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" },
{ label: "Spanish", value: "spa" },
{ label: "Chinese (Mandarin)", value: "cmn" },
{ label: "Hindi", value: "hin" },
{ label: "Arabic", value: "ara" },
{ label: "French", value: "fra" },
{ label: "Russian", value: "rus" },
{ label: "Portuguese", value: "por" },
{ label: "Japanese", value: "jpn" },
{ label: "German", value: "deu" },
{ label: "Italian", value: "ita" },
{ label: "Korean", value: "kor" },
{ label: "Turkish", value: "tur" },
{ label: "Dutch", value: "nld" },
{ label: "Polish", value: "pol" },
{ label: "Vietnamese", value: "vie" },
{ label: "Thai", value: "tha" },
{ label: "Indonesian", value: "ind" },
{ label: "Greek", value: "ell" },
{ label: "Swedish", value: "swe" },
{ label: "Danish", value: "dan" },
{ label: "Norwegian", value: "nor" },
{ label: "Finnish", value: "fin" },
{ label: "Czech", value: "ces" },
{ label: "Hungarian", value: "hun" },
{ label: "Romanian", value: "ron" },
{ label: "Ukrainian", value: "ukr" },
{ label: "Hebrew", value: "heb" },
{ label: "Bengali", value: "ben" },
{ label: "Punjabi", value: "pan" },
{ label: "Tagalog", value: "tgl" },
{ label: "Swahili", value: "swa" },
{ label: "Malay", value: "msa" },
{ label: "Persian", value: "fas" },
{ label: "Urdu", value: "urd" },
];

View File

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

15
edge-to-edge-fix.patch Normal file
View File

@@ -0,0 +1,15 @@
--- expo.js.original 2024-11-10 09:08:19
+++ node_modules/react-native-edge-to-edge/dist/commonjs/expo.js 2024-11-10 09:08:23
@@ -19,10 +19,8 @@
const {
barStyle
} = androidStatusBar;
+ const android = props?.android || {};
const {
- android = {}
- } = props;
- const {
parentTheme = "Default"
} = android;
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
\ No newline at end of file

56
expo.js.original Normal file
View File

@@ -0,0 +1,56 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _configPlugins = require("@expo/config-plugins");
const withAndroidEdgeToEdgeTheme = (config, props) => {
const themes = {
Material2: "Theme.EdgeToEdge.Material2",
Material3: "Theme.EdgeToEdge.Material3"
};
const ignoreList = new Set(["android:enforceNavigationBarContrast", "android:enforceStatusBarContrast", "android:fitsSystemWindows", "android:navigationBarColor", "android:statusBarColor", "android:windowDrawsSystemBarBackgrounds", "android:windowLayoutInDisplayCutoutMode", "android:windowLightNavigationBar", "android:windowLightStatusBar", "android:windowTranslucentNavigation", "android:windowTranslucentStatus"]);
return (0, _configPlugins.withAndroidStyles)(config, config => {
const {
androidStatusBar = {},
userInterfaceStyle = "light"
} = config;
const {
barStyle
} = androidStatusBar;
const {
android = {}
} = props;
const {
parentTheme = "Default"
} = android;
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
if (style.$.name === "AppTheme") {
style.$.parent = themes[parentTheme] ?? "Theme.EdgeToEdge";
if (style.item != null) {
style.item = style.item.filter(item => !ignoreList.has(item.$.name));
}
if (barStyle != null) {
style.item.push({
$: {
name: "android:windowLightStatusBar"
},
_: String(barStyle === "dark-content")
});
} else if (userInterfaceStyle !== "automatic") {
style.item.push({
$: {
name: "android:windowLightStatusBar"
},
_: String(userInterfaceStyle === "light")
});
}
}
return style;
});
return config;
});
};
var _default = exports.default = (0, _configPlugins.createRunOncePlugin)(withAndroidEdgeToEdgeTheme, "react-native-edge-to-edge");
//# sourceMappingURL=expo.js.map

View File

@@ -1,75 +1,98 @@
import { Api } from "@jellyfin/sdk";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useQuery } from "@tanstack/react-query";
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
import { useAtom } from "jotai";
import index from "@/app/(auth)/(tabs)/(home)";
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
interface AdjacentEpisodesProps {
currentlyPlaying?: CurrentlyPlayingState | null;
item?: BaseItemDto | null;
}
export const useAdjacentEpisodes = ({
currentlyPlaying,
}: AdjacentEpisodesProps) => {
const [api] = useAtom(apiAtom);
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
const api = useAtomValue(apiAtom);
const { data: previousItem } = useQuery({
queryKey: [
"previousItem",
currentlyPlaying?.item.ParentId,
currentlyPlaying?.item.IndexNumber,
],
queryKey: ["previousItem", item?.Id, item?.ParentId, item?.IndexNumber],
queryFn: async (): Promise<BaseItemDto | null> => {
const parentId = item?.AlbumId || item?.ParentId;
const indexNumber = item?.IndexNumber;
console.log("Getting previous item for " + indexNumber);
if (
!api ||
!currentlyPlaying?.item.ParentId ||
currentlyPlaying?.item.IndexNumber === undefined ||
currentlyPlaying?.item.IndexNumber === null ||
currentlyPlaying.item.IndexNumber - 2 < 0
!parentId ||
indexNumber === undefined ||
indexNumber === null ||
indexNumber - 1 < 1
) {
console.log("No previous item");
console.log("No previous item", {
itemIndex: indexNumber,
itemId: item?.Id,
parentId: parentId,
indexNumber: indexNumber,
});
return null;
}
const newIndexNumber = indexNumber - 2;
const res = await getItemsApi(api).getItems({
parentId: currentlyPlaying.item.ParentId!,
startIndex: currentlyPlaying.item.IndexNumber! - 2,
parentId: parentId!,
startIndex: newIndexNumber,
limit: 1,
sortBy: ["IndexNumber"],
includeItemTypes: ["Episode", "Audio"],
fields: ["MediaSources", "MediaStreams", "ParentId"],
});
if (res.data.Items?.[0]?.IndexNumber !== indexNumber - 1) {
throw new Error("Previous item is not correct");
}
return res.data.Items?.[0] || null;
},
enabled: currentlyPlaying?.item.Type === "Episode",
enabled: item?.Type === "Episode" || item?.Type === "Audio",
staleTime: 0,
});
const { data: nextItem } = useQuery({
queryKey: [
"nextItem",
currentlyPlaying?.item.ParentId,
currentlyPlaying?.item.IndexNumber,
],
queryKey: ["nextItem", item?.Id, item?.ParentId, item?.IndexNumber],
queryFn: async (): Promise<BaseItemDto | null> => {
const parentId = item?.AlbumId || item?.ParentId;
const indexNumber = item?.IndexNumber;
if (
!api ||
!currentlyPlaying?.item.ParentId ||
currentlyPlaying?.item.IndexNumber === undefined ||
currentlyPlaying?.item.IndexNumber === null
!parentId ||
indexNumber === undefined ||
indexNumber === null
) {
console.log("No next item");
console.log("No next item", {
itemId: item?.Id,
parentId: parentId,
indexNumber: indexNumber,
});
return null;
}
const res = await getItemsApi(api).getItems({
parentId: currentlyPlaying.item.ParentId!,
startIndex: currentlyPlaying.item.IndexNumber!,
parentId: parentId!,
startIndex: indexNumber,
sortBy: ["IndexNumber"],
limit: 1,
includeItemTypes: ["Episode", "Audio"],
fields: ["MediaSources", "MediaStreams", "ParentId"],
});
if (res.data.Items?.[0]?.IndexNumber !== indexNumber + 1) {
throw new Error("Previous item is not correct");
}
return res.data.Items?.[0] || null;
},
enabled: currentlyPlaying?.item.Type === "Episode",
enabled: item?.Type === "Episode" || item?.Type === "Audio",
staleTime: 0,
});
return { previousItem, nextItem };

View File

@@ -48,6 +48,7 @@ export const useCreditSkipper = (
return res?.data;
},
enabled: !!itemId,
retry: false,
});
useEffect(() => {
@@ -60,9 +61,13 @@ export const useCreditSkipper = (
}, [creditTimestamps, currentTime]);
const skipCredit = useCallback(() => {
console.log("skipCredits");
if (!creditTimestamps || !videoRef.current) return;
try {
videoRef.current.seek(creditTimestamps.Credits.End);
setTimeout(() => {
videoRef.current?.resume();
}, 200);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}

View File

@@ -1,6 +1,7 @@
// hooks/useFileOpener.ts
import { usePlayback } from "@/providers/PlaybackProvider";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
@@ -8,48 +9,44 @@ import { useCallback } from "react";
export const useFileOpener = () => {
const router = useRouter();
const { startDownloadedFilePlayback } = usePlayback();
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
const openFile = useCallback(
async (item: BaseItemDto) => {
const directory = FileSystem.documentDirectory;
const openFile = useCallback(async (item: BaseItemDto) => {
const directory = FileSystem.documentDirectory;
if (!directory) {
throw new Error("Document directory is not available");
if (!directory) {
throw new Error("Document directory is not available");
}
if (!item.Id) {
throw new Error("Item ID is not available");
}
try {
const files = await FileSystem.readDirectoryAsync(directory);
for (let f of files) {
console.log(f);
}
const path = item.Id!;
const matchingFile = files.find((file) => file.startsWith(path));
if (!matchingFile) {
throw new Error(`No file found for item ${path}`);
}
if (!item.Id) {
throw new Error("Item ID is not available");
}
const url = `${directory}${matchingFile}`;
try {
const files = await FileSystem.readDirectoryAsync(directory);
for (let f of files) {
console.log(f);
}
const path = item.Id!;
const matchingFile = files.find((file) => file.startsWith(path));
setOfflineSettings({
item,
});
setPlayUrl(url);
if (!matchingFile) {
throw new Error(`No file found for item ${path}`);
}
const url = `${directory}${matchingFile}`;
console.log("Opening " + url);
startDownloadedFilePlayback({
item,
url,
});
router.push("/play");
} catch (error) {
console.error("Error opening file:", error);
// Handle the error appropriately, e.g., show an error message to the user
}
},
[startDownloadedFilePlayback]
);
router.push("/play-offline-video");
} catch (error) {
writeToLog("ERROR", "Error opening file", error);
console.error("Error opening file:", error);
}
}, []);
return { openFile };
};

View File

@@ -8,7 +8,7 @@ import {
import { getItemImage } from "@/utils/getItemImage";
import { storage } from "@/utils/mmkv";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { getColors } from "react-native-image-colors";
@@ -19,19 +19,30 @@ import { getColors } from "react-native-image-colors";
* @param disabled - A boolean flag to disable color extraction.
*
*/
export const useImageColors = (item?: BaseItemDto | null, disabled = false) => {
const [api] = useAtom(apiAtom);
export const useImageColors = ({
item,
url,
disabled,
}: {
item?: BaseItemDto | null;
url?: string | null;
disabled?: boolean;
}) => {
const api = useAtomValue(apiAtom);
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
const source = useMemo(() => {
if (!api || !item) return;
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
if (!api) return;
if (url) return { uri: url };
else if (item)
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
else return null;
}, [api, item]);
useEffect(() => {
@@ -43,7 +54,7 @@ export const useImageColors = (item?: BaseItemDto | null, disabled = false) => {
// If colors are cached, use them and exit
if (_primary && _text) {
console.info("[useImageColors] Using cached colors for performance.");
console.info("useImageColors ~ Using cached colors for performance.");
setPrimaryColor({
primary: _primary,
text: _text,
@@ -54,22 +65,25 @@ export const useImageColors = (item?: BaseItemDto | null, disabled = false) => {
// Extract colors from the image
getColors(source.uri, {
fallback: "#fff",
cache: true,
key: source.uri,
cache: false,
})
.then((colors) => {
let primary: string = "#fff";
let text: string = "#000";
let backup: string = "#fff";
// Select the appropriate color based on the platform
if (colors.platform === "android") {
primary = colors.dominant;
backup = colors.vibrant;
} else if (colors.platform === "ios") {
primary = colors.primary;
primary = colors.detail;
backup = colors.primary;
}
// Adjust the primary color if it's too close to black
if (primary && isCloseToBlack(primary)) {
if (backup && !isCloseToBlack(backup)) primary = backup;
primary = adjustToNearBlack(primary);
}

View File

@@ -44,6 +44,7 @@ export const useIntroSkipper = (
return res?.data;
},
enabled: !!itemId,
retry: false,
});
useEffect(() => {
@@ -56,9 +57,13 @@ export const useIntroSkipper = (
}, [introTimestamps, currentTime]);
const skipIntro = useCallback(() => {
console.log("skipIntro");
if (!introTimestamps || !videoRef.current) return;
try {
videoRef.current.seek(introTimestamps.IntroEnd);
setTimeout(() => {
videoRef.current?.resume();
}, 200);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}

View File

@@ -1,27 +0,0 @@
// hooks/useNavigationBarVisibility.ts
import { useEffect } from "react";
import { Platform } from "react-native";
import * as NavigationBar from "expo-navigation-bar";
export const useNavigationBarVisibility = (isPlaying: boolean | null) => {
useEffect(() => {
const handleVisibility = async () => {
if (Platform.OS === "android") {
if (isPlaying) {
await NavigationBar.setVisibilityAsync("hidden");
} else {
await NavigationBar.setVisibilityAsync("visible");
}
}
};
handleVisibility();
return () => {
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("visible");
}
};
}, [isPlaying]);
};

28
hooks/useOrientation.ts Normal file
View File

@@ -0,0 +1,28 @@
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import * as ScreenOrientation from "expo-screen-orientation";
import { useEffect, useState } from "react";
export const useOrientation = () => {
const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
);
useEffect(() => {
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {
setOrientation(
orientationToOrientationLock(event.orientationInfo.orientation)
);
});
ScreenOrientation.getOrientationAsync().then((orientation) => {
setOrientation(orientationToOrientationLock(orientation));
});
return () => {
orientationSubscription.remove();
};
}, []);
return { orientation };
};

View File

@@ -0,0 +1,25 @@
import { useSettings } from "@/utils/atoms/settings";
import * as ScreenOrientation from "expo-screen-orientation";
import { useEffect } from "react";
export const useOrientationSettings = () => {
const [settings] = useSettings();
useEffect(() => {
if (settings?.autoRotate) {
// Don't need to do anything
} else if (settings?.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
return () => {
if (settings?.autoRotate) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}
};
}, [settings]);
};

View File

@@ -1,5 +1,5 @@
import { useCallback } from "react";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
@@ -10,6 +10,9 @@ import { toast } from "sonner-native";
import { useDownload } from "@/providers/DownloadProvider";
import { useRouter } from "expo-router";
import { JobStatus } from "@/utils/optimize-server";
import useImageStorage from "./useImageStorage";
import { getItemImage } from "@/utils/getItemImage";
import { apiAtom } from "@/providers/JellyfinProvider";
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -19,9 +22,12 @@ import { JobStatus } from "@/utils/optimize-server";
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();
const { saveDownloadedItemInfo, setProcesses } = useDownload();
const router = useRouter();
const { loadImage, saveImage, image2Base64, saveBase64Image } =
useImageStorage();
if (!item.Id || !item.Name) {
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
@@ -32,8 +38,19 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
const startRemuxing = useCallback(
async (url: string) => {
if (!api) throw new Error("API is not defined");
if (!item.Id) throw new Error("Item must have an Id");
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
toast.success(`Download started for ${item.Name}`, {
action: {
label: "Go to download",

View File

@@ -1,11 +1,9 @@
// hooks/useTrickplay.ts
import { useState, useCallback, useMemo, useRef } from "react";
import { Api } from "@jellyfin/sdk";
import { SharedValue } from "react-native-reanimated";
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
interface TrickplayData {
Interval?: number;
@@ -28,21 +26,19 @@ interface TrickplayUrl {
url: string;
}
export const useTrickplay = (
currentlyPlaying?: CurrentlyPlayingState | null
) => {
export const useTrickplay = (item: BaseItemDto, enabled = true) => {
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) {
if (!enabled || !item.Id || !item.Trickplay) {
return null;
}
const mediaSourceId = currentlyPlaying.item.Id;
const trickplayData = currentlyPlaying.item.Trickplay[mediaSourceId];
const mediaSourceId = item.Id;
const trickplayData = item.Trickplay[mediaSourceId];
if (!trickplayData) {
return null;
@@ -59,17 +55,21 @@ export const useTrickplay = (
data: trickplayData[firstResolution],
}
: null;
}, [currentlyPlaying]);
}, [item, enabled]);
const calculateTrickplayUrl = useCallback(
(progress: number) => {
if (!enabled) {
return null;
}
const now = Date.now();
if (now - lastCalculationTime.current < throttleDelay) {
return null;
}
lastCalculationTime.current = now;
if (!trickplayInfo || !api || !currentlyPlaying?.item.Id) {
if (!trickplayInfo || !api || !item.Id) {
return null;
}
@@ -95,14 +95,18 @@ export const useTrickplay = (
const newTrickPlayUrl = {
x: rowInTile,
y: colInTile,
url: `${api.basePath}/Videos/${currentlyPlaying.item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
};
setTrickPlayUrl(newTrickPlayUrl);
return newTrickPlayUrl;
},
[trickplayInfo, currentlyPlaying, api]
[trickplayInfo, item, api, enabled]
);
return { trickPlayUrl, calculateTrickplayUrl, trickplayInfo };
return {
trickPlayUrl: enabled ? trickPlayUrl : null,
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null,
trickplayInfo: enabled ? trickplayInfo : null,
};
};

112
hooks/useWebsockets.ts Normal file
View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { Alert } from "react-native";
import { Router, useRouter } from "expo-router";
import { Api } from "@jellyfin/sdk";
import { useAtomValue } from "jotai";
import {
apiAtom,
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
interface UseWebSocketProps {
isPlaying: boolean;
pauseVideo: () => void;
playVideo: () => void;
stopPlayback: () => void;
}
export const useWebSocket = ({
isPlaying,
pauseVideo,
playVideo,
stopPlayback,
}: UseWebSocketProps) => {
const router = useRouter();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const { data: deviceId } = useQuery({
queryKey: ["deviceId"],
queryFn: async () => {
return await getOrSetDeviceId();
},
staleTime: Infinity,
});
useEffect(() => {
if (!deviceId || !api?.accessToken) return;
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken
}&deviceId=${deviceId}`;
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
}
}, 30000);
};
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
newWebSocket.onclose = (e) => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId, user]);
useEffect(() => {
if (!ws) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
const command = json?.Data?.Command;
console.log("[WS] ~ ", json);
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
if (isPlaying) pauseVideo();
else playVideo();
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
router.canGoBack() && router.back();
} else if (json?.Data?.Name === "DisplayMessage") {
console.log("Command ~ DisplayMessage");
const title = json?.Data?.Arguments?.Header;
const body = json?.Data?.Arguments?.Text;
Alert.alert("Message from server: " + title, body);
}
};
}, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router]);
return { isConnected };
};

View File

@@ -9,7 +9,8 @@
"ios": "expo run:ios",
"web": "expo start --web",
"test": "jest --watchAll",
"lint": "expo lint"
"lint": "expo lint",
"postinstall": "patch-package"
},
"jest": {
"preset": "jest-expo"
@@ -18,24 +19,28 @@
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.3",
"@futurejj/react-native-visibility-sensor": "^1.3.4",
"@gorhom/bottom-sheet": "^4",
"@jellyfin/sdk": "^0.10.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.1",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.3.1",
"@react-native-menu/menu": "^1.1.3",
"@react-navigation/material-top-tabs": "^6.6.14",
"@react-navigation/native": "^6.0.2",
"@shopify/flash-list": "1.6.4",
"@tanstack/react-query": "^5.56.2",
"@types/lodash": "^4.17.9",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0",
"add": "^2.0.6",
"axios": "^1.7.7",
"expo": "~51.0.36",
"expo": "~51.0.39",
"expo-background-fetch": "~12.0.1",
"expo-blur": "~13.0.2",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.27",
"expo-dev-client": "~4.0.29",
"expo-device": "~6.0.2",
"expo-font": "~12.0.10",
"expo-haptics": "~13.0.1",
@@ -43,43 +48,48 @@
"expo-keep-awake": "~13.0.2",
"expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7",
"expo-network": "~6.0.1",
"expo-notifications": "~0.28.18",
"expo-router": "~3.5.23",
"expo-notifications": "~0.28.19",
"expo-router": "~3.5.24",
"expo-screen-orientation": "~7.0.5",
"expo-sensors": "~13.0.9",
"expo-splash-screen": "~0.27.6",
"expo-splash-screen": "~0.27.7",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-task-manager": "~11.8.2",
"expo-updates": "~0.25.26",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"install": "^0.13.0",
"jotai": "^2.10.0",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "~0.75.0",
"react-native": "0.74.5",
"react-native-awesome-slider": "^2.5.3",
"react-native-bottom-tabs": "^0.4.0",
"react-native-circular-progress": "^1.4.0",
"react-native-compressor": "^1.8.25",
"react-native-gesture-handler": "~2.18.1",
"react-native-edge-to-edge": "^1.1.0",
"react-native-gesture-handler": "~2.16.1",
"react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.8.3",
"react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^2.5.1",
"react-native-ios-utilities": "^4.4.5",
"react-native-mmkv": "^2.12.2",
"react-native-reanimated": "~3.15.0",
"react-native-pager-view": "6.3.0",
"react-native-reanimated": "~3.10.1",
"react-native-reanimated-carousel": "4.0.0-canary.15",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "~3.34.0",
"react-native-screens": "3.31.1",
"react-native-svg": "15.2.0",
"react-native-tab-view": "^3.5.2",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
"react-native-video": "^6.6.3",
"react-native-video": "^6.6.4",
"react-native-web": "~0.19.10",
"sonner-native": "^0.14.2",
"tailwindcss": "3.3.2",
@@ -95,18 +105,10 @@
"@types/react-test-renderer": "^18.0.7",
"jest": "^29.2.1",
"jest-expo": "~51.0.4",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "18.2.0",
"typescript": "~5.3.3"
},
"private": true,
"expo": {
"install": {
"exclude": [
"react-native@~0.74.0",
"react-native-reanimated@~3.10.0",
"react-native-gesture-handler@~2.16.1",
"react-native-screens@~3.31.1"
]
}
}
"private": true
}

View File

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

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

@@ -345,6 +345,7 @@ function useDownloadProvider() {
},
});
} catch (error) {
writeToLog("ERROR", "Error in startBackgroundDownload", error);
console.error("Error in startBackgroundDownload:", error);
if (axios.isAxiosError(error)) {
console.error("Axios error details:", {

View File

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

View File

@@ -0,0 +1,176 @@
import { Bitrate } from "@/components/BitrateSelector";
import { settingsAtom } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { apiAtom, userAtom } from "./JellyfinProvider";
import iosFmp4 from "@/utils/profiles/iosFmp4";
export type PlaybackType = {
item?: BaseItemDto | null;
mediaSource?: MediaSourceInfo | null;
subtitleIndex?: number | null;
audioIndex?: number | null;
bitrate?: Bitrate | null;
};
type PlaySettingsContextType = {
playSettings: PlaybackType | null;
setPlaySettings: (
dataOrUpdater:
| PlaybackType
| null
| ((prev: PlaybackType | null) => PlaybackType | null)
) => Promise<{ url: string | null; sessionId: string | null } | null>;
playUrl?: string | null;
setPlayUrl: React.Dispatch<React.SetStateAction<string | null>>;
playSessionId?: string | null;
setOfflineSettings: (data: PlaybackType) => void;
setMusicPlaySettings: (item: BaseItemDto, url: string) => void;
};
const PlaySettingsContext = createContext<PlaySettingsContextType | undefined>(
undefined
);
export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [playSettings, _setPlaySettings] = useState<PlaybackType | null>(null);
const [playUrl, setPlayUrl] = useState<string | null>(null);
const [playSessionId, setPlaySessionId] = useState<string | null>(null);
const api = useAtomValue(apiAtom);
const settings = useAtomValue(settingsAtom);
const user = useAtomValue(userAtom);
const setOfflineSettings = useCallback((data: PlaybackType) => {
_setPlaySettings(data);
}, []);
const setMusicPlaySettings = (item: BaseItemDto, url: string) => {
setPlaySettings({
item: item,
});
setPlayUrl(url);
};
const setPlaySettings = useCallback(
async (
dataOrUpdater:
| PlaybackType
| null
| ((prev: PlaybackType | null) => PlaybackType | null)
): Promise<{ url: string | null; sessionId: string | null } | null> => {
if (!api || !user || !settings) {
_setPlaySettings(null);
return null;
}
const newSettings =
typeof dataOrUpdater === "function"
? dataOrUpdater(playSettings)
: dataOrUpdater;
if (newSettings === null) {
_setPlaySettings(null);
return null;
}
let deviceProfile: any = iosFmp4;
if (settings?.deviceProfile === "Native") deviceProfile = native;
if (settings?.deviceProfile === "Old") deviceProfile = old;
try {
const data = await getStreamUrl({
api,
deviceProfile,
item: newSettings?.item,
mediaSourceId: newSettings?.mediaSource?.Id,
startTimeTicks: 0,
maxStreamingBitrate: newSettings?.bitrate?.value,
audioStreamIndex: newSettings?.audioIndex ?? 0,
subtitleStreamIndex: newSettings?.subtitleIndex ?? -1,
userId: user.Id,
forceDirectPlay: settings.forceDirectPlay,
});
console.log("getStreamUrl ~ ", data?.url);
_setPlaySettings(newSettings);
setPlayUrl(data?.url!);
setPlaySessionId(data?.sessionId!);
return data;
} catch (error) {
console.warn("Error getting stream URL:", error);
return null;
}
},
[api, user, settings, playSettings]
);
useEffect(() => {
let deviceProfile: any = ios;
if (settings?.deviceProfile === "Native") deviceProfile = native;
if (settings?.deviceProfile === "Old") deviceProfile = old;
const postCaps = async () => {
if (!api) return;
await getSessionApi(api).postFullCapabilities({
clientCapabilitiesDto: {
AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
DeviceProfile: deviceProfile,
IconUrl:
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/redesign/public/assets/images/icon_new_withoutBackground.png",
PlayableMediaTypes: ["Audio", "Video"],
SupportedCommands: ["Play"],
SupportsMediaControl: true,
SupportsPersistentIdentifier: true,
},
});
};
postCaps();
}, [settings, api]);
return (
<PlaySettingsContext.Provider
value={{
playSettings,
setPlaySettings,
playUrl,
setPlayUrl,
setMusicPlaySettings,
setOfflineSettings,
playSessionId,
}}
>
{children}
</PlaySettingsContext.Provider>
);
};
export const usePlaySettings = () => {
const context = useContext(PlaySettingsContext);
if (context === undefined) {
throw new Error(
"usePlaySettings must be used within a PlaySettingsProvider"
);
}
return context;
};

View File

@@ -1,386 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useSettings } from "@/utils/atoms/settings";
import { getDeviceId } from "@/utils/device";
import { SubtitleTrack } from "@/utils/hls/parseM3U8ForSubtitles";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { postCapabilities } from "@/utils/jellyfin/session/capabilities";
import {
BaseItemDto,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import * as Linking from "expo-linking";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import { Alert } from "react-native";
import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider";
export type CurrentlyPlayingState = {
url: string;
item: BaseItemDto;
};
interface PlaybackContextType {
sessionData: PlaybackInfoResponse | null | undefined;
currentlyPlaying: CurrentlyPlayingState | null;
videoRef: React.MutableRefObject<VideoRef | null>;
isPlaying: boolean;
isFullscreen: boolean;
progressTicks: number | null;
playVideo: (triggerRef?: boolean) => void;
pauseVideo: (triggerRef?: boolean) => void;
stopPlayback: () => void;
presentFullscreenPlayer: () => void;
dismissFullscreenPlayer: () => void;
setIsFullscreen: (isFullscreen: boolean) => void;
setIsPlaying: (isPlaying: boolean) => void;
isBuffering: boolean;
setIsBuffering: (val: boolean) => void;
onProgress: (data: OnProgressData) => void;
setVolume: (volume: number) => void;
setCurrentlyPlayingState: (
currentlyPlaying: CurrentlyPlayingState | null
) => void;
startDownloadedFilePlayback: (
currentlyPlaying: CurrentlyPlayingState | null
) => void;
subtitles: SubtitleTrack[];
}
const PlaybackContext = createContext<PlaybackContextType | null>(null);
export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter();
const videoRef = useRef<VideoRef | null>(null);
const [settings] = useSettings();
const previousVolume = useRef<number | null>(null);
const [isPlaying, _setIsPlaying] = useState<boolean>(false);
const [isBuffering, setIsBuffering] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [progressTicks, setProgressTicks] = useState<number | null>(0);
const [volume, _setVolume] = useState<number | null>(null);
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
const [subtitles, setSubtitles] = useState<SubtitleTrack[]>([]);
const [currentlyPlaying, setCurrentlyPlaying] =
useState<CurrentlyPlayingState | null>(null);
// WS
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const setVolume = useCallback(
(newVolume: number) => {
previousVolume.current = volume;
_setVolume(newVolume);
videoRef.current?.setVolume(newVolume);
},
[_setVolume]
);
const { data: deviceId } = useQuery({
queryKey: ["deviceId", api],
queryFn: getDeviceId,
});
const startDownloadedFilePlayback = useCallback(
async (state: CurrentlyPlayingState | null) => {
if (!state) {
setCurrentlyPlaying(null);
setIsPlaying(false);
return;
}
setCurrentlyPlaying(state);
setIsPlaying(true);
},
[]
);
const setCurrentlyPlayingState = useCallback(
async (state: CurrentlyPlayingState | null) => {
try {
if (state?.item.Id && user?.Id) {
const vlcLink = "vlc://" + state?.url;
if (vlcLink && settings?.openInVLC) {
Linking.openURL("vlc://" + state?.url || "");
return;
}
const res = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: state.item.Id,
userId: user.Id,
});
await postCapabilities({
api,
itemId: state.item.Id,
sessionId: res.data.PlaySessionId,
deviceProfile: settings?.deviceProfile,
});
setSession(res.data);
setCurrentlyPlaying(state);
setIsPlaying(true);
} else {
setCurrentlyPlaying(null);
setIsFullscreen(false);
setIsPlaying(false);
}
} catch (e) {
console.error(e);
Alert.alert(
"Something went wrong",
"The item could not be played. Maybe there is no internet connection?",
[
{
style: "destructive",
text: "Try force play",
onPress: () => {
setCurrentlyPlaying(state);
setIsPlaying(true);
},
},
{
text: "Ok",
style: "default",
},
]
);
}
},
[settings, user, api]
);
const playVideo = useCallback(
(triggerRef: boolean = true) => {
if (triggerRef === true) {
videoRef.current?.resume();
}
_setIsPlaying(true);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: session?.PlaySessionId,
IsPaused: false,
});
},
[api, currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks]
);
const pauseVideo = useCallback(
(triggerRef: boolean = true) => {
if (triggerRef === true) {
videoRef.current?.pause();
}
_setIsPlaying(false);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: session?.PlaySessionId,
IsPaused: true,
});
},
[session?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]
);
const stopPlayback = useCallback(async () => {
const id = currentlyPlaying?.item?.Id;
setCurrentlyPlayingState(null);
await reportPlaybackStopped({
api,
itemId: id,
sessionId: session?.PlaySessionId,
positionTicks: progressTicks ? progressTicks : 0,
});
}, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]);
const setIsPlaying = useCallback(
debounce((value: boolean) => {
_setIsPlaying(value);
}, 500),
[]
);
const _onProgress = useCallback(
({ currentTime }: OnProgressData) => {
if (
!session?.PlaySessionId ||
!currentlyPlaying?.item.Id ||
currentTime === 0
)
return;
const ticks = currentTime * 10000000;
setProgressTicks(ticks);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: ticks,
sessionId: session?.PlaySessionId,
IsPaused: !isPlaying,
});
},
[session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
);
const onProgress = useCallback(
debounce((e: OnProgressData) => {
_onProgress(e);
}, 500),
[_onProgress]
);
const presentFullscreenPlayer = useCallback(() => {
videoRef.current?.presentFullscreenPlayer();
setIsFullscreen(true);
}, []);
const dismissFullscreenPlayer = useCallback(() => {
videoRef.current?.dismissFullscreenPlayer();
setIsFullscreen(false);
}, []);
useEffect(() => {
if (!deviceId || !api?.accessToken) return;
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken
}&deviceId=${deviceId}`;
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
// Start sending "KeepAlive" message every 30 seconds
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
}
}, 30000);
};
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
newWebSocket.onclose = (e) => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId, user]);
useEffect(() => {
if (!ws) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
const command = json?.Data?.Command;
console.log("[WS] ~ ", json);
// On PlayPause
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
if (isPlaying) pauseVideo();
else playVideo();
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
router.canGoBack() && router.back();
} else if (command === "Mute") {
console.log("Command ~ Mute");
setVolume(0);
} else if (command === "Unmute") {
console.log("Command ~ Unmute");
setVolume(previousVolume.current || 20);
} else if (command === "SetVolume") {
console.log("Command ~ SetVolume");
} else if (json?.Data?.Name === "DisplayMessage") {
console.log("Command ~ DisplayMessage");
const title = json?.Data?.Arguments?.Header;
const body = json?.Data?.Arguments?.Text;
Alert.alert(title, body);
}
};
}, [ws, stopPlayback, playVideo, pauseVideo]);
return (
<PlaybackContext.Provider
value={{
onProgress,
isBuffering,
setIsBuffering,
progressTicks,
setVolume,
setIsPlaying,
setIsFullscreen,
isFullscreen,
isPlaying,
currentlyPlaying,
sessionData: session,
videoRef,
playVideo,
setCurrentlyPlayingState,
pauseVideo,
stopPlayback,
presentFullscreenPlayer,
dismissFullscreenPlayer,
startDownloadedFilePlayback,
subtitles,
}}
>
{children}
</PlaybackContext.Provider>
);
};
export const usePlayback = () => {
const context = useContext(PlaybackContext);
if (!context) {
throw new Error("usePlayback must be used within a PlaybackProvider");
}
return context;
};

View File

@@ -56,7 +56,7 @@ export const isCloseToBlack = (color: string): boolean => {
};
export const adjustToNearBlack = (color: string): string => {
return "#212121"; // A very dark gray, almost black
return "#313131"; // A very dark gray, almost black
};
export const itemThemeColorAtom = atom<ThemeColors>({

View File

@@ -133,7 +133,7 @@ const saveSettings = async (settings: Settings) => {
};
// Create an atom to store the settings in memory
const settingsAtom = atom<Settings | null>(null);
export const settingsAtom = atom<Settings | null>(null);
// A hook to manage settings, loading them on initial mount and providing a way to update them
export const useSettings = () => {

View File

@@ -0,0 +1,62 @@
// utils/getDefaultPlaySettings.ts
import { BITRATES } from "@/components/BitrateSelector";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { Settings } from "../atoms/settings";
interface PlaySettings {
item: BaseItemDto;
bitrate: (typeof BITRATES)[0];
mediaSource?: MediaSourceInfo | null;
audioIndex?: number | null;
subtitleIndex?: number | null;
}
export function getDefaultPlaySettings(
item: BaseItemDto,
settings: Settings
): PlaySettings {
if (item.Type === "Program") {
return {
item,
bitrate: BITRATES[0],
mediaSource: undefined,
audioIndex: undefined,
subtitleIndex: undefined,
};
}
// 1. Get first media source
const mediaSource = item.MediaSources?.[0];
// 2. Get default or preferred audio
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
(x) => x.Language === settings?.defaultAudioLanguage
)?.Index;
const firstAudioIndex = mediaSource?.MediaStreams?.find(
(x) => x.Type === "Audio"
)?.Index;
// 3. Get default or preferred subtitle
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
)?.Index;
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
(stream) => stream.Type === "Subtitle" && stream.IsDefault
)?.Index;
// 4. Get default bitrate
const bitrate = BITRATES[0];
return {
item,
bitrate,
mediaSource,
audioIndex: preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex,
subtitleIndex: preferedSubtitleIndex ?? defaultSubtitleIndex ?? -1,
};
}

View File

@@ -1,10 +1,11 @@
import ios from "@/utils/profiles/ios";
import iosFmp4 from "@/utils/profiles/iosFmp4";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
MediaSourceInfo,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { getAuthHeaders } from "../jellyfin";
export const getStreamUrl = async ({
@@ -14,11 +15,10 @@ export const getStreamUrl = async ({
startTimeTicks = 0,
maxStreamingBitrate,
sessionData,
deviceProfile = ios,
deviceProfile = iosFmp4,
audioStreamIndex = 0,
subtitleStreamIndex = undefined,
forceDirectPlay = false,
height,
mediaSourceId,
}: {
api: Api | null | undefined;
@@ -26,95 +26,135 @@ export const getStreamUrl = async ({
userId: string | null | undefined;
startTimeTicks: number;
maxStreamingBitrate?: number;
sessionData: PlaybackInfoResponse;
sessionData?: PlaybackInfoResponse | null;
deviceProfile: any;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
forceDirectPlay?: boolean;
height?: number;
mediaSourceId: string | null;
}) => {
if (!api || !userId || !item?.Id || !mediaSourceId) {
mediaSourceId?: string | null;
}): Promise<{
url: string | null;
sessionId: string | null;
} | null> => {
if (!api || !userId || !item?.Id) {
return null;
}
let mediaSource: MediaSourceInfo | undefined;
let url: string | null | undefined;
let sessionId: string | null | undefined;
if (item.Type === "Program") {
const res0 = await getMediaInfoApi(api).getPlaybackInfo(
{
userId,
itemId: item.ChannelId!,
},
{
method: "POST",
params: {
startTimeTicks: 0,
isPlayback: true,
autoOpenLiveStream: true,
maxStreamingBitrate,
audioStreamIndex,
},
data: {
deviceProfile,
},
}
);
const transcodeUrl = res0.data.MediaSources?.[0].TranscodingUrl;
sessionId = res0.data.PlaySessionId || null;
if (transcodeUrl) {
return { url: `${api.basePath}${transcodeUrl}`, sessionId };
}
}
const itemId = item.Id;
/**
* Build the stream URL for videos
*/
const response = await api.axiosInstance.post(
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
const res2 = await getMediaInfoApi(api).getPlaybackInfo(
{
DeviceProfile: deviceProfile,
UserId: userId,
MaxStreamingBitrate: maxStreamingBitrate,
StartTimeTicks: startTimeTicks,
EnableTranscoding: maxStreamingBitrate ? true : undefined,
AutoOpenLiveStream: true,
MediaSourceId: mediaSourceId,
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
AudioStreamIndex: audioStreamIndex,
SubtitleStreamIndex: subtitleStreamIndex,
DeInterlace: true,
BreakOnNonKeyFrames: false,
CopyTimestamps: false,
EnableMpegtsM2TsMode: false,
userId,
itemId: item.Id!,
},
{
headers: getAuthHeaders(api),
method: "POST",
data: {
deviceProfile,
userId,
maxStreamingBitrate,
startTimeTicks,
enableTranscoding: maxStreamingBitrate ? true : undefined,
autoOpenLiveStream: true,
mediaSourceId,
allowVideoStreamCopy: maxStreamingBitrate ? false : true,
audioStreamIndex,
subtitleStreamIndex,
deInterlace: true,
breakOnNonKeyFrames: false,
copyTimestamps: false,
enableMpegtsM2TsMode: false,
},
}
);
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
sessionId = res2.data.PlaySessionId || null;
mediaSource = res2.data.MediaSources?.find(
(source: MediaSourceInfo) => source.Id === mediaSourceId
);
if (!mediaSource) {
throw new Error("No media source");
}
console.log("getStreamUrl ~ ", item.MediaType);
if (!sessionData.PlaySessionId) {
throw new Error("no PlaySessionId");
}
let url: string | null | undefined;
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
if (item.MediaType === "Video") {
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
} else if (item.MediaType === "Audio") {
const searchParams = new URLSearchParams({
UserId: userId,
DeviceId: api.deviceInfo.id,
MaxStreamingBitrate: "140000000",
Container:
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
TranscodingContainer: "mp4",
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
PlaySessionId: sessionData.PlaySessionId,
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
url = `${
api.basePath
}/Audio/${itemId}/universal?${searchParams.toString()}`;
if (item.MediaType === "Video") {
if (mediaSource?.TranscodingUrl) {
return {
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
sessionId: sessionId,
};
}
if (mediaSource?.SupportsDirectPlay || forceDirectPlay === true) {
return {
url: `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData?.PlaySessionId}&mediaSourceId=${mediaSource?.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`,
sessionId: sessionId,
};
}
} else if (mediaSource.TranscodingUrl) {
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
}
if (!url) throw new Error("No url");
if (item.MediaType === "Audio") {
console.log("getStreamUrl ~ Audio");
console.log(
mediaSource.VideoType,
mediaSource.Container,
mediaSource.TranscodingContainer,
mediaSource.TranscodingSubProtocol
);
if (mediaSource?.TranscodingUrl) {
return { url: `${api.basePath}${mediaSource.TranscodingUrl}`, sessionId };
}
return url;
const searchParams = new URLSearchParams({
UserId: userId,
DeviceId: api.deviceInfo.id,
MaxStreamingBitrate: "140000000",
Container:
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
TranscodingContainer: "mp4",
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
PlaySessionId: sessionData?.PlaySessionId || "",
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
return {
url: `${
api.basePath
}/Audio/${itemId}/universal?${searchParams.toString()}`,
sessionId,
};
}
throw new Error("Unsupported media type");
};

View File

@@ -2,6 +2,16 @@ import { Api } from "@jellyfin/sdk";
import { getAuthHeaders } from "../jellyfin";
import { postCapabilities } from "../session/capabilities";
import { Settings } from "@/utils/atoms/settings";
import {
getMediaInfoApi,
getPlaystateApi,
getSessionApi,
} from "@jellyfin/sdk/lib/utils/api";
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client";
import { getOrSetDeviceId } from "@/providers/JellyfinProvider";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
interface ReportPlaybackProgressParams {
api?: Api | null;
@@ -33,31 +43,29 @@ export const reportPlaybackProgress = async ({
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
try {
await postCapabilities({
api,
await getPlaystateApi(api).onPlaybackProgress({
itemId,
sessionId,
deviceProfile,
audioStreamIndex: 0,
subtitleStreamIndex: 0,
mediaSourceId: itemId,
positionTicks: Math.round(positionTicks),
isPaused: IsPaused,
isMuted: false,
playMethod: "Transcode",
});
} catch (error) {
console.error("Failed to post capabilities.", error);
throw new Error("Failed to post capabilities.");
}
try {
await api.axiosInstance.post(
`${api.basePath}/Sessions/Playing/Progress`,
{
ItemId: itemId,
PlaySessionId: sessionId,
IsPaused,
PositionTicks: Math.round(positionTicks),
CanSeek: true,
MediaSourceId: itemId,
EventName: "timeupdate",
},
{ headers: getAuthHeaders(api) }
);
// await api.axiosInstance.post(
// `${api.basePath}/Sessions/Playing/Progress`,
// {
// ItemId: itemId,
// PlaySessionId: sessionId,
// IsPaused,
// PositionTicks: Math.round(positionTicks),
// CanSeek: true,
// MediaSourceId: itemId,
// EventName: "timeupdate",
// },
// { headers: getAuthHeaders(api) }
// );
} catch (error) {
console.error(error);
}

View File

@@ -1,68 +0,0 @@
import { Api } from "@jellyfin/sdk";
import { AxiosError } from "axios";
import { getAuthHeaders } from "../jellyfin";
interface PlaybackStoppedParams {
api: Api | null | undefined;
sessionId: string | null | undefined;
itemId: string | null | undefined;
positionTicks: number | null | undefined;
}
/**
* Reports playback stopped event to the Jellyfin server.
*
* @param {PlaybackStoppedParams} params - The parameters for the report.
* @param {Api} params.api - The Jellyfin API instance.
* @param {string} params.sessionId - The session ID.
* @param {string} params.itemId - The item ID.
* @param {number} params.positionTicks - The playback position in ticks.
*/
export const reportPlaybackStopped = async ({
api,
sessionId,
itemId,
positionTicks,
}: PlaybackStoppedParams): Promise<void> => {
if (!positionTicks || positionTicks === 0) return;
if (!api) {
console.error("Missing api");
return;
}
if (!sessionId) {
console.error("Missing sessionId", sessionId);
return;
}
if (!itemId) {
console.error("Missing itemId");
return;
}
try {
const url = `${api.basePath}/PlayingItems/${itemId}`;
const params = {
playSessionId: sessionId,
positionTicks: Math.round(positionTicks),
MediaSourceId: itemId,
IsPaused: true,
};
const headers = getAuthHeaders(api);
// Send DELETE request to report playback stopped
await api.axiosInstance.delete(url, { params, headers });
} catch (error) {
// Log the error with additional context
if (error instanceof AxiosError) {
console.error(
"Failed to report playback progress",
error.message,
error.response?.data
);
} else {
console.error("Failed to report playback progress", error);
}
}
};

View File

@@ -6,6 +6,7 @@ import { Api } from "@jellyfin/sdk";
import { AxiosError, AxiosResponse } from "axios";
import { useMemo } from "react";
import { getAuthHeaders } from "../jellyfin";
import iosFmp4 from "@/utils/profiles/iosFmp4";
interface PostCapabilitiesParams {
api: Api | null | undefined;
@@ -30,7 +31,7 @@ export const postCapabilities = async ({
throw new Error("Missing parameters for marking item as not played");
}
let profile: any = ios;
let profile: any = iosFmp4;
if (deviceProfile === "Native") {
profile = native;

View File

@@ -1,6 +1,7 @@
import { itemRouter } from "@/components/common/TouchableItemRouter";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import axios from "axios";
import { writeToLog } from "./log";
interface IJobInput {
deviceId?: string | null;
@@ -108,6 +109,7 @@ export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) {
});
});
} catch (error) {
writeToLog("ERROR", "Failed to cancel all jobs", error);
console.error(error);
return false;
}

View File

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