mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-22 16:54:42 +01:00
Compare commits
114 Commits
v0.21.0
...
feat/remov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6c7a7603 | ||
|
|
5a07eccd9b | ||
|
|
4dff26e8c3 | ||
|
|
ee2edda507 | ||
|
|
9e6a8424db | ||
|
|
d37ecc1bef | ||
|
|
e70fd3ee45 | ||
|
|
16e93513e2 | ||
|
|
b0c506f85d | ||
|
|
b762aff6e2 | ||
|
|
75639c4424 | ||
|
|
4606ce1834 | ||
|
|
44bde8f41e | ||
|
|
828edad749 | ||
|
|
f842c8a41f | ||
|
|
4d38573973 | ||
|
|
785e3b6859 | ||
|
|
40b3304f9b | ||
|
|
abf1b343cd | ||
|
|
e427802aae | ||
|
|
684e671750 | ||
|
|
5e9b28f2eb | ||
|
|
1d4c56265f | ||
|
|
1102df8384 | ||
|
|
15073f47db | ||
|
|
15f32bca6c | ||
|
|
108c5f9bab | ||
|
|
24d781050f | ||
|
|
353ebf3b0c | ||
|
|
c8b16f947d | ||
|
|
bd24f59199 | ||
|
|
a6b49c42cf | ||
|
|
5afb677b3a | ||
|
|
65d3da155f | ||
|
|
d616574232 | ||
|
|
b8b083abe2 | ||
|
|
49a1bffcf5 | ||
|
|
cb6c716830 | ||
|
|
a725af114c | ||
|
|
5b290fd667 | ||
|
|
de4f60f564 | ||
|
|
a4cd3ea600 | ||
|
|
3db12bd76a | ||
|
|
26305c2983 | ||
|
|
9c02fa2e72 | ||
|
|
b08ec474a4 | ||
|
|
416fb24ac0 | ||
|
|
0d2b15e5af | ||
|
|
ef036cb362 | ||
|
|
006e457d23 | ||
|
|
832a717585 | ||
|
|
39f86a9eb1 | ||
|
|
38445c6959 | ||
|
|
24320541c7 | ||
|
|
ee4e9fe347 | ||
|
|
6d43b34f66 | ||
|
|
63cf7eb622 | ||
|
|
32130f1a9c | ||
|
|
7f458f2f0b | ||
|
|
6ec6c6daa0 | ||
|
|
02a48fd958 | ||
|
|
04c4dfd13a | ||
|
|
40bdb10653 | ||
|
|
f16c486bfb | ||
|
|
19fc00e314 | ||
|
|
c51965016c | ||
|
|
3bcf73f0dd | ||
|
|
1ecef4be67 | ||
|
|
387525f9c3 | ||
|
|
cf182d8473 | ||
|
|
f0e3321a16 | ||
|
|
96c76e2b08 | ||
|
|
aaa07d93cf | ||
|
|
0716bba6ec | ||
|
|
15476f3686 | ||
|
|
97cf9185d3 | ||
|
|
c11ad17ca5 | ||
|
|
b0d563bc48 | ||
|
|
909fc84ec0 | ||
|
|
0400597061 | ||
|
|
b44a5fbbba | ||
|
|
a5f6ba27b1 | ||
|
|
ece1b8f2b9 | ||
|
|
beb6702112 | ||
|
|
98c0ed4ad5 | ||
|
|
b3f471bfa6 | ||
|
|
1a10f0debf | ||
|
|
ac266c6956 | ||
|
|
b23a50914c | ||
|
|
5c4a419d22 | ||
|
|
3d034864f9 | ||
|
|
ea183c426b | ||
|
|
92be991cf7 | ||
|
|
b73c29221a | ||
|
|
880a739dd4 | ||
|
|
69ffdc2ddf | ||
|
|
d686bd8c7b | ||
|
|
c8a60e735b | ||
|
|
05f7574e60 | ||
|
|
11b880863c | ||
|
|
aec172d8f5 | ||
|
|
7b52528d72 | ||
|
|
5fd1d9080e | ||
|
|
5cc0f381fa | ||
|
|
0f547deb39 | ||
|
|
5aeb80348a | ||
|
|
1dfc0ac762 | ||
|
|
2b8aee442a | ||
|
|
3e45adfeb5 | ||
|
|
b41363d347 | ||
|
|
2d5a27c015 | ||
|
|
b5c6403e2d | ||
|
|
7eb7d17fa9 | ||
|
|
91b4e403e6 |
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: '❌ bug'
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
|
||||||
- Device: [e.g. iPhone15Pro]
|
|
||||||
- OS: [e.g. iOS18]
|
|
||||||
- Version [e.g. 0.3.1]
|
|
||||||
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a report to help us improve
|
||||||
|
title: '[Bug]: '
|
||||||
|
labels:
|
||||||
|
- ['❌ bug']
|
||||||
|
projects:
|
||||||
|
- ['fredrikburmester/5']
|
||||||
|
assignees:
|
||||||
|
- fredrikburmester
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What happened?
|
||||||
|
description: Also tell us, what did you expect to happen?
|
||||||
|
placeholder: A clear and concise description of what the bug is.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Reproduction steps
|
||||||
|
description: "How do you trigger this bug? Please walk us through it step by step."
|
||||||
|
placeholder: |
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: device
|
||||||
|
attributes:
|
||||||
|
label: Which device and operating system are you using?
|
||||||
|
description: e.g. iPhone 15, iOS 18.1.1
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: What version of Streamyfin are you running?
|
||||||
|
options:
|
||||||
|
- 0.22.0
|
||||||
|
- 0.21.0
|
||||||
|
- older
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label:
|
||||||
|
If applicable, please add screenshots to help explain your problem.
|
||||||
|
You can drag and drop images here or paste them directly into the comment box.
|
||||||
15
app.json
15
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.21.0",
|
"version": "0.22.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -23,7 +23,10 @@
|
|||||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
}
|
},
|
||||||
|
"UISupportsTrueScreenSizeOnMac": true,
|
||||||
|
"UIFileSharingEnabled": true,
|
||||||
|
"LSSupportsOpeningDocumentsInPlace": true
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
@@ -33,14 +36,15 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 46,
|
"versionCode": 47,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.FOREGROUND_SERVICE",
|
"android.permission.FOREGROUND_SERVICE",
|
||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||||
|
"android.permission.WRITE_SETTINGS"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
@@ -70,7 +74,8 @@
|
|||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6"
|
"deploymentTarget": "15.6",
|
||||||
|
"useFrameworks": "static"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"android": {
|
"android": {
|
||||||
|
|||||||
20
app/(auth)/(tabs)/(custom-links)/_layout.tsx
Normal file
20
app/(auth)/(tabs)/(custom-links)/_layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import {Stack} from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export default function CustomMenuLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTitle: "Custom Links",
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
app/(auth)/(tabs)/(custom-links)/index.tsx
Normal file
73
app/(auth)/(tabs)/(custom-links)/index.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {FlatList, TouchableOpacity, View} from "react-native";
|
||||||
|
import {useSafeAreaInsets} from "react-native-safe-area-context";
|
||||||
|
import React, {useCallback, useEffect, useState} from "react";
|
||||||
|
import {useAtom} from "jotai/index";
|
||||||
|
import {apiAtom} from "@/providers/JellyfinProvider";
|
||||||
|
import {ListItem} from "@/components/ListItem";
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
|
||||||
|
export interface MenuLink {
|
||||||
|
name: string,
|
||||||
|
url: string,
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function menuLinks() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
|
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([])
|
||||||
|
|
||||||
|
const getMenuLinks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json")
|
||||||
|
const config = response?.data;
|
||||||
|
|
||||||
|
if (!config && !config.hasOwnProperty("menuLinks")) {
|
||||||
|
console.error("Menu links not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMenuLinks(config?.menuLinks as MenuLink[])
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to retrieve config:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => { getMenuLinks() }, []);
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
data={menuLinks}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }>
|
||||||
|
<ListItem
|
||||||
|
title={item.name}
|
||||||
|
iconAfter={<Ionicons name="link" size={24} color="white"/>}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
|
<Text className="font-bold text-xl text-neutral-500">No links</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
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
|
||||||
@@ -35,11 +32,17 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="downloads"
|
name="downloads/index"
|
||||||
options={{
|
options={{
|
||||||
title: "Downloads",
|
title: "Downloads",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="downloads/[seriesId]"
|
||||||
|
options={{
|
||||||
|
title: "TV-Series",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
|
||||||
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
const { removeProcess, downloadedFiles } = useDownload();
|
|
||||||
const router = useRouter();
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
|
||||||
try {
|
|
||||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
|
||||||
} catch {
|
|
||||||
migration_20241124();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const groupedBySeries = useMemo(() => {
|
|
||||||
try {
|
|
||||||
const episodes = downloadedFiles?.filter(
|
|
||||||
(f) => f.item.Type === "Episode"
|
|
||||||
);
|
|
||||||
const series: { [key: string]: DownloadedItem[] } = {};
|
|
||||||
episodes?.forEach((e) => {
|
|
||||||
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
|
||||||
series[e.item.SeriesName!].push(e);
|
|
||||||
});
|
|
||||||
return Object.values(series);
|
|
||||||
} catch {
|
|
||||||
migration_20241124();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="py-4">
|
|
||||||
<View className="mb-4 flex flex-col space-y-4 px-4">
|
|
||||||
{settings?.downloadMethod === "remux" && (
|
|
||||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
|
||||||
<Text className="text-lg font-bold">Queue</Text>
|
|
||||||
<Text className="text-xs opacity-70 text-red-600">
|
|
||||||
Queue and downloads will be lost on app restart
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-col space-y-2 mt-2">
|
|
||||||
{queue.map((q) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
|
||||||
}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className="font-semibold">{q.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
removeProcess(q.id);
|
|
||||||
setQueue((prev) => {
|
|
||||||
if (!prev) return [];
|
|
||||||
return [...prev.filter((i) => i.id !== q.id)];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="red" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{queue.length === 0 && (
|
|
||||||
<Text className="opacity-50">No items in queue</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ActiveDownloads />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
|
||||||
<Text className="text-lg font-bold">Movies</Text>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
|
||||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className="px-4 flex flex-row">
|
|
||||||
{movies?.map((item) => (
|
|
||||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{groupedBySeries?.map((items, index) => (
|
|
||||||
<SeriesCard
|
|
||||||
items={items.map((i) => i.item)}
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{downloadedFiles?.length === 0 && (
|
|
||||||
<View className="flex px-4">
|
|
||||||
<Text className="opacity-50">No downloaded items</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function migration_20241124() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { deleteAllFiles } = useDownload();
|
|
||||||
Alert.alert(
|
|
||||||
"New app version requires re-download",
|
|
||||||
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "Back",
|
|
||||||
onPress: () => router.back(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Delete",
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => await deleteAllFiles(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
132
app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx
Normal file
132
app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
|
||||||
|
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
SeasonDropdown,
|
||||||
|
SeasonIndexState,
|
||||||
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const local = useLocalSearchParams();
|
||||||
|
const { seriesId, episodeSeasonIndex } = local as {
|
||||||
|
seriesId: string;
|
||||||
|
episodeSeasonIndex: number | string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const { downloadedFiles, deleteItems } = useDownload();
|
||||||
|
|
||||||
|
const series = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
downloadedFiles
|
||||||
|
?.filter((f) => f.item.SeriesId == seriesId)
|
||||||
|
?.sort(
|
||||||
|
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
const seasonIndex =
|
||||||
|
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||||
|
episodeSeasonIndex ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||||
|
const seasons: Record<string, BaseItemDto[]> = {};
|
||||||
|
|
||||||
|
series?.forEach((episode) => {
|
||||||
|
if (!seasons[episode.item.ParentIndexNumber!]) {
|
||||||
|
seasons[episode.item.ParentIndexNumber!] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
seasons[episode.item.ParentIndexNumber!].push(episode.item);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}, [series, seasonIndex]);
|
||||||
|
|
||||||
|
const initialSeasonIndex = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||||
|
series?.[0]?.item?.ParentIndexNumber,
|
||||||
|
[groupBySeason]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (series.length > 0) {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: series[0].item.SeriesName,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
storage.delete(seriesId);
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
const deleteSeries = useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
"Delete season",
|
||||||
|
"Are you sure you want to delete the entire season?",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
onPress: () => deleteItems(groupBySeason),
|
||||||
|
style: "destructive",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}, [groupBySeason]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1">
|
||||||
|
{series.length > 0 && (
|
||||||
|
<View className="flex flex-row items-center justify-start my-2 px-4">
|
||||||
|
<SeasonDropdown
|
||||||
|
item={series[0].item}
|
||||||
|
seasons={series.map((s) => s.item)}
|
||||||
|
state={seasonIndexState}
|
||||||
|
initialSeasonIndex={initialSeasonIndex!}
|
||||||
|
onSelect={(season) => {
|
||||||
|
setSeasonIndexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
|
||||||
|
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
|
||||||
|
<TouchableOpacity onPress={deleteSeries}>
|
||||||
|
<Ionicons name="trash" size={20} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<ScrollView key={seasonIndex} className="px-4">
|
||||||
|
{groupBySeason.map((episode, index) => (
|
||||||
|
<EpisodeCard key={index} item={episode} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
app/(auth)/(tabs)/(home)/downloads/index.tsx
Normal file
231
app/(auth)/(tabs)/(home)/downloads/index.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||||
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
|
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {useNavigation, useRouter} from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, {useEffect, useMemo, useRef} from "react";
|
||||||
|
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
||||||
|
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
|
||||||
|
import {toast} from "sonner-native";
|
||||||
|
import {writeToLog} from "@/utils/log";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
|
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
||||||
|
const router = useRouter();
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
|
const movies = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||||
|
} catch {
|
||||||
|
migration_20241124();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
const groupedBySeries = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const episodes = downloadedFiles?.filter(
|
||||||
|
(f) => f.item.Type === "Episode"
|
||||||
|
);
|
||||||
|
const series: { [key: string]: DownloadedItem[] } = {};
|
||||||
|
episodes?.forEach((e) => {
|
||||||
|
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
||||||
|
series[e.item.SeriesName!].push(e);
|
||||||
|
});
|
||||||
|
return Object.values(series);
|
||||||
|
} catch {
|
||||||
|
migration_20241124();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={bottomSheetModalRef.current?.present}
|
||||||
|
>
|
||||||
|
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
const deleteMovies = () => deleteFileByType("Movie")
|
||||||
|
.then(() => toast.success("Deleted all movies successfully!"))
|
||||||
|
.catch((reason) => {
|
||||||
|
writeToLog("ERROR", reason);
|
||||||
|
toast.error("Failed to delete all movies");
|
||||||
|
});
|
||||||
|
const deleteShows = () => deleteFileByType("Episode")
|
||||||
|
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
||||||
|
.catch((reason) => {
|
||||||
|
writeToLog("ERROR", reason);
|
||||||
|
toast.error("Failed to delete all TV-Series");
|
||||||
|
});
|
||||||
|
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="py-4">
|
||||||
|
<View className="mb-4 flex flex-col space-y-4 px-4">
|
||||||
|
{settings?.downloadMethod === "remux" && (
|
||||||
|
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
|
<Text className="text-lg font-bold">Queue</Text>
|
||||||
|
<Text className="text-xs opacity-70 text-red-600">
|
||||||
|
Queue and downloads will be lost on app restart
|
||||||
|
</Text>
|
||||||
|
<View className="flex flex-col space-y-2 mt-2">
|
||||||
|
{queue.map((q, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||||
|
}
|
||||||
|
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text className="font-semibold">{q.item.Name}</Text>
|
||||||
|
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
removeProcess(q.id);
|
||||||
|
setQueue((prev) => {
|
||||||
|
if (!prev) return [];
|
||||||
|
return [...prev.filter((i) => i.id !== q.id)];
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="red"/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{queue.length === 0 && (
|
||||||
|
<Text className="opacity-50">No items in queue</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ActiveDownloads/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{movies.length > 0 && (
|
||||||
|
<View className="mb-4">
|
||||||
|
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||||
|
<Text className="text-lg font-bold">Movies</Text>
|
||||||
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
|
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className="px-4 flex flex-row">
|
||||||
|
{movies?.map((item) => (
|
||||||
|
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
||||||
|
<MovieCard item={item.item}/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{groupedBySeries.length > 0 && (
|
||||||
|
<View className="mb-4">
|
||||||
|
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||||
|
<Text className="text-lg font-bold">TV-Series</Text>
|
||||||
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
|
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className="px-4 flex flex-row">
|
||||||
|
{groupedBySeries?.map((items) => (
|
||||||
|
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
|
||||||
|
<SeriesCard
|
||||||
|
items={items.map((i) => i.item)}
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{downloadedFiles?.length === 0 && (
|
||||||
|
<View className="flex px-4">
|
||||||
|
<Text className="opacity-50">No downloaded items</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
enableDynamicSizing
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className="p-4 space-y-4 mb-4">
|
||||||
|
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
|
||||||
|
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
|
||||||
|
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migration_20241124() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { deleteAllFiles } = useDownload();
|
||||||
|
Alert.alert(
|
||||||
|
"New app version requires re-download",
|
||||||
|
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Back",
|
||||||
|
onPress: () => router.back(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => await deleteAllFiles(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -188,7 +188,7 @@ export default function index() {
|
|||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 50,
|
limit: 20,
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
|||||||
@@ -2,32 +2,41 @@ import { Button } from "@/components/Button";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListItem } from "@/components/ListItem";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs, readFromLog } from "@/utils/log";
|
import {clearLogs, useLog} from "@/utils/log";
|
||||||
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 { 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";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import * as Progress from 'react-native-progress';
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const { deleteAllFiles } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
|
const { logs } = useLog();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const { data: logs } = useQuery({
|
|
||||||
queryKey: ["logs"],
|
|
||||||
queryFn: async () => readFromLog(),
|
|
||||||
refetchInterval: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const {data: size , isLoading: appSizeLoading } = useQuery({
|
||||||
|
queryKey: ["appSize", appSizeUsage],
|
||||||
|
queryFn: async () => {
|
||||||
|
const app = await appSizeUsage
|
||||||
|
|
||||||
|
const remaining = await FileSystem.getFreeDiskStorageAsync()
|
||||||
|
const total = await FileSystem.getTotalDiskCapacityAsync()
|
||||||
|
|
||||||
|
return {app, remaining, total, used: (total - remaining) / total}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const openQuickConnectAuthCodeInput = () => {
|
const openQuickConnectAuthCodeInput = () => {
|
||||||
Alert.prompt(
|
Alert.prompt(
|
||||||
"Quick connect",
|
"Quick connect",
|
||||||
@@ -57,6 +66,27 @@ export default function settings() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDeleteClicked = async () => {
|
||||||
|
try {
|
||||||
|
await deleteAllFiles();
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Error
|
||||||
|
);
|
||||||
|
toast.error("Error deleting files");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClearLogsClicked = async () => {
|
||||||
|
clearLogs();
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
@@ -81,6 +111,9 @@ export default function settings() {
|
|||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
<ListItem title="Server" subTitle={api?.basePath} />
|
||||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
<ListItem title="Token" subTitle={api?.accessToken} />
|
||||||
</View>
|
</View>
|
||||||
|
<Button className="my-2.5" color="black" onPress={logout}>
|
||||||
|
Log out
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
@@ -92,42 +125,36 @@ export default function settings() {
|
|||||||
|
|
||||||
<SettingToggles />
|
<SettingToggles />
|
||||||
|
|
||||||
<View>
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="font-bold text-lg mb-2">Account and storage</Text>
|
<Text className="font-bold text-lg mb-2">Storage</Text>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="mb-4 space-y-2">
|
||||||
<Button color="black" onPress={logout}>
|
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
|
||||||
Log out
|
<Progress.Bar
|
||||||
</Button>
|
className="bg-gray-100/10"
|
||||||
<Button
|
indeterminate={appSizeLoading}
|
||||||
color="red"
|
color="#9333ea"
|
||||||
onPress={async () => {
|
width={null}
|
||||||
try {
|
height={10}
|
||||||
await deleteAllFiles();
|
borderRadius={6}
|
||||||
Haptics.notificationAsync(
|
borderWidth={0}
|
||||||
Haptics.NotificationFeedbackType.Success
|
progress={size?.used}
|
||||||
);
|
/>
|
||||||
} catch (e) {
|
{size && (
|
||||||
Haptics.notificationAsync(
|
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
|
||||||
Haptics.NotificationFeedbackType.Error
|
)}
|
||||||
);
|
|
||||||
toast.error("Error deleting files");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete all downloaded files
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onPress={async () => {
|
|
||||||
await clearLogs();
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete all logs
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onPress={onDeleteClicked}
|
||||||
|
>
|
||||||
|
Delete all downloaded files
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onPress={onClearLogsClicked}
|
||||||
|
>
|
||||||
|
Delete all logs
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||||
|
|||||||
@@ -8,13 +8,17 @@ import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"
|
|||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import {useLocalSearchParams, useNavigation} from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React, {useEffect} from "react";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import {DownloadItems} from "@/components/DownloadItem";
|
||||||
|
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||||
|
import {getTvShowsApi} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { id: seriesId, seasonIndex } = params as {
|
const { id: seriesId, seasonIndex } = params as {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,7 +60,43 @@ const page: React.FC = () => {
|
|||||||
[item]
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!item || !backdropUrl) return null;
|
const {data: allEpisodes, isLoading} = useQuery({
|
||||||
|
queryKey: ["AllEpisodes", item?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getTvShowsApi(api!).getEpisodes({
|
||||||
|
seriesId: item?.Id!,
|
||||||
|
userId: user?.Id!,
|
||||||
|
enableUserData: true,
|
||||||
|
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||||
|
});
|
||||||
|
return res?.data.Items || []
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!item?.Id
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
(!isLoading && allEpisodes && allEpisodes.length > 0) && (
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<DownloadItems
|
||||||
|
items={allEpisodes || []}
|
||||||
|
MissingDownloadIconComponent={() => (
|
||||||
|
<MaterialCommunityIcons name="folder-download" size={24} color="white"/>
|
||||||
|
)}
|
||||||
|
DownloadedIconComponent={() => (
|
||||||
|
<MaterialCommunityIcons name="folder-check" size={26} color="#9333ea"/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [allEpisodes, isLoading]);
|
||||||
|
|
||||||
|
if (!item || !backdropUrl)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParallaxScrollView
|
<ParallaxScrollView
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { withLayoutContext } from "expo-router";
|
|||||||
import {
|
import {
|
||||||
createNativeBottomTabNavigator,
|
createNativeBottomTabNavigator,
|
||||||
NativeBottomTabNavigationEventMap,
|
NativeBottomTabNavigationEventMap,
|
||||||
} from "react-native-bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
BottomTabNavigationOptions,
|
BottomTabNavigationOptions,
|
||||||
@@ -27,6 +28,7 @@ export const NativeTabs = withLayoutContext<
|
|||||||
>(Navigator);
|
>(Navigator);
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
|
const [settings] = useSettings();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden={false} style="light" />
|
<SystemBars hidden={false} style="light" />
|
||||||
@@ -71,6 +73,18 @@ export default function TabLayout() {
|
|||||||
: () => ({ sfSymbol: "rectangle.stack" }),
|
: () => ({ sfSymbol: "rectangle.stack" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<NativeTabs.Screen
|
||||||
|
name="(custom-links)"
|
||||||
|
options={{
|
||||||
|
title: "Custom Links",
|
||||||
|
// @ts-expect-error
|
||||||
|
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
||||||
|
tabBarIcon:
|
||||||
|
Platform.OS == "android"
|
||||||
|
? () => require("@/assets/icons/list.png")
|
||||||
|
: () => ({ sfSymbol: "list.dash" }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,9 +30,23 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
import { Alert, BackHandler, View } from "react-native";
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
BackHandler,
|
||||||
|
View,
|
||||||
|
AppState,
|
||||||
|
AppStateStatus,
|
||||||
|
} from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import settings from "../(tabs)/(home)/settings";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
@@ -73,7 +87,7 @@ export default function page() {
|
|||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
offline: string;
|
offline: string;
|
||||||
}>();
|
}>();
|
||||||
|
const [settings] = useSettings();
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||||
@@ -166,69 +180,58 @@ export default function page() {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
const togglePlay = useCallback(async () => {
|
||||||
async (ms: number) => {
|
if (!api) return;
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
|
|
||||||
if (!offline && stream) {
|
if (!offline && stream) {
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
positionTicks: msToTicks(ms),
|
positionTicks: msToTicks(progress.value),
|
||||||
isPaused: true,
|
isPaused: true,
|
||||||
playMethod: stream.url?.includes("m3u8")
|
playMethod: stream.url?.includes("m3u8")
|
||||||
? "Transcode"
|
? "Transcode"
|
||||||
: "DirectStream",
|
: "DirectStream",
|
||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Actually marked as paused");
|
|
||||||
} else {
|
|
||||||
videoRef.current?.play();
|
|
||||||
if (!offline && stream) {
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: msToTicks(ms),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
stream,
|
|
||||||
videoRef,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
offline,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
console.log("Actually marked as paused");
|
||||||
videoRef.current?.play();
|
} else {
|
||||||
reportPlaybackStart();
|
videoRef.current?.play();
|
||||||
}, [videoRef]);
|
if (!offline && stream) {
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
const pause = useCallback(() => {
|
itemId: item?.Id!,
|
||||||
videoRef.current?.pause();
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
}, [videoRef]);
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: msToTicks(progress.value),
|
||||||
|
isPaused: false,
|
||||||
|
playMethod: stream?.url.includes("m3u8")
|
||||||
|
? "Transcode"
|
||||||
|
: "DirectStream",
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isPlaying,
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
stream,
|
||||||
|
videoRef,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
offline,
|
||||||
|
progress.value,
|
||||||
|
]);
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
@@ -284,6 +287,8 @@ export default function page() {
|
|||||||
|
|
||||||
if (!item?.Id || !stream) return;
|
if (!item?.Id || !stream) return;
|
||||||
|
|
||||||
|
console.log("onProgress ~", currentTimeInTicks, isPlaying);
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
itemId: item.Id,
|
itemId: item.Id,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
@@ -303,8 +308,7 @@ export default function page() {
|
|||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
pauseVideo: pause,
|
togglePlay: togglePlay,
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
offline,
|
offline,
|
||||||
});
|
});
|
||||||
@@ -338,29 +342,49 @@ export default function page() {
|
|||||||
: 0;
|
: 0;
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const backAction = () => {
|
|
||||||
videoRef.current?.stop();
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
const onBackPress = () => {
|
|
||||||
return backAction();
|
|
||||||
};
|
|
||||||
|
|
||||||
BackHandler.addEventListener("hardwareBackPress", onBackPress);
|
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
videoRef.current?.stop();
|
stop();
|
||||||
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
|
console.log("Unmounted");
|
||||||
};
|
};
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [appState, setAppState] = useState(AppState.currentState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
|
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
||||||
|
console.log("App has come to the foreground!");
|
||||||
|
// Handle app coming to the foreground
|
||||||
|
} else if (nextAppState.match(/inactive|background/)) {
|
||||||
|
console.log("App has gone to the background!");
|
||||||
|
// Handle app going to the background
|
||||||
|
if (videoRef.current && videoRef.current.pause) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAppState(nextAppState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use AppState.addEventListener and return a cleanup function
|
||||||
|
const subscription = AppState.addEventListener(
|
||||||
|
"change",
|
||||||
|
handleAppStateChange
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup the event listener when the component is unmounted
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [appState]);
|
||||||
|
|
||||||
// Preselection of audio and subtitle tracks.
|
// Preselection of audio and subtitle tracks.
|
||||||
|
|
||||||
let initOptions = ["--sub-text-scale=60"];
|
if (!settings) return null;
|
||||||
|
|
||||||
|
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
let externalTrack = { name: "", DeliveryUrl: "" };
|
let externalTrack = { name: "", DeliveryUrl: "" };
|
||||||
|
|
||||||
const allSubs =
|
const allSubs =
|
||||||
@@ -455,21 +479,6 @@ export default function page() {
|
|||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
opacity: isBuffering ? 1 : 0,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
pointerEvents="none"
|
|
||||||
>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
{videoRef.current && (
|
{videoRef.current && (
|
||||||
<Controls
|
<Controls
|
||||||
|
|||||||
@@ -169,51 +169,44 @@ const Player = () => {
|
|||||||
const poster = usePoster(item, api);
|
const poster = usePoster(item, api);
|
||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
const togglePlay = useCallback(async () => {
|
||||||
async (ticks: number) => {
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
if (isPlaying) {
|
||||||
if (isPlaying) {
|
videoRef.current?.pause();
|
||||||
videoRef.current?.pause();
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
itemId: item?.Id!,
|
||||||
itemId: item?.Id!,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
mediaSourceId: mediaSourceId,
|
||||||
mediaSourceId: mediaSourceId,
|
positionTicks: Math.floor(progress.value),
|
||||||
positionTicks: Math.floor(ticks),
|
isPaused: true,
|
||||||
isPaused: true,
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
playMethod: stream?.url.includes("m3u8")
|
playSessionId: stream?.sessionId,
|
||||||
? "Transcode"
|
});
|
||||||
: "DirectStream",
|
} else {
|
||||||
playSessionId: stream?.sessionId,
|
videoRef.current?.resume();
|
||||||
});
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
} else {
|
itemId: item?.Id!,
|
||||||
videoRef.current?.resume();
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
itemId: item?.Id!,
|
mediaSourceId: mediaSourceId,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
positionTicks: Math.floor(progress.value),
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
isPaused: false,
|
||||||
mediaSourceId: mediaSourceId,
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
positionTicks: Math.floor(ticks),
|
playSessionId: stream?.sessionId,
|
||||||
isPaused: false,
|
});
|
||||||
playMethod: stream?.url.includes("m3u8")
|
}
|
||||||
? "Transcode"
|
}, [
|
||||||
: "DirectStream",
|
isPlaying,
|
||||||
playSessionId: stream?.sessionId,
|
api,
|
||||||
});
|
item,
|
||||||
}
|
videoRef,
|
||||||
},
|
settings,
|
||||||
[
|
stream,
|
||||||
isPlaying,
|
audioIndex,
|
||||||
api,
|
subtitleIndex,
|
||||||
item,
|
mediaSourceId,
|
||||||
videoRef,
|
]);
|
||||||
settings,
|
|
||||||
stream,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
const play = useCallback(() => {
|
||||||
videoRef.current?.resume();
|
videoRef.current?.resume();
|
||||||
@@ -307,9 +300,9 @@ const Player = () => {
|
|||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
pauseVideo: pause,
|
togglePlay: togglePlay,
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
|
offline: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
||||||
@@ -369,23 +362,10 @@ const Player = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const backAction = () => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
const onBackPress = () => {
|
|
||||||
return backAction();
|
|
||||||
};
|
|
||||||
|
|
||||||
BackHandler.addEventListener("hardwareBackPress", onBackPress);
|
|
||||||
play();
|
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
videoRef.current?.pause();
|
stop();
|
||||||
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
|
|
||||||
};
|
};
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
@@ -467,20 +447,6 @@ const Player = () => {
|
|||||||
selectedTextTrack={selectedTextTrack}
|
selectedTextTrack={selectedTextTrack}
|
||||||
selectedAudioTrack={selectedAudioTrack}
|
selectedAudioTrack={selectedAudioTrack}
|
||||||
/>
|
/>
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
opacity: isBuffering ? 1 : 0,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
pointerEvents="none"
|
|
||||||
>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text>No video source...</Text>
|
<Text>No video source...</Text>
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import {
|
|||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
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 { LogProvider, writeToLog } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
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";
|
||||||
@@ -310,50 +311,54 @@ function Layout() {
|
|||||||
<JobQueueProvider>
|
<JobQueueProvider>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
<DownloadProvider>
|
<LogProvider>
|
||||||
<BottomSheetModalProvider>
|
<WebSocketProvider>
|
||||||
<SystemBars style="light" hidden={false} />
|
<DownloadProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<BottomSheetModalProvider>
|
||||||
<Stack initialRouteName="/home">
|
<SystemBars style="light" hidden={false} />
|
||||||
<Stack.Screen
|
<ThemeProvider value={DarkTheme}>
|
||||||
name="(auth)/(tabs)"
|
<Stack initialRouteName="/home">
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name="(auth)/(tabs)"
|
||||||
title: "",
|
options={{
|
||||||
header: () => null,
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
header: () => null,
|
||||||
<Stack.Screen
|
}}
|
||||||
name="(auth)/player"
|
/>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name="(auth)/player"
|
||||||
title: "",
|
options={{
|
||||||
header: () => null,
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
header: () => null,
|
||||||
<Stack.Screen
|
}}
|
||||||
name="login"
|
/>
|
||||||
options={{ headerShown: false, title: "Login" }}
|
<Stack.Screen
|
||||||
/>
|
name="login"
|
||||||
<Stack.Screen name="+not-found" />
|
options={{ headerShown: false, title: "Login" }}
|
||||||
</Stack>
|
/>
|
||||||
<Toaster
|
<Stack.Screen name="+not-found" />
|
||||||
duration={4000}
|
</Stack>
|
||||||
toastOptions={{
|
<Toaster
|
||||||
style: {
|
duration={4000}
|
||||||
backgroundColor: "#262626",
|
toastOptions={{
|
||||||
borderColor: "#363639",
|
style: {
|
||||||
borderWidth: 1,
|
backgroundColor: "#262626",
|
||||||
},
|
borderColor: "#363639",
|
||||||
titleStyle: {
|
borderWidth: 1,
|
||||||
color: "white",
|
},
|
||||||
},
|
titleStyle: {
|
||||||
}}
|
color: "white",
|
||||||
closeButton
|
},
|
||||||
/>
|
}}
|
||||||
</ThemeProvider>
|
closeButton
|
||||||
</BottomSheetModalProvider>
|
/>
|
||||||
</DownloadProvider>
|
</ThemeProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
</DownloadProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
</PlaySettingsProvider>
|
</PlaySettingsProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</JobQueueProvider>
|
</JobQueueProvider>
|
||||||
|
|||||||
BIN
assets/icons/list.png
Normal file
BIN
assets/icons/list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -1,27 +1,30 @@
|
|||||||
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, useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
useEpisodePoster?: boolean;
|
useEpisodePoster?: boolean;
|
||||||
size?: "small" | "normal";
|
size?: "small" | "normal";
|
||||||
|
showPlayButton?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||||
item,
|
item,
|
||||||
useEpisodePoster = false,
|
useEpisodePoster = false,
|
||||||
size = "normal",
|
size = "normal",
|
||||||
|
showPlayButton = false,
|
||||||
}) => {
|
}) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get horrizontal poster for movie and episode, with failover to primary.
|
* Get horizontal poster for movie and episode, with failover to primary.
|
||||||
*/
|
*/
|
||||||
const url = useMemo(() => {
|
const url = useMemo(() => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
@@ -73,16 +76,23 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
${size === "small" ? "w-32" : "w-44"}
|
${size === "small" ? "w-32" : "w-44"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Image
|
<View className="w-full h-full flex items-center justify-center">
|
||||||
key={item.Id}
|
<Image
|
||||||
id={item.Id}
|
key={item.Id}
|
||||||
source={{
|
id={item.Id}
|
||||||
uri: url,
|
source={{
|
||||||
}}
|
uri: url,
|
||||||
cachePolicy={"memory-disk"}
|
}}
|
||||||
contentFit="cover"
|
cachePolicy={"memory-disk"}
|
||||||
className="w-full h-full"
|
contentFit="cover"
|
||||||
/>
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
{showPlayButton && (
|
||||||
|
<View className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Ionicons name="play-circle" size={40} color="white" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
{!progress && <WatchedIndicator item={item} />}
|
{!progress && <WatchedIndicator item={item} />}
|
||||||
{progress > 0 && (
|
{progress > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||||
import native from "@/utils/profiles/native";
|
|
||||||
import download from "@/utils/profiles/download";
|
import download from "@/utils/profiles/download";
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import {
|
import {
|
||||||
@@ -19,9 +18,9 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { router, useFocusEffect } from "expo-router";
|
import { Href, router, useFocusEffect } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
@@ -34,15 +33,22 @@ import ProgressCircle from "./ProgressCircle";
|
|||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
item: BaseItemDto;
|
items: BaseItemDto[];
|
||||||
|
MissingDownloadIconComponent: () => React.ReactElement;
|
||||||
|
DownloadedIconComponent: () => React.ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
export const DownloadItems: React.FC<DownloadProps> = ({
|
||||||
|
items,
|
||||||
|
MissingDownloadIconComponent,
|
||||||
|
DownloadedIconComponent,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { processes, startBackgroundDownload } = useDownload();
|
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||||
const { startRemuxing } = useRemuxHlsToMp4();
|
const { startRemuxing } = useRemuxHlsToMp4();
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||||
@@ -56,23 +62,14 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
value: undefined,
|
value: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
useFocusEffect(
|
const userCanDownload = useMemo(
|
||||||
useCallback(() => {
|
() => user?.Policy?.EnableContentDownloading,
|
||||||
if (!settings) return;
|
[user]
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
);
|
||||||
getDefaultPlaySettings(item, settings);
|
const usingOptimizedServer = useMemo(
|
||||||
|
() => settings?.downloadMethod === "optimized",
|
||||||
// 4. Set states
|
[settings]
|
||||||
setSelectedMediaSource(mediaSource ?? undefined);
|
|
||||||
setSelectedAudioStream(audioIndex ?? 0);
|
|
||||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
|
||||||
setMaxBitrate(bitrate);
|
|
||||||
}, [item, settings])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const userCanDownload = useMemo(() => {
|
|
||||||
return user?.Policy?.EnableContentDownloading;
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bottom sheet
|
* Bottom sheet
|
||||||
@@ -89,69 +86,169 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
// region computed
|
||||||
* Start download
|
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||||
*/
|
const pendingItems = useMemo(
|
||||||
const initiateDownload = useCallback(async () => {
|
() =>
|
||||||
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
|
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
||||||
throw new Error(
|
[items, downloadedFiles]
|
||||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
);
|
||||||
);
|
const isDownloaded = useMemo(() => {
|
||||||
}
|
if (!downloadedFiles) return false;
|
||||||
|
return pendingItems.length == 0;
|
||||||
|
}, [downloadedFiles, pendingItems]);
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
const itemsProcesses = useMemo(
|
||||||
api,
|
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
||||||
item,
|
[processes, itemIds]
|
||||||
startTimeTicks: 0,
|
);
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: selectedAudioStream,
|
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
|
||||||
mediaSourceId: selectedMediaSource.Id,
|
|
||||||
subtitleStreamIndex: selectedSubtitleStream,
|
|
||||||
deviceProfile: download,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) {
|
const progress = useMemo(() => {
|
||||||
Alert.alert(
|
if (itemIds.length == 1)
|
||||||
"Something went wrong",
|
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
||||||
"Could not get stream url from Jellyfin"
|
return (
|
||||||
);
|
((itemIds.length -
|
||||||
return;
|
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
||||||
}
|
itemIds.length) *
|
||||||
|
100
|
||||||
|
);
|
||||||
|
}, [queue, itemsProcesses, itemIds]);
|
||||||
|
|
||||||
const { mediaSource, url } = res;
|
const itemsQueued = useMemo(() => {
|
||||||
|
return (
|
||||||
|
pendingItems.length > 0 &&
|
||||||
|
pendingItems.every((p) => queue.some((q) => p.Id == q.item.Id))
|
||||||
|
);
|
||||||
|
}, [queue, pendingItems]);
|
||||||
|
// endregion computed
|
||||||
|
|
||||||
if (!url || !mediaSource) throw new Error("No url");
|
// region helper functions
|
||||||
|
const navigateToDownloads = () => router.push("/downloads");
|
||||||
|
|
||||||
saveDownloadItemInfoToDiskTmp(item, mediaSource, url);
|
const onDownloadedPress = () => {
|
||||||
|
const firstItem = items?.[0];
|
||||||
|
router.push(
|
||||||
|
firstItem.Type !== "Episode"
|
||||||
|
? "/downloads"
|
||||||
|
: ({
|
||||||
|
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||||
|
params: {
|
||||||
|
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||||
|
},
|
||||||
|
} as Href)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (settings?.downloadMethod === "optimized") {
|
const acceptDownloadOptions = useCallback(() => {
|
||||||
return await startBackgroundDownload(url, item, mediaSource);
|
if (userCanDownload === true) {
|
||||||
|
if (pendingItems.some((i) => !i.Id)) {
|
||||||
|
throw new Error("No item id");
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
|
||||||
|
if (usingOptimizedServer) initiateDownload(...pendingItems);
|
||||||
|
else {
|
||||||
|
queueActions.enqueue(
|
||||||
|
queue,
|
||||||
|
setQueue,
|
||||||
|
...pendingItems.map((item) => ({
|
||||||
|
id: item.Id!,
|
||||||
|
execute: async () => await initiateDownload(item),
|
||||||
|
item,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return await startRemuxing(item, url, mediaSource);
|
toast.error("You are not allowed to download files.");
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
api,
|
queue,
|
||||||
item,
|
setQueue,
|
||||||
startBackgroundDownload,
|
pendingItems,
|
||||||
user?.Id,
|
usingOptimizedServer,
|
||||||
|
userCanDownload,
|
||||||
|
|
||||||
|
// Need to be reference at the time async lambda is created for initiateDownload
|
||||||
|
maxBitrate,
|
||||||
selectedMediaSource,
|
selectedMediaSource,
|
||||||
selectedAudioStream,
|
selectedAudioStream,
|
||||||
selectedSubtitleStream,
|
selectedSubtitleStream,
|
||||||
maxBitrate,
|
|
||||||
settings?.downloadMethod,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if item is downloaded
|
* Start download
|
||||||
*/
|
*/
|
||||||
const { downloadedFiles } = useDownload();
|
const initiateDownload = useCallback(
|
||||||
|
async (...items: BaseItemDto[]) => {
|
||||||
|
if (
|
||||||
|
!api ||
|
||||||
|
!user?.Id ||
|
||||||
|
items.some((p) => !p.Id) ||
|
||||||
|
(pendingItems.length === 1 && !selectedMediaSource?.Id)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mediaSource = selectedMediaSource;
|
||||||
|
let audioIndex: number | undefined = selectedAudioStream;
|
||||||
|
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
||||||
|
|
||||||
const isDownloaded = useMemo(() => {
|
for (const item of items) {
|
||||||
if (!downloadedFiles) return false;
|
if (pendingItems.length > 1) {
|
||||||
|
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
||||||
|
item,
|
||||||
|
settings!
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
return downloadedFiles.some((file) => file.item.Id === item.Id);
|
const res = await getStreamUrl({
|
||||||
}, [downloadedFiles, item.Id]);
|
api,
|
||||||
|
item,
|
||||||
|
startTimeTicks: 0,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: audioIndex,
|
||||||
|
maxStreamingBitrate: maxBitrate.value,
|
||||||
|
mediaSourceId: mediaSource?.Id,
|
||||||
|
subtitleStreamIndex: subtitleIndex,
|
||||||
|
deviceProfile: download,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
Alert.alert(
|
||||||
|
"Something went wrong",
|
||||||
|
"Could not get stream url from Jellyfin"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mediaSource: source, url } = res;
|
||||||
|
|
||||||
|
if (!url || !source) throw new Error("No url");
|
||||||
|
|
||||||
|
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||||
|
|
||||||
|
if (usingOptimizedServer) {
|
||||||
|
await startBackgroundDownload(url, item, source);
|
||||||
|
} else {
|
||||||
|
await startRemuxing(item, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
pendingItems,
|
||||||
|
selectedMediaSource,
|
||||||
|
selectedAudioStream,
|
||||||
|
selectedSubtitleStream,
|
||||||
|
settings,
|
||||||
|
maxBitrate,
|
||||||
|
usingOptimizedServer,
|
||||||
|
startBackgroundDownload,
|
||||||
|
startRemuxing,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -163,31 +260,38 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
),
|
),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
// endregion helper functions
|
||||||
|
|
||||||
const process = useMemo(() => {
|
// Allow to select & set settings for single download
|
||||||
if (!processes) return null;
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (!settings) return;
|
||||||
|
if (pendingItems.length !== 1) return;
|
||||||
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
|
getDefaultPlaySettings(items[0], settings);
|
||||||
|
|
||||||
return processes.find((process) => process?.item?.Id === item.Id);
|
// 4. Set states
|
||||||
}, [processes, item.Id]);
|
setSelectedMediaSource(mediaSource ?? undefined);
|
||||||
|
setSelectedAudioStream(audioIndex ?? 0);
|
||||||
|
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||||
|
setMaxBitrate(bitrate);
|
||||||
|
}, [items, pendingItems, settings])
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{process && process?.item.Id === item.Id ? (
|
{processes && itemsProcesses.length > 0 ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={navigateToDownloads}>
|
||||||
onPress={() => {
|
{progress === 0 ? (
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{process.progress === 0 ? (
|
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<View className="-rotate-45">
|
<View className="-rotate-45">
|
||||||
<ProgressCircle
|
<ProgressCircle
|
||||||
size={24}
|
size={24}
|
||||||
fill={process.progress}
|
fill={progress}
|
||||||
width={4}
|
width={4}
|
||||||
tintColor="#9334E9"
|
tintColor="#9334E9"
|
||||||
backgroundColor="#bdc3c7"
|
backgroundColor="#bdc3c7"
|
||||||
@@ -195,25 +299,17 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : queue.some((i) => i.id === item.Id) ? (
|
) : itemsQueued ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={navigateToDownloads}>
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="hourglass" size={24} color="white" />
|
<Ionicons name="hourglass" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : isDownloaded ? (
|
) : isDownloaded ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={onDownloadedPress}>
|
||||||
onPress={() => {
|
{DownloadedIconComponent()}
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity onPress={handlePresentModalPress}>
|
<TouchableOpacity onPress={handlePresentModalPress}>
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
{MissingDownloadIconComponent()}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
@@ -236,62 +332,46 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
<View className="flex flex-col space-y-2 w-full items-start">
|
<View className="flex flex-col space-y-2 w-full items-start">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
inverted
|
inverted
|
||||||
onChange={(val) => setMaxBitrate(val)}
|
onChange={setMaxBitrate}
|
||||||
selected={maxBitrate}
|
selected={maxBitrate}
|
||||||
/>
|
/>
|
||||||
<MediaSourceSelector
|
{pendingItems.length === 1 && (
|
||||||
item={item}
|
<>
|
||||||
onChange={setSelectedMediaSource}
|
<MediaSourceSelector
|
||||||
selected={selectedMediaSource}
|
item={items[0]}
|
||||||
/>
|
onChange={setSelectedMediaSource}
|
||||||
{selectedMediaSource && (
|
selected={selectedMediaSource}
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
<AudioTrackSelector
|
|
||||||
source={selectedMediaSource}
|
|
||||||
onChange={setSelectedAudioStream}
|
|
||||||
selected={selectedAudioStream}
|
|
||||||
/>
|
/>
|
||||||
<SubtitleTrackSelector
|
{selectedMediaSource && (
|
||||||
source={selectedMediaSource}
|
<View className="flex flex-col space-y-2">
|
||||||
onChange={setSelectedSubtitleStream}
|
<AudioTrackSelector
|
||||||
selected={selectedSubtitleStream}
|
source={selectedMediaSource}
|
||||||
/>
|
onChange={setSelectedAudioStream}
|
||||||
</View>
|
selected={selectedAudioStream}
|
||||||
|
/>
|
||||||
|
<SubtitleTrackSelector
|
||||||
|
source={selectedMediaSource}
|
||||||
|
onChange={setSelectedSubtitleStream}
|
||||||
|
selected={selectedSubtitleStream}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
className="mt-auto"
|
className="mt-auto"
|
||||||
onPress={() => {
|
onPress={acceptDownloadOptions}
|
||||||
if (userCanDownload === true) {
|
|
||||||
if (!item.Id) {
|
|
||||||
throw new Error("No item id");
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
if (settings?.downloadMethod === "remux") {
|
|
||||||
queueActions.enqueue(queue, setQueue, {
|
|
||||||
id: item.Id,
|
|
||||||
execute: async () => {
|
|
||||||
await initiateDownload();
|
|
||||||
},
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
initiateDownload();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error("You are not allowed to download files.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
color="purple"
|
color="purple"
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
<View className="opacity-70 text-center w-full flex items-center">
|
<View className="opacity-70 text-center w-full flex items-center">
|
||||||
{settings?.downloadMethod === "optimized" ? (
|
<Text className="text-xs">
|
||||||
<Text className="text-xs">Using optimized server</Text>
|
{usingOptimizedServer
|
||||||
) : (
|
? "Using optimized server"
|
||||||
<Text className="text-xs">Using default method</Text>
|
: "Using default method"}
|
||||||
)}
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
@@ -299,3 +379,19 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({
|
||||||
|
item,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<DownloadItems
|
||||||
|
items={[item]}
|
||||||
|
MissingDownloadIconComponent={() => (
|
||||||
|
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
DownloadedIconComponent={() => (
|
||||||
|
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
@@ -32,6 +32,16 @@ import { Chromecast } from "./Chromecast";
|
|||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
|
import {
|
||||||
|
brightness,
|
||||||
|
ColorMatrix,
|
||||||
|
colorTone,
|
||||||
|
concatColorMatrices,
|
||||||
|
contrast,
|
||||||
|
saturate,
|
||||||
|
sepia,
|
||||||
|
tint,
|
||||||
|
} from "react-native-color-matrix-image-filters";
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -49,7 +59,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
useImageColors({ item });
|
useImageColors({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(false);
|
||||||
const [headerHeight, setHeaderHeight] = useState(350);
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
@@ -87,7 +97,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<Chromecast background="blur" width={22} height={22} />
|
<Chromecast background="blur" width={22} height={22} />
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<DownloadItem item={item} />
|
<DownloadSingleItem item={item} />
|
||||||
<PlayedStatus item={item} />
|
<PlayedStatus item={item} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -139,18 +149,45 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
logo={
|
logo={
|
||||||
<>
|
<>
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<Image
|
<ColorMatrix
|
||||||
source={{
|
matrix={[
|
||||||
uri: logoUrl,
|
1,
|
||||||
}}
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0, // Red channel remains unchanged
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0, // Green channel remains unchanged
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0, // Blue channel remains unchanged
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
-1, // Make black (R=0, G=0, B=0) transparent
|
||||||
|
]}
|
||||||
style={{
|
style={{
|
||||||
height: 130,
|
height: 130,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
resizeMode: "contain",
|
|
||||||
}}
|
}}
|
||||||
onLoad={() => setLoadingLogo(false)}
|
>
|
||||||
onError={() => setLoadingLogo(false)}
|
<Image
|
||||||
/>
|
source={{
|
||||||
|
uri: logoUrl,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: 130,
|
||||||
|
width: "100%",
|
||||||
|
resizeMode: "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ColorMatrix>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -228,6 +265,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<OverviewText text={item.Overview} className="px-4 my-4" />
|
<OverviewText text={item.Overview} className="px-4 my-4" />
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<>
|
<>
|
||||||
|
{item.Type === "Episode" && (
|
||||||
|
<CurrentSeries item={item} className="mb-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||||
|
|
||||||
{item.People && item.People.length > 0 && (
|
{item.People && item.People.length > 0 && (
|
||||||
@@ -243,10 +284,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
|
||||||
<CurrentSeries item={item} className="mb-4" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SimilarItems itemId={item.Id} />
|
<SimilarItems itemId={item.Id} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { SelectedOptions } from "./ItemContent";
|
import { SelectedOptions } from "./ItemContent";
|
||||||
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -111,18 +112,11 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
if (state && state !== PlayServicesState.SUCCESS)
|
if (state && state !== PlayServicesState.SUCCESS)
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
else {
|
else {
|
||||||
// If we're opening a currently playing item, don't restart the media.
|
|
||||||
// Instead just open controls.
|
|
||||||
if (isOpeningCurrentlyPlayingMedia) {
|
|
||||||
CastContext.showExpandedControls();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a new URL with the Chromecast device profile:
|
// Get a new URL with the Chromecast device profile:
|
||||||
const data = await getStreamUrl({
|
const data = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
deviceProfile: ios,
|
deviceProfile: chromecastProfile,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
|||||||
<Text className="text-lg font-bold mb-2">Active downloads</Text>
|
<Text className="text-lg font-bold mb-2">Active downloads</Text>
|
||||||
<View className="space-y-2">
|
<View className="space-y-2">
|
||||||
{processes?.map((p) => (
|
{processes?.map((p) => (
|
||||||
<DownloadCard key={p.id} process={p} />
|
<DownloadCard key={p.item.Id} process={p} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -77,7 +77,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
FFmpegKit.cancel();
|
FFmpegKit.cancel(Number(id));
|
||||||
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
47
components/downloads/DownloadSize.tsx
Normal file
47
components/downloads/DownloadSize.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { TextProps } from "react-native";
|
||||||
|
|
||||||
|
interface DownloadSizeProps extends TextProps {
|
||||||
|
items: BaseItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
||||||
|
items,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { downloadedFiles, getDownloadedItemSize } = useDownload();
|
||||||
|
const [size, setSize] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!downloadedFiles) return;
|
||||||
|
|
||||||
|
let s = 0;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.Id) continue;
|
||||||
|
const size = getDownloadedItemSize(item.Id);
|
||||||
|
if (size) {
|
||||||
|
s += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSize(bytesToReadable(s));
|
||||||
|
}, [itemIds]);
|
||||||
|
|
||||||
|
const sizeText = useMemo(() => {
|
||||||
|
if (!size) return "...";
|
||||||
|
return size;
|
||||||
|
}, [size]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text className="text-xs text-neutral-500" {...props}>
|
||||||
|
{sizeText}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,37 +1,35 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import React, { useCallback, useMemo, useRef } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
ActionSheetProvider,
|
ActionSheetProvider,
|
||||||
useActionSheet,
|
useActionSheet,
|
||||||
} from "@expo/react-native-action-sheet";
|
} from "@expo/react-native-action-sheet";
|
||||||
|
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
|
|
||||||
interface EpisodeCardProps {
|
interface EpisodeCardProps extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||||
* EpisodeCard component displays an episode with action sheet options.
|
|
||||||
* @param {EpisodeCardProps} props - The component props.
|
|
||||||
* @returns {React.ReactElement} The rendered EpisodeCard component.
|
|
||||||
*/
|
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(item.Id!);
|
return storage.getString(item.Id!);
|
||||||
}, []);
|
}, [item]);
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
openFile(item);
|
openFile(item);
|
||||||
@@ -76,32 +74,29 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleOpenFile}
|
onPress={handleOpenFile}
|
||||||
onLongPress={showActionSheet}
|
onLongPress={showActionSheet}
|
||||||
className="flex flex-col w-44 mr-2"
|
key={item.Id}
|
||||||
|
className="flex flex-col mb-4"
|
||||||
>
|
>
|
||||||
{base64Image ? (
|
<View className="flex flex-row items-start mb-2">
|
||||||
<View className="w-44 aspect-video rounded-lg overflow-hidden">
|
<View className="mr-2">
|
||||||
<Image
|
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
|
||||||
source={{
|
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
resizeMode: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
<View className="shrink">
|
||||||
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
|
<Text numberOfLines={2} className="">
|
||||||
<Ionicons
|
{item.Name}
|
||||||
name="image-outline"
|
</Text>
|
||||||
size={24}
|
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||||
color="gray"
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
className="self-center mt-16"
|
</Text>
|
||||||
/>
|
<Text className="text-xs text-neutral-500">
|
||||||
|
{runtimeTicksToSeconds(item.RunTimeTicks)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
</View>
|
||||||
<ItemCardText item={item} />
|
|
||||||
|
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
||||||
|
{item.Overview}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as Haptics from "expo-haptics";
|
|||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
@@ -97,6 +98,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
|
<DownloadSize items={[item]} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,55 +1,82 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { ScrollView, View } from "react-native";
|
import {TouchableOpacity, View} from "react-native";
|
||||||
import { EpisodeCard } from "./EpisodeCard";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useMemo } from "react";
|
import React, {useCallback, useMemo} from "react";
|
||||||
import { SeasonPicker } from "../series/SeasonPicker";
|
import {storage} from "@/utils/mmkv";
|
||||||
|
import {Image} from "expo-image";
|
||||||
|
import {Ionicons} from "@expo/vector-icons";
|
||||||
|
import {router} from "expo-router";
|
||||||
|
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
||||||
|
import {useDownload} from "@/providers/DownloadProvider";
|
||||||
|
import {useActionSheet} from "@expo/react-native-action-sheet";
|
||||||
|
|
||||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
|
||||||
const groupBySeason = useMemo(() => {
|
const { deleteItems } = useDownload();
|
||||||
const seasons: Record<string, BaseItemDto[]> = {};
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
items.forEach((item) => {
|
const base64Image = useMemo(() => {
|
||||||
if (!seasons[item.SeasonName!]) {
|
return storage.getString(items[0].SeriesId!);
|
||||||
seasons[item.SeasonName!] = [];
|
}, []);
|
||||||
|
|
||||||
|
const deleteSeries = useCallback(
|
||||||
|
async () => deleteItems(items),
|
||||||
|
[items]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showActionSheet = useCallback(() => {
|
||||||
|
const options = ["Delete", "Cancel"];
|
||||||
|
const destructiveButtonIndex = 0;
|
||||||
|
|
||||||
|
showActionSheetWithOptions({
|
||||||
|
options,
|
||||||
|
destructiveButtonIndex,
|
||||||
|
},
|
||||||
|
(selectedIndex) => {
|
||||||
|
if (selectedIndex == destructiveButtonIndex) {
|
||||||
|
deleteSeries();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seasons[item.SeasonName!].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.values(seasons).sort(
|
|
||||||
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
|
|
||||||
);
|
);
|
||||||
}, [items]);
|
}, [showActionSheetWithOptions, deleteSeries]);
|
||||||
|
|
||||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
|
|
||||||
return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<TouchableOpacity
|
||||||
<View className="flex flex-row items-center justify-between px-4">
|
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
|
||||||
<Text className="text-lg font-bold shrink">{items[0].SeriesName}</Text>
|
onLongPress={showActionSheet}
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
>
|
||||||
<Text className="text-xs font-bold">{items.length}</Text>
|
{base64Image ? (
|
||||||
|
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1">
|
||||||
|
<Text className="text-xs font-bold">{items.length}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
) : (
|
||||||
|
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
|
||||||
|
<Ionicons
|
||||||
|
name="image-outline"
|
||||||
|
size={24}
|
||||||
|
color="gray"
|
||||||
|
className="self-center mt-16"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text className="opacity-50 mb-2 px-4">TV-Series</Text>
|
<View className="w-28 mt-2 flex flex-col">
|
||||||
{groupBySeason.map((seasonItems, seasonIndex) => (
|
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
|
||||||
<View key={seasonIndex}>
|
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
|
||||||
<Text className="mb-2 font-semibold px-4">
|
<DownloadSize items={items} />
|
||||||
{seasonItems[0].SeasonName}
|
</View>
|
||||||
</Text>
|
</TouchableOpacity>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className="px-4 flex flex-row">
|
|
||||||
{seasonItems.sort(sortByIndex)?.map((item, index) => (
|
|
||||||
<EpisodeCard item={item} key={index} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
44
components/inputs/Stepper.tsx
Normal file
44
components/inputs/Stepper.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {TouchableOpacity, View} from "react-native";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface StepperProps {
|
||||||
|
value: number,
|
||||||
|
step: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
onUpdate: (value: number) => void,
|
||||||
|
appendValue?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Stepper: React.FC<StepperProps> = ({
|
||||||
|
value,
|
||||||
|
step,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
onUpdate,
|
||||||
|
appendValue
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onUpdate(Math.max(min, value - step))}
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
"w-auto h-8 bg-neutral-800 py-2 px-1 flex items-center justify-center" + (appendValue ? "first-letter:px-2" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value}{appendValue}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||||
|
onPress={() => onUpdate(Math.min(max, value + step))}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
components/series/SeasonDropdown.tsx
Normal file
121
components/series/SeasonDropdown.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
seasons: BaseItemDto[];
|
||||||
|
initialSeasonIndex?: number;
|
||||||
|
state: SeasonIndexState;
|
||||||
|
onSelect: (season: BaseItemDto) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeasonKeys = {
|
||||||
|
id: keyof BaseItemDto;
|
||||||
|
title: keyof BaseItemDto;
|
||||||
|
index: keyof BaseItemDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeasonIndexState = {
|
||||||
|
[seriesId: string]: number | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SeasonDropdown: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
seasons,
|
||||||
|
initialSeasonIndex,
|
||||||
|
state,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
const keys = useMemo<SeasonKeys>(
|
||||||
|
() =>
|
||||||
|
item.Type === "Episode"
|
||||||
|
? {
|
||||||
|
id: "ParentId",
|
||||||
|
title: "SeasonName",
|
||||||
|
index: "ParentIndexNumber",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: "Id",
|
||||||
|
title: "Name",
|
||||||
|
index: "IndexNumber",
|
||||||
|
},
|
||||||
|
[item]
|
||||||
|
);
|
||||||
|
|
||||||
|
const seasonIndex = useMemo(
|
||||||
|
() => state[(item[keys.id] as string) ?? ""],
|
||||||
|
[state]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||||
|
let initialIndex: number | undefined;
|
||||||
|
|
||||||
|
if (initialSeasonIndex !== undefined) {
|
||||||
|
// Use the provided initialSeasonIndex if it exists in the seasons
|
||||||
|
const seasonExists = seasons.some(
|
||||||
|
(season: any) => season[keys.index] === initialSeasonIndex
|
||||||
|
);
|
||||||
|
if (seasonExists) {
|
||||||
|
initialIndex = initialSeasonIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialIndex === undefined) {
|
||||||
|
// Fall back to the previous logic if initialIndex is not set
|
||||||
|
const season1 = seasons.find((season: any) => season[keys.index] === 1);
|
||||||
|
const season0 = seasons.find((season: any) => season[keys.index] === 0);
|
||||||
|
const firstSeason = season1 || season0 || seasons[0];
|
||||||
|
onSelect(firstSeason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialIndex !== undefined) {
|
||||||
|
const initialSeason = seasons.find(
|
||||||
|
(season: any) => season[keys.index] === initialIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
if (initialSeason) onSelect(initialSeason!);
|
||||||
|
else throw Error("Initial index could not be found!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [seasons, seasonIndex, item[keys.id], initialSeasonIndex]);
|
||||||
|
|
||||||
|
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
||||||
|
Number(a[keys.index]) - Number(b[keys.index]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<View className="flex flex-row">
|
||||||
|
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
|
<Text>Season {seasonIndex}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
||||||
|
{seasons?.sort(sortByIndex).map((season: any) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={season[keys.title]}
|
||||||
|
onSelect={() => onSelect(season)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{season[keys.title]}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,30 +2,27 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { DownloadItem } from "../DownloadItem";
|
import { DownloadItems, DownloadSingleItem } from "../DownloadItem";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import {
|
||||||
|
SeasonDropdown,
|
||||||
|
SeasonIndexState,
|
||||||
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
initialSeasonIndex?: number;
|
initialSeasonIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SeasonIndexState = {
|
|
||||||
[seriesId: string]: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||||
|
|
||||||
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||||
@@ -35,8 +32,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
|
|
||||||
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { data: seasons } = useQuery({
|
const { data: seasons } = useQuery({
|
||||||
queryKey: ["seasons", item.Id],
|
queryKey: ["seasons", item.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -61,37 +56,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
enabled: !!api && !!user?.Id && !!item.Id,
|
enabled: !!api && !!user?.Id && !!item.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
|
||||||
let initialIndex: number | undefined;
|
|
||||||
|
|
||||||
if (initialSeasonIndex !== undefined) {
|
|
||||||
// Use the provided initialSeasonIndex if it exists in the seasons
|
|
||||||
const seasonExists = seasons.some(
|
|
||||||
(season: any) => season.IndexNumber === initialSeasonIndex
|
|
||||||
);
|
|
||||||
if (seasonExists) {
|
|
||||||
initialIndex = initialSeasonIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialIndex === undefined) {
|
|
||||||
// Fall back to the previous logic if initialIndex is not set
|
|
||||||
const season1 = seasons.find((season: any) => season.IndexNumber === 1);
|
|
||||||
const season0 = seasons.find((season: any) => season.IndexNumber === 0);
|
|
||||||
const firstSeason = season1 || season0 || seasons[0];
|
|
||||||
initialIndex = firstSeason.IndexNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialIndex !== undefined) {
|
|
||||||
setSeasonIndexState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[item.Id ?? ""]: initialIndex,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]);
|
|
||||||
|
|
||||||
const selectedSeasonId: string | null = useMemo(
|
const selectedSeasonId: string | null = useMemo(
|
||||||
() =>
|
() =>
|
||||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||||
@@ -148,39 +112,37 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
minHeight: 144 * nrOfEpisodes,
|
minHeight: 144 * nrOfEpisodes,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
<View className="flex flex-row justify-start items-center px-4">
|
||||||
<DropdownMenu.Trigger>
|
<SeasonDropdown
|
||||||
<View className="flex flex-row px-4">
|
item={item}
|
||||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
seasons={seasons}
|
||||||
<Text>Season {seasonIndex}</Text>
|
state={seasonIndexState}
|
||||||
</TouchableOpacity>
|
onSelect={(season) => {
|
||||||
</View>
|
setSeasonIndexState((prev) => ({
|
||||||
</DropdownMenu.Trigger>
|
...prev,
|
||||||
<DropdownMenu.Content
|
[item.Id ?? ""]: season.IndexNumber,
|
||||||
loop={true}
|
}));
|
||||||
side="bottom"
|
}}
|
||||||
align="start"
|
/>
|
||||||
alignOffset={0}
|
<DownloadItems
|
||||||
avoidCollisions={true}
|
className="ml-2"
|
||||||
collisionPadding={8}
|
items={episodes || []}
|
||||||
sideOffset={8}
|
MissingDownloadIconComponent={() => (
|
||||||
>
|
<MaterialCommunityIcons
|
||||||
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
name="download-multiple"
|
||||||
{seasons?.map((season: any) => (
|
size={20}
|
||||||
<DropdownMenu.Item
|
color="white"
|
||||||
key={season.Name}
|
/>
|
||||||
onSelect={() => {
|
)}
|
||||||
setSeasonIndexState((prev) => ({
|
DownloadedIconComponent={() => (
|
||||||
...prev,
|
<MaterialCommunityIcons
|
||||||
[item.Id ?? ""]: season.IndexNumber,
|
name="check-all"
|
||||||
}));
|
size={20}
|
||||||
}}
|
color="#9333ea"
|
||||||
>
|
/>
|
||||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
)}
|
||||||
</DropdownMenu.Item>
|
/>
|
||||||
))}
|
</View>
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
<View className="px-4 flex flex-col my-4">
|
<View className="px-4 flex flex-col my-4">
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
<View
|
<View
|
||||||
@@ -218,7 +180,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="self-start ml-auto -mt-0.5">
|
<View className="self-start ml-auto -mt-0.5">
|
||||||
<DownloadItem item={e} />
|
<DownloadSingleItem item={e} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TouchableOpacity, View, ViewProps } 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 { LANGUAGES } from "@/constants/Languages";
|
import { LANGUAGES } from "@/constants/Languages";
|
||||||
|
import { TextInput } from "react-native-gesture-handler";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -122,6 +123,45 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Subtitle Size</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose a default subtitle size for direct play (only works for
|
||||||
|
some subtitle formats).
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
subtitleSize: Math.max(0, settings.subtitleSize - 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||||
|
{settings.subtitleSize}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
subtitleSize: Math.min(120, settings.subtitleSize + 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
userAtom,
|
userAtom,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import {ScreenOrientationEnum, Settings, useSettings} from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
registerBackgroundFetchAsync,
|
registerBackgroundFetchAsync,
|
||||||
@@ -17,7 +17,7 @@ import * as BackgroundFetch from "expo-background-fetch";
|
|||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import * as TaskManager from "expo-task-manager";
|
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 {
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -32,6 +32,7 @@ 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 {Stepper} from "@/components/inputs/Stepper";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -403,6 +404,26 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="shrink">
|
||||||
|
<Text className="font-semibold">Show Custom Menu Links</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Show custom menu links defined inside your Jellyfin web config.json file
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
Linking.openURL("https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="text-xs text-purple-600">More info</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.showCustomMenuLinks}
|
||||||
|
onValueChange={(value) => updateSettings({ showCustomMenuLinks: value })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -463,7 +484,44 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
<View
|
||||||
|
pointerEvents={
|
||||||
|
settings.downloadMethod === "remux" ? "auto" : "none"
|
||||||
|
}
|
||||||
|
className={`
|
||||||
|
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
||||||
|
${
|
||||||
|
settings.downloadMethod === "remux"
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Remux max download</Text>
|
||||||
|
<Text className="text-xs opacity-50 shrink">
|
||||||
|
This is the total media you want to be able to download at the same time.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Stepper
|
||||||
|
value={settings.remuxConcurrentLimit}
|
||||||
|
step={1}
|
||||||
|
min={1}
|
||||||
|
max={4}
|
||||||
|
onUpdate={(value) => updateSettings({remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"]})}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
pointerEvents={
|
||||||
|
settings.downloadMethod === "optimized" ? "auto" : "none"
|
||||||
|
}
|
||||||
|
className={`
|
||||||
|
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
||||||
|
${
|
||||||
|
settings.downloadMethod === "optimized"
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Auto download</Text>
|
<Text className="font-semibold">Auto download</Text>
|
||||||
<Text className="text-xs opacity-50 shrink">
|
<Text className="text-xs opacity-50 shrink">
|
||||||
|
|||||||
68
components/video-player/controls/BrightnessSlider.tsx
Normal file
68
components/video-player/controls/BrightnessSlider.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import * as Brightness from "expo-brightness";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
|
||||||
|
|
||||||
|
const BrightnessSlider = () => {
|
||||||
|
const brightness = useSharedValue(50);
|
||||||
|
const min = useSharedValue(0);
|
||||||
|
const max = useSharedValue(100);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchInitialBrightness = async () => {
|
||||||
|
const initialBrightness = await Brightness.getBrightnessAsync();
|
||||||
|
console.log("initialBrightness", initialBrightness);
|
||||||
|
brightness.value = initialBrightness * 100;
|
||||||
|
};
|
||||||
|
fetchInitialBrightness();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleValueChange = async (value: number) => {
|
||||||
|
brightness.value = value;
|
||||||
|
await Brightness.setBrightnessAsync(value / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.sliderContainer}>
|
||||||
|
<Slider
|
||||||
|
progress={brightness}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={max}
|
||||||
|
thumbWidth={0}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 50,
|
||||||
|
}}
|
||||||
|
theme={{
|
||||||
|
minimumTrackTintColor: "#FDFDFD",
|
||||||
|
maximumTrackTintColor: "#5A5A5A",
|
||||||
|
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
||||||
|
bubbleTextColor: "transparent", // Hide the value text
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Ionicons
|
||||||
|
name="sunny"
|
||||||
|
size={20}
|
||||||
|
color="#FDFDFD"
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
sliderContainer: {
|
||||||
|
width: 150,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default BrightnessSlider;
|
||||||
@@ -50,6 +50,14 @@ import { VideoProvider } from "./contexts/VideoContext";
|
|||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
|
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
|
||||||
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
|
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
|
||||||
|
import BrightnessSlider from "./BrightnessSlider";
|
||||||
|
import SkipButton from "./SkipButton";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { EpisodeList } from "./EpisodeList";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -63,7 +71,7 @@ interface Props {
|
|||||||
ignoreSafeAreas?: boolean;
|
ignoreSafeAreas?: boolean;
|
||||||
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
|
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
enableTrickplay?: boolean;
|
enableTrickplay?: boolean;
|
||||||
togglePlay: (ticks: number) => void;
|
togglePlay: () => void;
|
||||||
setShowControls: (shown: boolean) => void;
|
setShowControls: (shown: boolean) => void;
|
||||||
offline?: boolean;
|
offline?: boolean;
|
||||||
isVideoLoaded?: boolean;
|
isVideoLoaded?: boolean;
|
||||||
@@ -110,12 +118,15 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
const {
|
||||||
item,
|
trickPlayUrl,
|
||||||
!offline && enableTrickplay
|
calculateTrickplayUrl,
|
||||||
);
|
trickplayInfo,
|
||||||
|
prefetchAllTrickplayImages,
|
||||||
|
} = useTrickplay(item, !offline && enableTrickplay);
|
||||||
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [remainingTime, setRemainingTime] = useState(0);
|
const [remainingTime, setRemainingTime] = useState(0);
|
||||||
@@ -238,12 +249,28 @@ export const Controls: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [item, isVlc]);
|
}, [item, isVlc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prefetchAllTrickplayImages();
|
||||||
|
}, []);
|
||||||
const toggleControls = () => setShowControls(!showControls);
|
const toggleControls = () => setShowControls(!showControls);
|
||||||
|
|
||||||
|
const handleSliderStart = useCallback(() => {
|
||||||
|
if (showControls === false) return;
|
||||||
|
|
||||||
|
setIsSliding(true);
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
lastProgressRef.current = progress.value;
|
||||||
|
|
||||||
|
pause();
|
||||||
|
isSeeking.value = true;
|
||||||
|
}, [showControls, isPlaying]);
|
||||||
|
|
||||||
|
const [isSliding, setIsSliding] = useState(false);
|
||||||
const handleSliderComplete = useCallback(
|
const handleSliderComplete = useCallback(
|
||||||
async (value: number) => {
|
async (value: number) => {
|
||||||
isSeeking.value = false;
|
isSeeking.value = false;
|
||||||
progress.value = value;
|
progress.value = value;
|
||||||
|
setIsSliding(false);
|
||||||
|
|
||||||
await seek(
|
await seek(
|
||||||
Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
|
Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
|
||||||
@@ -254,27 +281,20 @@ export const Controls: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||||
|
const handleSliderChange = useCallback(
|
||||||
|
debounce((value: number) => {
|
||||||
|
const progressInTicks = msToTicks(value);
|
||||||
|
console.log("Progress in ticks", progressInTicks);
|
||||||
|
calculateTrickplayUrl(progressInTicks);
|
||||||
|
|
||||||
const handleSliderChange = (value: number) => {
|
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||||
const progressInTicks = isVlc ? msToTicks(value) : value;
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
calculateTrickplayUrl(progressInTicks);
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||||
|
const seconds = progressInSeconds % 60;
|
||||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
setTime({ hours, minutes, seconds });
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
}, 10),
|
||||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
[]
|
||||||
const seconds = progressInSeconds % 60;
|
);
|
||||||
setTime({ hours, minutes, seconds });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSliderStart = useCallback(() => {
|
|
||||||
if (showControls === false) return;
|
|
||||||
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
lastProgressRef.current = progress.value;
|
|
||||||
|
|
||||||
pause();
|
|
||||||
isSeeking.value = true;
|
|
||||||
}, [showControls, isPlaying]);
|
|
||||||
|
|
||||||
const handleSkipBackward = useCallback(async () => {
|
const handleSkipBackward = useCallback(async () => {
|
||||||
if (!settings?.rewindSkipTime) return;
|
if (!settings?.rewindSkipTime) return;
|
||||||
@@ -318,282 +338,408 @@ export const Controls: React.FC<Props> = ({
|
|||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const memoizedRenderBubble = useCallback(() => {
|
||||||
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { x, y, url } = trickPlayUrl;
|
||||||
|
const tileWidth = 150;
|
||||||
|
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||||
|
|
||||||
|
console.log("time, ", time);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: -57,
|
||||||
|
bottom: 15,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 5,
|
||||||
|
width: tileWidth * 1.5,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: tileWidth,
|
||||||
|
height: tileHeight,
|
||||||
|
alignSelf: "center",
|
||||||
|
transform: [{ scale: 1.4 }],
|
||||||
|
borderRadius: 5,
|
||||||
|
}}
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
source={{ uri: url }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 30,
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||||
|
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||||
|
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [trickPlayUrl, trickplayInfo, time]);
|
||||||
|
|
||||||
|
const [EpisodeView, setEpisodeView] = useState(false);
|
||||||
|
|
||||||
|
const switchOnEpisodeMode = () => {
|
||||||
|
setEpisodeView(true);
|
||||||
|
if (isPlaying) togglePlay(progress.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gotoEpisode = async (itemId: string) => {
|
||||||
|
const item = await getItemById(api, itemId);
|
||||||
|
console.log("Item", item);
|
||||||
|
if (!settings || !item) return;
|
||||||
|
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
|
getDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||||
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
|
bitrateValue: bitrate.toString(),
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
if (!bitrate.value) {
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlProvider
|
<ControlProvider
|
||||||
item={item}
|
item={item}
|
||||||
mediaSource={mediaSource}
|
mediaSource={mediaSource}
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={isVideoLoaded}
|
||||||
>
|
>
|
||||||
<SafeAreaView
|
{EpisodeView ? (
|
||||||
style={{
|
<EpisodeList item={item} close={() => setEpisodeView(false)} />
|
||||||
flex: 1,
|
) : (
|
||||||
position: "absolute",
|
<>
|
||||||
top: insets.top,
|
<VideoProvider
|
||||||
left: insets.left,
|
getAudioTracks={getAudioTracks}
|
||||||
right: insets.right,
|
getSubtitleTracks={getSubtitleTracks}
|
||||||
bottom: insets.bottom,
|
setAudioTrack={setAudioTrack}
|
||||||
}}
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
>
|
setSubtitleURL={setSubtitleURL}
|
||||||
<VideoProvider
|
|
||||||
getAudioTracks={getAudioTracks}
|
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
|
||||||
setAudioTrack={setAudioTrack}
|
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
|
||||||
setSubtitleURL={setSubtitleURL}
|
|
||||||
>
|
|
||||||
{!mediaSource?.TranscodingUrl ? (
|
|
||||||
<DropdownViewDirect showControls={showControls} />
|
|
||||||
) : (
|
|
||||||
<DropdownViewTranscoding showControls={showControls} />
|
|
||||||
)}
|
|
||||||
</VideoProvider>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 97,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
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>
|
{!mediaSource?.TranscodingUrl ? (
|
||||||
</TouchableOpacity>
|
<DropdownViewDirect showControls={showControls} />
|
||||||
</View>
|
) : (
|
||||||
|
<DropdownViewTranscoding showControls={showControls} />
|
||||||
|
)}
|
||||||
|
</VideoProvider>
|
||||||
|
|
||||||
<View
|
<Pressable
|
||||||
style={{
|
onPressIn={() => {
|
||||||
position: "absolute",
|
toggleControls();
|
||||||
bottom: 94,
|
|
||||||
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
|
|
||||||
onPressIn={() => {
|
|
||||||
toggleControls();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: Dimensions.get("window").width,
|
|
||||||
height: Dimensions.get("window").height,
|
|
||||||
}}
|
|
||||||
></Pressable>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
|
||||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
|
||||||
>
|
|
||||||
{Platform.OS !== "ios" && (
|
|
||||||
<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={async () => {
|
|
||||||
if (stop) await stop();
|
|
||||||
router.back();
|
|
||||||
}}
|
}}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
style={{
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: 0,
|
width: Dimensions.get("window").width,
|
||||||
left: 0,
|
height: Dimensions.get("window").height,
|
||||||
bottom: 0,
|
}}
|
||||||
opacity: showControls ? 1 : 0,
|
></Pressable>
|
||||||
},
|
|
||||||
]}
|
|
||||||
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
|
<View
|
||||||
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800`}
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
|
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||||
>
|
>
|
||||||
<View className="flex flex-row items-center space-x-4">
|
{item?.Type === "Episode" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={{
|
onPress={() => {
|
||||||
opacity: !previousItem ? 0.5 : 1,
|
switchOnEpisodeMode();
|
||||||
}}
|
}}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="list" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{previousItem && (
|
||||||
|
<TouchableOpacity
|
||||||
onPress={goToPreviousItem}
|
onPress={goToPreviousItem}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity onPress={handleSkipBackward}>
|
)}
|
||||||
|
|
||||||
|
{nextItem && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={goToNextItem}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mediaSource?.TranscodingUrl && (
|
||||||
|
<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={async () => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%", // Center vertically
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
|
||||||
|
paddingHorizontal: "28%", // Add some padding to the left and right
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
alignItems: "center",
|
||||||
|
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
|
||||||
|
bottom: 30,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BrightnessSlider />
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={handleSkipBackward}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="refresh-outline"
|
name="refresh-outline"
|
||||||
size={26}
|
size={50}
|
||||||
color="white"
|
color="white"
|
||||||
style={{
|
style={{
|
||||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
<Text
|
||||||
<TouchableOpacity
|
style={{
|
||||||
onPress={() => {
|
position: "absolute",
|
||||||
togglePlay(progress.value);
|
color: "white",
|
||||||
}}
|
fontSize: 16,
|
||||||
>
|
fontWeight: "bold",
|
||||||
|
bottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settings?.rewindSkipTime}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
togglePlay();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isBuffering ? (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={isPlaying ? "pause" : "play"}
|
name={isPlaying ? "pause" : "play"}
|
||||||
size={30}
|
size={50}
|
||||||
color="white"
|
color="white"
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
) : (
|
||||||
<TouchableOpacity onPress={handleSkipForward}>
|
<Loader size={"large"} />
|
||||||
<Ionicons name="refresh-outline" size={26} color="white" />
|
)}
|
||||||
</TouchableOpacity>
|
</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;
|
<TouchableOpacity onPress={handleSkipForward}>
|
||||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
<View
|
||||||
return (
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh-outline" size={50} color="white" />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
bottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settings?.forwardSkipTime}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
right: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
|
className={`flex flex-col p-4`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="shrink flex flex-col justify-center h-full mb-2"
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
alignSelf: "flex-end", // Shrink height based on content
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
marginRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SkipButton
|
||||||
|
showButton={showSkipButton}
|
||||||
|
onPress={skipIntro}
|
||||||
|
buttonText="Skip Intro"
|
||||||
|
/>
|
||||||
|
<SkipButton
|
||||||
|
showButton={showSkipCreditButton}
|
||||||
|
onPress={skipCredit}
|
||||||
|
buttonText="Skip Credits"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`flex flex-col-reverse py-4 pb-1 px-4 rounded-lg items-center bg-neutral-800`}
|
||||||
|
>
|
||||||
|
<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: "#666",
|
||||||
|
heartbeatColor: "#999",
|
||||||
|
}}
|
||||||
|
renderThumb={() => (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
width: 18,
|
||||||
bottom: 0,
|
height: 18,
|
||||||
left: 0,
|
left: -2,
|
||||||
width: tileWidth,
|
borderRadius: 10,
|
||||||
height: tileHeight,
|
backgroundColor: "#fff",
|
||||||
marginLeft: -tileWidth / 4,
|
justifyContent: "center",
|
||||||
marginTop: -tileHeight / 4 - 60,
|
alignItems: "center",
|
||||||
zIndex: 10,
|
|
||||||
}}
|
}}
|
||||||
className=" bg-neutral-800 overflow-hidden"
|
/>
|
||||||
>
|
)}
|
||||||
<Image
|
cache={cacheProgress}
|
||||||
cachePolicy={"memory-disk"}
|
onSlidingStart={handleSliderStart}
|
||||||
style={{
|
onSlidingComplete={handleSliderComplete}
|
||||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
onValueChange={handleSliderChange}
|
||||||
height:
|
containerStyle={{
|
||||||
(150 / trickplayInfo.aspectRatio!) *
|
borderRadius: 100,
|
||||||
trickplayInfo?.data.TileHeight!,
|
}}
|
||||||
transform: [
|
renderBubble={() => isSliding && memoizedRenderBubble()}
|
||||||
{ translateX: -x * tileWidth },
|
sliderHeight={10}
|
||||||
{ translateY: -y * tileHeight },
|
thumbWidth={0}
|
||||||
],
|
progress={progress}
|
||||||
}}
|
minimumValue={min}
|
||||||
source={{ uri: url }}
|
maximumValue={max}
|
||||||
contentFit="cover"
|
/>
|
||||||
/>
|
<View className="flex flex-row items-center justify-between mt-0.5">
|
||||||
<Text
|
<Text className="text-[12px] text-neutral-400">
|
||||||
style={{
|
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
||||||
position: "absolute",
|
</Text>
|
||||||
bottom: 5,
|
<Text className="text-[12px] text-neutral-400">
|
||||||
left: 5,
|
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
||||||
color: "white",
|
</Text>
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
</View>
|
||||||
padding: 5,
|
|
||||||
borderRadius: 5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
|
||||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
|
||||||
}:${
|
|
||||||
time.seconds < 10 ? `0${time.seconds}` : time.seconds
|
|
||||||
}`}
|
|
||||||
</Text>
|
|
||||||
</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, isVlc ? "ms" : "s")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[12px] text-neutral-400">
|
|
||||||
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</>
|
||||||
</SafeAreaView>
|
)}
|
||||||
</ControlProvider>
|
</ControlProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
289
components/video-player/controls/EpisodeList.tsx
Normal file
289
components/video-player/controls/EpisodeList.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { useEffect, useMemo, useState, useRef } from "react";
|
||||||
|
import { View, TouchableOpacity } from "react-native";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import {
|
||||||
|
HorizontalScroll,
|
||||||
|
HorizontalScrollRef,
|
||||||
|
} from "@/components/common/HorrizontalScroll";
|
||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import {
|
||||||
|
SeasonDropdown,
|
||||||
|
SeasonIndexState,
|
||||||
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { Item } from "zeego/dropdown-menu";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||||
|
|
||||||
|
export const EpisodeList: React.FC<Props> = ({ item, close }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets(); // Get safe area insets
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
|
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
||||||
|
const scrollToIndex = (index: number) => {
|
||||||
|
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set the initial season index
|
||||||
|
useEffect(() => {
|
||||||
|
if (item.SeriesId) {
|
||||||
|
setSeasonIndexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.SeriesId ?? ""]: item.ParentIndexNumber ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
|
||||||
|
const [seriesItem, setSeriesItem] = useState<BaseItemDto | null>(null);
|
||||||
|
|
||||||
|
// This effect fetches the series item data/
|
||||||
|
useEffect(() => {
|
||||||
|
if (item.SeriesId) {
|
||||||
|
getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then(
|
||||||
|
(res) => {
|
||||||
|
setSeriesItem(res);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [item.SeriesId]);
|
||||||
|
|
||||||
|
const { data: seasons } = useQuery({
|
||||||
|
queryKey: ["seasons", item.SeriesId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !item.SeriesId) return [];
|
||||||
|
const response = await api.axiosInstance.get(
|
||||||
|
`${api.basePath}/Shows/${item.SeriesId}/Seasons`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: item.SeriesId,
|
||||||
|
Fields:
|
||||||
|
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount",
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data.Items;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!item.SeasonId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSeasonId: string | null = useMemo(
|
||||||
|
() =>
|
||||||
|
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||||
|
[seasons, seasonIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: episodes, isFetching } = useQuery({
|
||||||
|
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.SeriesId || "",
|
||||||
|
userId: user.Id,
|
||||||
|
seasonId: selectedSeasonId || undefined,
|
||||||
|
enableUserData: true,
|
||||||
|
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data.Items;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!selectedSeasonId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item?.Type === "Episode" && item.Id) {
|
||||||
|
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
|
||||||
|
if (index !== undefined && index !== -1) {
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToIndex(index);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [episodes, item]);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
useEffect(() => {
|
||||||
|
for (let e of episodes || []) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["item", e.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!e.Id) return;
|
||||||
|
const res = await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: e.Id,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
staleTime: 60 * 5 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [episodes]);
|
||||||
|
|
||||||
|
// Scroll to the current item when episodes are fetched
|
||||||
|
useEffect(() => {
|
||||||
|
if (episodes && scrollViewRef.current) {
|
||||||
|
const currentItemIndex = episodes.findIndex((e) => e.Id === item.Id);
|
||||||
|
if (currentItemIndex !== -1) {
|
||||||
|
scrollViewRef.current.scrollToIndex(currentItemIndex, 16); // Adjust the scroll position based on item width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [episodes, item.Id]);
|
||||||
|
|
||||||
|
const { audioIndex, subtitleIndex, bitrateValue } = useLocalSearchParams<{
|
||||||
|
audioIndex: string;
|
||||||
|
subtitleIndex: string;
|
||||||
|
mediaSourceId: string;
|
||||||
|
bitrateValue: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const gotoEpisode = async (itemId: string) => {
|
||||||
|
const item = await getItemById(api, itemId);
|
||||||
|
if (!settings || !item) return;
|
||||||
|
|
||||||
|
const { mediaSource } = getDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||||
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
|
bitrateValue: bitrateValue,
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
if (!bitrateValue) {
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!episodes) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
backgroundColor: "black",
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
className={`flex flex-row items-center space-x-2 z-10 p-4`}
|
||||||
|
>
|
||||||
|
{seriesItem && (
|
||||||
|
<SeasonDropdown
|
||||||
|
item={seriesItem}
|
||||||
|
seasons={seasons}
|
||||||
|
state={seasonIndexState}
|
||||||
|
onSelect={(season) => {
|
||||||
|
setSeasonIndexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.SeriesId ?? ""]: season.IndexNumber,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={async () => {
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<HorizontalScroll
|
||||||
|
ref={scrollViewRef}
|
||||||
|
data={episodes}
|
||||||
|
extraData={item}
|
||||||
|
renderItem={(_item, idx) => (
|
||||||
|
<View
|
||||||
|
key={_item.Id}
|
||||||
|
style={{}}
|
||||||
|
className={`flex flex-col w-44 ${
|
||||||
|
item.Id !== _item.Id ? "opacity-75" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
gotoEpisode(_item.Id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContinueWatchingPoster
|
||||||
|
item={_item}
|
||||||
|
useEpisodePoster
|
||||||
|
showPlayButton={_item.Id !== item.Id}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View className="shrink">
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={{
|
||||||
|
lineHeight: 18, // Adjust this value based on your text size
|
||||||
|
height: 36, // lineHeight * 2 for consistent two-line space
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{_item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className="text-xs text-neutral-475">
|
||||||
|
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-neutral-500">
|
||||||
|
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="self-start mt-2">
|
||||||
|
<DownloadSingleItem item={_item} />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
numberOfLines={5}
|
||||||
|
className="text-xs text-neutral-500 shrink"
|
||||||
|
>
|
||||||
|
{_item.Overview}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
|
||||||
|
estimatedItemSize={200}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
39
components/video-player/controls/SkipButton.tsx
Normal file
39
components/video-player/controls/SkipButton.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, TouchableOpacity, Text, StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
interface SkipButtonProps {
|
||||||
|
onPress: () => void;
|
||||||
|
showButton: boolean;
|
||||||
|
buttonText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkipButton: React.FC<SkipButtonProps> = ({
|
||||||
|
onPress,
|
||||||
|
showButton,
|
||||||
|
buttonText,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View style={{ display: showButton ? "flex" : "none" }}>
|
||||||
|
<TouchableOpacity onPress={onPress} style={styles.button}>
|
||||||
|
<Text style={styles.text}>{buttonText}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.75)",
|
||||||
|
borderRadius: 5,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 15,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "#5A5454",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: "white",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SkipButton;
|
||||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.21.0",
|
"channel": "0.22.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.21.0",
|
"channel": "0.22.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import index from "@/app/(auth)/(tabs)/(home)";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
|
||||||
interface AdjacentEpisodesProps {
|
interface AdjacentEpisodesProps {
|
||||||
@@ -12,81 +12,53 @@ interface AdjacentEpisodesProps {
|
|||||||
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
const { data: previousItem } = useQuery({
|
const { data: adjacentItems } = useQuery({
|
||||||
queryKey: ["previousItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
|
||||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||||
const parentId = item?.AlbumId || item?.ParentId;
|
if (!api || !item || !item.SeriesId) {
|
||||||
const indexNumber = item?.IndexNumber;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!api ||
|
|
||||||
!parentId ||
|
|
||||||
indexNumber === undefined ||
|
|
||||||
indexNumber === null ||
|
|
||||||
indexNumber - 1 < 1
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newIndexNumber = indexNumber - 2;
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.SeriesId,
|
||||||
const res = await getItemsApi(api).getItems({
|
adjacentTo: item.Id,
|
||||||
parentId: parentId!,
|
limit: 3,
|
||||||
startIndex: newIndexNumber,
|
|
||||||
limit: 1,
|
|
||||||
sortBy: ["IndexNumber"],
|
|
||||||
includeItemTypes: ["Episode", "Audio"],
|
|
||||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.data.Items?.[0]?.IndexNumber !== indexNumber - 1) {
|
return res.data.Items || null;
|
||||||
throw new Error("Previous item is not correct");
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.data.Items?.[0] || null;
|
|
||||||
},
|
},
|
||||||
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
enabled:
|
||||||
|
!!api &&
|
||||||
|
!!item?.Id &&
|
||||||
|
!!item?.SeriesId &&
|
||||||
|
(item?.Type === "Episode" || item?.Type === "Audio"),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: nextItem } = useQuery({
|
const previousItem = useMemo(() => {
|
||||||
queryKey: ["nextItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
return null;
|
||||||
const parentId = item?.AlbumId || item?.ParentId;
|
}
|
||||||
const indexNumber = item?.IndexNumber;
|
|
||||||
|
|
||||||
if (
|
if (adjacentItems.length === 2) {
|
||||||
!api ||
|
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||||
!parentId ||
|
}
|
||||||
indexNumber === undefined ||
|
|
||||||
indexNumber === null
|
|
||||||
) {
|
|
||||||
console.log("No next item", {
|
|
||||||
itemId: item?.Id,
|
|
||||||
parentId: parentId,
|
|
||||||
indexNumber: indexNumber,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getItemsApi(api).getItems({
|
return adjacentItems[0];
|
||||||
parentId: parentId!,
|
}, [adjacentItems, item]);
|
||||||
startIndex: indexNumber,
|
|
||||||
sortBy: ["IndexNumber"],
|
|
||||||
limit: 1,
|
|
||||||
includeItemTypes: ["Episode", "Audio"],
|
|
||||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.data.Items?.[0]?.IndexNumber !== indexNumber + 1) {
|
const nextItem = useMemo(() => {
|
||||||
throw new Error("Previous item is not correct");
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return res.data.Items?.[0] || null;
|
if (adjacentItems.length === 2) {
|
||||||
},
|
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||||
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
}
|
||||||
staleTime: 0,
|
|
||||||
});
|
return adjacentItems[2];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
return { previousItem, nextItem };
|
return { previousItem, nextItem };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,16 +17,20 @@ const useDefaultPlaySettings = (
|
|||||||
// 2. Get default or preferred audio
|
// 2. Get default or preferred audio
|
||||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||||
(x) => x.Language === settings?.defaultAudioLanguage
|
(x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage
|
||||||
)?.Index;
|
)?.Index;
|
||||||
|
|
||||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||||
(x) => x.Type === "Audio"
|
(x) => x.Type === "Audio"
|
||||||
)?.Index;
|
)?.Index;
|
||||||
|
|
||||||
// 3. Get default or preferred subtitle
|
// 3. Get default or preferred subtitle
|
||||||
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
|
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||||
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
|
(x) =>
|
||||||
|
x.Type === "Subtitle" &&
|
||||||
|
x.Language === settings?.defaultSubtitleLanguage?.value
|
||||||
)?.Index;
|
)?.Index;
|
||||||
|
|
||||||
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
|
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||||
(stream) => stream.Type === "Subtitle" && stream.IsDefault
|
(stream) => stream.Type === "Subtitle" && stream.IsDefault
|
||||||
)?.Index;
|
)?.Index;
|
||||||
@@ -38,7 +42,9 @@ const useDefaultPlaySettings = (
|
|||||||
defaultAudioIndex:
|
defaultAudioIndex:
|
||||||
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
||||||
defaultSubtitleIndex:
|
defaultSubtitleIndex:
|
||||||
preferedSubtitleIndex || defaultSubtitleIndex || undefined,
|
preferedSubtitleIndex !== undefined
|
||||||
|
? preferedSubtitleIndex
|
||||||
|
: defaultSubtitleIndex || undefined,
|
||||||
defaultMediaSource: mediaSource || undefined,
|
defaultMediaSource: mediaSource || undefined,
|
||||||
defaultBitrate: bitrate || undefined,
|
defaultBitrate: bitrate || undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeErrorLog, writeInfoLog, writeToLog } from "@/utils/log";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
@@ -9,11 +9,34 @@ import {
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import useImageStorage from "./useImageStorage";
|
import useImageStorage from "./useImageStorage";
|
||||||
|
import useDownloadHelper from "@/utils/download";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { JobStatus } from "@/utils/optimize-server";
|
||||||
|
|
||||||
|
const createFFmpegCommand = (url: string, output: string) => [
|
||||||
|
"-y", // overwrite output files without asking
|
||||||
|
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
||||||
|
|
||||||
|
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
|
||||||
|
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
|
||||||
|
"-multiple_requests 1", // http
|
||||||
|
"-tcp_nodelay 1", // http
|
||||||
|
// endregion ffmpeg protocol commands
|
||||||
|
|
||||||
|
"-fflags +genpts", // format flags
|
||||||
|
`-i ${url}`, // infile
|
||||||
|
"-map 0:v -map 0:a", // select all streams for video & audio
|
||||||
|
"-c copy", // streamcopy, preventing transcoding
|
||||||
|
"-bufsize 25M", // amount of data processed before calculating current bitrate
|
||||||
|
"-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
|
||||||
|
output,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||||
@@ -24,26 +47,125 @@ import useImageStorage from "./useImageStorage";
|
|||||||
*/
|
*/
|
||||||
export const useRemuxHlsToMp4 = () => {
|
export const useRemuxHlsToMp4 = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [settings] = useSettings();
|
||||||
const { saveImage } = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
|
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||||
|
const { saveDownloadedItemInfo, setProcesses, processes } = useDownload();
|
||||||
|
|
||||||
|
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
||||||
|
await saveSeriesPrimaryImage(item);
|
||||||
|
const itemImage = getItemImage({
|
||||||
|
item,
|
||||||
|
api,
|
||||||
|
variant: "Primary",
|
||||||
|
quality: 90,
|
||||||
|
width: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveImage(item.Id, itemImage?.uri);
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeCallback = useCallback(
|
||||||
|
async (session: FFmpegSession, item: BaseItemDto) => {
|
||||||
|
try {
|
||||||
|
let endTime;
|
||||||
|
const returnCode = await session.getReturnCode();
|
||||||
|
const startTime = new Date();
|
||||||
|
|
||||||
|
if (returnCode.isValueSuccess()) {
|
||||||
|
endTime = new Date();
|
||||||
|
const stat = await session.getLastReceivedStatistics();
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["downloadedItems"],
|
||||||
|
});
|
||||||
|
|
||||||
|
saveDownloadedItemInfo(item, stat.getSize());
|
||||||
|
writeInfoLog(
|
||||||
|
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${
|
||||||
|
item.Name
|
||||||
|
},
|
||||||
|
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
|
||||||
|
duration: ${
|
||||||
|
(endTime.getTime() - startTime.getTime()) / 1000
|
||||||
|
}s`.replace(/^ +/g, "")
|
||||||
|
);
|
||||||
|
toast.success("Download completed");
|
||||||
|
} else if (returnCode.isValueError()) {
|
||||||
|
endTime = new Date();
|
||||||
|
const allLogs = session.getAllLogsAsString();
|
||||||
|
writeErrorLog(
|
||||||
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
||||||
|
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
|
||||||
|
duration: ${
|
||||||
|
(endTime.getTime() - startTime.getTime()) / 1000
|
||||||
|
}s. All logs: ${allLogs}`.replace(/^ +/g, "")
|
||||||
|
);
|
||||||
|
} else if (returnCode.isValueCancel()) {
|
||||||
|
endTime = new Date();
|
||||||
|
writeInfoLog(
|
||||||
|
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name},
|
||||||
|
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
|
||||||
|
duration: ${
|
||||||
|
(endTime.getTime() - startTime.getTime()) / 1000
|
||||||
|
}s`.replace(/^ +/g, "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcesses((prev) => {
|
||||||
|
return prev.filter((process) => process.itemId !== item.Id);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as Error;
|
||||||
|
writeErrorLog(
|
||||||
|
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name},
|
||||||
|
Error: ${error.message}, Stack: ${error.stack}`.replace(/^ +/g, "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[processes, setProcesses]
|
||||||
|
);
|
||||||
|
|
||||||
|
const statisticsCallback = useCallback(
|
||||||
|
(statistics: Statistics, item: BaseItemDto) => {
|
||||||
|
const videoLength =
|
||||||
|
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||||
|
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||||
|
const totalFrames = videoLength * fps;
|
||||||
|
const processedFrames = statistics.getVideoFrameNumber();
|
||||||
|
const speed = statistics.getSpeed();
|
||||||
|
|
||||||
|
const percentage =
|
||||||
|
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
|
||||||
|
|
||||||
|
if (!item.Id) throw new Error("Item is undefined");
|
||||||
|
setProcesses((prev) => {
|
||||||
|
return prev.map((process) => {
|
||||||
|
if (process.itemId === item.Id) {
|
||||||
|
return {
|
||||||
|
...process,
|
||||||
|
id: statistics.getSessionId().toString(),
|
||||||
|
progress: percentage,
|
||||||
|
speed: Math.max(speed, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return process;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setProcesses, completeCallback]
|
||||||
|
);
|
||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
async (item: BaseItemDto, url: string) => {
|
||||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||||
if (!api) throw new Error("API is not defined");
|
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({
|
// First lets save any important assets we want to present to the user offline
|
||||||
item,
|
await onSaveAssets(api, 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: {
|
||||||
@@ -55,128 +177,34 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const command = `-y -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -map 0:v -map 0:a -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
|
||||||
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setProcesses((prev) => [
|
const job: JobStatus = {
|
||||||
...prev,
|
id: "",
|
||||||
{
|
deviceId: "",
|
||||||
id: "",
|
inputUrl: url,
|
||||||
deviceId: "",
|
item: item,
|
||||||
inputUrl: "",
|
itemId: item.Id!,
|
||||||
item: item,
|
outputPath: output,
|
||||||
itemId: item.Id!,
|
progress: 0,
|
||||||
outputPath: "",
|
status: "downloading",
|
||||||
progress: 0,
|
timestamp: new Date(),
|
||||||
status: "downloading",
|
};
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
||||||
const videoLength =
|
setProcesses((prev) => [...prev, job]);
|
||||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
|
||||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
|
||||||
const totalFrames = videoLength * fps;
|
|
||||||
const processedFrames = statistics.getVideoFrameNumber();
|
|
||||||
const speed = statistics.getSpeed();
|
|
||||||
|
|
||||||
const percentage =
|
await FFmpegKit.executeAsync(
|
||||||
totalFrames > 0
|
createFFmpegCommand(url, output).join(" "),
|
||||||
? Math.floor((processedFrames / totalFrames) * 100)
|
(session) => completeCallback(session, item),
|
||||||
: 0;
|
undefined,
|
||||||
|
(s) => statisticsCallback(s, item)
|
||||||
if (!item.Id) throw new Error("Item is undefined");
|
);
|
||||||
setProcesses((prev) => {
|
|
||||||
return prev.map((process) => {
|
|
||||||
if (process.itemId === item.Id) {
|
|
||||||
return {
|
|
||||||
...process,
|
|
||||||
progress: percentage,
|
|
||||||
speed: Math.max(speed, 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return process;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
FFmpegKit.executeAsync(command, async (session) => {
|
|
||||||
try {
|
|
||||||
const returnCode = await session.getReturnCode();
|
|
||||||
const startTime = new Date();
|
|
||||||
|
|
||||||
let endTime;
|
|
||||||
if (returnCode.isValueSuccess()) {
|
|
||||||
endTime = new Date();
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${
|
|
||||||
item.Name
|
|
||||||
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
|
|
||||||
(endTime.getTime() - startTime.getTime()) / 1000
|
|
||||||
}s`
|
|
||||||
);
|
|
||||||
if (!item) throw new Error("Item is undefined");
|
|
||||||
await saveDownloadedItemInfo(item);
|
|
||||||
toast.success("Download completed");
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["downloadedItems"],
|
|
||||||
});
|
|
||||||
resolve();
|
|
||||||
} else if (returnCode.isValueError()) {
|
|
||||||
endTime = new Date();
|
|
||||||
const allLogs = session.getAllLogsAsString();
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${
|
|
||||||
item.Name
|
|
||||||
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
|
|
||||||
(endTime.getTime() - startTime.getTime()) / 1000
|
|
||||||
}s. All logs: ${allLogs}`
|
|
||||||
);
|
|
||||||
reject(new Error("Remuxing failed"));
|
|
||||||
} else if (returnCode.isValueCancel()) {
|
|
||||||
endTime = new Date();
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${
|
|
||||||
item.Name
|
|
||||||
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
|
|
||||||
(endTime.getTime() - startTime.getTime()) / 1000
|
|
||||||
}s`
|
|
||||||
);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
setProcesses((prev) => {
|
|
||||||
return prev.filter((process) => process.itemId !== item.Id);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name}, ${errorLog}`
|
|
||||||
);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e as Error;
|
const error = e as Error;
|
||||||
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
|
|
||||||
console.error("Failed to remux:", error);
|
console.error("Failed to remux:", error);
|
||||||
writeToLog(
|
writeErrorLog(
|
||||||
"ERROR",
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, ${errorLog}`
|
Error: ${error.message}, Stack: ${error.stack}`
|
||||||
);
|
);
|
||||||
setProcesses((prev) => {
|
setProcesses((prev) => {
|
||||||
return prev.filter((process) => process.itemId !== item.Id);
|
return prev.filter((process) => process.itemId !== item.Id);
|
||||||
@@ -184,7 +212,7 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
throw error; // Re-throw the error to propagate it to the caller
|
throw error; // Re-throw the error to propagate it to the caller
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[settings, processes, setProcesses, completeCallback, statisticsCallback]
|
||||||
);
|
);
|
||||||
|
|
||||||
const cancelRemuxing = useCallback(() => {
|
const cancelRemuxing = useCallback(() => {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
// hooks/useTrickplay.ts
|
|
||||||
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { ticksToMs } from "@/utils/time";
|
import { ticksToMs } from "@/utils/time";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
@@ -111,9 +110,45 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
|
|||||||
[trickplayInfo, item, api, enabled]
|
[trickplayInfo, item, api, enabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const prefetchAllTrickplayImages = useCallback(() => {
|
||||||
|
if (!api || !enabled || !trickplayInfo || !item.Id || !item.RunTimeTicks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, resolution } = trickplayInfo;
|
||||||
|
const { Interval, TileWidth, TileHeight, Width, Height } = data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Interval ||
|
||||||
|
!TileWidth ||
|
||||||
|
!TileHeight ||
|
||||||
|
!resolution ||
|
||||||
|
!Width ||
|
||||||
|
!Height
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid trickplay data");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate tiles per sheet
|
||||||
|
const tilesPerRow = TileWidth;
|
||||||
|
const tilesPerColumn = TileHeight;
|
||||||
|
const tilesPerSheet = tilesPerRow * tilesPerColumn;
|
||||||
|
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
|
||||||
|
const totalIndexes = Math.ceil(totalTiles / tilesPerSheet);
|
||||||
|
|
||||||
|
// Prefetch all trickplay images
|
||||||
|
for (let index = 0; index < totalIndexes; index++) {
|
||||||
|
const url = `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`;
|
||||||
|
Image.prefetch(url);
|
||||||
|
}
|
||||||
|
}, [trickplayInfo, item, api, enabled]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trickPlayUrl: enabled ? trickPlayUrl : null,
|
trickPlayUrl: enabled ? trickPlayUrl : null,
|
||||||
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null,
|
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null,
|
||||||
|
prefetchAllTrickplayImages: enabled
|
||||||
|
? prefetchAllTrickplayImages
|
||||||
|
: () => null,
|
||||||
trickplayInfo: enabled ? trickplayInfo : null,
|
trickplayInfo: enabled ? trickplayInfo : null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,91 +1,27 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import { Router, useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { Api } from "@jellyfin/sdk";
|
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import {
|
|
||||||
apiAtom,
|
|
||||||
getOrSetDeviceId,
|
|
||||||
userAtom,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
interface UseWebSocketProps {
|
interface UseWebSocketProps {
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
pauseVideo: () => void;
|
togglePlay: () => void;
|
||||||
playVideo: () => void;
|
|
||||||
stopPlayback: () => void;
|
stopPlayback: () => void;
|
||||||
offline?: boolean;
|
offline: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWebSocket = ({
|
export const useWebSocket = ({
|
||||||
isPlaying,
|
isPlaying,
|
||||||
pauseVideo,
|
togglePlay,
|
||||||
playVideo,
|
|
||||||
stopPlayback,
|
stopPlayback,
|
||||||
offline = false,
|
offline,
|
||||||
}: UseWebSocketProps) => {
|
}: UseWebSocketProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const user = useAtomValue(userAtom);
|
const { ws } = useWebSocketContext();
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (offline || !deviceId || !api?.accessToken) return;
|
if (!ws) return;
|
||||||
|
if (offline) 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, offline]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (offline || !ws) return;
|
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
const json = JSON.parse(e.data);
|
const json = JSON.parse(e.data);
|
||||||
@@ -95,8 +31,7 @@ export const useWebSocket = ({
|
|||||||
|
|
||||||
if (command === "PlayPause") {
|
if (command === "PlayPause") {
|
||||||
console.log("Command ~ PlayPause");
|
console.log("Command ~ PlayPause");
|
||||||
if (isPlaying) pauseVideo();
|
togglePlay();
|
||||||
else playVideo();
|
|
||||||
} else if (command === "Stop") {
|
} else if (command === "Stop") {
|
||||||
console.log("Command ~ Stop");
|
console.log("Command ~ Stop");
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
@@ -108,7 +43,9 @@ export const useWebSocket = ({
|
|||||||
Alert.alert("Message from server: " + title, body);
|
Alert.alert("Message from server: " + title, body);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router, offline]);
|
|
||||||
|
|
||||||
return { isConnected };
|
return () => {
|
||||||
|
ws.onmessage = null;
|
||||||
|
};
|
||||||
|
}, [ws, stopPlayback, togglePlay, isPlaying, router]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package expo.modules.vlcplayer
|
package expo.modules.vlcplayer
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
@@ -32,6 +34,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
private var startPosition: Int? = 0
|
private var startPosition: Int? = 0
|
||||||
private var isMediaReady: Boolean = false
|
private var isMediaReady: Boolean = false
|
||||||
private var externalTrack: Map<String, String>? = null
|
private var externalTrack: Map<String, String>? = null
|
||||||
|
var hasSource: Boolean = false
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val updateInterval = 1000L // 1 second
|
||||||
|
private val updateProgressRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
updateVideoProgress()
|
||||||
|
handler.postDelayed(this, updateInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setupView()
|
setupView()
|
||||||
@@ -48,6 +60,11 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setSource(source: Map<String, Any>) {
|
fun setSource(source: Map<String, Any>) {
|
||||||
|
if (hasSource) {
|
||||||
|
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||||
|
play()
|
||||||
|
return
|
||||||
|
}
|
||||||
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
|
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
|
||||||
val autoplay = source["autoplay"] as? Boolean ?: false
|
val autoplay = source["autoplay"] as? Boolean ?: false
|
||||||
val isNetwork = source["isNetwork"] as? Boolean ?: false
|
val isNetwork = source["isNetwork"] as? Boolean ?: false
|
||||||
@@ -87,6 +104,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
// Log.d("VlcPlayerView", "Debug: Subtitle track index is less than -1, not setting")
|
// Log.d("VlcPlayerView", "Debug: Subtitle track index is less than -1, not setting")
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
hasSource = true
|
||||||
|
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
Log.d("VlcPlayerView", "Playing...")
|
Log.d("VlcPlayerView", "Playing...")
|
||||||
@@ -97,15 +115,18 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
fun play() {
|
fun play() {
|
||||||
mediaPlayer?.play()
|
mediaPlayer?.play()
|
||||||
isPaused = false
|
isPaused = false
|
||||||
|
handler.post(updateProgressRunnable) // Start updating progress
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
mediaPlayer?.pause()
|
mediaPlayer?.pause()
|
||||||
isPaused = true
|
isPaused = true
|
||||||
|
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
mediaPlayer?.stop()
|
mediaPlayer?.stop()
|
||||||
|
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekTo(time: Int) {
|
fun seekTo(time: Int) {
|
||||||
@@ -170,6 +191,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
println("onDetachedFromWindow")
|
println("onDetachedFromWindow")
|
||||||
super.onDetachedFromWindow()
|
super.onDetachedFromWindow()
|
||||||
mediaPlayer?.stop()
|
mediaPlayer?.stop()
|
||||||
|
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||||
|
|
||||||
media?.release()
|
media?.release()
|
||||||
mediaPlayer?.release()
|
mediaPlayer?.release()
|
||||||
@@ -187,7 +209,9 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
MediaPlayer.Event.Buffering,
|
MediaPlayer.Event.Buffering,
|
||||||
MediaPlayer.Event.EndReached,
|
MediaPlayer.Event.EndReached,
|
||||||
MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
|
MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
|
||||||
MediaPlayer.Event.TimeChanged -> updateVideoProgress()
|
MediaPlayer.Event.TimeChanged -> {
|
||||||
|
// Do nothing here, as we are updating progress every 1 second
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +265,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
val currentTimeMs = player.time.toInt()
|
val currentTimeMs = player.time.toInt()
|
||||||
val durationMs = player.media?.duration?.toInt() ?: 0
|
val durationMs = player.media?.duration?.toInt() ?: 0
|
||||||
|
|
||||||
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
|
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
|
||||||
// Set subtitle URL if available
|
// Set subtitle URL if available
|
||||||
if (player.isPlaying && !isMediaReady) {
|
if (player.isPlaying && !isMediaReady) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import UIKit
|
|||||||
class VlcPlayerView: ExpoView {
|
class VlcPlayerView: ExpoView {
|
||||||
private var mediaPlayer: VLCMediaPlayer?
|
private var mediaPlayer: VLCMediaPlayer?
|
||||||
private var videoView: UIView?
|
private var videoView: UIView?
|
||||||
private var progressUpdateInterval: TimeInterval = 0.5
|
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
||||||
private var isPaused: Bool = false
|
private var isPaused: Bool = false
|
||||||
private var currentGeometryCString: [CChar]?
|
private var currentGeometryCString: [CChar]?
|
||||||
private var lastReportedState: VLCMediaPlayerState?
|
private var lastReportedState: VLCMediaPlayerState?
|
||||||
@@ -14,13 +14,17 @@ class VlcPlayerView: ExpoView {
|
|||||||
private var startPosition: Int32 = 0
|
private var startPosition: Int32 = 0
|
||||||
private var isMediaReady: Bool = false
|
private var isMediaReady: Bool = false
|
||||||
private var externalTrack: [String: String]?
|
private var externalTrack: [String: String]?
|
||||||
|
private var progressTimer: DispatchSourceTimer?
|
||||||
|
private var isStopping: Bool = false // Define isStopping here
|
||||||
|
private var lastProgressCall = Date().timeIntervalSince1970
|
||||||
|
var hasSource = false
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
required init(appContext: AppContext? = nil) {
|
required init(appContext: AppContext? = nil) {
|
||||||
super.init(appContext: appContext)
|
super.init(appContext: appContext)
|
||||||
setupView()
|
setupView()
|
||||||
// setupNotifications()
|
setupNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
@@ -48,82 +52,71 @@ class VlcPlayerView: ExpoView {
|
|||||||
self, selector: #selector(applicationWillResignActive),
|
self, selector: #selector(applicationWillResignActive),
|
||||||
name: UIApplication.willResignActiveNotification, object: nil)
|
name: UIApplication.willResignActiveNotification, object: nil)
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self, selector: #selector(applicationWillEnterForeground),
|
self, selector: #selector(applicationDidBecomeActive),
|
||||||
name: UIApplication.willEnterForegroundNotification, object: nil)
|
name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
|
|
||||||
@objc func play() {
|
@objc func play() {
|
||||||
DispatchQueue.main.async { [weak self] in
|
self.mediaPlayer?.play()
|
||||||
guard let self = self else { return }
|
self.isPaused = false
|
||||||
self.mediaPlayer?.play()
|
print("Play")
|
||||||
self.isPaused = false
|
|
||||||
print("Play")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func pause() {
|
@objc func pause() {
|
||||||
DispatchQueue.main.async { [weak self] in
|
self.mediaPlayer?.pause()
|
||||||
guard let self = self else { return }
|
self.isPaused = true
|
||||||
self.mediaPlayer?.pause()
|
|
||||||
self.isPaused = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func seekTo(_ time: Int32) {
|
@objc func seekTo(_ time: Int32) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
guard let player = self.mediaPlayer else { return }
|
||||||
guard let self = self, let player = self.mediaPlayer else { return }
|
|
||||||
|
let wasPlaying = player.isPlaying
|
||||||
|
if wasPlaying {
|
||||||
|
self.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let duration = player.media?.length.intValue {
|
||||||
|
print("Seeking to time: \(time) Video Duration \(duration)")
|
||||||
|
|
||||||
|
// If the specified time is greater than the duration, seek to the end
|
||||||
|
let seekTime = time > duration ? duration - 1000 : time
|
||||||
|
player.time = VLCTime(int: seekTime)
|
||||||
|
|
||||||
let wasPlaying = player.isPlaying
|
|
||||||
if wasPlaying {
|
if wasPlaying {
|
||||||
player.pause()
|
self.play()
|
||||||
}
|
|
||||||
|
|
||||||
if let duration = player.media?.length.intValue {
|
|
||||||
print("Seeking to time: \(time) Video Duration \(duration)")
|
|
||||||
|
|
||||||
// If the specified time is greater than the duration, seek to the end
|
|
||||||
let seekTime = time > duration ? duration - 1000 : time
|
|
||||||
player.time = VLCTime(int: seekTime)
|
|
||||||
|
|
||||||
// Wait for a short moment to ensure the seek has been processed
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
||||||
if wasPlaying {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
self.updatePlayerState()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
print("Error: Unable to retrieve video duration")
|
|
||||||
}
|
}
|
||||||
|
self.updatePlayerState()
|
||||||
|
} else {
|
||||||
|
print("Error: Unable to retrieve video duration")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setSource(_ source: [String: Any]) {
|
@objc func setSource(_ source: [String: Any]) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
if self.hasSource {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
||||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
var initOptions = source["initOptions"] as? [Any] ?? []
|
||||||
startPosition = source["startPosition"] as? Int32 ?? 0
|
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||||
initOptions.append("--start-time=\(startPosition)")
|
initOptions.append("--start-time=\(self.startPosition)")
|
||||||
|
|
||||||
let uri = source["uri"] as? String
|
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||||
|
|
||||||
let autoplay = source["autoplay"] as? Bool ?? false
|
|
||||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
|
||||||
|
|
||||||
guard let uri = uri, !uri.isEmpty else {
|
|
||||||
print("Error: Invalid or empty URI")
|
print("Error: Invalid or empty URI")
|
||||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let autoplay = source["autoplay"] as? Bool ?? false
|
||||||
|
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||||
|
|
||||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
||||||
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
|
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
|
||||||
|
|
||||||
self.mediaPlayer?.delegate = self
|
self.mediaPlayer?.delegate = self
|
||||||
self.mediaPlayer?.drawable = self.videoView
|
self.mediaPlayer?.drawable = self.videoView
|
||||||
self.mediaPlayer?.scaleFactor = 0
|
self.mediaPlayer?.scaleFactor = 0
|
||||||
@@ -134,14 +127,8 @@ class VlcPlayerView: ExpoView {
|
|||||||
media = VLCMedia(url: URL(string: uri)!)
|
media = VLCMedia(url: URL(string: uri)!)
|
||||||
} else {
|
} else {
|
||||||
print("Loading local file: \(uri)")
|
print("Loading local file: \(uri)")
|
||||||
if uri.starts(with: "file://") {
|
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
||||||
if let url = URL(string: uri) {
|
media = VLCMedia(url: url)
|
||||||
media = VLCMedia(url: url)
|
|
||||||
} else {
|
|
||||||
print("Error: Invalid local file URL")
|
|
||||||
self.onVideoError?(["error": "Invalid local file URL"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
media = VLCMedia(path: uri)
|
media = VLCMedia(path: uri)
|
||||||
}
|
}
|
||||||
@@ -150,18 +137,8 @@ class VlcPlayerView: ExpoView {
|
|||||||
print("Debug: Media options: \(mediaOptions)")
|
print("Debug: Media options: \(mediaOptions)")
|
||||||
media.addOptions(mediaOptions)
|
media.addOptions(mediaOptions)
|
||||||
|
|
||||||
// Apply subtitle options
|
|
||||||
let subtitleTrackIndex = source["subtitleTrackIndex"] as? Int ?? -1
|
|
||||||
print("Debug: Subtitle track index from source: \(subtitleTrackIndex)")
|
|
||||||
|
|
||||||
if subtitleTrackIndex >= -1 {
|
|
||||||
self.setSubtitleTrack(subtitleTrackIndex)
|
|
||||||
print("Debug: Set subtitle track to index: \(subtitleTrackIndex)")
|
|
||||||
} else {
|
|
||||||
print("Debug: Subtitle track index is less than -1, not setting")
|
|
||||||
}
|
|
||||||
|
|
||||||
self.mediaPlayer?.media = media
|
self.mediaPlayer?.media = media
|
||||||
|
self.hasSource = true
|
||||||
|
|
||||||
if autoplay {
|
if autoplay {
|
||||||
print("Playing...")
|
print("Playing...")
|
||||||
@@ -170,33 +147,8 @@ class VlcPlayerView: ExpoView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setMuted(_ muted: Bool) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.audio?.isMuted = muted
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setVolume(_ volume: Int) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.audio?.volume = Int32(volume)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setVideoAspectRatio(_ ratio: String) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// ratio.withCString { cString in
|
|
||||||
// self.mediaPlayer?.videoAspectRatio = UnsafeMutablePointer(mutating: cString)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
@objc func setAudioTrack(_ trackIndex: Int) {
|
||||||
DispatchQueue.main.async {
|
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
||||||
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
@objc func getAudioTracks() -> [[String: Any]]? {
|
||||||
@@ -211,73 +163,27 @@ class VlcPlayerView: ExpoView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// @objc func getAudioTracks(
|
|
||||||
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
|
|
||||||
// ) {
|
|
||||||
// DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
||||||
// guard let self = self, let mediaPlayer = self.mediaPlayer else {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// reject("ERROR", "Media player not available", nil)
|
|
||||||
// }
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// guard let trackNames = mediaPlayer.audioTrackNames,
|
|
||||||
// let trackIndexes = mediaPlayer.audioTrackIndexes
|
|
||||||
// else {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// reject("ERROR", "No audio tracks available", nil)
|
|
||||||
// }
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// let tracks = zip(trackNames, trackIndexes).map { name, index in
|
|
||||||
// return ["name": name, "index": index]
|
|
||||||
// }
|
|
||||||
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// resolve(tracks)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
||||||
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
||||||
DispatchQueue.main.async {
|
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
|
||||||
if trackIndex == -1 {
|
print(
|
||||||
print("Debug: Disabling subtitles")
|
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
|
||||||
// Disable subtitles
|
)
|
||||||
self.mediaPlayer?.currentVideoSubTitleIndex = -1
|
|
||||||
} else {
|
|
||||||
print("Debug: Setting subtitle track to index: \(trackIndex)")
|
|
||||||
// Set the subtitle track
|
|
||||||
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print the result
|
|
||||||
if let currentIndex = self.mediaPlayer?.currentVideoSubTitleIndex {
|
|
||||||
print("Debug: Current subtitle track index after setting: \(currentIndex)")
|
|
||||||
} else {
|
|
||||||
print("Debug: Unable to retrieve current subtitle track index")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
guard let url = URL(string: subtitleURL) else {
|
||||||
guard let self = self, let url = URL(string: subtitleURL) else {
|
print("Error: Invalid subtitle URL")
|
||||||
print("Error: Invalid subtitle URL")
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
||||||
if let result = result {
|
if let result = result {
|
||||||
let internalName = "Track \(self.customSubtitles.count + 1)"
|
let internalName = "Track \(self.customSubtitles.count + 1)"
|
||||||
print("Subtitle added with result: \(result) \(internalName)")
|
print("Subtitle added with result: \(result) \(internalName)")
|
||||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
self.customSubtitles.append((internalName: internalName, originalName: name))
|
||||||
} else {
|
} else {
|
||||||
print("Failed to add subtitle")
|
print("Failed to add subtitle")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,135 +236,6 @@ class VlcPlayerView: ExpoView {
|
|||||||
print("Track not found for name: \(trackName)")
|
print("Track not found for name: \(trackName)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// @objc func getSubtitleTracks(
|
|
||||||
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
|
|
||||||
// ) {
|
|
||||||
// DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
||||||
// guard let self = self, let mediaPlayer = self.mediaPlayer else {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// reject("ERROR", "Media player not available", nil)
|
|
||||||
// }
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// let count = mediaPlayer.numberOfSubtitlesTracks
|
|
||||||
// guard count > 0 else {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// reject("ERROR", "No subtitle tracks available", nil)
|
|
||||||
// }
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// var tracks: [[String: Any]] = [["name": "Disabled", "index": -1]]
|
|
||||||
|
|
||||||
// if let names = mediaPlayer.videoSubTitlesNames as? [String],
|
|
||||||
// let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
|
|
||||||
// {
|
|
||||||
// for (index, name) in zip(indexes, names) {
|
|
||||||
// tracks.append(["name": name, "index": index.intValue])
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// resolve(tracks)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setSubtitleDelay(_ delay: Int) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.currentVideoSubTitleDelay = NSInteger(delay)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setAudioDelay(_ delay: Int) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.currentAudioPlaybackDelay = NSInteger(delay)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func takeSnapshot(_ path: String, width: Int, height: Int) {
|
|
||||||
// DispatchQueue.main.async { [weak self] in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.mediaPlayer?.saveVideoSnapshot(
|
|
||||||
// at: path, withWidth: Int32(width), andHeight: Int32(height))
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setVideoCropGeometry(_ geometry: String?) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// if let geometry = geometry, !geometry.isEmpty {
|
|
||||||
// self.currentGeometryCString = geometry.cString(using: .utf8)
|
|
||||||
// self.currentGeometryCString?.withUnsafeMutableBufferPointer { buffer in
|
|
||||||
// self.mediaPlayer?.videoCropGeometry = buffer.baseAddress
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// self.currentGeometryCString = nil
|
|
||||||
// self.mediaPlayer?.videoCropGeometry = nil
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func getVideoCropGeometry() -> String? {
|
|
||||||
// guard let cString = mediaPlayer?.videoCropGeometry else {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
// return String(cString: cString)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setRate(_ rate: Float) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.rate = rate
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func nextChapter() {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.nextChapter()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func previousChapter() {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.previousChapter()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func getChapters() -> [[String: Any]]? {
|
|
||||||
// guard let currentTitleIndex = mediaPlayer?.currentTitleIndex,
|
|
||||||
// let chapters = mediaPlayer?.chapterDescriptions(ofTitle: currentTitleIndex)
|
|
||||||
// as? [[String: Any]]
|
|
||||||
// else {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return chapters.compactMap { chapter in
|
|
||||||
// guard let name = chapter[VLCChapterDescriptionName] as? String,
|
|
||||||
// let timeOffset = chapter[VLCChapterDescriptionTimeOffset] as? NSNumber,
|
|
||||||
// let duration = chapter[VLCChapterDescriptionDuration] as? NSNumber
|
|
||||||
// else {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return [
|
|
||||||
// "name": name,
|
|
||||||
// "timeOffset": timeOffset.doubleValue,
|
|
||||||
// "duration": duration.doubleValue,
|
|
||||||
// ]
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
private var isStopping: Bool = false
|
|
||||||
|
|
||||||
@objc func stop(completion: (() -> Void)? = nil) {
|
@objc func stop(completion: (() -> Void)? = nil) {
|
||||||
guard !isStopping else {
|
guard !isStopping else {
|
||||||
completion?()
|
completion?()
|
||||||
@@ -479,15 +256,11 @@ class VlcPlayerView: ExpoView {
|
|||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
@objc private func applicationWillResignActive() {
|
@objc private func applicationWillResignActive() {
|
||||||
if !isPaused {
|
|
||||||
pause()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func applicationWillEnterForeground() {
|
@objc private func applicationDidBecomeActive() {
|
||||||
if !isPaused {
|
|
||||||
play()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performStop(completion: (() -> Void)? = nil) {
|
private func performStop(completion: (() -> Void)? = nil) {
|
||||||
@@ -509,42 +282,31 @@ class VlcPlayerView: ExpoView {
|
|||||||
completion?()
|
completion?()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getSubtitleOptions() -> [String: Any] {
|
private func updateVideoProgress() {
|
||||||
return [
|
guard let player = self.mediaPlayer else { return }
|
||||||
// Text scaling (100 is default, increase for larger text)
|
|
||||||
"sub-text-scale": "105",
|
|
||||||
|
|
||||||
// Text color (RRGGBB format, 16777215 is white)
|
let currentTimeMs = player.time.intValue
|
||||||
"freetype-color": "16777215",
|
let durationMs = player.media?.length.intValue ?? 0
|
||||||
|
|
||||||
// Outline thickness (reduced from 2 to 1 for less border)
|
print("Debug: Current time: \(currentTimeMs)")
|
||||||
"freetype-outline-thickness": "1",
|
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
||||||
|
if player.isPlaying && !self.isMediaReady {
|
||||||
// Outline color (RRGGBB format, 0 is black)
|
self.isMediaReady = true
|
||||||
"freetype-outline-color": "0",
|
// Set external track subtitle when starting.
|
||||||
|
if let externalTrack = self.externalTrack {
|
||||||
// Text opacity (0-255, 255 is fully opaque)
|
if let name = externalTrack["name"], !name.isEmpty {
|
||||||
"freetype-opacity": "255",
|
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
|
||||||
|
self.setSubtitleURL(deliveryUrl, name: name)
|
||||||
// Shadow opacity (increased from 128 to 180 for more shadow)
|
}
|
||||||
"freetype-shadow-opacity": "180",
|
}
|
||||||
|
}
|
||||||
// Shadow offset (increased from 2 to 3 for more pronounced shadow)
|
self.onVideoProgress?([
|
||||||
"freetype-shadow-offset": "3",
|
"currentTime": currentTimeMs,
|
||||||
|
"duration": durationMs,
|
||||||
// Text alignment (0: center, 1: left, 2: right)
|
])
|
||||||
"sub-text-alignment": "0",
|
}
|
||||||
|
|
||||||
// Vertical margin (from bottom of the screen, in pixels)
|
|
||||||
"sub-margin-bottom": "50",
|
|
||||||
|
|
||||||
// Background opacity (0-255, 0 for no background)
|
|
||||||
"freetype-background-opacity": "64",
|
|
||||||
|
|
||||||
// Background color (RRGGBB format)
|
|
||||||
"freetype-background-color": "0",
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Expo Events
|
// MARK: - Expo Events
|
||||||
|
|
||||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
||||||
@@ -562,116 +324,64 @@ class VlcPlayerView: ExpoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension VlcPlayerView: VLCMediaPlayerDelegate {
|
extension VlcPlayerView: VLCMediaPlayerDelegate {
|
||||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
// self?.updateVideoProgress()
|
||||||
guard let self = self else { return }
|
let timeNow = Date().timeIntervalSince1970
|
||||||
self.updatePlayerState()
|
if timeNow - lastProgressCall >= 1 {
|
||||||
|
lastProgressCall = timeNow
|
||||||
|
updateVideoProgress()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
||||||
|
self.updatePlayerState()
|
||||||
|
}
|
||||||
|
|
||||||
private func updatePlayerState() {
|
private func updatePlayerState() {
|
||||||
DispatchQueue.main.async { [weak self] in
|
guard let player = self.mediaPlayer else { return }
|
||||||
guard let self = self, let player = self.mediaPlayer else { return }
|
let currentState = player.state
|
||||||
let currentState = player.state
|
|
||||||
|
|
||||||
var stateInfo: [String: Any] = [
|
var stateInfo: [String: Any] = [
|
||||||
"target": self.reactTag ?? NSNull(),
|
"target": self.reactTag ?? NSNull(),
|
||||||
"currentTime": player.time.intValue,
|
"currentTime": player.time.intValue,
|
||||||
"duration": player.media?.length.intValue ?? 0,
|
"duration": player.media?.length.intValue ?? 0,
|
||||||
"error": false,
|
"error": false,
|
||||||
]
|
]
|
||||||
|
|
||||||
if player.isPlaying {
|
if player.isPlaying {
|
||||||
stateInfo["isPlaying"] = true
|
stateInfo["isPlaying"] = true
|
||||||
stateInfo["isBuffering"] = false
|
stateInfo["isBuffering"] = false
|
||||||
stateInfo["state"] = "Playing"
|
stateInfo["state"] = "Playing"
|
||||||
} else {
|
} else {
|
||||||
stateInfo["isPlaying"] = false
|
stateInfo["isPlaying"] = false
|
||||||
stateInfo["state"] = "Paused"
|
stateInfo["state"] = "Paused"
|
||||||
}
|
|
||||||
|
|
||||||
if player.state == VLCMediaPlayerState.buffering {
|
|
||||||
stateInfo["isBuffering"] = true
|
|
||||||
stateInfo["state"] = "Buffering"
|
|
||||||
} else if player.state == VLCMediaPlayerState.error {
|
|
||||||
print("player.state ~ error")
|
|
||||||
stateInfo["state"] = "Error"
|
|
||||||
self.onVideoLoadEnd?(stateInfo)
|
|
||||||
} else if player.state == VLCMediaPlayerState.opening {
|
|
||||||
print("player.state ~ opening")
|
|
||||||
stateInfo["state"] = "Opening"
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.lastReportedState != currentState
|
|
||||||
|| self.lastReportedIsPlaying != player.isPlaying
|
|
||||||
{
|
|
||||||
self.lastReportedState = currentState
|
|
||||||
self.lastReportedIsPlaying = player.isPlaying
|
|
||||||
self.onVideoStateChange?(stateInfo)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// func seekToStartTime() {
|
if player.state == VLCMediaPlayerState.buffering {
|
||||||
// DispatchQueue.main.async { [weak self] in
|
stateInfo["isBuffering"] = true
|
||||||
// guard let self = self, let player = self.mediaPlayer else { return }
|
stateInfo["state"] = "Buffering"
|
||||||
|
} else if player.state == VLCMediaPlayerState.error {
|
||||||
// if let startPosition = self.startPosition, startPosition > 0 {
|
print("player.state ~ error")
|
||||||
// print("Debug: Seeking to start position: \(startPosition)")
|
stateInfo["state"] = "Error"
|
||||||
// player.time = VLCTime(int: Int32(startPosition))
|
self.onVideoLoadEnd?(stateInfo)
|
||||||
|
} else if player.state == VLCMediaPlayerState.opening {
|
||||||
// // Ensure the player continues playing after seeking
|
print("player.state ~ opening")
|
||||||
// if !player.isPlaying {
|
stateInfo["state"] = "Opening"
|
||||||
// player.play()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.updateVideoProgress()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func updateVideoProgress() {
|
if self.lastReportedState != currentState
|
||||||
DispatchQueue.main.async {
|
|| self.lastReportedIsPlaying != player.isPlaying
|
||||||
guard let player = self.mediaPlayer else { return }
|
{
|
||||||
|
self.lastReportedState = currentState
|
||||||
let currentTimeMs = player.time.intValue
|
self.lastReportedIsPlaying = player.isPlaying
|
||||||
let durationMs = player.media?.length.intValue ?? 0
|
self.onVideoStateChange?(stateInfo)
|
||||||
|
|
||||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
|
||||||
if player.isPlaying && !self.isMediaReady {
|
|
||||||
self.isMediaReady = true
|
|
||||||
// Set external track subtitle when starting.
|
|
||||||
if let externalTrack = self.externalTrack {
|
|
||||||
if let name = externalTrack["name"] as? String, !name.isEmpty {
|
|
||||||
let deliveryUrl = externalTrack["DeliveryUrl"] as? String ?? ""
|
|
||||||
self.setSubtitleURL(deliveryUrl, name: name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.onVideoProgress?([
|
|
||||||
"currentTime": currentTimeMs,
|
|
||||||
"duration": durationMs,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VlcPlayerView: VLCMediaDelegate {
|
extension VlcPlayerView: VLCMediaDelegate {
|
||||||
// func mediaMetaDataDidChange(_ aMedia: VLCMedia) {
|
// Implement VLCMediaDelegate methods if needed
|
||||||
// // Implement if needed
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func mediaDidFinishParsing(_ aMedia: VLCMedia) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// let duration = aMedia.length.intValue
|
|
||||||
// self.onVideoStateChange?(["type": "MediaParsed", "duration": duration])
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VLCMediaPlayerState {
|
extension VLCMediaPlayerState {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"preset": "jest-expo"
|
"preset": "jest-expo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bottom-tabs/react-navigation": "^0.7.1",
|
||||||
"@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.4",
|
"@expo/vector-icons": "^14.0.4",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"expo-asset": "~10.0.10",
|
"expo-asset": "~10.0.10",
|
||||||
"expo-background-fetch": "~12.0.1",
|
"expo-background-fetch": "~12.0.1",
|
||||||
"expo-blur": "~13.0.2",
|
"expo-blur": "~13.0.2",
|
||||||
|
"expo-brightness": "~12.0.1",
|
||||||
"expo-build-properties": "~0.12.5",
|
"expo-build-properties": "~0.12.5",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~16.0.2",
|
||||||
"expo-dev-client": "~4.0.29",
|
"expo-dev-client": "~4.0.29",
|
||||||
@@ -69,8 +71,9 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-native": "0.74.5",
|
"react-native": "0.74.5",
|
||||||
"react-native-awesome-slider": "^2.5.6",
|
"react-native-awesome-slider": "^2.5.6",
|
||||||
"react-native-bottom-tabs": "^0.4.0",
|
"react-native-bottom-tabs": "^0.7.3",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
|
"react-native-color-matrix-image-filters": "^7.0.1",
|
||||||
"react-native-compressor": "^1.9.0",
|
"react-native-compressor": "^1.9.0",
|
||||||
"react-native-device-info": "^14.0.1",
|
"react-native-device-info": "^14.0.1",
|
||||||
"react-native-edge-to-edge": "^1.1.1",
|
"react-native-edge-to-edge": "^1.1.1",
|
||||||
@@ -82,6 +85,7 @@
|
|||||||
"react-native-ios-utilities": "^4.5.1",
|
"react-native-ios-utilities": "^4.5.1",
|
||||||
"react-native-mmkv": "^2.12.2",
|
"react-native-mmkv": "^2.12.2",
|
||||||
"react-native-pager-view": "6.3.0",
|
"react-native-pager-view": "6.3.0",
|
||||||
|
"react-native-progress": "^5.0.1",
|
||||||
"react-native-reanimated": "~3.10.1",
|
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { useLog, writeToLog } from "@/utils/log";
|
||||||
import {
|
import {
|
||||||
cancelAllJobs,
|
cancelAllJobs,
|
||||||
cancelJobById,
|
cancelJobById,
|
||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -46,12 +46,17 @@ import * as Notifications from "expo-notifications";
|
|||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import useImageStorage from "@/hooks/useImageStorage";
|
import useImageStorage from "@/hooks/useImageStorage";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import useDownloadHelper from "@/utils/download";
|
||||||
|
import { FileInfo } from "expo-file-system";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
export type DownloadedItem = {
|
export type DownloadedItem = {
|
||||||
item: Partial<BaseItemDto>;
|
item: Partial<BaseItemDto>;
|
||||||
mediaSource: MediaSourceInfo;
|
mediaSource: MediaSourceInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const processesAtom = atom<JobStatus[]>([]);
|
||||||
|
|
||||||
function onAppStateChange(status: AppStateStatus) {
|
function onAppStateChange(status: AppStateStatus) {
|
||||||
focusManager.setFocused(status === "active");
|
focusManager.setFocused(status === "active");
|
||||||
}
|
}
|
||||||
@@ -65,10 +70,12 @@ function useDownloadProvider() {
|
|||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const { logs } = useLog();
|
||||||
|
|
||||||
|
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||||
const { saveImage } = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
|
|
||||||
const [processes, setProcesses] = useState<JobStatus[]>([]);
|
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||||
|
|
||||||
const authHeader = useMemo(() => {
|
const authHeader = useMemo(() => {
|
||||||
return api?.accessToken;
|
return api?.accessToken;
|
||||||
@@ -258,8 +265,11 @@ function useDownloadProvider() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.done(async () => {
|
.done(async (doneHandler) => {
|
||||||
await saveDownloadedItemInfo(process.item);
|
await saveDownloadedItemInfo(
|
||||||
|
process.item,
|
||||||
|
doneHandler.bytesDownloaded
|
||||||
|
);
|
||||||
toast.success(`Download completed for ${process.item.Name}`, {
|
toast.success(`Download completed for ${process.item.Name}`, {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
action: {
|
action: {
|
||||||
@@ -311,6 +321,7 @@ function useDownloadProvider() {
|
|||||||
const fileExtension = mediaSource.TranscodingContainer;
|
const fileExtension = mediaSource.TranscodingContainer;
|
||||||
const deviceId = await getOrSetDeviceId();
|
const deviceId = await getOrSetDeviceId();
|
||||||
|
|
||||||
|
await saveSeriesPrimaryImage(item);
|
||||||
const itemImage = getItemImage({
|
const itemImage = getItemImage({
|
||||||
item,
|
item,
|
||||||
api,
|
api,
|
||||||
@@ -384,19 +395,25 @@ function useDownloadProvider() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const deleteAllFiles = async (): Promise<void> => {
|
const deleteAllFiles = async (): Promise<void> => {
|
||||||
try {
|
Promise.all([
|
||||||
await deleteLocalFiles();
|
deleteLocalFiles(),
|
||||||
removeDownloadedItemsFromStorage();
|
removeDownloadedItemsFromStorage(),
|
||||||
await cancelAllServerJobs();
|
cancelAllServerJobs(),
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
|
||||||
toast.success("All files, folders, and jobs deleted successfully");
|
])
|
||||||
} catch (error) {
|
.then(() =>
|
||||||
console.error("Failed to delete all files, folders, and jobs:", error);
|
toast.success("All files, folders, and jobs deleted successfully")
|
||||||
toast.error("An error occurred while deleting files and jobs");
|
)
|
||||||
}
|
.catch((reason) => {
|
||||||
|
console.error("Failed to delete all files, folders, and jobs:", reason);
|
||||||
|
toast.error("An error occurred while deleting files and jobs");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteLocalFiles = async (): Promise<void> => {
|
const forEveryDirectoryFile = async (
|
||||||
|
includeMMKV: boolean = true,
|
||||||
|
callback: (file: FileInfo) => void
|
||||||
|
) => {
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
if (!baseDirectory) {
|
if (!baseDirectory) {
|
||||||
throw new Error("Base directory not found");
|
throw new Error("Base directory not found");
|
||||||
@@ -404,25 +421,31 @@ function useDownloadProvider() {
|
|||||||
|
|
||||||
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
||||||
for (const item of dirContents) {
|
for (const item of dirContents) {
|
||||||
const itemPath = `${baseDirectory}${item}`;
|
// Exclude mmkv directory.
|
||||||
const itemInfo = await FileSystem.getInfoAsync(itemPath);
|
// Deleting this deletes all user information as well. Logout should handle this.
|
||||||
|
if (item == "mmkv" && !includeMMKV) continue;
|
||||||
|
const itemInfo = await FileSystem.getInfoAsync(`${baseDirectory}${item}`);
|
||||||
if (itemInfo.exists) {
|
if (itemInfo.exists) {
|
||||||
if (itemInfo.isDirectory) {
|
callback(itemInfo);
|
||||||
await FileSystem.deleteAsync(itemPath, { idempotent: true });
|
|
||||||
} else {
|
|
||||||
await FileSystem.deleteAsync(itemPath, { idempotent: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeDownloadedItemsFromStorage = (): void => {
|
const deleteLocalFiles = async (): Promise<void> => {
|
||||||
try {
|
await forEveryDirectoryFile(false, (file) => {
|
||||||
storage.delete("downloadedItems");
|
console.warn("Deleting file", file.uri);
|
||||||
} catch (error) {
|
FileSystem.deleteAsync(file.uri, { idempotent: true });
|
||||||
console.error("Failed to remove downloadedItems from storage:", error);
|
});
|
||||||
throw error;
|
};
|
||||||
}
|
|
||||||
|
const removeDownloadedItemsFromStorage = async () => {
|
||||||
|
// delete any saved images first
|
||||||
|
Promise.all([deleteFileByType("Movie"), deleteFileByType("Episode")])
|
||||||
|
.then(() => storage.delete("downloadedItems"))
|
||||||
|
.catch((reason) => {
|
||||||
|
console.error("Failed to remove downloadedItems from storage:", reason);
|
||||||
|
throw reason;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelAllServerJobs = async (): Promise<void> => {
|
const cancelAllServerJobs = async (): Promise<void> => {
|
||||||
@@ -430,7 +453,8 @@ function useDownloadProvider() {
|
|||||||
throw new Error("No auth header available");
|
throw new Error("No auth header available");
|
||||||
}
|
}
|
||||||
if (!settings?.optimizedVersionsServerUrl) {
|
if (!settings?.optimizedVersionsServerUrl) {
|
||||||
throw new Error("No server URL configured");
|
console.error("No server URL configured");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceId = await getOrSetDeviceId();
|
const deviceId = await getOrSetDeviceId();
|
||||||
@@ -490,6 +514,40 @@ function useDownloadProvider() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteItems = async (items: BaseItemDto[]) => {
|
||||||
|
Promise.all(
|
||||||
|
items.map((i) => {
|
||||||
|
if (i.Id) return deleteFile(i.Id);
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
).then(() =>
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
|
||||||
|
await Promise.all(
|
||||||
|
downloadedFiles
|
||||||
|
?.filter((file) => file.item.Type == type)
|
||||||
|
?.flatMap((file) => {
|
||||||
|
const promises = [];
|
||||||
|
if (type == "Episode" && file.item.SeriesId)
|
||||||
|
promises.push(deleteFile(file.item.SeriesId));
|
||||||
|
promises.push(deleteFile(file.item.Id!));
|
||||||
|
return promises;
|
||||||
|
}) || []
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appSizeUsage = useMemo(async () => {
|
||||||
|
const sizes: number[] = [];
|
||||||
|
await forEveryDirectoryFile(true, (file) => {
|
||||||
|
if (file.exists) sizes.push(file.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sizes.reduce((sum, size) => sum + size, 0);
|
||||||
|
}, [logs, downloadedFiles]);
|
||||||
|
|
||||||
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
||||||
try {
|
try {
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
const downloadedItems = storage.getString("downloadedItems");
|
||||||
@@ -519,7 +577,7 @@ function useDownloadProvider() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {
|
||||||
try {
|
try {
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
const downloadedItems = storage.getString("downloadedItems");
|
||||||
let items: DownloadedItem[] = downloadedItems
|
let items: DownloadedItem[] = downloadedItems
|
||||||
@@ -546,6 +604,8 @@ function useDownloadProvider() {
|
|||||||
deleteDownloadItemInfoFromDiskTmp(item.Id!);
|
deleteDownloadItemInfoFromDiskTmp(item.Id!);
|
||||||
|
|
||||||
storage.set("downloadedItems", JSON.stringify(items));
|
storage.set("downloadedItems", JSON.stringify(items));
|
||||||
|
storage.set("downloadedItemSize-" + item.Id, size.toString());
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||||
refetch();
|
refetch();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -556,17 +616,26 @@ function useDownloadProvider() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDownloadedItemSize(itemId: string): number {
|
||||||
|
const size = storage.getString("downloadedItemSize-" + itemId);
|
||||||
|
return size ? parseInt(size) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
processes,
|
processes,
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
downloadedFiles,
|
downloadedFiles,
|
||||||
deleteAllFiles,
|
deleteAllFiles,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
|
deleteItems,
|
||||||
saveDownloadedItemInfo,
|
saveDownloadedItemInfo,
|
||||||
removeProcess,
|
removeProcess,
|
||||||
setProcesses,
|
setProcesses,
|
||||||
startDownload,
|
startDownload,
|
||||||
getDownloadedItem,
|
getDownloadedItem,
|
||||||
|
deleteFileByType,
|
||||||
|
appSizeUsage,
|
||||||
|
getDownloadedItemSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,3 +656,16 @@ export function useDownload() {
|
|||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function bytesToReadable(bytes: number): string {
|
||||||
|
const gb = bytes / 1e9;
|
||||||
|
|
||||||
|
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||||
|
|
||||||
|
const mb = bytes / 1024 / 1024;
|
||||||
|
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
||||||
|
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
||||||
|
return `${bytes.toFixed(2)} B`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.21.0" },
|
clientInfo: { name: "Streamyfin", version: "0.22.0" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -91,7 +91,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.21.0"`,
|
}, DeviceId="${deviceId}", Version="0.22.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Bitrate } from "@/components/BitrateSelector";
|
import { Bitrate } from "@/components/BitrateSelector";
|
||||||
import { settingsAtom } from "@/utils/atoms/settings";
|
import { settingsAtom } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import ios from "@/utils/profiles/ios";
|
|
||||||
import native from "@/utils/profiles/native";
|
import native from "@/utils/profiles/native";
|
||||||
import old from "@/utils/profiles/old";
|
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
PlaybackInfoResponse,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -19,7 +16,6 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
|
||||||
|
|
||||||
export type PlaybackType = {
|
export type PlaybackType = {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -124,25 +120,25 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
[api, user, settings, playSettings]
|
[api, user, settings, playSettings]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
const postCaps = async () => {
|
// const postCaps = async () => {
|
||||||
if (!api) return;
|
// if (!api) return;
|
||||||
await getSessionApi(api).postFullCapabilities({
|
// await getSessionApi(api).postFullCapabilities({
|
||||||
clientCapabilitiesDto: {
|
// clientCapabilitiesDto: {
|
||||||
AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
|
// AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
|
||||||
DeviceProfile: native as any,
|
// DeviceProfile: native as any,
|
||||||
IconUrl:
|
// IconUrl:
|
||||||
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
|
// "https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
|
||||||
PlayableMediaTypes: ["Audio", "Video"],
|
// PlayableMediaTypes: ["Audio", "Video"],
|
||||||
SupportedCommands: ["Play"],
|
// SupportedCommands: ["Play"],
|
||||||
SupportsMediaControl: true,
|
// SupportsMediaControl: true,
|
||||||
SupportsPersistentIdentifier: true,
|
// SupportsPersistentIdentifier: true,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
};
|
// };
|
||||||
|
|
||||||
postCaps();
|
// postCaps();
|
||||||
}, [settings, api]);
|
// }, [settings, api]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaySettingsContext.Provider
|
<PlaySettingsContext.Provider
|
||||||
|
|||||||
144
providers/WebSocketProvider.tsx
Normal file
144
providers/WebSocketProvider.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
ReactNode,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
} from "react";
|
||||||
|
import { Alert, AppState, AppStateStatus } from "react-native";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
apiAtom,
|
||||||
|
getOrSetDeviceId,
|
||||||
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import native from "@/utils/profiles/native";
|
||||||
|
|
||||||
|
interface WebSocketProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebSocketContextType {
|
||||||
|
ws: WebSocket | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||||
|
|
||||||
|
export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
const deviceId = useMemo(() => {
|
||||||
|
return getOrSetDeviceId();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connectWebSocket = useCallback(() => {
|
||||||
|
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 = () => {
|
||||||
|
if (keepAliveInterval) clearInterval(keepAliveInterval);
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
setWs(newWebSocket);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (keepAliveInterval) clearInterval(keepAliveInterval);
|
||||||
|
newWebSocket.close();
|
||||||
|
};
|
||||||
|
}, [api, deviceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = connectWebSocket();
|
||||||
|
return cleanup;
|
||||||
|
}, [connectWebSocket]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deviceId || !api || !api?.accessToken) return;
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
await getSessionApi(api).postFullCapabilities({
|
||||||
|
clientCapabilitiesDto: {
|
||||||
|
AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
|
||||||
|
IconUrl:
|
||||||
|
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
|
||||||
|
PlayableMediaTypes: ["Audio", "Video"],
|
||||||
|
SupportedCommands: ["Play"],
|
||||||
|
SupportsMediaControl: true,
|
||||||
|
SupportsPersistentIdentifier: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
}, [api, deviceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAppStateChange = (state: AppStateStatus) => {
|
||||||
|
if (state === "background" || state === "inactive") {
|
||||||
|
console.log("App moving to background, closing WebSocket...");
|
||||||
|
ws?.close();
|
||||||
|
} else if (state === "active") {
|
||||||
|
console.log("App coming to foreground, reconnecting WebSocket...");
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener(
|
||||||
|
"change",
|
||||||
|
handleAppStateChange
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
ws?.close();
|
||||||
|
};
|
||||||
|
}, [ws, connectWebSocket]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebSocketContext.Provider value={{ ws, isConnected }}>
|
||||||
|
{children}
|
||||||
|
</WebSocketContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useWebSocketContext = (): WebSocketContextType => {
|
||||||
|
const context = useContext(WebSocketContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useWebSocketContext must be used within a WebSocketProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import {JobStatus} from "@/utils/optimize-server";
|
||||||
|
import {processesAtom} from "@/providers/DownloadProvider";
|
||||||
|
import {useSettings} from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,8 +16,8 @@ export const runningAtom = atom<boolean>(false);
|
|||||||
export const queueAtom = atom<Job[]>([]);
|
export const queueAtom = atom<Job[]>([]);
|
||||||
|
|
||||||
export const queueActions = {
|
export const queueActions = {
|
||||||
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
|
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, ...job: Job[]) => {
|
||||||
const updatedQueue = [...queue, job];
|
const updatedQueue = [...queue, ...job];
|
||||||
console.info("Enqueueing job", job, updatedQueue);
|
console.info("Enqueueing job", job, updatedQueue);
|
||||||
setQueue(updatedQueue);
|
setQueue(updatedQueue);
|
||||||
},
|
},
|
||||||
@@ -24,14 +27,17 @@ export const queueActions = {
|
|||||||
setProcessing: (processing: boolean) => void
|
setProcessing: (processing: boolean) => void
|
||||||
) => {
|
) => {
|
||||||
const [job, ...rest] = queue;
|
const [job, ...rest] = queue;
|
||||||
setQueue(rest);
|
|
||||||
|
|
||||||
console.info("Processing job", job);
|
console.info("Processing job", job);
|
||||||
|
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
// Excute the function assiociated with the job.
|
// Allow job to execute so that it gets added as a processes first BEFORE updating new queue
|
||||||
await job.execute();
|
try {
|
||||||
|
await job.execute();
|
||||||
|
} finally {
|
||||||
|
setQueue(rest);
|
||||||
|
}
|
||||||
|
|
||||||
console.info("Job done", job);
|
console.info("Job done", job);
|
||||||
|
|
||||||
@@ -49,11 +55,13 @@ export const queueActions = {
|
|||||||
export const useJobProcessor = () => {
|
export const useJobProcessor = () => {
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const [running, setRunning] = useAtom(runningAtom);
|
const [running, setRunning] = useAtom(runningAtom);
|
||||||
|
const [processes] = useAtom<JobStatus[]>(processesAtom);
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queue.length > 0 && !running) {
|
if (!running && queue.length > 0 && settings && processes.length < settings?.remuxConcurrentLimit) {
|
||||||
console.info("Processing queue", queue);
|
console.info("Processing queue", queue);
|
||||||
queueActions.processJob(queue, setQueue, setRunning);
|
queueActions.processJob(queue, setQueue, setRunning);
|
||||||
}
|
}
|
||||||
}, [queue, running, setQueue, setRunning]);
|
}, [processes, queue, running, setQueue, setRunning]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { atom, useAtom } from "jotai";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { storage } from "../mmkv";
|
import { storage } from "../mmkv";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export type DownloadQuality = "original" | "high" | "low";
|
export type DownloadQuality = "original" | "high" | "low";
|
||||||
|
|
||||||
@@ -74,6 +75,9 @@ export type Settings = {
|
|||||||
optimizedVersionsServerUrl?: string | null;
|
optimizedVersionsServerUrl?: string | null;
|
||||||
downloadMethod: "optimized" | "remux";
|
downloadMethod: "optimized" | "remux";
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
|
showCustomMenuLinks: boolean;
|
||||||
|
subtitleSize: number;
|
||||||
|
remuxConcurrentLimit: 1 | 2 | 3 | 4; // TODO: Maybe let people choose their own limit? 4 seems like a safe max?
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettings = (): Settings => {
|
const loadSettings = (): Settings => {
|
||||||
@@ -103,6 +107,9 @@ const loadSettings = (): Settings => {
|
|||||||
optimizedVersionsServerUrl: null,
|
optimizedVersionsServerUrl: null,
|
||||||
downloadMethod: "remux",
|
downloadMethod: "remux",
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
|
showCustomMenuLinks: false,
|
||||||
|
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
||||||
|
remuxConcurrentLimit: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
33
utils/download.ts
Normal file
33
utils/download.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import useImageStorage from "@/hooks/useImageStorage";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
|
const useDownloadHelper = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const { saveImage } = useImageStorage();
|
||||||
|
|
||||||
|
const saveSeriesPrimaryImage = async (item: BaseItemDto) => {
|
||||||
|
console.log(`Attempting to save primary image for item: ${item.Id}`);
|
||||||
|
if (
|
||||||
|
item.Type === "Episode" &&
|
||||||
|
item.SeriesId &&
|
||||||
|
!storage.getString(item.SeriesId)
|
||||||
|
) {
|
||||||
|
console.log(`Saving primary image for series: ${item.SeriesId}`);
|
||||||
|
await saveImage(
|
||||||
|
item.SeriesId,
|
||||||
|
getPrimaryImageUrlById({ api, id: item.SeriesId })
|
||||||
|
);
|
||||||
|
console.log(`Primary image saved for series: ${item.SeriesId}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Skipping primary image save for item: ${item.Id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { saveSeriesPrimaryImage };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDownloadHelper;
|
||||||
@@ -10,8 +10,8 @@ interface PlaySettings {
|
|||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
bitrate: (typeof BITRATES)[0];
|
bitrate: (typeof BITRATES)[0];
|
||||||
mediaSource?: MediaSourceInfo | null;
|
mediaSource?: MediaSourceInfo | null;
|
||||||
audioIndex?: number | null;
|
audioIndex?: number | undefined;
|
||||||
subtitleIndex?: number | null;
|
subtitleIndex?: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultPlaySettings(
|
export function getDefaultPlaySettings(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||||
import { storage } from "./mmkv";
|
import { storage } from "./mmkv";
|
||||||
|
import {useQuery} from "@tanstack/react-query";
|
||||||
|
import React, {createContext, useContext} from "react";
|
||||||
|
|
||||||
type LogLevel = "INFO" | "WARN" | "ERROR";
|
type LogLevel = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
@@ -17,6 +19,24 @@ const mmkvStorage = createJSONStorage(() => ({
|
|||||||
}));
|
}));
|
||||||
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
|
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
|
||||||
|
|
||||||
|
const LogContext = createContext<ReturnType<typeof useLogProvider> | null>(null);
|
||||||
|
const DownloadContext = createContext<ReturnType<
|
||||||
|
typeof useLogProvider
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
|
function useLogProvider() {
|
||||||
|
const { data: logs } = useQuery({
|
||||||
|
queryKey: ["logs"],
|
||||||
|
queryFn: async () => readFromLog(),
|
||||||
|
refetchInterval: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const writeToLog = (level: LogLevel, message: string, data?: any) => {
|
export const writeToLog = (level: LogLevel, message: string, data?: any) => {
|
||||||
const newEntry: LogEntry = {
|
const newEntry: LogEntry = {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -35,6 +55,9 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => {
|
|||||||
storage.set("logs", JSON.stringify(recentLogs));
|
storage.set("logs", JSON.stringify(recentLogs));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const writeInfoLog = (message: string, data?: any) => writeToLog("INFO", message, data);
|
||||||
|
export const writeErrorLog = (message: string, data?: any) => writeToLog("ERROR", message, data);
|
||||||
|
|
||||||
export const readFromLog = (): LogEntry[] => {
|
export const readFromLog = (): LogEntry[] => {
|
||||||
const logs = storage.getString("logs");
|
const logs = storage.getString("logs");
|
||||||
return logs ? JSON.parse(logs) : [];
|
return logs ? JSON.parse(logs) : [];
|
||||||
@@ -44,4 +67,22 @@ export const clearLogs = () => {
|
|||||||
storage.delete("logs");
|
storage.delete("logs");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function useLog() {
|
||||||
|
const context = useContext(LogContext);
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error("useLog must be used within a LogProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogProvider({children}: { children: React.ReactNode }) {
|
||||||
|
const provider = useLogProvider();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogContext.Provider value={provider}>
|
||||||
|
{children}
|
||||||
|
</LogContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default logsAtom;
|
export default logsAtom;
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
import {
|
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
DeviceProfile
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
|
|
||||||
export const chromecastProfile: DeviceProfile = {
|
export const chromecastProfile: DeviceProfile = {
|
||||||
Name: "Chromecast Video Profile",
|
Name: "Chromecast Video Profile",
|
||||||
Id: "chromecast-001",
|
MaxStreamingBitrate: 8000000, // 8 Mbps
|
||||||
MaxStreamingBitrate: 4000000, // 4 Mbps
|
MaxStaticBitrate: 8000000, // 8 Mbps
|
||||||
MaxStaticBitrate: 4000000, // 4 Mbps
|
|
||||||
MusicStreamingTranscodingBitrate: 384000, // 384 kbps
|
MusicStreamingTranscodingBitrate: 384000, // 384 kbps
|
||||||
|
CodecProfiles: [
|
||||||
|
{
|
||||||
|
Type: "Video",
|
||||||
|
Codec: "h264",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "Audio",
|
||||||
|
Codec: "aac,mp3,flac,opus,vorbis",
|
||||||
|
},
|
||||||
|
],
|
||||||
DirectPlayProfiles: [
|
DirectPlayProfiles: [
|
||||||
{
|
{
|
||||||
Container: "mp4,webm",
|
Container: "mp4",
|
||||||
Type: "Video",
|
Type: "Video",
|
||||||
VideoCodec: "h264,vp8,vp9",
|
VideoCodec: "h264",
|
||||||
AudioCodec: "aac,mp3,opus,vorbis",
|
AudioCodec: "aac,mp3,opus,vorbis",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -34,89 +41,32 @@ export const chromecastProfile: DeviceProfile = {
|
|||||||
],
|
],
|
||||||
TranscodingProfiles: [
|
TranscodingProfiles: [
|
||||||
{
|
{
|
||||||
Container: "ts",
|
|
||||||
Type: "Video",
|
Type: "Video",
|
||||||
VideoCodec: "h264",
|
Context: "Streaming",
|
||||||
AudioCodec: "aac,mp3",
|
|
||||||
Protocol: "hls",
|
Protocol: "hls",
|
||||||
Context: "Streaming",
|
Container: "ts",
|
||||||
MaxAudioChannels: "2",
|
VideoCodec: "h264, hevc",
|
||||||
MinSegments: 2,
|
AudioCodec: "aac,mp3,ac3",
|
||||||
BreakOnNonKeyFrames: true,
|
CopyTimestamps: false,
|
||||||
|
EnableSubtitlesInManifest: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Container: "mp4",
|
Type: "Audio",
|
||||||
Type: "Video",
|
Context: "Streaming",
|
||||||
VideoCodec: "h264",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Protocol: "http",
|
Protocol: "http",
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "mp3",
|
Container: "mp3",
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "mp3",
|
AudioCodec: "mp3",
|
||||||
Protocol: "http",
|
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: "2",
|
MaxAudioChannels: "2",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Container: "aac",
|
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Protocol: "http",
|
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
ContainerProfiles: [
|
|
||||||
{
|
|
||||||
Type: "Video",
|
|
||||||
Container: "mp4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: "Video",
|
|
||||||
Container: "webm",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
CodecProfiles: [
|
|
||||||
{
|
|
||||||
Type: "Video",
|
|
||||||
Codec: "h264",
|
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "VideoBitDepth",
|
|
||||||
Value: "8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "VideoLevel",
|
|
||||||
Value: "41",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: "Video",
|
|
||||||
Codec: "vp9",
|
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "VideoBitDepth",
|
|
||||||
Value: "10",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
SubtitleProfiles: [
|
SubtitleProfiles: [
|
||||||
{
|
{
|
||||||
Format: "vtt",
|
Format: "vtt",
|
||||||
Method: "Hls",
|
Method: "Encode",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Format: "vtt",
|
Format: "vtt",
|
||||||
Method: "External",
|
Method: "Encode",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
import MediaTypes from '../../constants/MediaTypes';
|
|
||||||
|
|
||||||
import BaseProfile from './base';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Device profile for Expo Video player on iOS 10
|
|
||||||
*/
|
|
||||||
export default {
|
|
||||||
...BaseProfile,
|
|
||||||
Name: 'Expo iOS 10 Video Profile',
|
|
||||||
CodecProfiles: [
|
|
||||||
// iOS<13 only supports max h264 level 4.2 in ts containers
|
|
||||||
{
|
|
||||||
Codec: 'h264',
|
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: 'NotEquals',
|
|
||||||
IsRequired: false,
|
|
||||||
Property: 'IsAnamorphic',
|
|
||||||
Value: 'true'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: 'EqualsAny',
|
|
||||||
IsRequired: false,
|
|
||||||
Property: 'VideoProfile',
|
|
||||||
Value: 'high|main|baseline|constrained baseline'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: 'NotEquals',
|
|
||||||
IsRequired: false,
|
|
||||||
Property: 'IsInterlaced',
|
|
||||||
Value: 'true'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: 'LessThanEqual',
|
|
||||||
IsRequired: false,
|
|
||||||
Property: 'VideoLevel',
|
|
||||||
Value: '42'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
Container: 'ts',
|
|
||||||
Type: MediaTypes.Video
|
|
||||||
},
|
|
||||||
...BaseProfile.CodecProfiles
|
|
||||||
],
|
|
||||||
DirectPlayProfiles: [
|
|
||||||
{
|
|
||||||
AudioCodec: 'aac,mp3,dca,dts,alac',
|
|
||||||
Container: 'mp4,m4v',
|
|
||||||
Type: MediaTypes.Video,
|
|
||||||
VideoCodec: 'h264,vc1'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'aac,mp3,dca,dts,alac',
|
|
||||||
Container: 'mov',
|
|
||||||
Type: MediaTypes.Video,
|
|
||||||
VideoCodec: 'h264'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: 'mp3',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: 'aac',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'aac',
|
|
||||||
Container: 'm4a',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'aac',
|
|
||||||
Container: 'm4b',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: 'alac',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'alac',
|
|
||||||
Container: 'm4a',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'alac',
|
|
||||||
Container: 'm4b',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: 'wav',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
}
|
|
||||||
],
|
|
||||||
TranscodingProfiles: [
|
|
||||||
{
|
|
||||||
AudioCodec: 'aac',
|
|
||||||
BreakOnNonKeyFrames: true,
|
|
||||||
Container: 'aac',
|
|
||||||
Context: 'Streaming',
|
|
||||||
MaxAudioChannels: '6',
|
|
||||||
MinSegments: '2',
|
|
||||||
Protocol: 'hls',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'aac',
|
|
||||||
Container: 'aac',
|
|
||||||
Context: 'Streaming',
|
|
||||||
MaxAudioChannels: '6',
|
|
||||||
Protocol: 'http',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'mp3',
|
|
||||||
Container: 'mp3',
|
|
||||||
Context: 'Streaming',
|
|
||||||
MaxAudioChannels: '6',
|
|
||||||
Protocol: 'http',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'wav',
|
|
||||||
Container: 'wav',
|
|
||||||
Context: 'Streaming',
|
|
||||||
MaxAudioChannels: '6',
|
|
||||||
Protocol: 'http',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'mp3',
|
|
||||||
Container: 'mp3',
|
|
||||||
Context: 'Static',
|
|
||||||
MaxAudioChannels: '6',
|
|
||||||
Protocol: 'http',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'aac',
|
|
||||||
Container: 'aac',
|
|
||||||
Context: 'Static',
|
|
||||||
MaxAudioChannels: '6',
|
|
||||||
Protocol: 'http',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'wav',
|
|
||||||
Container: 'wav',
|
|
||||||
Context: 'Static',
|
|
||||||
MaxAudioChannels: '6',
|
|
||||||
Protocol: 'http',
|
|
||||||
Type: MediaTypes.Audio
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'aac,mp3',
|
|
||||||
BreakOnNonKeyFrames: true,
|
|
||||||
Container: 'ts',
|
|
||||||
Context: 'Streaming',
|
|
||||||
MaxAudioChannels: '6',
|
|
||||||
MinSegments: '2',
|
|
||||||
Protocol: 'hls',
|
|
||||||
Type: MediaTypes.Video,
|
|
||||||
VideoCodec: 'h264'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AudioCodec: 'aac,mp3,dca,dts,alac',
|
|
||||||
Container: 'mp4',
|
|
||||||
Context: 'Static',
|
|
||||||
Protocol: 'http',
|
|
||||||
Type: MediaTypes.Video,
|
|
||||||
VideoCodec: 'h264'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
import iOSProfile from './ios';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Device profile for Expo Video player on iOS 11-12
|
|
||||||
*/
|
|
||||||
export default {
|
|
||||||
...iOSProfile,
|
|
||||||
Name: 'Expo iOS 12 Video Profile',
|
|
||||||
CodecProfiles: [
|
|
||||||
// iOS<13 only supports max h264 level 4.2 in ts containers
|
|
||||||
{
|
|
||||||
Codec: 'h264',
|
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: 'NotEquals',
|
|
||||||
IsRequired: false,
|
|
||||||
Property: 'IsAnamorphic',
|
|
||||||
Value: 'true'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: 'EqualsAny',
|
|
||||||
IsRequired: false,
|
|
||||||
Property: 'VideoProfile',
|
|
||||||
Value: 'high|main|baseline|constrained baseline'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: 'NotEquals',
|
|
||||||
IsRequired: false,
|
|
||||||
Property: 'IsInterlaced',
|
|
||||||
Value: 'true'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: 'LessThanEqual',
|
|
||||||
IsRequired: false,
|
|
||||||
Property: 'VideoLevel',
|
|
||||||
Value: '42'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
Container: 'ts',
|
|
||||||
Type: 'Video'
|
|
||||||
},
|
|
||||||
...iOSProfile.CodecProfiles
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
import MediaTypes from '../../constants/MediaTypes';
|
|
||||||
|
|
||||||
import iOSProfile from './ios';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Device profile for Expo Video player on iOS 13+ with fMP4 support
|
|
||||||
*/
|
|
||||||
export default {
|
|
||||||
...iOSProfile,
|
|
||||||
Name: 'Expo iOS fMP4 Video Profile',
|
|
||||||
TranscodingProfiles: [
|
|
||||||
// Add all audio profiles from default profile
|
|
||||||
...iOSProfile.TranscodingProfiles.filter(profile => profile.Type === MediaTypes.Audio),
|
|
||||||
// Add fMP4 profile
|
|
||||||
{
|
|
||||||
AudioCodec: 'aac,mp3,flac,alac',
|
|
||||||
BreakOnNonKeyFrames: true,
|
|
||||||
Container: 'mp4',
|
|
||||||
Context: 'Streaming',
|
|
||||||
MaxAudioChannels: '6',
|
|
||||||
MinSegments: '2',
|
|
||||||
Protocol: 'hls',
|
|
||||||
Type: MediaTypes.Video,
|
|
||||||
VideoCodec: 'hevc,h264'
|
|
||||||
},
|
|
||||||
// Add all video profiles from default profile
|
|
||||||
...iOSProfile.TranscodingProfiles.filter(profile => profile.Type === MediaTypes.Video)
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@ import MediaTypes from "../../constants/MediaTypes";
|
|||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
Name: "1. Vlc Player",
|
Name: "1. Vlc Player",
|
||||||
MaxStaticBitrate: 20_000_000,
|
MaxStaticBitrate: 999_999_999,
|
||||||
MaxStreamingBitrate: 20_000_000,
|
MaxStreamingBitrate: 999_999_999,
|
||||||
CodecProfiles: [
|
CodecProfiles: [
|
||||||
{
|
{
|
||||||
Type: MediaTypes.Video,
|
Type: MediaTypes.Video,
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
/**
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
import MediaTypes from "../../constants/MediaTypes";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Device profile for old phones (aka does not support HEVC)
|
|
||||||
*
|
|
||||||
* This file is a modified version of the original file.
|
|
||||||
*
|
|
||||||
* Link to original: https://github.com/jellyfin/jellyfin-expo/blob/e7b7e736a8602c94612917ef02de22f87c7c28f2/utils/profiles/ios.js#L4
|
|
||||||
*/
|
|
||||||
export default {
|
|
||||||
MaxStreamingBitrate: 3000000,
|
|
||||||
MaxStaticBitrate: 3000000,
|
|
||||||
MusicStreamingTranscodingBitrate: 256000,
|
|
||||||
DirectPlayProfiles: [
|
|
||||||
{
|
|
||||||
Container: "mp4,m4v",
|
|
||||||
Type: "Video",
|
|
||||||
VideoCodec: "h264",
|
|
||||||
AudioCodec: "aac,mp3,mp2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "mkv",
|
|
||||||
Type: "Video",
|
|
||||||
VideoCodec: "h264",
|
|
||||||
AudioCodec: "aac,mp3,mp2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "mov",
|
|
||||||
Type: "Video",
|
|
||||||
VideoCodec: "h264",
|
|
||||||
AudioCodec: "aac,mp3,mp2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "mp3",
|
|
||||||
Type: "Audio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "aac",
|
|
||||||
Type: "Audio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "m4a",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Type: "Audio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "m4b",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Type: "Audio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "hls",
|
|
||||||
Type: "Video",
|
|
||||||
VideoCodec: "h264",
|
|
||||||
AudioCodec: "aac,mp3,mp2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
TranscodingProfiles: [
|
|
||||||
{
|
|
||||||
Container: "mp4",
|
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Context: "Streaming",
|
|
||||||
Protocol: "hls",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
MinSegments: "1",
|
|
||||||
BreakOnNonKeyFrames: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "aac",
|
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Context: "Streaming",
|
|
||||||
Protocol: "http",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "mp3",
|
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "mp3",
|
|
||||||
Context: "Streaming",
|
|
||||||
Protocol: "http",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "mp3",
|
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "mp3",
|
|
||||||
Context: "Static",
|
|
||||||
Protocol: "http",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "aac",
|
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Context: "Static",
|
|
||||||
Protocol: "http",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "mp4",
|
|
||||||
Type: "Video",
|
|
||||||
AudioCodec: "aac,mp2",
|
|
||||||
VideoCodec: "h264",
|
|
||||||
Context: "Streaming",
|
|
||||||
Protocol: "hls",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
MinSegments: "1",
|
|
||||||
BreakOnNonKeyFrames: true,
|
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "Width",
|
|
||||||
Value: "960",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "Height",
|
|
||||||
Value: "960",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "VideoFramerate",
|
|
||||||
Value: "60",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "ts",
|
|
||||||
Type: "Video",
|
|
||||||
AudioCodec: "aac,mp3,mp2",
|
|
||||||
VideoCodec: "h264",
|
|
||||||
Context: "Streaming",
|
|
||||||
Protocol: "hls",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
MinSegments: "1",
|
|
||||||
BreakOnNonKeyFrames: true,
|
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "Width",
|
|
||||||
Value: "960",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "Height",
|
|
||||||
Value: "960",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "VideoFramerate",
|
|
||||||
Value: "60",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
ContainerProfiles: [],
|
|
||||||
CodecProfiles: [
|
|
||||||
{
|
|
||||||
Type: "VideoAudio",
|
|
||||||
Codec: "aac",
|
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: "Equals",
|
|
||||||
Property: "IsSecondaryAudio",
|
|
||||||
Value: "false",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: "VideoAudio",
|
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: "Equals",
|
|
||||||
Property: "IsSecondaryAudio",
|
|
||||||
Value: "false",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: "Video",
|
|
||||||
Codec: "h264",
|
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: "NotEquals",
|
|
||||||
Property: "IsAnamorphic",
|
|
||||||
Value: "true",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "EqualsAny",
|
|
||||||
Property: "VideoProfile",
|
|
||||||
Value: "high|main|baseline|constrained baseline",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "EqualsAny",
|
|
||||||
Property: "VideoRangeType",
|
|
||||||
Value: "SDR",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "VideoLevel",
|
|
||||||
Value: "52",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "NotEquals",
|
|
||||||
Property: "IsInterlaced",
|
|
||||||
Value: "true",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: "Video",
|
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "Width",
|
|
||||||
Value: "960",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "Height",
|
|
||||||
Value: "960",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "VideoFramerate",
|
|
||||||
Value: "65",
|
|
||||||
IsRequired: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
SubtitleProfiles: [
|
|
||||||
{
|
|
||||||
Method: "Encode",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user