Compare commits
42 Commits
v0.16.0
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c051f6f61 | ||
|
|
0a098bf26e | ||
|
|
f6cb90e5dc | ||
|
|
b878e93dec | ||
|
|
66cd36a899 | ||
|
|
91b926e6c2 | ||
|
|
d4cc7499c0 | ||
|
|
317e719460 | ||
|
|
6012f8c8d2 | ||
|
|
ec0843d737 | ||
|
|
a5b4f6cc78 | ||
|
|
4b60de4d43 | ||
|
|
aa56749402 | ||
|
|
d6f02bd970 | ||
|
|
862e783de1 | ||
|
|
0233862fc1 | ||
|
|
cc242a971f | ||
|
|
4fc3044838 | ||
|
|
df6cd17099 | ||
|
|
5e8a0a9fa9 | ||
|
|
005938a421 | ||
|
|
81aafa26d4 | ||
|
|
0080874213 | ||
|
|
aa89c66e6e | ||
|
|
b1e2020b43 | ||
|
|
5ab53738d5 | ||
|
|
95de03f8b1 | ||
|
|
48570489d5 | ||
|
|
2c14a18e53 | ||
|
|
200ccc6070 | ||
|
|
1c20a3453f | ||
|
|
bf1efd7ca2 | ||
|
|
387add4c83 | ||
|
|
d064622055 | ||
|
|
a04296f395 | ||
|
|
eb11b928af | ||
|
|
4fd67091ea | ||
|
|
57c911cc94 | ||
|
|
61255e6dc4 | ||
|
|
d9037e72f0 | ||
|
|
b25cdce702 | ||
|
|
a85d5bbc92 |
@@ -12,6 +12,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🚀 **Skp intro / credits support**
|
- 🚀 **Skp intro / credits support**
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||||
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
|
- 📺 **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
|
## Get it now
|
||||||
|
|
||||||
<div style="display: flex; gap: 5px;">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ Key points of the MPL-2.0:
|
|||||||
|
|
||||||
## 🌐 Connect with Us
|
## 🌐 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:
|
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.
|
- [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.
|
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||||
- The Jellyfin devs for always being helpful in the Discord.
|
- The Jellyfin devs for always being helpful in the Discord.
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#fredrikburmester/streamyfin&Date)
|
||||||
|
|||||||
8
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.16.0",
|
"version": "0.17.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/images/splash.png",
|
"image": "./assets/images/splash.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#29164B"
|
"backgroundColor": "#2E2E2E"
|
||||||
},
|
},
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": ["**/*"],
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 42,
|
"versionCode": 43,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -40,11 +42,17 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings/index"
|
||||||
options={{
|
options={{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/audio-language"
|
||||||
|
options={{
|
||||||
|
title: "Audio Language",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
|||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
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 { Api } from "@jellyfin/sdk";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
@@ -21,13 +24,15 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -52,8 +57,8 @@ export default function index() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [settings, _] = useSettings();
|
const [settings, _] = useSettings();
|
||||||
@@ -61,6 +66,29 @@ export default function index() {
|
|||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
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 () => {
|
const checkConnection = useCallback(async () => {
|
||||||
setLoadingRetry(true);
|
setLoadingRetry(true);
|
||||||
const state = await NetInfo.fetch();
|
const state = await NetInfo.fetch();
|
||||||
@@ -89,7 +117,7 @@ export default function index() {
|
|||||||
isError: e1,
|
isError: e1,
|
||||||
isLoading: l1,
|
isLoading: l1,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["userViews", user?.Id],
|
queryKey: ["home", "userViews", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) {
|
if (!api || !user?.Id) {
|
||||||
return null;
|
return null;
|
||||||
@@ -110,7 +138,7 @@ export default function index() {
|
|||||||
isError: e2,
|
isError: e2,
|
||||||
isLoading: l2,
|
isLoading: l2,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
@@ -139,9 +167,26 @@ export default function index() {
|
|||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await queryClient.invalidateQueries();
|
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);
|
setLoading(false);
|
||||||
}, [queryClient, user?.Id]);
|
}, [queryClient]);
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
const createCollectionConfig = useCallback(
|
||||||
(
|
(
|
||||||
@@ -180,7 +225,12 @@ export default function index() {
|
|||||||
const includeItemTypes: BaseItemKind[] =
|
const includeItemTypes: BaseItemKind[] =
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||||
const title = "Recently Added in " + c.Name;
|
const title = "Recently Added in " + c.Name;
|
||||||
const queryKey = ["recentlyAddedIn" + c.CollectionType, user?.Id!, c.Id!];
|
const queryKey = [
|
||||||
|
"home",
|
||||||
|
"recentlyAddedIn" + c.CollectionType,
|
||||||
|
user?.Id!,
|
||||||
|
c.Id!,
|
||||||
|
];
|
||||||
return createCollectionConfig(
|
return createCollectionConfig(
|
||||||
title || "",
|
title || "",
|
||||||
queryKey,
|
queryKey,
|
||||||
@@ -192,12 +242,13 @@ export default function index() {
|
|||||||
const ss: Section[] = [
|
const ss: Section[] = [
|
||||||
{
|
{
|
||||||
title: "Continue Watching",
|
title: "Continue Watching",
|
||||||
queryKey: ["resumeItems", user.Id],
|
queryKey: ["home", "resumeItems", user.Id],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
@@ -205,7 +256,7 @@ export default function index() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Next Up",
|
title: "Next Up",
|
||||||
queryKey: ["nextUp-all", user?.Id],
|
queryKey: ["home", "nextUp-all", user?.Id],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
@@ -213,6 +264,7 @@ export default function index() {
|
|||||||
fields: ["MediaSourceCount"],
|
fields: ["MediaSourceCount"],
|
||||||
limit: 20,
|
limit: 20,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: false,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
@@ -223,7 +275,7 @@ export default function index() {
|
|||||||
(ml) =>
|
(ml) =>
|
||||||
({
|
({
|
||||||
title: ml.Name,
|
title: ml.Name,
|
||||||
queryKey: ["mediaList", ml.Id!],
|
queryKey: ["home", "mediaList", ml.Id!],
|
||||||
queryFn: async () => ml,
|
queryFn: async () => ml,
|
||||||
type: "MediaListSection",
|
type: "MediaListSection",
|
||||||
orientation: "vertical",
|
orientation: "vertical",
|
||||||
@@ -231,7 +283,7 @@ export default function index() {
|
|||||||
) || []),
|
) || []),
|
||||||
{
|
{
|
||||||
title: "Suggested Movies",
|
title: "Suggested Movies",
|
||||||
queryKey: ["suggestedMovies", user?.Id],
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
@@ -246,7 +298,7 @@ export default function index() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Suggested Episodes",
|
title: "Suggested Episodes",
|
||||||
queryKey: ["suggestedEpisodes", user?.Id],
|
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const suggestions = await getSuggestions(api, user.Id);
|
const suggestions = await getSuggestions(api, user.Id);
|
||||||
@@ -312,7 +364,7 @@ export default function index() {
|
|||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (e1 || e2 || !api)
|
if (e1 || e2)
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
||||||
@@ -340,33 +392,38 @@ export default function index() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
}}
|
}}
|
||||||
className="flex flex-col space-y-4 mb-20"
|
|
||||||
>
|
>
|
||||||
<LargeMovieCarousel />
|
<View className="flex flex-col space-y-4">
|
||||||
|
<LargeMovieCarousel />
|
||||||
|
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
if (section.type === "ScrollingCollectionList") {
|
if (section.type === "ScrollingCollectionList") {
|
||||||
return (
|
return (
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
key={index}
|
key={index}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (section.type === "MediaListSection") {
|
} else if (section.type === "MediaListSection") {
|
||||||
return (
|
return (
|
||||||
<MediaListSection
|
<MediaListSection
|
||||||
key={index}
|
key={index}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
61
app/(auth)/(tabs)/(home)/settings/audio-language.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ScrollView, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { LANGUAGES } from "@/constants/Languages";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { ListSection } from "@/components/list/ListSection";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
import { DefaultLanguageOption, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="py-4 px-4">
|
||||||
|
<ListSection title="LANGUAGES">
|
||||||
|
{LANGUAGES.sort(sortByName).map((l) => (
|
||||||
|
<ListItem
|
||||||
|
key={l.value}
|
||||||
|
title={l.label}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
...settings,
|
||||||
|
defaultAudioLanguage: l,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
iconAfter={
|
||||||
|
settings?.defaultAudioLanguage?.value === l.value ? (
|
||||||
|
<Ionicons name="checkmark" size={24} color={Colors.primary} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ListSection>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortByName = (a: DefaultLanguageOption, b: DefaultLanguageOption) => {
|
||||||
|
if (a.label < b.label) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.label > b.label) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListInputItem } from "@/components/list/ListInputItem";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { ListSection } from "@/components/list/ListSection";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { clearLogs, readFromLog } from "@/utils/log";
|
import { clearLogs, readFromLog } from "@/utils/log";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -16,6 +21,7 @@ import { toast } from "sonner-native";
|
|||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const { deleteAllFiles } = useDownload();
|
const { deleteAllFiles } = useDownload();
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -57,6 +63,8 @@ export default function settings() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
@@ -73,15 +81,46 @@ export default function settings() {
|
|||||||
>
|
>
|
||||||
registerBackgroundFetchAsync
|
registerBackgroundFetchAsync
|
||||||
</Button> */}
|
</Button> */}
|
||||||
<View>
|
<ListSection title="USER INFO">
|
||||||
<Text className="font-bold text-lg mb-2">Information</Text>
|
<ListItem title="User" text={user?.Name} />
|
||||||
|
<ListItem title="Server" text={api?.basePath} />
|
||||||
|
<ListItem title="Token" text={api?.accessToken} />
|
||||||
|
</ListSection>
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<ListSection title="MEDIA">
|
||||||
<ListItem title="User" subTitle={user?.Name} />
|
<ListItem
|
||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
title="Audio language"
|
||||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
iconAfter={
|
||||||
</View>
|
<Ionicons name="chevron-forward" size={20} color="white" />
|
||||||
</View>
|
}
|
||||||
|
onPress={() => router.push("/settings/audio-language")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Subtitle language"
|
||||||
|
iconAfter={
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="white" />
|
||||||
|
}
|
||||||
|
onPress={() => router.push("/settings/subtitle-language")}
|
||||||
|
/>
|
||||||
|
<ListInputItem
|
||||||
|
textInputProps={{
|
||||||
|
placeholder: "30",
|
||||||
|
clearButtonMode: "never",
|
||||||
|
returnKeyType: "done",
|
||||||
|
}}
|
||||||
|
defaultValue={(settings?.forwardSkipTime || "").toString()}
|
||||||
|
title={"Forward skip"}
|
||||||
|
onChange={(val) => {
|
||||||
|
// 1. validate positive number
|
||||||
|
// 2. save settings
|
||||||
|
if (val.length === 0) return;
|
||||||
|
if (val.match(/^\d+$/)) {
|
||||||
|
} else {
|
||||||
|
toast.error("Invalid number");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListSection>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
||||||
@@ -143,7 +182,9 @@ export default function settings() {
|
|||||||
>
|
>
|
||||||
{log.level}
|
{log.level}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs">{log.message}</Text>
|
<Text uiTextView selectable className="text-xs">
|
||||||
|
{log.message}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
{logs?.length === 0 && (
|
{logs?.length === 0 && (
|
||||||
61
app/(auth)/(tabs)/(home)/settings/subtitle-language.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ScrollView, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { LANGUAGES } from "@/constants/Languages";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { ListSection } from "@/components/list/ListSection";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
import { DefaultLanguageOption, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="py-4 px-4">
|
||||||
|
<ListSection title="LANGUAGES">
|
||||||
|
{LANGUAGES.sort(sortByName).map((l) => (
|
||||||
|
<ListItem
|
||||||
|
key={l.value}
|
||||||
|
title={l.label}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
...settings,
|
||||||
|
defaultSubtitleLanguage: l,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
iconAfter={
|
||||||
|
settings?.defaultSubtitleLanguage?.value === l.value ? (
|
||||||
|
<Ionicons name="checkmark" size={24} color={Colors.primary} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ListSection>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortByName = (a: DefaultLanguageOption, b: DefaultLanguageOption) => {
|
||||||
|
if (a.label < b.label) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.label > b.label) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
@@ -50,7 +50,7 @@ const page: React.FC = () => {
|
|||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
personIds: [actorId],
|
personIds: [actorId],
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
limit: 8,
|
limit: 16,
|
||||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||||
includeItemTypes: ["Movie", "Series"],
|
includeItemTypes: ["Movie", "Series"],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
|||||||
@@ -1,15 +1,94 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { ItemContent } from "@/components/ItemContent";
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
import { Stack, useLocalSearchParams } from "expo-router";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import React from "react";
|
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 Page: React.FC = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
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 (
|
return (
|
||||||
<>
|
<View className="flex flex-1 relative">
|
||||||
<Stack.Screen options={{ autoHideHomeIndicator: true }} />
|
<Animated.View
|
||||||
<ItemContent id={id} />
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
49
app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -278,7 +278,7 @@ export default function search() {
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
item={item}
|
item={item}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28 mr-2"
|
||||||
>
|
>
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
<Text numberOfLines={2} className="mt-2">
|
<Text numberOfLines={2} className="mt-2">
|
||||||
@@ -297,7 +297,7 @@ export default function search() {
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-44"
|
className="flex flex-col w-44 mr-2"
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
@@ -311,7 +311,7 @@ export default function search() {
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
item={item}
|
item={item}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28 mr-2"
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} key={item.Id} />
|
<MoviePoster item={item} key={item.Id} />
|
||||||
<Text numberOfLines={2} className="mt-2">
|
<Text numberOfLines={2} className="mt-2">
|
||||||
@@ -327,7 +327,7 @@ export default function search() {
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28 mr-2"
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} />
|
<MoviePoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
@@ -341,7 +341,7 @@ export default function search() {
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28 mr-2"
|
||||||
>
|
>
|
||||||
<AlbumCover id={item.Id} />
|
<AlbumCover id={item.Id} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
@@ -355,7 +355,7 @@ export default function search() {
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28 mr-2"
|
||||||
>
|
>
|
||||||
<AlbumCover id={item.Id} />
|
<AlbumCover id={item.Id} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
@@ -369,7 +369,7 @@ export default function search() {
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28 mr-2"
|
||||||
>
|
>
|
||||||
<AlbumCover id={item.AlbumId} />
|
<AlbumCover id={item.AlbumId} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
|
|||||||
@@ -1,14 +1,308 @@
|
|||||||
import { FullScreenMusicPlayer } from "@/components/FullScreenMusicPlayer";
|
import { Text } from "@/components/common/Text";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import AlbumCover from "@/components/posters/AlbumCover";
|
||||||
import { View, ViewProps } from "react-native";
|
import { Controls } from "@/components/video-player/Controls";
|
||||||
|
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||||
interface Props extends ViewProps {}
|
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 { debounce } from "lodash";
|
||||||
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||||
|
|
||||||
export default function page() {
|
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();
|
||||||
|
useAndroidNavigationBar();
|
||||||
|
|
||||||
|
useWebSocket({
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
pauseVideo: pause,
|
||||||
|
playVideo: play,
|
||||||
|
stopPlayback: stop,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="">
|
<View
|
||||||
<StatusBar hidden={false} />
|
style={{
|
||||||
<FullScreenMusicPlayer />
|
width: screenDimensions.width,
|
||||||
|
height: screenDimensions.height,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<StatusBar 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>
|
</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;
|
||||||
|
}
|
||||||
|
|||||||
180
app/(auth)/play-offline-video.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { Controls } from "@/components/video-player/Controls";
|
||||||
|
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||||
|
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 orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||||
|
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
|
import { useFocusEffect } from "expo-router";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||||
|
|
||||||
|
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 screenDimensions = Dimensions.get("screen");
|
||||||
|
|
||||||
|
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 { orientation } = useOrientation();
|
||||||
|
useOrientationSettings();
|
||||||
|
useAndroidNavigationBar();
|
||||||
|
|
||||||
|
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: screenDimensions.width,
|
||||||
|
height: screenDimensions.height,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<StatusBar 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;
|
||||||
|
}
|
||||||
332
app/(auth)/play-video.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import { Controls } from "@/components/video-player/Controls";
|
||||||
|
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||||
|
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 { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import Video, {
|
||||||
|
OnProgressData,
|
||||||
|
VideoRef,
|
||||||
|
SelectedTrack,
|
||||||
|
SelectedTrackType,
|
||||||
|
} from "react-native-video";
|
||||||
|
import { WithDefault } from "react-native/Libraries/Types/CodegenTypes";
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
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])
|
||||||
|
);
|
||||||
|
|
||||||
|
const { orientation } = useOrientation();
|
||||||
|
useOrientationSettings();
|
||||||
|
useAndroidNavigationBar();
|
||||||
|
|
||||||
|
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={{
|
||||||
|
width: screenDimensions.width,
|
||||||
|
height: screenDimensions.height,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<StatusBar 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;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
JellyfinProvider,
|
JellyfinProvider,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
@@ -198,8 +199,10 @@ const checkAndRequestPermissions = async () => {
|
|||||||
const { status } = await Notifications.requestPermissionsAsync();
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
|
||||||
if (status === "granted") {
|
if (status === "granted") {
|
||||||
|
writeToLog("INFO", "Notification permissions granted.");
|
||||||
console.log("Notification permissions granted.");
|
console.log("Notification permissions granted.");
|
||||||
} else {
|
} else {
|
||||||
|
writeToLog("ERROR", "Notification permissions denied.");
|
||||||
console.log("Notification permissions denied.");
|
console.log("Notification permissions denied.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +211,11 @@ const checkAndRequestPermissions = async () => {
|
|||||||
console.log("Already asked for notification permissions before.");
|
console.log("Already asked for notification permissions before.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
writeToLog(
|
||||||
|
"ERROR",
|
||||||
|
"Error checking/requesting notification permissions:",
|
||||||
|
error
|
||||||
|
);
|
||||||
console.error("Error checking/requesting notification permissions:", error);
|
console.error("Error checking/requesting notification permissions:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -312,20 +320,15 @@ function Layout() {
|
|||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<QueryClientProvider client={queryClientRef.current}>
|
<QueryClientProvider client={queryClientRef.current}>
|
||||||
<JobQueueProvider>
|
<ActionSheetProvider>
|
||||||
<DownloadProvider>
|
<JobQueueProvider>
|
||||||
<ActionSheetProvider>
|
<JellyfinProvider>
|
||||||
<BottomSheetModalProvider>
|
<PlaySettingsProvider>
|
||||||
<JellyfinProvider>
|
<DownloadProvider>
|
||||||
<PlaybackProvider>
|
<BottomSheetModalProvider>
|
||||||
<StatusBar style="light" backgroundColor="#000" />
|
<StatusBar style="light" backgroundColor="#000" />
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack
|
<Stack initialRouteName="/home">
|
||||||
initialRouteName="/home"
|
|
||||||
screenOptions={{
|
|
||||||
autoHideHomeIndicator: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/(tabs)"
|
name="(auth)/(tabs)"
|
||||||
options={{
|
options={{
|
||||||
@@ -334,9 +337,19 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/play"
|
name="(auth)/play-video"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
|
title: "",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(auth)/play-offline-video"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
title: "",
|
title: "",
|
||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
@@ -345,6 +358,7 @@ function Layout() {
|
|||||||
name="(auth)/play-music"
|
name="(auth)/play-music"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
title: "",
|
title: "",
|
||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
@@ -370,12 +384,12 @@ function Layout() {
|
|||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PlaybackProvider>
|
</BottomSheetModalProvider>
|
||||||
</JellyfinProvider>
|
</DownloadProvider>
|
||||||
</BottomSheetModalProvider>
|
</PlaySettingsProvider>
|
||||||
</ActionSheetProvider>
|
</JellyfinProvider>
|
||||||
</DownloadProvider>
|
</JobQueueProvider>
|
||||||
</JobQueueProvider>
|
</ActionSheetProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
@@ -397,6 +411,7 @@ async function saveDownloadedItemInfo(item: BaseItemDto) {
|
|||||||
|
|
||||||
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||||
console.error("Failed to save downloaded item information:", error);
|
console.error("Failed to save downloaded item information:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
@@ -129,7 +130,7 @@ const Login: React.FC = () => {
|
|||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
console.log(`Request to ${protocol}${url} timed out`);
|
console.log(`Request to ${protocol}${url} timed out`);
|
||||||
} else {
|
} 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"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
style={{ flex: 1, height: "100%" }}
|
style={{ flex: 1, height: "100%" }}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col justify-between px-4 h-full gap-y-2">
|
<View className="flex flex-col w-full h-full relative items-center justify-center">
|
||||||
<View></View>
|
<View className="px-4 -mt-20">
|
||||||
<View>
|
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<Text className="text-3xl font-bold mb-1">
|
<Text className="text-3xl font-bold mb-1">
|
||||||
{serverName || "Streamyfin"}
|
{serverName || "Streamyfin"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-neutral-500 mb-2">
|
<View className="bg-neutral-900 rounded-xl p-4 mb-2 flex flex-row items-center justify-between">
|
||||||
Server: {api.basePath}
|
<Text className="">URL</Text>
|
||||||
</Text>
|
<Text numberOfLines={1} className="shrink">
|
||||||
|
{api.basePath}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<Button
|
<Button
|
||||||
color="black"
|
color="black"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -261,11 +264,11 @@ const Login: React.FC = () => {
|
|||||||
<Text className="text-red-600 mb-2">{error}</Text>
|
<Text className="text-red-600 mb-2">{error}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="mt-auto mb-2">
|
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
|
||||||
<Button
|
<Button
|
||||||
color="black"
|
color="black"
|
||||||
onPress={handleQuickConnect}
|
onPress={handleQuickConnect}
|
||||||
className="mb-2"
|
className="w-full mb-2"
|
||||||
>
|
>
|
||||||
Use Quick Connect
|
Use Quick Connect
|
||||||
</Button>
|
</Button>
|
||||||
@@ -285,9 +288,17 @@ const Login: React.FC = () => {
|
|||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col px-4 justify-between h-full">
|
<View className="flex flex-col h-full relative items-center justify-center w-full">
|
||||||
<View></View>
|
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||||
<View className="flex flex-col gap-y-2">
|
<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-3xl font-bold">Streamyfin</Text>
|
||||||
<Text className="text-neutral-500">
|
<Text className="text-neutral-500">
|
||||||
Connect to your Jellyfin server
|
Connect to your Jellyfin server
|
||||||
@@ -303,14 +314,16 @@ const Login: React.FC = () => {
|
|||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||||
loading={loadingServerCheck}
|
<Button
|
||||||
disabled={loadingServerCheck}
|
loading={loadingServerCheck}
|
||||||
onPress={async () => await handleConnect(serverURL)}
|
disabled={loadingServerCheck}
|
||||||
className="mb-2"
|
onPress={async () => await handleConnect(serverURL)}
|
||||||
>
|
className="w-full grow"
|
||||||
Connect
|
>
|
||||||
</Button>
|
Connect
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
BIN
assets/images/StreamyFinFinal.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
assets/images/adaptive_icon.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 49 KiB |
@@ -1,20 +1,13 @@
|
|||||||
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
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> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source: MediaSourceInfo;
|
source: MediaSourceInfo;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
selected: number;
|
selected?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AudioTrackSelector: React.FC<Props> = ({
|
export const AudioTrackSelector: React.FC<Props> = ({
|
||||||
@@ -23,8 +16,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source]
|
[source]
|
||||||
@@ -35,23 +26,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
[audioStreams, selected]
|
[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 (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type Bitrate = {
|
|||||||
height?: number;
|
height?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BITRATES: Bitrate[] = [
|
export const BITRATES: Bitrate[] = [
|
||||||
{
|
{
|
||||||
key: "Max",
|
key: "Max",
|
||||||
value: undefined,
|
value: undefined,
|
||||||
@@ -39,12 +39,12 @@ const BITRATES: Bitrate[] = [
|
|||||||
value: 250000,
|
value: 250000,
|
||||||
height: 480,
|
height: 480,
|
||||||
},
|
},
|
||||||
];
|
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
onChange: (value: Bitrate) => void;
|
onChange: (value: Bitrate) => void;
|
||||||
selected: Bitrate;
|
selected?: Bitrate | null;
|
||||||
inverted?: boolean;
|
inverted?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BitrateSelector: React.FC<Props> = ({
|
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>
|
<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">
|
<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}>
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
@@ -18,7 +18,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
useEpisodePoster = false,
|
useEpisodePoster = false,
|
||||||
size = "normal",
|
size = "normal",
|
||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get horrizontal poster for movie and episode, with failover to primary.
|
* Get horrizontal poster for movie and episode, with failover to primary.
|
||||||
@@ -40,11 +40,26 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
else
|
else
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
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]);
|
}, [item]);
|
||||||
|
|
||||||
const [progress, setProgress] = useState(
|
const progress = useMemo(() => {
|
||||||
item.UserData?.PlayedPercentage || 0
|
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)
|
if (!url)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { router } from "expo-router";
|
import { router, useFocusEffect } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { Bitrate, BITRATES, BitrateSelector } from "./BitrateSelector";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
@@ -30,6 +30,8 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
|
|||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -41,10 +43,11 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { processes, startBackgroundDownload } = useDownload();
|
const { processes, startBackgroundDownload } = useDownload();
|
||||||
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item);
|
const { startRemuxing } = useRemuxHlsToMp4(item);
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] =
|
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||||
useState<MediaSourceInfo | null>(null);
|
MediaSourceInfo | undefined
|
||||||
|
>(undefined);
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(0);
|
useState<number>(0);
|
||||||
@@ -53,6 +56,20 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
value: undefined,
|
value: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (!settings) return;
|
||||||
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
|
getDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
|
// 4. Set states
|
||||||
|
setSelectedMediaSource(mediaSource);
|
||||||
|
setSelectedAudioStream(audioIndex ?? 0);
|
||||||
|
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||||
|
setMaxBitrate(bitrate);
|
||||||
|
}, [item, settings])
|
||||||
|
);
|
||||||
|
|
||||||
const userCanDownload = useMemo(() => {
|
const userCanDownload = useMemo(() => {
|
||||||
return user?.Policy?.EnableContentDownloading;
|
return user?.Policy?.EnableContentDownloading;
|
||||||
}, [user]);
|
}, [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") {
|
if (settings?.deviceProfile === "Native") {
|
||||||
deviceProfile = native;
|
deviceProfile = native;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -12,296 +12,205 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
|
|||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
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 { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import {
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
BaseItemDto,
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
MediaSourceInfo,
|
||||||
import ios from "@/utils/profiles/ios";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
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 { Image } from "expo-image";
|
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 * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useCastDevice } from "react-native-google-cast";
|
|
||||||
import Animated, {
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Chromecast } from "./Chromecast";
|
import { Chromecast } from "./Chromecast";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { Loader } from "./Loader";
|
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
|
|
||||||
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||||
const [api] = useAtom(apiAtom);
|
({ item }) => {
|
||||||
const [user] = useAtom(userAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const opacity = useSharedValue(0);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
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 [orientation, setOrientation] = useState(
|
||||||
|
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
const [orientation, setOrientation] = useState(
|
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
|
||||||
(event) => {
|
|
||||||
setOrientation(event.orientationInfo.orientation);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
useFocusEffect(
|
||||||
setOrientation(initialOrientation);
|
useCallback(() => {
|
||||||
});
|
if (!settings) return;
|
||||||
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
|
getDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
return () => {
|
setPlaySettings({
|
||||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
item,
|
||||||
};
|
bitrate,
|
||||||
}, []);
|
mediaSource,
|
||||||
|
audioIndex,
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
subtitleIndex,
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
} else {
|
}, [item, settings])
|
||||||
// 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(() => {
|
const selectedMediaSource = useMemo(() => {
|
||||||
navigation.setOptions({
|
return playSettings?.mediaSource || undefined;
|
||||||
headerRight: () =>
|
}, [playSettings?.mediaSource]);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const setSelectedMediaSource = (mediaSource: MediaSourceInfo) => {
|
||||||
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
setPlaySettings((prev) => ({
|
||||||
headerHeightRef.current = 230;
|
...prev,
|
||||||
return;
|
mediaSource,
|
||||||
}
|
}));
|
||||||
if (item?.Type === "Episode") headerHeightRef.current = 400;
|
};
|
||||||
else if (item?.Type === "Movie") headerHeightRef.current = 500;
|
|
||||||
else headerHeightRef.current = 400;
|
|
||||||
}, [item, orientation]);
|
|
||||||
|
|
||||||
const { data: sessionData } = useQuery({
|
const selectedAudioStream = useMemo(() => {
|
||||||
queryKey: ["sessionData", item?.Id],
|
return playSettings?.audioIndex;
|
||||||
queryFn: async () => {
|
}, [playSettings?.audioIndex]);
|
||||||
if (!api || !user?.Id || !item?.Id) return null;
|
|
||||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
const setSelectedAudioStream = (audioIndex: number) => {
|
||||||
itemId: item?.Id,
|
setPlaySettings((prev) => ({
|
||||||
userId: user?.Id,
|
...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;
|
return () => {
|
||||||
},
|
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||||
enabled: !!item?.Id && !!api && !!user?.Id,
|
};
|
||||||
staleTime: 0,
|
}, []);
|
||||||
});
|
|
||||||
|
|
||||||
const { data: playbackUrl } = useQuery({
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
queryKey: [
|
|
||||||
"playbackUrl",
|
|
||||||
item?.Id,
|
|
||||||
maxBitrate,
|
|
||||||
castDevice,
|
|
||||||
selectedMediaSource,
|
|
||||||
selectedAudioStream,
|
|
||||||
selectedSubtitleStream,
|
|
||||||
settings,
|
|
||||||
],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id || !sessionData || !selectedMediaSource?.Id)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
let deviceProfile: any = ios;
|
useImageColors({ item });
|
||||||
|
|
||||||
if (castDevice?.deviceId) {
|
useEffect(() => {
|
||||||
deviceProfile = chromecastProfile;
|
navigation.setOptions({
|
||||||
} else if (settings?.deviceProfile === "Native") {
|
headerRight: () =>
|
||||||
deviceProfile = native;
|
item && (
|
||||||
} else if (settings?.deviceProfile === "Old") {
|
<View className="flex flex-row items-center space-x-2">
|
||||||
deviceProfile = old;
|
<Chromecast background="blur" width={22} height={22} />
|
||||||
}
|
{item.Type !== "Program" && (
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
const url = await getStreamUrl({
|
<DownloadItem item={item} />
|
||||||
api,
|
<PlayedStatus item={item} />
|
||||||
userId: user.Id,
|
</View>
|
||||||
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%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</View>
|
||||||
</>
|
),
|
||||||
}
|
});
|
||||||
logo={
|
}, [item]);
|
||||||
<>
|
|
||||||
{logoUrl ? (
|
useEffect(() => {
|
||||||
<Image
|
// If landscape
|
||||||
source={{
|
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
||||||
uri: logoUrl,
|
setHeaderHeight(230);
|
||||||
}}
|
return;
|
||||||
style={{
|
}
|
||||||
height: 130,
|
|
||||||
width: "100%",
|
if (item.Type === "Movie") setHeaderHeight(500);
|
||||||
resizeMode: "contain",
|
else setHeaderHeight(350);
|
||||||
}}
|
}, [item.Type, orientation]);
|
||||||
onLoad={() => setLoadingLogo(false)}
|
|
||||||
onError={() => setLoadingLogo(false)}
|
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||||
/>
|
|
||||||
) : null}
|
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">
|
<ParallaxScrollView
|
||||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||||
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
headerHeight={headerHeight}
|
||||||
<ItemHeader item={localItem} className="mb-4" />
|
headerImage={
|
||||||
{localItem ? (
|
<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">
|
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
className="mr-1"
|
className="mr-1"
|
||||||
@@ -310,7 +219,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
/>
|
/>
|
||||||
<MediaSourceSelector
|
<MediaSourceSelector
|
||||||
className="mr-1"
|
className="mr-1"
|
||||||
item={localItem}
|
item={item}
|
||||||
onChange={setSelectedMediaSource}
|
onChange={setSelectedMediaSource}
|
||||||
selected={selectedMediaSource}
|
selected={selectedMediaSource}
|
||||||
/>
|
/>
|
||||||
@@ -330,46 +239,45 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</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" />
|
<PlayButton item={item} url={playUrl} 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"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
{item?.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<CurrentSeries item={item} className="mb-4" />
|
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||||
)}
|
)}
|
||||||
<SimilarItems itemId={item?.Id} />
|
|
||||||
|
|
||||||
<View className="h-16"></View>
|
<OverviewText text={item.Overview} className="px-4 my-4" />
|
||||||
</View>
|
{item.Type !== "Program" && (
|
||||||
</ParallaxScrollView>
|
<>
|
||||||
</View>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { PropsWithChildren, ReactNode } from "react";
|
|
||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
title?: string | null | undefined;
|
|
||||||
subTitle?: string | null | undefined;
|
|
||||||
children?: ReactNode;
|
|
||||||
iconAfter?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|
||||||
title,
|
|
||||||
subTitle,
|
|
||||||
iconAfter,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="font-bold ">{title}</Text>
|
|
||||||
{subTitle && (
|
|
||||||
<Text className="text-xs" selectable>
|
|
||||||
{subTitle}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{iconAfter}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -12,7 +12,7 @@ import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
|||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
onChange: (value: MediaSourceInfo) => void;
|
onChange: (value: MediaSourceInfo) => void;
|
||||||
selected: MediaSourceInfo | null;
|
selected?: MediaSourceInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MediaSourceSelector: React.FC<Props> = ({
|
export const MediaSourceSelector: React.FC<Props> = ({
|
||||||
@@ -21,21 +21,19 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const mediaSources = useMemo(() => {
|
const selectedName = useMemo(
|
||||||
return item.MediaSources;
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const selectedMediaSource = useMemo(
|
|
||||||
() =>
|
() =>
|
||||||
mediaSources
|
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||||
?.find((x) => x.Id === selected?.Id)
|
(x) => x.Type === "Video"
|
||||||
?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "",
|
)?.DisplayTitle || "",
|
||||||
[mediaSources, selected]
|
[item.MediaSources, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mediaSources?.length) onChange(mediaSources[0]);
|
if (!selected && item.MediaSources && item.MediaSources.length > 0) {
|
||||||
}, [mediaSources]);
|
onChange(item.MediaSources[0]);
|
||||||
|
}
|
||||||
|
}, [item.MediaSources, selected]);
|
||||||
|
|
||||||
const name = (name?: string | null) => {
|
const name = (name?: string | null) => {
|
||||||
if (name && name.length > 40)
|
if (name && name.length > 40)
|
||||||
@@ -56,8 +54,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
<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 ">
|
<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>
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
@@ -71,7 +69,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
||||||
{mediaSources?.map((source, idx: number) => (
|
{item.MediaSources?.map((source, idx: number) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={idx.toString()}
|
key={idx.toString()}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
|
|||||||
@@ -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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Linking, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
@@ -28,6 +27,7 @@ import Animated, {
|
|||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -40,7 +40,6 @@ const MIN_PLAYBACK_WIDTH = 15;
|
|||||||
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const { setCurrentlyPlayingState } = usePlayback();
|
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
|
|
||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
@@ -57,18 +56,33 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
const startColor = useSharedValue(memoizedColor);
|
const startColor = useSharedValue(memoizedColor);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const directStream = useMemo(() => {
|
const directStream = useMemo(() => {
|
||||||
return !url?.includes("m3u8");
|
return !url?.includes("m3u8");
|
||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
const onPress = async () => {
|
const onPress = async () => {
|
||||||
if (!url || !item) return;
|
if (!url || !item) {
|
||||||
if (!client) {
|
console.warn(
|
||||||
setCurrentlyPlayingState({ item, url });
|
"No URL or item provided to PlayButton",
|
||||||
router.push("/play");
|
url?.slice(0, 100),
|
||||||
|
item?.Id
|
||||||
|
);
|
||||||
return;
|
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 options = ["Chromecast", "Device", "Cancel"];
|
||||||
const cancelButtonIndex = 2;
|
const cancelButtonIndex = 2;
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
@@ -163,8 +177,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
setCurrentlyPlayingState({ item, url });
|
router.push("/play-video");
|
||||||
router.push("/play");
|
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
@@ -307,6 +320,15 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
<Feather name="cast" size={22} />
|
<Feather name="cast" size={22} />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
|
{!client && settings?.openInVLC && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="vlc"
|
||||||
|
size={18}
|
||||||
|
color={animatedTextStyle.color}
|
||||||
|
/>
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["nextUp-all"],
|
queryKey: ["nextUp-all"],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["home"],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
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> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source: MediaSourceInfo;
|
source: MediaSourceInfo;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
selected: number;
|
selected?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||||
@@ -23,8 +17,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||||
[source]
|
[source]
|
||||||
@@ -35,23 +27,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
[subtitleStreams, selected]
|
[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;
|
if (subtitleStreams.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import React from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
|||||||
22
components/_page_template.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ScrollView, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
|
></ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,14 +24,14 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
if (background === "transparent" && Platform.OS !== "android")
|
if (background === "transparent" && Platform.OS !== "android")
|
||||||
return (
|
return (
|
||||||
<BlurView
|
<TouchableOpacity
|
||||||
{...props}
|
onPress={() => router.back()}
|
||||||
intensity={100}
|
{...touchableOpacityProps}
|
||||||
className="overflow-hidden rounded-full p-2"
|
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<BlurView
|
||||||
onPress={() => router.back()}
|
{...props}
|
||||||
{...touchableOpacityProps}
|
intensity={100}
|
||||||
|
className="overflow-hidden rounded-full p-2"
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
className="drop-shadow-2xl"
|
className="drop-shadow-2xl"
|
||||||
@@ -39,8 +39,8 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
size={24}
|
size={24}
|
||||||
color="white"
|
color="white"
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</BlurView>
|
||||||
</BlurView>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ export function Input(props: TextInputProps) {
|
|||||||
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
|
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
{...otherProps}
|
|
||||||
placeholderTextColor={"#9CA3AF"}
|
placeholderTextColor={"#9CA3AF"}
|
||||||
clearButtonMode="while-editing"
|
clearButtonMode="while-editing"
|
||||||
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { TextProps } from "react-native";
|
import { TextProps } from "react-native";
|
||||||
import { Text as DefaultText } from "react-native";
|
import { UITextView } from "react-native-uitextview";
|
||||||
export function Text(props: TextProps) {
|
|
||||||
|
export function Text(
|
||||||
|
props: TextProps & {
|
||||||
|
uiTextView?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
const { style, ...otherProps } = props;
|
const { style, ...otherProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DefaultText
|
<UITextView
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ interface Props extends TouchableOpacityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||||
|
if (item.CollectionType === "livetv") {
|
||||||
|
return `/(auth)/(tabs)/${from}/livetv`;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.Type === "Series") {
|
if (item.Type === "Series") {
|
||||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleOpenFile}
|
onPress={handleOpenFile}
|
||||||
onLongPress={showActionSheet}
|
onLongPress={showActionSheet}
|
||||||
className="flex flex-col"
|
className="flex flex-col w-44 mr-2"
|
||||||
>
|
>
|
||||||
{base64Image ? (
|
{base64Image ? (
|
||||||
<View className="w-44 aspect-video rounded-lg overflow-hidden mr-2">
|
<View className="w-44 aspect-video rounded-lg overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
source={{
|
source={{
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||||
@@ -92,7 +92,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</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
|
<Ionicons
|
||||||
name="image-outline"
|
name="image-outline"
|
||||||
size={24}
|
size={24}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{seasonItems.sort(sortByIndex)?.map((item, index) => (
|
{seasonItems.sort(sortByIndex)?.map((item, index) => (
|
||||||
<EpisodeCard item={item} />
|
<EpisodeCard item={item} key={index} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ import {
|
|||||||
import { ScrollView, View, ViewProps } from "react-native";
|
import { ScrollView, View, ViewProps } from "react-native";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import SeriesPoster from "../posters/SeriesPoster";
|
import SeriesPoster from "../posters/SeriesPoster";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
@@ -34,7 +32,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
enabled: !disabled,
|
enabled: !disabled,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (disabled || !title) return null;
|
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">
|
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
{isLoading === false && data?.length === 0 && (
|
||||||
|
<View className="px-4">
|
||||||
|
<Text className="text-neutral-500">No items</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
@@ -98,6 +101,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
<MoviePoster item={item} />
|
<MoviePoster item={item} />
|
||||||
)}
|
)}
|
||||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
|
{item.Type === "Program" && (
|
||||||
|
<ContinueWatchingPoster item={item} />
|
||||||
|
)}
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -15,17 +15,13 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacityProps, View } from "react-native";
|
||||||
import { getColors } from "react-native-image-colors";
|
import { getColors } from "react-native-image-colors";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
library: BaseItemDto;
|
library: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryColor = {
|
|
||||||
dominantColor: string;
|
|
||||||
averageColor: string;
|
|
||||||
secondary: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
const icons: Record<CollectionType, IconName> = {
|
const icons: Record<CollectionType, IconName> = {
|
||||||
@@ -48,12 +44,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const [imageInfo, setImageInfo] = useState<LibraryColor>({
|
|
||||||
dominantColor: "#fff",
|
|
||||||
averageColor: "#fff",
|
|
||||||
secondary: "#fff",
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getPrimaryImageUrl({
|
getPrimaryImageUrl({
|
||||||
@@ -63,6 +53,10 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
[library]
|
[library]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If we want to use image colors for library cards
|
||||||
|
// const [color] = useAtom(itemThemeColorAtom)
|
||||||
|
// useImageColors({ url });
|
||||||
|
|
||||||
const { data: itemsCount } = useQuery({
|
const { data: itemsCount } = useQuery({
|
||||||
queryKey: ["library-count", library.Id],
|
queryKey: ["library-count", library.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -76,40 +70,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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 (!url) return null;
|
||||||
|
|
||||||
if (settings?.libraryOptions?.display === "row") {
|
if (settings?.libraryOptions?.display === "row") {
|
||||||
|
|||||||
64
components/list/ListInputItem.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { PropsWithChildren, ReactNode, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
TextInputProps,
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title?: string | null | undefined;
|
||||||
|
text?: string | null | undefined;
|
||||||
|
children?: ReactNode;
|
||||||
|
iconAfter?: ReactNode;
|
||||||
|
iconBefore?: ReactNode;
|
||||||
|
textInputProps?: TextInputProps;
|
||||||
|
defaultValue?: string;
|
||||||
|
onChange: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListInputItem: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
iconAfter,
|
||||||
|
iconBefore,
|
||||||
|
children,
|
||||||
|
onChange,
|
||||||
|
textInputProps,
|
||||||
|
defaultValue,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [value, setValue] = useState<string>(defaultValue || "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`flex flex-row items-center justify-between px-4 h-12 bg-neutral-900`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{iconBefore && <View className="mr-2">{iconBefore}</View>}
|
||||||
|
<View>
|
||||||
|
<Text className="">{title}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="ml-auto">
|
||||||
|
<TextInput
|
||||||
|
inputMode="numeric"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
style={{ color: "white" }}
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
className=""
|
||||||
|
{...textInputProps}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{iconAfter && <View className="ml-2">{iconAfter}</View>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
components/list/ListItem.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { PropsWithChildren, ReactNode, useState } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps {
|
||||||
|
title?: string | null | undefined;
|
||||||
|
text?: string | null | undefined;
|
||||||
|
children?: ReactNode;
|
||||||
|
iconAfter?: ReactNode;
|
||||||
|
iconBefore?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
iconAfter,
|
||||||
|
iconBefore,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`flex flex-row items-center justify-between px-4 h-12 bg-neutral-900`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{iconBefore && <View className="mr-2">{iconBefore}</View>}
|
||||||
|
<View>
|
||||||
|
<Text className="">{title}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="ml-auto">
|
||||||
|
<Text selectable className="">
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{iconAfter && <View className="ml-2">{iconAfter}</View>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
components/list/ListSection.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Children, PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListSection: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<Text className="ml-4 mb-1 text-xs text-neutral-500 uppercase">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800">
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
components/livetv/HourHeader.tsx
Normal 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;
|
||||||
|
});
|
||||||
|
};
|
||||||
96
components/livetv/LiveTVGuideRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -31,10 +31,10 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const { data: collection, isLoading } = useQuery({
|
const { data: collection } = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ interface Props extends ViewProps {
|
|||||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className=" font-bold text-2xl mb-1" selectable>
|
<Text uiTextView selectable className="font-bold text-2xl mb-1">
|
||||||
{item?.Name}
|
{item?.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className=" opacity-50">{item?.ProductionYear}</Text>
|
<Text className="opacity-50">{item?.ProductionYear}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import ios from "@/utils/profiles/ios";
|
|
||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
@@ -40,7 +37,7 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const { setCurrentlyPlayingState } = usePlayback();
|
const { setPlaySettings } = usePlaySettings();
|
||||||
|
|
||||||
const openSelect = () => {
|
const openSelect = () => {
|
||||||
if (!castDevice?.deviceId) {
|
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) {
|
if (!user?.Id || !api || !item.Id) {
|
||||||
console.warn("No user, api or item", user, api, item.Id);
|
console.warn("No user, api or item", user, api, item.Id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getMediaInfoApi(api!).getPlaybackInfo({
|
const data = await setPlaySettings({
|
||||||
itemId: item?.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessionData = response.data;
|
|
||||||
|
|
||||||
const url = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
userId: user.Id,
|
|
||||||
item,
|
item,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
|
||||||
sessionData,
|
|
||||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
|
||||||
mediaSourceId: item.Id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url || !item) {
|
if (!data?.url) {
|
||||||
console.warn("No url or item", url, item.Id);
|
throw new Error("play-music ~ No stream url");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
if (type === "cast" && client) {
|
||||||
@@ -106,7 +89,7 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
else {
|
else {
|
||||||
client.loadMedia({
|
client.loadMedia({
|
||||||
mediaInfo: {
|
mediaInfo: {
|
||||||
contentUrl: url,
|
contentUrl: data.url!,
|
||||||
contentType: "video/mp4",
|
contentType: "video/mp4",
|
||||||
metadata: {
|
metadata: {
|
||||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||||
@@ -119,14 +102,10 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log("Playing on device", url, item.Id);
|
console.log("Playing on device", data.url, item.Id);
|
||||||
setCurrentlyPlayingState({
|
|
||||||
item,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
router.push("/play-music");
|
router.push("/play-music");
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="font-bold text-2xl" selectable>
|
<Text uiTextView className="font-bold text-2xl" selectable>
|
||||||
{item?.Name}
|
{item?.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex flex-row items-center mb-1">
|
<View className="flex flex-row items-center mb-1">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface Props extends React.ComponentProps<typeof Button> {
|
|||||||
type?: "next" | "previous";
|
type?: "next" | "previous";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NextEpisodeButton: React.FC<Props> = ({
|
export const NextItemButton: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
type = "next",
|
type = "next",
|
||||||
...props
|
...props
|
||||||
@@ -23,8 +23,8 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const { data: nextEpisode } = useQuery({
|
const { data: nextItem } = useQuery({
|
||||||
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
|
queryKey: ["nextItem", item.Id, item.ParentId, type],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (
|
if (
|
||||||
!api ||
|
!api ||
|
||||||
@@ -47,16 +47,16 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const disabled = useMemo(() => {
|
const disabled = useMemo(() => {
|
||||||
if (!nextEpisode) return true;
|
if (!nextItem) return true;
|
||||||
if (nextEpisode.Id === item.Id) return true;
|
if (nextItem.Id === item.Id) return true;
|
||||||
return false;
|
return false;
|
||||||
}, [nextEpisode, type]);
|
}, [nextItem, type]);
|
||||||
|
|
||||||
if (item.Type !== "Episode") return null;
|
if (item.Type !== "Episode") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onPress={() => router.setParams({ id: nextEpisode?.Id })}
|
onPress={() => router.setParams({ id: nextItem?.Id })}
|
||||||
className={`h-12 aspect-square`}
|
className={`h-12 aspect-square`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -85,7 +85,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
itemId: previousId,
|
itemId: previousId,
|
||||||
}),
|
}),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000 * 5,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
itemId: nextId,
|
itemId: nextId,
|
||||||
}),
|
}),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000 * 5,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [episodes, api, user?.Id, item]);
|
}, [episodes, api, user?.Id, item]);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
registerBackgroundFetchAsync,
|
registerBackgroundFetchAsync,
|
||||||
unregisterBackgroundFetchAsync,
|
unregisterBackgroundFetchAsync,
|
||||||
} from "@/utils/background-tasks";
|
} from "@/utils/background-tasks";
|
||||||
|
import { getStatistics } from "@/utils/optimize-server";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
import * as BackgroundFetch from "expo-background-fetch";
|
||||||
@@ -18,7 +19,6 @@ import * as TaskManager from "expo-task-manager";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -32,8 +32,6 @@ import { Input } from "../common/Input";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { MediaToggles } from "./MediaToggles";
|
import { MediaToggles } from "./MediaToggles";
|
||||||
import axios from "axios";
|
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Stack } from "expo-router";
|
|
||||||
import { Chromecast } from "../Chromecast";
|
|
||||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||||
|
|
||||||
const commonScreenOptions = {
|
const commonScreenOptions = {
|
||||||
|
|||||||
529
components/video-player/Controls.tsx
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
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 op = useSharedValue<number>(1);
|
||||||
|
const tr = useSharedValue<number>(10);
|
||||||
|
const animatedStyles = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: op.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const animatedTopStyles = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: op.value,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: -tr.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const animatedBottomStyles = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: op.value,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: tr.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showControls || isBuffering) {
|
||||||
|
op.value = withTiming(1, { duration: 200 });
|
||||||
|
tr.value = withTiming(0, { duration: 200 });
|
||||||
|
} else {
|
||||||
|
op.value = withTiming(0, { duration: 200 });
|
||||||
|
tr.value = withTiming(10, { duration: 200 });
|
||||||
|
}
|
||||||
|
}, [showControls, isBuffering]);
|
||||||
|
|
||||||
|
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||||
|
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||||
|
item,
|
||||||
|
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 updateTimes = useCallback(
|
||||||
|
(currentProgress: number, maxValue: number) => {
|
||||||
|
const current = ticksToSeconds(currentProgress);
|
||||||
|
const remaining = ticksToSeconds(maxValue - currentProgress);
|
||||||
|
|
||||||
|
setCurrentTime(current);
|
||||||
|
setRemainingTime(remaining);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: windowDimensions.width + 100,
|
||||||
|
height: windowDimensions.height + 100,
|
||||||
|
},
|
||||||
|
animatedStyles,
|
||||||
|
]}
|
||||||
|
className={`bg-black/50 z-0`}
|
||||||
|
></Animated.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>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: insets.top,
|
||||||
|
right: insets.right,
|
||||||
|
},
|
||||||
|
animatedTopStyles,
|
||||||
|
]}
|
||||||
|
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>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.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,
|
||||||
|
},
|
||||||
|
animatedBottomStyles,
|
||||||
|
]}
|
||||||
|
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>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 = {
|
export const Colors = {
|
||||||
|
primary: "#9334E9",
|
||||||
text: "#ECEDEE",
|
text: "#ECEDEE",
|
||||||
background: "#151718",
|
background: "#151718",
|
||||||
tint: tintColorDark,
|
tint: "#fff",
|
||||||
icon: "#9BA1A6",
|
icon: "#9BA1A6",
|
||||||
tabIconDefault: "#9BA1A6",
|
tabIconDefault: "#9BA1A6",
|
||||||
tabIconSelected: "#9333ea",
|
tabIconSelected: "#9333ea",
|
||||||
|
|||||||
@@ -2,38 +2,38 @@ import { DefaultLanguageOption } from "@/utils/atoms/settings";
|
|||||||
|
|
||||||
export const LANGUAGES: DefaultLanguageOption[] = [
|
export const LANGUAGES: DefaultLanguageOption[] = [
|
||||||
{ label: "English", value: "eng" },
|
{ label: "English", value: "eng" },
|
||||||
{ label: "Spanish", value: "es" },
|
{ label: "Spanish", value: "spa" },
|
||||||
{ label: "Chinese (Mandarin)", value: "zh" },
|
{ label: "Chinese (Mandarin)", value: "cmn" },
|
||||||
{ label: "Hindi", value: "hi" },
|
{ label: "Hindi", value: "hin" },
|
||||||
{ label: "Arabic", value: "ar" },
|
{ label: "Arabic", value: "ara" },
|
||||||
{ label: "French", value: "fr" },
|
{ label: "French", value: "fra" },
|
||||||
{ label: "Russian", value: "ru" },
|
{ label: "Russian", value: "rus" },
|
||||||
{ label: "Portuguese", value: "pt" },
|
{ label: "Portuguese", value: "por" },
|
||||||
{ label: "Japanese", value: "ja" },
|
{ label: "Japanese", value: "jpn" },
|
||||||
{ label: "German", value: "de" },
|
{ label: "German", value: "deu" },
|
||||||
{ label: "Italian", value: "it" },
|
{ label: "Italian", value: "ita" },
|
||||||
{ label: "Korean", value: "ko" },
|
{ label: "Korean", value: "kor" },
|
||||||
{ label: "Turkish", value: "tr" },
|
{ label: "Turkish", value: "tur" },
|
||||||
{ label: "Dutch", value: "nl" },
|
{ label: "Dutch", value: "nld" },
|
||||||
{ label: "Polish", value: "pl" },
|
{ label: "Polish", value: "pol" },
|
||||||
{ label: "Vietnamese", value: "vi" },
|
{ label: "Vietnamese", value: "vie" },
|
||||||
{ label: "Thai", value: "th" },
|
{ label: "Thai", value: "tha" },
|
||||||
{ label: "Indonesian", value: "id" },
|
{ label: "Indonesian", value: "ind" },
|
||||||
{ label: "Greek", value: "el" },
|
{ label: "Greek", value: "ell" },
|
||||||
{ label: "Swedish", value: "sv" },
|
{ label: "Swedish", value: "swe" },
|
||||||
{ label: "Danish", value: "da" },
|
{ label: "Danish", value: "dan" },
|
||||||
{ label: "Norwegian", value: "no" },
|
{ label: "Norwegian", value: "nor" },
|
||||||
{ label: "Finnish", value: "fi" },
|
{ label: "Finnish", value: "fin" },
|
||||||
{ label: "Czech", value: "cs" },
|
{ label: "Czech", value: "ces" },
|
||||||
{ label: "Hungarian", value: "hu" },
|
{ label: "Hungarian", value: "hun" },
|
||||||
{ label: "Romanian", value: "ro" },
|
{ label: "Romanian", value: "ron" },
|
||||||
{ label: "Ukrainian", value: "uk" },
|
{ label: "Ukrainian", value: "ukr" },
|
||||||
{ label: "Hebrew", value: "he" },
|
{ label: "Hebrew", value: "heb" },
|
||||||
{ label: "Bengali", value: "bn" },
|
{ label: "Bengali", value: "ben" },
|
||||||
{ label: "Punjabi", value: "pa" },
|
{ label: "Punjabi", value: "pan" },
|
||||||
{ label: "Tagalog", value: "tl" },
|
{ label: "Tagalog", value: "tgl" },
|
||||||
{ label: "Swahili", value: "sw" },
|
{ label: "Swahili", value: "swa" },
|
||||||
{ label: "Malay", value: "ms" },
|
{ label: "Malay", value: "msa" },
|
||||||
{ label: "Persian", value: "fa" },
|
{ label: "Persian", value: "fas" },
|
||||||
{ label: "Urdu", value: "ur" },
|
{ label: "Urdu", value: "urd" },
|
||||||
];
|
];
|
||||||
|
|||||||
4
eas.json
@@ -22,13 +22,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.16.0",
|
"channel": "0.17.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.16.0",
|
"channel": "0.17.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -1,75 +1,98 @@
|
|||||||
import { Api } from "@jellyfin/sdk";
|
import index from "@/app/(auth)/(tabs)/(home)";
|
||||||
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 { apiAtom } from "@/providers/JellyfinProvider";
|
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 {
|
interface AdjacentEpisodesProps {
|
||||||
currentlyPlaying?: CurrentlyPlayingState | null;
|
item?: BaseItemDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAdjacentEpisodes = ({
|
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||||
currentlyPlaying,
|
const api = useAtomValue(apiAtom);
|
||||||
}: AdjacentEpisodesProps) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
|
|
||||||
const { data: previousItem } = useQuery({
|
const { data: previousItem } = useQuery({
|
||||||
queryKey: [
|
queryKey: ["previousItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
||||||
"previousItem",
|
|
||||||
currentlyPlaying?.item.ParentId,
|
|
||||||
currentlyPlaying?.item.IndexNumber,
|
|
||||||
],
|
|
||||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||||
|
const parentId = item?.AlbumId || item?.ParentId;
|
||||||
|
const indexNumber = item?.IndexNumber;
|
||||||
|
|
||||||
|
console.log("Getting previous item for " + indexNumber);
|
||||||
if (
|
if (
|
||||||
!api ||
|
!api ||
|
||||||
!currentlyPlaying?.item.ParentId ||
|
!parentId ||
|
||||||
currentlyPlaying?.item.IndexNumber === undefined ||
|
indexNumber === undefined ||
|
||||||
currentlyPlaying?.item.IndexNumber === null ||
|
indexNumber === null ||
|
||||||
currentlyPlaying.item.IndexNumber - 2 < 0
|
indexNumber - 1 < 1
|
||||||
) {
|
) {
|
||||||
console.log("No previous item");
|
console.log("No previous item", {
|
||||||
|
itemIndex: indexNumber,
|
||||||
|
itemId: item?.Id,
|
||||||
|
parentId: parentId,
|
||||||
|
indexNumber: indexNumber,
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newIndexNumber = indexNumber - 2;
|
||||||
|
|
||||||
const res = await getItemsApi(api).getItems({
|
const res = await getItemsApi(api).getItems({
|
||||||
parentId: currentlyPlaying.item.ParentId!,
|
parentId: parentId!,
|
||||||
startIndex: currentlyPlaying.item.IndexNumber! - 2,
|
startIndex: newIndexNumber,
|
||||||
limit: 1,
|
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;
|
return res.data.Items?.[0] || null;
|
||||||
},
|
},
|
||||||
enabled: currentlyPlaying?.item.Type === "Episode",
|
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
||||||
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: nextItem } = useQuery({
|
const { data: nextItem } = useQuery({
|
||||||
queryKey: [
|
queryKey: ["nextItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
||||||
"nextItem",
|
|
||||||
currentlyPlaying?.item.ParentId,
|
|
||||||
currentlyPlaying?.item.IndexNumber,
|
|
||||||
],
|
|
||||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||||
|
const parentId = item?.AlbumId || item?.ParentId;
|
||||||
|
const indexNumber = item?.IndexNumber;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!api ||
|
!api ||
|
||||||
!currentlyPlaying?.item.ParentId ||
|
!parentId ||
|
||||||
currentlyPlaying?.item.IndexNumber === undefined ||
|
indexNumber === undefined ||
|
||||||
currentlyPlaying?.item.IndexNumber === null
|
indexNumber === null
|
||||||
) {
|
) {
|
||||||
console.log("No next item");
|
console.log("No next item", {
|
||||||
|
itemId: item?.Id,
|
||||||
|
parentId: parentId,
|
||||||
|
indexNumber: indexNumber,
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getItemsApi(api).getItems({
|
const res = await getItemsApi(api).getItems({
|
||||||
parentId: currentlyPlaying.item.ParentId!,
|
parentId: parentId!,
|
||||||
startIndex: currentlyPlaying.item.IndexNumber!,
|
startIndex: indexNumber,
|
||||||
|
sortBy: ["IndexNumber"],
|
||||||
limit: 1,
|
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;
|
return res.data.Items?.[0] || null;
|
||||||
},
|
},
|
||||||
enabled: currentlyPlaying?.item.Type === "Episode",
|
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
||||||
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { previousItem, nextItem };
|
return { previousItem, nextItem };
|
||||||
|
|||||||
17
hooks/useAndroidNavigationBar.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export const useAndroidNavigationBar = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
NavigationBar.setVisibilityAsync("hidden");
|
||||||
|
NavigationBar.setBehaviorAsync("overlay-swipe");
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
NavigationBar.setVisibilityAsync("visible");
|
||||||
|
NavigationBar.setBehaviorAsync("inset-swipe");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
@@ -48,6 +48,7 @@ export const useCreditSkipper = (
|
|||||||
return res?.data;
|
return res?.data;
|
||||||
},
|
},
|
||||||
enabled: !!itemId,
|
enabled: !!itemId,
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,9 +61,13 @@ export const useCreditSkipper = (
|
|||||||
}, [creditTimestamps, currentTime]);
|
}, [creditTimestamps, currentTime]);
|
||||||
|
|
||||||
const skipCredit = useCallback(() => {
|
const skipCredit = useCallback(() => {
|
||||||
|
console.log("skipCredits");
|
||||||
if (!creditTimestamps || !videoRef.current) return;
|
if (!creditTimestamps || !videoRef.current) return;
|
||||||
try {
|
try {
|
||||||
videoRef.current.seek(creditTimestamps.Credits.End);
|
videoRef.current.seek(creditTimestamps.Credits.End);
|
||||||
|
setTimeout(() => {
|
||||||
|
videoRef.current?.resume();
|
||||||
|
}, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error skipping intro", error);
|
writeToLog("ERROR", "Error skipping intro", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// hooks/useFileOpener.ts
|
// 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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
@@ -8,48 +9,44 @@ import { useCallback } from "react";
|
|||||||
|
|
||||||
export const useFileOpener = () => {
|
export const useFileOpener = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { startDownloadedFilePlayback } = usePlayback();
|
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||||
|
|
||||||
const openFile = useCallback(
|
const openFile = useCallback(async (item: BaseItemDto) => {
|
||||||
async (item: BaseItemDto) => {
|
const directory = FileSystem.documentDirectory;
|
||||||
const directory = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
if (!directory) {
|
if (!directory) {
|
||||||
throw new Error("Document directory is not available");
|
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) {
|
const url = `${directory}${matchingFile}`;
|
||||||
throw new Error("Item ID is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
setOfflineSettings({
|
||||||
const files = await FileSystem.readDirectoryAsync(directory);
|
item,
|
||||||
for (let f of files) {
|
});
|
||||||
console.log(f);
|
setPlayUrl(url);
|
||||||
}
|
|
||||||
const path = item.Id!;
|
|
||||||
const matchingFile = files.find((file) => file.startsWith(path));
|
|
||||||
|
|
||||||
if (!matchingFile) {
|
router.push("/play-offline-video");
|
||||||
throw new Error(`No file found for item ${path}`);
|
} catch (error) {
|
||||||
}
|
writeToLog("ERROR", "Error opening file", error);
|
||||||
|
console.error("Error opening file:", error);
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { openFile };
|
return { openFile };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { getColors } from "react-native-image-colors";
|
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.
|
* @param disabled - A boolean flag to disable color extraction.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export const useImageColors = (item?: BaseItemDto | null, disabled = false) => {
|
export const useImageColors = ({
|
||||||
const [api] = useAtom(apiAtom);
|
item,
|
||||||
|
url,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
url?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
const source = useMemo(() => {
|
const source = useMemo(() => {
|
||||||
if (!api || !item) return;
|
if (!api) return;
|
||||||
return getItemImage({
|
if (url) return { uri: url };
|
||||||
item,
|
else if (item)
|
||||||
api,
|
return getItemImage({
|
||||||
variant: "Primary",
|
item,
|
||||||
quality: 80,
|
api,
|
||||||
width: 300,
|
variant: "Primary",
|
||||||
});
|
quality: 80,
|
||||||
|
width: 300,
|
||||||
|
});
|
||||||
|
else return null;
|
||||||
}, [api, item]);
|
}, [api, item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,7 +54,7 @@ export const useImageColors = (item?: BaseItemDto | null, disabled = false) => {
|
|||||||
|
|
||||||
// If colors are cached, use them and exit
|
// If colors are cached, use them and exit
|
||||||
if (_primary && _text) {
|
if (_primary && _text) {
|
||||||
console.info("[useImageColors] Using cached colors for performance.");
|
console.info("useImageColors ~ Using cached colors for performance.");
|
||||||
setPrimaryColor({
|
setPrimaryColor({
|
||||||
primary: _primary,
|
primary: _primary,
|
||||||
text: _text,
|
text: _text,
|
||||||
@@ -54,22 +65,25 @@ export const useImageColors = (item?: BaseItemDto | null, disabled = false) => {
|
|||||||
// Extract colors from the image
|
// Extract colors from the image
|
||||||
getColors(source.uri, {
|
getColors(source.uri, {
|
||||||
fallback: "#fff",
|
fallback: "#fff",
|
||||||
cache: true,
|
cache: false,
|
||||||
key: source.uri,
|
|
||||||
})
|
})
|
||||||
.then((colors) => {
|
.then((colors) => {
|
||||||
let primary: string = "#fff";
|
let primary: string = "#fff";
|
||||||
let text: string = "#000";
|
let text: string = "#000";
|
||||||
|
let backup: string = "#fff";
|
||||||
|
|
||||||
// Select the appropriate color based on the platform
|
// Select the appropriate color based on the platform
|
||||||
if (colors.platform === "android") {
|
if (colors.platform === "android") {
|
||||||
primary = colors.dominant;
|
primary = colors.dominant;
|
||||||
|
backup = colors.vibrant;
|
||||||
} else if (colors.platform === "ios") {
|
} 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
|
// Adjust the primary color if it's too close to black
|
||||||
if (primary && isCloseToBlack(primary)) {
|
if (primary && isCloseToBlack(primary)) {
|
||||||
|
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||||
primary = adjustToNearBlack(primary);
|
primary = adjustToNearBlack(primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const useIntroSkipper = (
|
|||||||
return res?.data;
|
return res?.data;
|
||||||
},
|
},
|
||||||
enabled: !!itemId,
|
enabled: !!itemId,
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,9 +57,13 @@ export const useIntroSkipper = (
|
|||||||
}, [introTimestamps, currentTime]);
|
}, [introTimestamps, currentTime]);
|
||||||
|
|
||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
|
console.log("skipIntro");
|
||||||
if (!introTimestamps || !videoRef.current) return;
|
if (!introTimestamps || !videoRef.current) return;
|
||||||
try {
|
try {
|
||||||
videoRef.current.seek(introTimestamps.IntroEnd);
|
videoRef.current.seek(introTimestamps.IntroEnd);
|
||||||
|
setTimeout(() => {
|
||||||
|
videoRef.current?.resume();
|
||||||
|
}, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error skipping intro", error);
|
writeToLog("ERROR", "Error skipping intro", error);
|
||||||
}
|
}
|
||||||
|
|||||||
28
hooks/useOrientation.ts
Normal 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 };
|
||||||
|
};
|
||||||
25
hooks/useOrientationSettings.ts
Normal 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]);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||||
@@ -10,6 +10,9 @@ import { toast } from "sonner-native";
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { JobStatus } from "@/utils/optimize-server";
|
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.
|
* 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
|
* @returns An object with remuxing-related functions
|
||||||
*/
|
*/
|
||||||
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { loadImage, saveImage, image2Base64, saveBase64Image } =
|
||||||
|
useImageStorage();
|
||||||
|
|
||||||
if (!item.Id || !item.Name) {
|
if (!item.Id || !item.Name) {
|
||||||
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
||||||
@@ -32,8 +38,19 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (url: string) => {
|
async (url: string) => {
|
||||||
|
if (!api) throw new Error("API is not defined");
|
||||||
if (!item.Id) throw new Error("Item must have an Id");
|
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}`, {
|
toast.success(`Download started for ${item.Name}`, {
|
||||||
action: {
|
action: {
|
||||||
label: "Go to download",
|
label: "Go to download",
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
// hooks/useTrickplay.ts
|
// 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 { 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 {
|
interface TrickplayData {
|
||||||
Interval?: number;
|
Interval?: number;
|
||||||
@@ -28,21 +26,19 @@ interface TrickplayUrl {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTrickplay = (
|
export const useTrickplay = (item: BaseItemDto, enabled = true) => {
|
||||||
currentlyPlaying?: CurrentlyPlayingState | null
|
|
||||||
) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||||
const lastCalculationTime = useRef(0);
|
const lastCalculationTime = useRef(0);
|
||||||
const throttleDelay = 200; // 200ms throttle
|
const throttleDelay = 200; // 200ms throttle
|
||||||
|
|
||||||
const trickplayInfo = useMemo(() => {
|
const trickplayInfo = useMemo(() => {
|
||||||
if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) {
|
if (!enabled || !item.Id || !item.Trickplay) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaSourceId = currentlyPlaying.item.Id;
|
const mediaSourceId = item.Id;
|
||||||
const trickplayData = currentlyPlaying.item.Trickplay[mediaSourceId];
|
const trickplayData = item.Trickplay[mediaSourceId];
|
||||||
|
|
||||||
if (!trickplayData) {
|
if (!trickplayData) {
|
||||||
return null;
|
return null;
|
||||||
@@ -59,17 +55,21 @@ export const useTrickplay = (
|
|||||||
data: trickplayData[firstResolution],
|
data: trickplayData[firstResolution],
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
}, [currentlyPlaying]);
|
}, [item, enabled]);
|
||||||
|
|
||||||
const calculateTrickplayUrl = useCallback(
|
const calculateTrickplayUrl = useCallback(
|
||||||
(progress: number) => {
|
(progress: number) => {
|
||||||
|
if (!enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastCalculationTime.current < throttleDelay) {
|
if (now - lastCalculationTime.current < throttleDelay) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
lastCalculationTime.current = now;
|
lastCalculationTime.current = now;
|
||||||
|
|
||||||
if (!trickplayInfo || !api || !currentlyPlaying?.item.Id) {
|
if (!trickplayInfo || !api || !item.Id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,14 +95,18 @@ export const useTrickplay = (
|
|||||||
const newTrickPlayUrl = {
|
const newTrickPlayUrl = {
|
||||||
x: rowInTile,
|
x: rowInTile,
|
||||||
y: colInTile,
|
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);
|
setTrickPlayUrl(newTrickPlayUrl);
|
||||||
return 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
@@ -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 };
|
||||||
|
};
|
||||||
28
package.json
@@ -18,12 +18,14 @@
|
|||||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||||
"@expo/react-native-action-sheet": "^4.1.0",
|
"@expo/react-native-action-sheet": "^4.1.0",
|
||||||
"@expo/vector-icons": "^14.0.3",
|
"@expo/vector-icons": "^14.0.3",
|
||||||
|
"@futurejj/react-native-visibility-sensor": "^1.3.4",
|
||||||
"@gorhom/bottom-sheet": "^4",
|
"@gorhom/bottom-sheet": "^4",
|
||||||
"@jellyfin/sdk": "^0.10.0",
|
"@jellyfin/sdk": "^0.10.0",
|
||||||
"@kesha-antonov/react-native-background-downloader": "^3.2.1",
|
"@kesha-antonov/react-native-background-downloader": "^3.2.1",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-native-community/netinfo": "11.3.1",
|
"@react-native-community/netinfo": "11.3.1",
|
||||||
"@react-native-menu/menu": "^1.1.3",
|
"@react-native-menu/menu": "^1.1.3",
|
||||||
|
"@react-navigation/material-top-tabs": "^6.6.14",
|
||||||
"@react-navigation/native": "^6.0.2",
|
"@react-navigation/native": "^6.0.2",
|
||||||
"@shopify/flash-list": "1.6.4",
|
"@shopify/flash-list": "1.6.4",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
@@ -56,30 +58,34 @@
|
|||||||
"expo-updates": "~0.25.26",
|
"expo-updates": "~0.25.26",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"ffmpeg-kit-react-native": "^6.0.2",
|
"ffmpeg-kit-react-native": "^6.0.2",
|
||||||
|
"install": "^0.13.0",
|
||||||
"jotai": "^2.10.0",
|
"jotai": "^2.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "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-awesome-slider": "^2.5.3",
|
||||||
"react-native-circular-progress": "^1.4.0",
|
"react-native-circular-progress": "^1.4.0",
|
||||||
"react-native-compressor": "^1.8.25",
|
"react-native-compressor": "^1.8.25",
|
||||||
"react-native-gesture-handler": "~2.18.1",
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
"react-native-get-random-values": "^1.11.0",
|
||||||
"react-native-google-cast": "^4.8.3",
|
"react-native-google-cast": "^4.8.3",
|
||||||
"react-native-image-colors": "^2.4.0",
|
"react-native-image-colors": "^2.4.0",
|
||||||
"react-native-ios-context-menu": "^2.5.1",
|
"react-native-ios-context-menu": "^2.5.1",
|
||||||
"react-native-ios-utilities": "^4.4.5",
|
"react-native-ios-utilities": "^4.4.5",
|
||||||
"react-native-mmkv": "^2.12.2",
|
"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-reanimated-carousel": "4.0.0-canary.15",
|
||||||
"react-native-safe-area-context": "4.10.5",
|
"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-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-url-polyfill": "^2.0.0",
|
||||||
"react-native-uuid": "^2.0.2",
|
"react-native-uuid": "^2.0.2",
|
||||||
"react-native-video": "^6.6.3",
|
"react-native-video": "^6.6.4",
|
||||||
"react-native-web": "~0.19.10",
|
"react-native-web": "~0.19.10",
|
||||||
"sonner-native": "^0.14.2",
|
"sonner-native": "^0.14.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
@@ -98,15 +104,5 @@
|
|||||||
"react-test-renderer": "18.2.0",
|
"react-test-renderer": "18.2.0",
|
||||||
"typescript": "~5.3.3"
|
"typescript": "~5.3.3"
|
||||||
},
|
},
|
||||||
"private": true,
|
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ function useDownloadProvider() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error in startBackgroundDownload", error);
|
||||||
console.error("Error in startBackgroundDownload:", error);
|
console.error("Error in startBackgroundDownload:", error);
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
console.error("Axios error details:", {
|
console.error("Axios error details:", {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.16.0" },
|
clientInfo: { name: "Streamyfin", version: "0.17.0" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -86,7 +86,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.16.0"`,
|
}, DeviceId="${deviceId}", Version="0.17.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
176
providers/PlaySettingsProvider.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -56,7 +56,7 @@ export const isCloseToBlack = (color: string): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const adjustToNearBlack = (color: string): string => {
|
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>({
|
export const itemThemeColorAtom = atom<ThemeColors>({
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ const saveSettings = async (settings: Settings) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create an atom to store the settings in memory
|
// 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
|
// A hook to manage settings, loading them on initial mount and providing a way to update them
|
||||||
export const useSettings = () => {
|
export const useSettings = () => {
|
||||||
|
|||||||
63
utils/jellyfin/getDefaultPlaySettings.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// 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 | undefined;
|
||||||
|
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];
|
||||||
|
|
||||||
|
if (!mediaSource) throw new Error("No media source found");
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import ios from "@/utils/profiles/ios";
|
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
PlaybackInfoResponse,
|
PlaybackInfoResponse,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
export const getStreamUrl = async ({
|
export const getStreamUrl = async ({
|
||||||
@@ -14,11 +15,10 @@ export const getStreamUrl = async ({
|
|||||||
startTimeTicks = 0,
|
startTimeTicks = 0,
|
||||||
maxStreamingBitrate,
|
maxStreamingBitrate,
|
||||||
sessionData,
|
sessionData,
|
||||||
deviceProfile = ios,
|
deviceProfile = iosFmp4,
|
||||||
audioStreamIndex = 0,
|
audioStreamIndex = 0,
|
||||||
subtitleStreamIndex = undefined,
|
subtitleStreamIndex = undefined,
|
||||||
forceDirectPlay = false,
|
forceDirectPlay = false,
|
||||||
height,
|
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
}: {
|
}: {
|
||||||
api: Api | null | undefined;
|
api: Api | null | undefined;
|
||||||
@@ -26,95 +26,135 @@ export const getStreamUrl = async ({
|
|||||||
userId: string | null | undefined;
|
userId: string | null | undefined;
|
||||||
startTimeTicks: number;
|
startTimeTicks: number;
|
||||||
maxStreamingBitrate?: number;
|
maxStreamingBitrate?: number;
|
||||||
sessionData: PlaybackInfoResponse;
|
sessionData?: PlaybackInfoResponse | null;
|
||||||
deviceProfile: any;
|
deviceProfile: any;
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
subtitleStreamIndex?: number;
|
subtitleStreamIndex?: number;
|
||||||
forceDirectPlay?: boolean;
|
forceDirectPlay?: boolean;
|
||||||
height?: number;
|
height?: number;
|
||||||
mediaSourceId: string | null;
|
mediaSourceId?: string | null;
|
||||||
}) => {
|
}): Promise<{
|
||||||
if (!api || !userId || !item?.Id || !mediaSourceId) {
|
url: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
} | null> => {
|
||||||
|
if (!api || !userId || !item?.Id) {
|
||||||
return null;
|
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;
|
const itemId = item.Id;
|
||||||
|
|
||||||
/**
|
const res2 = await getMediaInfoApi(api).getPlaybackInfo(
|
||||||
* Build the stream URL for videos
|
|
||||||
*/
|
|
||||||
const response = await api.axiosInstance.post(
|
|
||||||
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
|
|
||||||
{
|
{
|
||||||
DeviceProfile: deviceProfile,
|
userId,
|
||||||
UserId: userId,
|
itemId: item.Id!,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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
|
(source: MediaSourceInfo) => source.Id === mediaSourceId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mediaSource) {
|
console.log("getStreamUrl ~ ", item.MediaType);
|
||||||
throw new Error("No media source");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionData.PlaySessionId) {
|
if (item.MediaType === "Video") {
|
||||||
throw new Error("no PlaySessionId");
|
if (mediaSource?.TranscodingUrl) {
|
||||||
}
|
return {
|
||||||
|
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
|
||||||
let url: string | null | undefined;
|
sessionId: sessionId,
|
||||||
|
};
|
||||||
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}`;
|
if (mediaSource?.SupportsDirectPlay || forceDirectPlay === true) {
|
||||||
} else if (item.MediaType === "Audio") {
|
return {
|
||||||
const searchParams = new URLSearchParams({
|
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}`,
|
||||||
UserId: userId,
|
sessionId: sessionId,
|
||||||
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()}`;
|
|
||||||
}
|
}
|
||||||
} 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(
|
if (mediaSource?.TranscodingUrl) {
|
||||||
mediaSource.VideoType,
|
return { url: `${api.basePath}${mediaSource.TranscodingUrl}`, sessionId };
|
||||||
mediaSource.Container,
|
}
|
||||||
mediaSource.TranscodingContainer,
|
|
||||||
mediaSource.TranscodingSubProtocol
|
|
||||||
);
|
|
||||||
|
|
||||||
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");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import { Api } from "@jellyfin/sdk";
|
|||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
import { postCapabilities } from "../session/capabilities";
|
import { postCapabilities } from "../session/capabilities";
|
||||||
import { Settings } from "@/utils/atoms/settings";
|
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 {
|
interface ReportPlaybackProgressParams {
|
||||||
api?: Api | null;
|
api?: Api | null;
|
||||||
@@ -33,31 +43,29 @@ export const reportPlaybackProgress = async ({
|
|||||||
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
|
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await postCapabilities({
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
api,
|
|
||||||
itemId,
|
itemId,
|
||||||
sessionId,
|
audioStreamIndex: 0,
|
||||||
deviceProfile,
|
subtitleStreamIndex: 0,
|
||||||
|
mediaSourceId: itemId,
|
||||||
|
positionTicks: Math.round(positionTicks),
|
||||||
|
isPaused: IsPaused,
|
||||||
|
isMuted: false,
|
||||||
|
playMethod: "Transcode",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
// await api.axiosInstance.post(
|
||||||
console.error("Failed to post capabilities.", error);
|
// `${api.basePath}/Sessions/Playing/Progress`,
|
||||||
throw new Error("Failed to post capabilities.");
|
// {
|
||||||
}
|
// ItemId: itemId,
|
||||||
|
// PlaySessionId: sessionId,
|
||||||
try {
|
// IsPaused,
|
||||||
await api.axiosInstance.post(
|
// PositionTicks: Math.round(positionTicks),
|
||||||
`${api.basePath}/Sessions/Playing/Progress`,
|
// CanSeek: true,
|
||||||
{
|
// MediaSourceId: itemId,
|
||||||
ItemId: itemId,
|
// EventName: "timeupdate",
|
||||||
PlaySessionId: sessionId,
|
// },
|
||||||
IsPaused,
|
// { headers: getAuthHeaders(api) }
|
||||||
PositionTicks: Math.round(positionTicks),
|
// );
|
||||||
CanSeek: true,
|
|
||||||
MediaSourceId: itemId,
|
|
||||||
EventName: "timeupdate",
|
|
||||||
},
|
|
||||||
{ headers: getAuthHeaders(api) }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -6,6 +6,7 @@ import { Api } from "@jellyfin/sdk";
|
|||||||
import { AxiosError, AxiosResponse } from "axios";
|
import { AxiosError, AxiosResponse } from "axios";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||||
|
|
||||||
interface PostCapabilitiesParams {
|
interface PostCapabilitiesParams {
|
||||||
api: Api | null | undefined;
|
api: Api | null | undefined;
|
||||||
@@ -30,7 +31,7 @@ export const postCapabilities = async ({
|
|||||||
throw new Error("Missing parameters for marking item as not played");
|
throw new Error("Missing parameters for marking item as not played");
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile: any = ios;
|
let profile: any = iosFmp4;
|
||||||
|
|
||||||
if (deviceProfile === "Native") {
|
if (deviceProfile === "Native") {
|
||||||
profile = native;
|
profile = native;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { itemRouter } from "@/components/common/TouchableItemRouter";
|
import { itemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { writeToLog } from "./log";
|
||||||
|
|
||||||
interface IJobInput {
|
interface IJobInput {
|
||||||
deviceId?: string | null;
|
deviceId?: string | null;
|
||||||
@@ -108,6 +109,7 @@ export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Failed to cancel all jobs", error);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||