mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
114 Commits
0.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": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.21.0",
|
||||
"version": "0.22.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -23,7 +23,10 @@
|
||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
}
|
||||
},
|
||||
"UISupportsTrueScreenSizeOnMac": true,
|
||||
"UIFileSharingEnabled": true,
|
||||
"LSSupportsOpeningDocumentsInPlace": true
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
@@ -33,14 +36,15 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 46,
|
||||
"versionCode": 47,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
"permissions": [
|
||||
"android.permission.FOREGROUND_SERVICE",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||
"android.permission.WRITE_SETTINGS"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
@@ -70,7 +74,8 @@
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"deploymentTarget": "15.6"
|
||||
"deploymentTarget": "15.6",
|
||||
"useFrameworks": "static"
|
||||
},
|
||||
"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 { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
@@ -35,11 +32,17 @@ export default function IndexLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads"
|
||||
name="downloads/index"
|
||||
options={{
|
||||
title: "Downloads",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads/[seriesId]"
|
||||
options={{
|
||||
title: "TV-Series",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
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({
|
||||
userId: user?.Id,
|
||||
limit: 50,
|
||||
limit: 20,
|
||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
|
||||
@@ -2,32 +2,41 @@ import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListItem } from "@/components/ListItem";
|
||||
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 { clearLogs, readFromLog } from "@/utils/log";
|
||||
import {clearLogs, useLog} from "@/utils/log";
|
||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
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 { toast } from "sonner-native";
|
||||
import * as Progress from 'react-native-progress';
|
||||
import * as FileSystem from "expo-file-system";
|
||||
|
||||
export default function settings() {
|
||||
const { logout } = useJellyfin();
|
||||
const { deleteAllFiles } = useDownload();
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { logs } = useLog();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: logs } = useQuery({
|
||||
queryKey: ["logs"],
|
||||
queryFn: async () => readFromLog(),
|
||||
refetchInterval: 1000,
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
Alert.prompt(
|
||||
"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 (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
@@ -81,6 +111,9 @@ export default function settings() {
|
||||
<ListItem title="Server" subTitle={api?.basePath} />
|
||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
||||
</View>
|
||||
<Button className="my-2.5" color="black" onPress={logout}>
|
||||
Log out
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
@@ -92,42 +125,36 @@ export default function settings() {
|
||||
|
||||
<SettingToggles />
|
||||
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Account and storage</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Button color="black" onPress={logout}>
|
||||
Log out
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
} catch (e) {
|
||||
Haptics.notificationAsync(
|
||||
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 className="flex flex-col space-y-2">
|
||||
<Text className="font-bold text-lg mb-2">Storage</Text>
|
||||
<View className="mb-4 space-y-2">
|
||||
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
|
||||
<Progress.Bar
|
||||
className="bg-gray-100/10"
|
||||
indeterminate={appSizeLoading}
|
||||
color="#9333ea"
|
||||
width={null}
|
||||
height={10}
|
||||
borderRadius={6}
|
||||
borderWidth={0}
|
||||
progress={size?.used}
|
||||
/>
|
||||
{size && (
|
||||
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={onDeleteClicked}
|
||||
>
|
||||
Delete all downloaded files
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={onClearLogsClicked}
|
||||
>
|
||||
Delete all logs
|
||||
</Button>
|
||||
</View>
|
||||
<View>
|
||||
<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 { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import {useLocalSearchParams, useNavigation} from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import React, {useEffect} from "react";
|
||||
import { useMemo } from "react";
|
||||
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 navigation = useNavigation();
|
||||
const params = useLocalSearchParams();
|
||||
const { id: seriesId, seasonIndex } = params as {
|
||||
id: string;
|
||||
@@ -56,7 +60,43 @@ const page: React.FC = () => {
|
||||
[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 (
|
||||
<ParallaxScrollView
|
||||
|
||||
@@ -6,7 +6,7 @@ import { withLayoutContext } from "expo-router";
|
||||
import {
|
||||
createNativeBottomTabNavigator,
|
||||
NativeBottomTabNavigationEventMap,
|
||||
} from "react-native-bottom-tabs/react-navigation";
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export const NativeTabs = withLayoutContext<
|
||||
BottomTabNavigationOptions,
|
||||
@@ -27,6 +28,7 @@ export const NativeTabs = withLayoutContext<
|
||||
>(Navigator);
|
||||
|
||||
export default function TabLayout() {
|
||||
const [settings] = useSettings();
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden={false} style="light" />
|
||||
@@ -71,6 +73,18 @@ export default function TabLayout() {
|
||||
: () => ({ 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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -30,9 +30,23 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, BackHandler, View } from "react-native";
|
||||
import React, {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
BackHandler,
|
||||
View,
|
||||
AppState,
|
||||
AppStateStatus,
|
||||
} from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import settings from "../(tabs)/(home)/settings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
@@ -73,7 +87,7 @@ export default function page() {
|
||||
bitrateValue: string;
|
||||
offline: string;
|
||||
}>();
|
||||
|
||||
const [settings] = useSettings();
|
||||
const offline = offlineStr === "true";
|
||||
|
||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||
@@ -166,69 +180,58 @@ export default function page() {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const togglePlay = useCallback(
|
||||
async (ms: number) => {
|
||||
if (!api) return;
|
||||
const togglePlay = useCallback(async () => {
|
||||
if (!api) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
|
||||
if (!offline && stream) {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: msToTicks(ms),
|
||||
isPaused: true,
|
||||
playMethod: stream.url?.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
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,
|
||||
});
|
||||
}
|
||||
if (!offline && stream) {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: msToTicks(progress.value),
|
||||
isPaused: true,
|
||||
playMethod: stream.url?.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
isPlaying,
|
||||
api,
|
||||
item,
|
||||
stream,
|
||||
videoRef,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
offline,
|
||||
]
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.play();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
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(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 () => {
|
||||
if (offline) return;
|
||||
@@ -284,6 +287,8 @@ export default function page() {
|
||||
|
||||
if (!item?.Id || !stream) return;
|
||||
|
||||
console.log("onProgress ~", currentTimeInTicks, isPlaying);
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
@@ -303,8 +308,7 @@ export default function page() {
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
pauseVideo: pause,
|
||||
playVideo: play,
|
||||
togglePlay: togglePlay,
|
||||
stopPlayback: stop,
|
||||
offline,
|
||||
});
|
||||
@@ -338,29 +342,49 @@ export default function page() {
|
||||
: 0;
|
||||
}, [item]);
|
||||
|
||||
const backAction = () => {
|
||||
videoRef.current?.stop();
|
||||
return false;
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
const onBackPress = () => {
|
||||
return backAction();
|
||||
};
|
||||
|
||||
BackHandler.addEventListener("hardwareBackPress", onBackPress);
|
||||
|
||||
return async () => {
|
||||
videoRef.current?.stop();
|
||||
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
|
||||
stop();
|
||||
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.
|
||||
|
||||
let initOptions = ["--sub-text-scale=60"];
|
||||
if (!settings) return null;
|
||||
|
||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
let externalTrack = { name: "", DeliveryUrl: "" };
|
||||
|
||||
const allSubs =
|
||||
@@ -455,21 +479,6 @@ export default function page() {
|
||||
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>
|
||||
{videoRef.current && (
|
||||
<Controls
|
||||
|
||||
@@ -169,51 +169,44 @@ const Player = () => {
|
||||
const poster = usePoster(item, api);
|
||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||
|
||||
const togglePlay = useCallback(
|
||||
async (ticks: number) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: true,
|
||||
playMethod: stream?.url.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: false,
|
||||
playMethod: stream?.url.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
isPlaying,
|
||||
api,
|
||||
item,
|
||||
videoRef,
|
||||
settings,
|
||||
stream,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
]
|
||||
);
|
||||
const togglePlay = useCallback(async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
isPaused: true,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
isPaused: false,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isPlaying,
|
||||
api,
|
||||
item,
|
||||
videoRef,
|
||||
settings,
|
||||
stream,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.resume();
|
||||
@@ -307,9 +300,9 @@ const Player = () => {
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
pauseVideo: pause,
|
||||
playVideo: play,
|
||||
togglePlay: togglePlay,
|
||||
stopPlayback: stop,
|
||||
offline: false,
|
||||
});
|
||||
|
||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
||||
@@ -369,23 +362,10 @@ const Player = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const backAction = () => {
|
||||
videoRef.current?.pause();
|
||||
return false;
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
const onBackPress = () => {
|
||||
return backAction();
|
||||
};
|
||||
|
||||
BackHandler.addEventListener("hardwareBackPress", onBackPress);
|
||||
play();
|
||||
|
||||
return async () => {
|
||||
videoRef.current?.pause();
|
||||
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
|
||||
stop();
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
@@ -467,20 +447,6 @@ const Player = () => {
|
||||
selectedTextTrack={selectedTextTrack}
|
||||
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>
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { LogProvider, writeToLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
@@ -310,50 +311,54 @@ function Layout() {
|
||||
<JobQueueProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style="light" hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style="light" hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
import React from "react";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
item: BaseItemDto;
|
||||
useEpisodePoster?: boolean;
|
||||
size?: "small" | "normal";
|
||||
showPlayButton?: boolean;
|
||||
};
|
||||
|
||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
item,
|
||||
useEpisodePoster = false,
|
||||
size = "normal",
|
||||
showPlayButton = false,
|
||||
}) => {
|
||||
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(() => {
|
||||
if (!api) return;
|
||||
@@ -73,16 +76,23 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
${size === "small" ? "w-32" : "w-44"}
|
||||
`}
|
||||
>
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
<View className="w-full h-full flex items-center justify-center">
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
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 > 0 && (
|
||||
<>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||
import native from "@/utils/profiles/native";
|
||||
import download from "@/utils/profiles/download";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import {
|
||||
@@ -19,9 +18,9 @@ import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} 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 { useCallback, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
@@ -34,15 +33,22 @@ import ProgressCircle from "./ProgressCircle";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
|
||||
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 [user] = useAtom(userAtom);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [settings] = useSettings();
|
||||
const { processes, startBackgroundDownload } = useDownload();
|
||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||
const { startRemuxing } = useRemuxHlsToMp4();
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
@@ -56,23 +62,14 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(item, settings);
|
||||
|
||||
// 4. Set states
|
||||
setSelectedMediaSource(mediaSource ?? undefined);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [item, settings])
|
||||
const userCanDownload = useMemo(
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
[user]
|
||||
);
|
||||
const usingOptimizedServer = useMemo(
|
||||
() => settings?.downloadMethod === "optimized",
|
||||
[settings]
|
||||
);
|
||||
|
||||
const userCanDownload = useMemo(() => {
|
||||
return user?.Policy?.EnableContentDownloading;
|
||||
}, [user]);
|
||||
|
||||
/**
|
||||
* Bottom sheet
|
||||
@@ -89,69 +86,169 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Start download
|
||||
*/
|
||||
const initiateDownload = useCallback(async () => {
|
||||
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||
);
|
||||
}
|
||||
// region computed
|
||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||
const pendingItems = useMemo(
|
||||
() =>
|
||||
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
||||
[items, downloadedFiles]
|
||||
);
|
||||
const isDownloaded = useMemo(() => {
|
||||
if (!downloadedFiles) return false;
|
||||
return pendingItems.length == 0;
|
||||
}, [downloadedFiles, pendingItems]);
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: 0,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: selectedAudioStream,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
mediaSourceId: selectedMediaSource.Id,
|
||||
subtitleStreamIndex: selectedSubtitleStream,
|
||||
deviceProfile: download,
|
||||
});
|
||||
const itemsProcesses = useMemo(
|
||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
||||
[processes, itemIds]
|
||||
);
|
||||
|
||||
if (!res) {
|
||||
Alert.alert(
|
||||
"Something went wrong",
|
||||
"Could not get stream url from Jellyfin"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const progress = useMemo(() => {
|
||||
if (itemIds.length == 1)
|
||||
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
||||
return (
|
||||
((itemIds.length -
|
||||
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") {
|
||||
return await startBackgroundDownload(url, item, mediaSource);
|
||||
const acceptDownloadOptions = useCallback(() => {
|
||||
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 {
|
||||
return await startRemuxing(item, url, mediaSource);
|
||||
toast.error("You are not allowed to download files.");
|
||||
}
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
startBackgroundDownload,
|
||||
user?.Id,
|
||||
queue,
|
||||
setQueue,
|
||||
pendingItems,
|
||||
usingOptimizedServer,
|
||||
userCanDownload,
|
||||
|
||||
// Need to be reference at the time async lambda is created for initiateDownload
|
||||
maxBitrate,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
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(() => {
|
||||
if (!downloadedFiles) return false;
|
||||
for (const item of items) {
|
||||
if (pendingItems.length > 1) {
|
||||
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
||||
item,
|
||||
settings!
|
||||
));
|
||||
}
|
||||
|
||||
return downloadedFiles.some((file) => file.item.Id === item.Id);
|
||||
}, [downloadedFiles, item.Id]);
|
||||
const res = await getStreamUrl({
|
||||
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(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
@@ -163,31 +260,38 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
),
|
||||
[]
|
||||
);
|
||||
// endregion helper functions
|
||||
|
||||
const process = useMemo(() => {
|
||||
if (!processes) return null;
|
||||
// Allow to select & set settings for single download
|
||||
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);
|
||||
}, [processes, item.Id]);
|
||||
// 4. Set states
|
||||
setSelectedMediaSource(mediaSource ?? undefined);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [items, pendingItems, settings])
|
||||
);
|
||||
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
{process && process?.item.Id === item.Id ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
{process.progress === 0 ? (
|
||||
{processes && itemsProcesses.length > 0 ? (
|
||||
<TouchableOpacity onPress={navigateToDownloads}>
|
||||
{progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={process.progress}
|
||||
fill={progress}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
@@ -195,25 +299,17 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : queue.some((i) => i.id === item.Id) ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
) : itemsQueued ? (
|
||||
<TouchableOpacity onPress={navigateToDownloads}>
|
||||
<Ionicons name="hourglass" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
) : isDownloaded ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
<TouchableOpacity onPress={onDownloadedPress}>
|
||||
{DownloadedIconComponent()}
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity onPress={handlePresentModalPress}>
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
{MissingDownloadIconComponent()}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<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">
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
onChange={setMaxBitrate}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
<MediaSourceSelector
|
||||
item={item}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
/>
|
||||
{selectedMediaSource && (
|
||||
<View className="flex flex-col space-y-2">
|
||||
<AudioTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
{pendingItems.length === 1 && (
|
||||
<>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
{selectedMediaSource && (
|
||||
<View className="flex flex-col space-y-2">
|
||||
<AudioTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Button
|
||||
className="mt-auto"
|
||||
onPress={() => {
|
||||
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.");
|
||||
}
|
||||
}}
|
||||
onPress={acceptDownloadOptions}
|
||||
color="purple"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<View className="opacity-70 text-center w-full flex items-center">
|
||||
{settings?.downloadMethod === "optimized" ? (
|
||||
<Text className="text-xs">Using optimized server</Text>
|
||||
) : (
|
||||
<Text className="text-xs">Using default method</Text>
|
||||
)}
|
||||
<Text className="text-xs">
|
||||
{usingOptimizedServer
|
||||
? "Using optimized server"
|
||||
: "Using default method"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
@@ -299,3 +379,19 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
</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 { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
@@ -32,6 +32,16 @@ import { Chromecast } from "./Chromecast";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import {
|
||||
brightness,
|
||||
ColorMatrix,
|
||||
colorTone,
|
||||
concatColorMatrices,
|
||||
contrast,
|
||||
saturate,
|
||||
sepia,
|
||||
tint,
|
||||
} from "react-native-color-matrix-image-filters";
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
@@ -49,7 +59,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
const insets = useSafeAreaInsets();
|
||||
useImageColors({ item });
|
||||
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
const [loadingLogo, setLoadingLogo] = useState(false);
|
||||
const [headerHeight, setHeaderHeight] = useState(350);
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
@@ -87,7 +97,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
<Chromecast background="blur" width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<DownloadItem item={item} />
|
||||
<DownloadSingleItem item={item} />
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
)}
|
||||
@@ -139,18 +149,45 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
logo={
|
||||
<>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
<ColorMatrix
|
||||
matrix={[
|
||||
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={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
/>
|
||||
</ColorMatrix>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
@@ -228,6 +265,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
<OverviewText text={item.Overview} className="px-4 my-4" />
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className="mb-4" />
|
||||
)}
|
||||
|
||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||
|
||||
{item.People && item.People.length > 0 && (
|
||||
@@ -243,10 +284,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
</View>
|
||||
)}
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className="mb-4" />
|
||||
)}
|
||||
|
||||
<SimilarItems itemId={item.Id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { SelectedOptions } from "./ItemContent";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
@@ -111,18 +112,11 @@ export const PlayButton: React.FC<Props> = ({
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
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:
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
deviceProfile: ios,
|
||||
deviceProfile: chromecastProfile,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
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>
|
||||
<View className="space-y-2">
|
||||
{processes?.map((p) => (
|
||||
<DownloadCard key={p.id} process={p} />
|
||||
<DownloadCard key={p.item.Id} process={p} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
@@ -77,7 +77,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||
}
|
||||
} else {
|
||||
FFmpegKit.cancel();
|
||||
FFmpegKit.cancel(Number(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 * as Haptics from "expo-haptics";
|
||||
import React, { useCallback, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||
import {
|
||||
ActionSheetProvider,
|
||||
useActionSheet,
|
||||
} from "@expo/react-native-action-sheet";
|
||||
|
||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||
import { Text } from "../common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Image } from "expo-image";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }) => {
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useDownloadedFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item.Id!);
|
||||
}, []);
|
||||
}, [item]);
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
@@ -76,32 +74,29 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
onLongPress={showActionSheet}
|
||||
className="flex flex-col w-44 mr-2"
|
||||
key={item.Id}
|
||||
className="flex flex-col mb-4"
|
||||
>
|
||||
{base64Image ? (
|
||||
<View className="w-44 aspect-video rounded-lg overflow-hidden">
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "cover",
|
||||
}}
|
||||
/>
|
||||
<View className="flex flex-row items-start mb-2">
|
||||
<View className="mr-2">
|
||||
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
|
||||
</View>
|
||||
) : (
|
||||
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color="gray"
|
||||
className="self-center mt-16"
|
||||
/>
|
||||
<View className="shrink">
|
||||
<Text numberOfLines={2} className="">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
{runtimeTicksToSeconds(item.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
|
||||
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
||||
{item.Overview}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as Haptics from "expo-haptics";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
@@ -97,6 +98,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
</View>
|
||||
)}
|
||||
<ItemCardText item={item} />
|
||||
<DownloadSize items={[item]} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,55 +1,82 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { EpisodeCard } from "./EpisodeCard";
|
||||
import {TouchableOpacity, View} from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { useMemo } from "react";
|
||||
import { SeasonPicker } from "../series/SeasonPicker";
|
||||
import React, {useCallback, useMemo} from "react";
|
||||
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 }) => {
|
||||
const groupBySeason = useMemo(() => {
|
||||
const seasons: Record<string, BaseItemDto[]> = {};
|
||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
|
||||
const { deleteItems } = useDownload();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
items.forEach((item) => {
|
||||
if (!seasons[item.SeasonName!]) {
|
||||
seasons[item.SeasonName!] = [];
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
|
||||
return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
|
||||
};
|
||||
}, [showActionSheetWithOptions, deleteSeries]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className="flex flex-row items-center justify-between px-4">
|
||||
<Text className="text-lg font-bold shrink">{items[0].SeriesName}</Text>
|
||||
<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>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
|
||||
onLongPress={showActionSheet}
|
||||
>
|
||||
{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 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>
|
||||
{groupBySeason.map((seasonItems, seasonIndex) => (
|
||||
<View key={seasonIndex}>
|
||||
<Text className="mb-2 font-semibold px-4">
|
||||
{seasonItems[0].SeasonName}
|
||||
</Text>
|
||||
<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>
|
||||
<View className="w-28 mt-2 flex flex-col">
|
||||
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
|
||||
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
|
||||
<DownloadSize items={items} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { View } from "react-native";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { DownloadItem } from "../DownloadItem";
|
||||
import { DownloadItems, DownloadSingleItem } from "../DownloadItem";
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "../common/Text";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Image } from "expo-image";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import {
|
||||
SeasonDropdown,
|
||||
SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
initialSeasonIndex?: number;
|
||||
};
|
||||
|
||||
type SeasonIndexState = {
|
||||
[seriesId: string]: number;
|
||||
};
|
||||
|
||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||
|
||||
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 router = useRouter();
|
||||
|
||||
const { data: seasons } = useQuery({
|
||||
queryKey: ["seasons", item.Id],
|
||||
queryFn: async () => {
|
||||
@@ -61,37 +56,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
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(
|
||||
() =>
|
||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||
@@ -148,39 +112,37 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
minHeight: 144 * nrOfEpisodes,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-row px-4">
|
||||
<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?.map((season: any) => (
|
||||
<DropdownMenu.Item
|
||||
key={season.Name}
|
||||
onSelect={() => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.Id ?? ""]: season.IndexNumber,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<View className="flex flex-row justify-start items-center px-4">
|
||||
<SeasonDropdown
|
||||
item={item}
|
||||
seasons={seasons}
|
||||
state={seasonIndexState}
|
||||
onSelect={(season) => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.Id ?? ""]: season.IndexNumber,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<DownloadItems
|
||||
className="ml-2"
|
||||
items={episodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<MaterialCommunityIcons
|
||||
name="download-multiple"
|
||||
size={20}
|
||||
color="white"
|
||||
/>
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<MaterialCommunityIcons
|
||||
name="check-all"
|
||||
size={20}
|
||||
color="#9333ea"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<View className="px-4 flex flex-col my-4">
|
||||
{isFetching ? (
|
||||
<View
|
||||
@@ -218,7 +180,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
</Text>
|
||||
</View>
|
||||
<View className="self-start ml-auto -mt-0.5">
|
||||
<DownloadItem item={e} />
|
||||
<DownloadSingleItem item={e} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { LANGUAGES } from "@/constants/Languages";
|
||||
import { TextInput } from "react-native-gesture-handler";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -122,6 +123,45 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">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
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getOrSetDeviceId,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import {ScreenOrientationEnum, Settings, useSettings} from "@/utils/atoms/settings";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
registerBackgroundFetchAsync,
|
||||
@@ -17,7 +17,7 @@ import * as BackgroundFetch from "expo-background-fetch";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {
|
||||
Linking,
|
||||
Switch,
|
||||
@@ -32,6 +32,7 @@ import { Input } from "../common/Input";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
import { MediaToggles } from "./MediaToggles";
|
||||
import {Stepper} from "@/components/inputs/Stepper";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -403,6 +404,26 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
</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>
|
||||
|
||||
@@ -463,7 +484,44 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</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">
|
||||
<Text className="font-semibold">Auto download</Text>
|
||||
<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 DropdownViewDirect from "./dropdown/DropdownViewDirect";
|
||||
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 {
|
||||
item: BaseItemDto;
|
||||
@@ -63,7 +71,7 @@ interface Props {
|
||||
ignoreSafeAreas?: boolean;
|
||||
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
enableTrickplay?: boolean;
|
||||
togglePlay: (ticks: number) => void;
|
||||
togglePlay: () => void;
|
||||
setShowControls: (shown: boolean) => void;
|
||||
offline?: boolean;
|
||||
isVideoLoaded?: boolean;
|
||||
@@ -110,12 +118,15 @@ export const Controls: React.FC<Props> = ({
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
item,
|
||||
!offline && enableTrickplay
|
||||
);
|
||||
const {
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
prefetchAllTrickplayImages,
|
||||
} = useTrickplay(item, !offline && enableTrickplay);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(0);
|
||||
@@ -238,12 +249,28 @@ export const Controls: React.FC<Props> = ({
|
||||
}
|
||||
}, [item, isVlc]);
|
||||
|
||||
useEffect(() => {
|
||||
prefetchAllTrickplayImages();
|
||||
}, []);
|
||||
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(
|
||||
async (value: number) => {
|
||||
isSeeking.value = false;
|
||||
progress.value = value;
|
||||
setIsSliding(false);
|
||||
|
||||
await seek(
|
||||
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 handleSliderChange = useCallback(
|
||||
debounce((value: number) => {
|
||||
const progressInTicks = msToTicks(value);
|
||||
console.log("Progress in ticks", progressInTicks);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
|
||||
const handleSliderChange = (value: number) => {
|
||||
const progressInTicks = isVlc ? msToTicks(value) : value;
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
|
||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
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 progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTime({ hours, minutes, seconds });
|
||||
}, 10),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
if (!settings?.rewindSkipTime) return;
|
||||
@@ -318,282 +338,408 @@ export const Controls: React.FC<Props> = ({
|
||||
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 (
|
||||
<ControlProvider
|
||||
item={item}
|
||||
mediaSource={mediaSource}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
>
|
||||
<SafeAreaView
|
||||
style={{
|
||||
flex: 1,
|
||||
position: "absolute",
|
||||
top: insets.top,
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
bottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
{EpisodeView ? (
|
||||
<EpisodeList item={item} close={() => setEpisodeView(false)} />
|
||||
) : (
|
||||
<>
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
<Text className="text-white">Skip Intro</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{!mediaSource?.TranscodingUrl ? (
|
||||
<DropdownViewDirect showControls={showControls} />
|
||||
) : (
|
||||
<DropdownViewTranscoding showControls={showControls} />
|
||||
)}
|
||||
</VideoProvider>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
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();
|
||||
<Pressable
|
||||
onPressIn={() => {
|
||||
toggleControls();
|
||||
}}
|
||||
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={[
|
||||
{
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
opacity: showControls ? 1 : 0,
|
||||
},
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-col p-4`}
|
||||
>
|
||||
<View className="shrink flex flex-col justify-center h-full mb-2">
|
||||
<Text className="font-bold">{item?.Name}</Text>
|
||||
{item?.Type === "Episode" && (
|
||||
<Text className="opacity-50">{item.SeriesName}</Text>
|
||||
)}
|
||||
{item?.Type === "Movie" && (
|
||||
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
|
||||
)}
|
||||
{item?.Type === "Audio" && (
|
||||
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
||||
)}
|
||||
</View>
|
||||
width: Dimensions.get("window").width,
|
||||
height: Dimensions.get("window").height,
|
||||
}}
|
||||
></Pressable>
|
||||
|
||||
<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
|
||||
style={{
|
||||
opacity: !previousItem ? 0.5 : 1,
|
||||
onPress={() => {
|
||||
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}
|
||||
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" />
|
||||
</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
|
||||
name="refresh-outline"
|
||||
size={26}
|
||||
size={50}
|
||||
color="white"
|
||||
style={{
|
||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
togglePlay(progress.value);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
{settings?.rewindSkipTime}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
togglePlay();
|
||||
}}
|
||||
>
|
||||
{!isBuffering ? (
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={30}
|
||||
size={50}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSkipForward}>
|
||||
<Ionicons name="refresh-outline" size={26} color="white" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
opacity: !nextItem ? 0.5 : 1,
|
||||
}}
|
||||
onPress={goToNextItem}
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className={`flex flex-col w-full shrink`}>
|
||||
<Slider
|
||||
theme={{
|
||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||
minimumTrackTintColor: "#fff",
|
||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||
bubbleBackgroundColor: "#fff",
|
||||
bubbleTextColor: "#000",
|
||||
heartbeatColor: "#999",
|
||||
}}
|
||||
cache={cacheProgress}
|
||||
onSlidingStart={handleSliderStart}
|
||||
onSlidingComplete={handleSliderComplete}
|
||||
onValueChange={handleSliderChange}
|
||||
containerStyle={{
|
||||
borderRadius: 100,
|
||||
}}
|
||||
renderBubble={() => {
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return null;
|
||||
}
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
) : (
|
||||
<Loader size={"large"} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
const tileWidth = 150;
|
||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||
return (
|
||||
<TouchableOpacity onPress={handleSkipForward}>
|
||||
<View
|
||||
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
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
marginLeft: -tileWidth / 4,
|
||||
marginTop: -tileHeight / 4 - 60,
|
||||
zIndex: 10,
|
||||
width: 18,
|
||||
height: 18,
|
||||
left: -2,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#fff",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
className=" bg-neutral-800 overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
style={{
|
||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||
height:
|
||||
(150 / trickplayInfo.aspectRatio!) *
|
||||
trickplayInfo?.data.TileHeight!,
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
],
|
||||
}}
|
||||
source={{ uri: url }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 5,
|
||||
left: 5,
|
||||
color: "white",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
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>
|
||||
/>
|
||||
)}
|
||||
cache={cacheProgress}
|
||||
onSlidingStart={handleSliderStart}
|
||||
onSlidingComplete={handleSliderComplete}
|
||||
onValueChange={handleSliderChange}
|
||||
containerStyle={{
|
||||
borderRadius: 100,
|
||||
}}
|
||||
renderBubble={() => isSliding && memoizedRenderBubble()}
|
||||
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>
|
||||
</SafeAreaView>
|
||||
</>
|
||||
)}
|
||||
</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": {
|
||||
"channel": "0.21.0",
|
||||
"channel": "0.22.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.21.0",
|
||||
"channel": "0.22.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import index from "@/app/(auth)/(tabs)/(home)";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
interface AdjacentEpisodesProps {
|
||||
@@ -12,81 +12,53 @@ interface AdjacentEpisodesProps {
|
||||
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
const { data: previousItem } = useQuery({
|
||||
queryKey: ["previousItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||
const parentId = item?.AlbumId || item?.ParentId;
|
||||
const indexNumber = item?.IndexNumber;
|
||||
|
||||
if (
|
||||
!api ||
|
||||
!parentId ||
|
||||
indexNumber === undefined ||
|
||||
indexNumber === null ||
|
||||
indexNumber - 1 < 1
|
||||
) {
|
||||
const { data: adjacentItems } = useQuery({
|
||||
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
|
||||
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||
if (!api || !item || !item.SeriesId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newIndexNumber = indexNumber - 2;
|
||||
|
||||
const res = await getItemsApi(api).getItems({
|
||||
parentId: parentId!,
|
||||
startIndex: newIndexNumber,
|
||||
limit: 1,
|
||||
sortBy: ["IndexNumber"],
|
||||
includeItemTypes: ["Episode", "Audio"],
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.SeriesId,
|
||||
adjacentTo: item.Id,
|
||||
limit: 3,
|
||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
||||
});
|
||||
|
||||
if (res.data.Items?.[0]?.IndexNumber !== indexNumber - 1) {
|
||||
throw new Error("Previous item is not correct");
|
||||
}
|
||||
|
||||
return res.data.Items?.[0] || null;
|
||||
return res.data.Items || null;
|
||||
},
|
||||
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
||||
enabled:
|
||||
!!api &&
|
||||
!!item?.Id &&
|
||||
!!item?.SeriesId &&
|
||||
(item?.Type === "Episode" || item?.Type === "Audio"),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: nextItem } = useQuery({
|
||||
queryKey: ["nextItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||
const parentId = item?.AlbumId || item?.ParentId;
|
||||
const indexNumber = item?.IndexNumber;
|
||||
const previousItem = useMemo(() => {
|
||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!api ||
|
||||
!parentId ||
|
||||
indexNumber === undefined ||
|
||||
indexNumber === null
|
||||
) {
|
||||
console.log("No next item", {
|
||||
itemId: item?.Id,
|
||||
parentId: parentId,
|
||||
indexNumber: indexNumber,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (adjacentItems.length === 2) {
|
||||
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||
}
|
||||
|
||||
const res = await getItemsApi(api).getItems({
|
||||
parentId: parentId!,
|
||||
startIndex: indexNumber,
|
||||
sortBy: ["IndexNumber"],
|
||||
limit: 1,
|
||||
includeItemTypes: ["Episode", "Audio"],
|
||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
||||
});
|
||||
return adjacentItems[0];
|
||||
}, [adjacentItems, item]);
|
||||
|
||||
if (res.data.Items?.[0]?.IndexNumber !== indexNumber + 1) {
|
||||
throw new Error("Previous item is not correct");
|
||||
}
|
||||
const nextItem = useMemo(() => {
|
||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return res.data.Items?.[0] || null;
|
||||
},
|
||||
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
||||
staleTime: 0,
|
||||
});
|
||||
if (adjacentItems.length === 2) {
|
||||
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||
}
|
||||
|
||||
return adjacentItems[2];
|
||||
}, [adjacentItems, item]);
|
||||
|
||||
return { previousItem, nextItem };
|
||||
};
|
||||
|
||||
@@ -17,16 +17,20 @@ const useDefaultPlaySettings = (
|
||||
// 2. Get default or preferred audio
|
||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Language === settings?.defaultAudioLanguage
|
||||
(x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage
|
||||
)?.Index;
|
||||
|
||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Type === "Audio"
|
||||
)?.Index;
|
||||
|
||||
// 3. Get default or preferred subtitle
|
||||
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
|
||||
(x) =>
|
||||
x.Type === "Subtitle" &&
|
||||
x.Language === settings?.defaultSubtitleLanguage?.value
|
||||
)?.Index;
|
||||
|
||||
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||
(stream) => stream.Type === "Subtitle" && stream.IsDefault
|
||||
)?.Index;
|
||||
@@ -38,7 +42,9 @@ const useDefaultPlaySettings = (
|
||||
defaultAudioIndex:
|
||||
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
||||
defaultSubtitleIndex:
|
||||
preferedSubtitleIndex || defaultSubtitleIndex || undefined,
|
||||
preferedSubtitleIndex !== undefined
|
||||
? preferedSubtitleIndex
|
||||
: defaultSubtitleIndex || undefined,
|
||||
defaultMediaSource: mediaSource || undefined,
|
||||
defaultBitrate: bitrate || undefined,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { writeErrorLog, writeInfoLog, writeToLog } from "@/utils/log";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
@@ -9,11 +9,34 @@ import {
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
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 { useCallback } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
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.
|
||||
@@ -24,26 +47,125 @@ import useImageStorage from "./useImageStorage";
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [settings] = useSettings();
|
||||
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(
|
||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
||||
async (item: BaseItemDto, url: string) => {
|
||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||
if (!api) throw new Error("API is not defined");
|
||||
if (!item.Id) throw new Error("Item must have an Id");
|
||||
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
|
||||
await saveImage(item.Id, itemImage?.uri);
|
||||
// First lets save any important assets we want to present to the user offline
|
||||
await onSaveAssets(api, item);
|
||||
|
||||
toast.success(`Download started for ${item.Name}`, {
|
||||
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 {
|
||||
setProcesses((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: "",
|
||||
deviceId: "",
|
||||
inputUrl: "",
|
||||
item: item,
|
||||
itemId: item.Id!,
|
||||
outputPath: "",
|
||||
progress: 0,
|
||||
status: "downloading",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
const job: JobStatus = {
|
||||
id: "",
|
||||
deviceId: "",
|
||||
inputUrl: url,
|
||||
item: item,
|
||||
itemId: item.Id!,
|
||||
outputPath: output,
|
||||
progress: 0,
|
||||
status: "downloading",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||
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();
|
||||
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
||||
setProcesses((prev) => [...prev, job]);
|
||||
|
||||
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,
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
await FFmpegKit.executeAsync(
|
||||
createFFmpegCommand(url, output).join(" "),
|
||||
(session) => completeCallback(session, item),
|
||||
undefined,
|
||||
(s) => statisticsCallback(s, item)
|
||||
);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
|
||||
console.error("Failed to remux:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, ${errorLog}`
|
||||
writeErrorLog(
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
||||
Error: ${error.message}, Stack: ${error.stack}`
|
||||
);
|
||||
setProcesses((prev) => {
|
||||
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
|
||||
}
|
||||
},
|
||||
[]
|
||||
[settings, processes, setProcesses, completeCallback, statisticsCallback]
|
||||
);
|
||||
|
||||
const cancelRemuxing = useCallback(() => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// hooks/useTrickplay.ts
|
||||
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -111,9 +110,45 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
|
||||
[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 {
|
||||
trickPlayUrl: enabled ? trickPlayUrl : null,
|
||||
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null,
|
||||
prefetchAllTrickplayImages: enabled
|
||||
? prefetchAllTrickplayImages
|
||||
: () => null,
|
||||
trickplayInfo: enabled ? trickplayInfo : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,91 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { Router, useRouter } from "expo-router";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
apiAtom,
|
||||
getOrSetDeviceId,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||
|
||||
interface UseWebSocketProps {
|
||||
isPlaying: boolean;
|
||||
pauseVideo: () => void;
|
||||
playVideo: () => void;
|
||||
togglePlay: () => void;
|
||||
stopPlayback: () => void;
|
||||
offline?: boolean;
|
||||
offline: boolean;
|
||||
}
|
||||
|
||||
export const useWebSocket = ({
|
||||
isPlaying,
|
||||
pauseVideo,
|
||||
playVideo,
|
||||
togglePlay,
|
||||
stopPlayback,
|
||||
offline = false,
|
||||
offline,
|
||||
}: UseWebSocketProps) => {
|
||||
const router = useRouter();
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
const { data: deviceId } = useQuery({
|
||||
queryKey: ["deviceId"],
|
||||
queryFn: async () => {
|
||||
return await getOrSetDeviceId();
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const { ws } = useWebSocketContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (offline || !deviceId || !api?.accessToken) return;
|
||||
|
||||
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
|
||||
|
||||
const url = `${protocol}://${api?.basePath
|
||||
.replace("https://", "")
|
||||
.replace("http://", "")}/socket?api_key=${
|
||||
api?.accessToken
|
||||
}&deviceId=${deviceId}`;
|
||||
|
||||
const newWebSocket = new WebSocket(url);
|
||||
|
||||
let keepAliveInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
newWebSocket.onopen = () => {
|
||||
setIsConnected(true);
|
||||
keepAliveInterval = setInterval(() => {
|
||||
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
||||
}
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
newWebSocket.onerror = (e) => {
|
||||
console.error("WebSocket error:", e);
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
newWebSocket.onclose = (e) => {
|
||||
if (keepAliveInterval) {
|
||||
clearInterval(keepAliveInterval);
|
||||
}
|
||||
};
|
||||
|
||||
setWs(newWebSocket);
|
||||
|
||||
return () => {
|
||||
if (keepAliveInterval) {
|
||||
clearInterval(keepAliveInterval);
|
||||
}
|
||||
newWebSocket.close();
|
||||
};
|
||||
}, [api, deviceId, user, offline]);
|
||||
|
||||
useEffect(() => {
|
||||
if (offline || !ws) return;
|
||||
if (!ws) return;
|
||||
if (offline) return;
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const json = JSON.parse(e.data);
|
||||
@@ -95,8 +31,7 @@ export const useWebSocket = ({
|
||||
|
||||
if (command === "PlayPause") {
|
||||
console.log("Command ~ PlayPause");
|
||||
if (isPlaying) pauseVideo();
|
||||
else playVideo();
|
||||
togglePlay();
|
||||
} else if (command === "Stop") {
|
||||
console.log("Command ~ Stop");
|
||||
stopPlayback();
|
||||
@@ -108,7 +43,9 @@ export const useWebSocket = ({
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
@@ -32,6 +34,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
private var startPosition: Int? = 0
|
||||
private var isMediaReady: Boolean = false
|
||||
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 {
|
||||
setupView()
|
||||
@@ -48,6 +60,11 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
|
||||
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 autoplay = source["autoplay"] 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")
|
||||
// }
|
||||
|
||||
hasSource = true
|
||||
|
||||
if (autoplay) {
|
||||
Log.d("VlcPlayerView", "Playing...")
|
||||
@@ -97,15 +115,18 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
fun play() {
|
||||
mediaPlayer?.play()
|
||||
isPaused = false
|
||||
handler.post(updateProgressRunnable) // Start updating progress
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
mediaPlayer?.pause()
|
||||
isPaused = true
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
mediaPlayer?.stop()
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
}
|
||||
|
||||
fun seekTo(time: Int) {
|
||||
@@ -170,6 +191,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
println("onDetachedFromWindow")
|
||||
super.onDetachedFromWindow()
|
||||
mediaPlayer?.stop()
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
|
||||
media?.release()
|
||||
mediaPlayer?.release()
|
||||
@@ -187,7 +209,9 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
MediaPlayer.Event.Buffering,
|
||||
MediaPlayer.Event.EndReached,
|
||||
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 durationMs = player.media?.duration?.toInt() ?: 0
|
||||
|
||||
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
|
||||
// Set subtitle URL if available
|
||||
if (player.isPlaying && !isMediaReady) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import UIKit
|
||||
class VlcPlayerView: ExpoView {
|
||||
private var mediaPlayer: VLCMediaPlayer?
|
||||
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 currentGeometryCString: [CChar]?
|
||||
private var lastReportedState: VLCMediaPlayerState?
|
||||
@@ -14,13 +14,17 @@ class VlcPlayerView: ExpoView {
|
||||
private var startPosition: Int32 = 0
|
||||
private var isMediaReady: Bool = false
|
||||
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
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupView()
|
||||
// setupNotifications()
|
||||
setupNotifications()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
@@ -48,82 +52,71 @@ class VlcPlayerView: ExpoView {
|
||||
self, selector: #selector(applicationWillResignActive),
|
||||
name: UIApplication.willResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationWillEnterForeground),
|
||||
name: UIApplication.willEnterForegroundNotification, object: nil)
|
||||
self, selector: #selector(applicationDidBecomeActive),
|
||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
@objc func play() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.mediaPlayer?.play()
|
||||
self.isPaused = false
|
||||
print("Play")
|
||||
}
|
||||
self.mediaPlayer?.play()
|
||||
self.isPaused = false
|
||||
print("Play")
|
||||
}
|
||||
|
||||
@objc func pause() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.mediaPlayer?.pause()
|
||||
self.isPaused = true
|
||||
}
|
||||
self.mediaPlayer?.pause()
|
||||
self.isPaused = true
|
||||
}
|
||||
|
||||
@objc func seekTo(_ time: Int32) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, let player = self.mediaPlayer else { return }
|
||||
guard 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 {
|
||||
player.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)
|
||||
|
||||
// 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.play()
|
||||
}
|
||||
self.updatePlayerState()
|
||||
} else {
|
||||
print("Error: Unable to retrieve video duration")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSource(_ source: [String: Any]) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.hasSource {
|
||||
return
|
||||
}
|
||||
|
||||
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
||||
startPosition = source["startPosition"] as? Int32 ?? 0
|
||||
initOptions.append("--start-time=\(startPosition)")
|
||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||
initOptions.append("--start-time=\(self.startPosition)")
|
||||
|
||||
let uri = source["uri"] as? String
|
||||
|
||||
let autoplay = source["autoplay"] as? Bool ?? false
|
||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||
|
||||
guard let uri = uri, !uri.isEmpty else {
|
||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||
print("Error: Invalid or empty URI")
|
||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||
return
|
||||
}
|
||||
|
||||
let autoplay = source["autoplay"] as? Bool ?? false
|
||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||
|
||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
||||
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
|
||||
|
||||
self.mediaPlayer?.delegate = self
|
||||
self.mediaPlayer?.drawable = self.videoView
|
||||
self.mediaPlayer?.scaleFactor = 0
|
||||
@@ -134,14 +127,8 @@ class VlcPlayerView: ExpoView {
|
||||
media = VLCMedia(url: URL(string: uri)!)
|
||||
} else {
|
||||
print("Loading local file: \(uri)")
|
||||
if uri.starts(with: "file://") {
|
||||
if let url = URL(string: uri) {
|
||||
media = VLCMedia(url: url)
|
||||
} else {
|
||||
print("Error: Invalid local file URL")
|
||||
self.onVideoError?(["error": "Invalid local file URL"])
|
||||
return
|
||||
}
|
||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
||||
media = VLCMedia(url: url)
|
||||
} else {
|
||||
media = VLCMedia(path: uri)
|
||||
}
|
||||
@@ -150,18 +137,8 @@ class VlcPlayerView: ExpoView {
|
||||
print("Debug: Media options: \(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.hasSource = true
|
||||
|
||||
if autoplay {
|
||||
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) {
|
||||
DispatchQueue.main.async {
|
||||
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
||||
}
|
||||
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
||||
}
|
||||
|
||||
@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) {
|
||||
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
||||
DispatchQueue.main.async {
|
||||
if trackIndex == -1 {
|
||||
print("Debug: Disabling subtitles")
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
|
||||
print(
|
||||
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
|
||||
)
|
||||
}
|
||||
|
||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, let url = URL(string: subtitleURL) else {
|
||||
print("Error: Invalid subtitle URL")
|
||||
return
|
||||
}
|
||||
guard let url = URL(string: subtitleURL) else {
|
||||
print("Error: Invalid subtitle URL")
|
||||
return
|
||||
}
|
||||
|
||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
||||
if let result = result {
|
||||
let internalName = "Track \(self.customSubtitles.count + 1)"
|
||||
print("Subtitle added with result: \(result) \(internalName)")
|
||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
||||
} else {
|
||||
print("Failed to add subtitle")
|
||||
}
|
||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
||||
if let result = result {
|
||||
let internalName = "Track \(self.customSubtitles.count + 1)"
|
||||
print("Subtitle added with result: \(result) \(internalName)")
|
||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
||||
} else {
|
||||
print("Failed to add subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,135 +236,6 @@ class VlcPlayerView: ExpoView {
|
||||
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) {
|
||||
guard !isStopping else {
|
||||
completion?()
|
||||
@@ -479,15 +256,11 @@ class VlcPlayerView: ExpoView {
|
||||
// MARK: - Private Methods
|
||||
|
||||
@objc private func applicationWillResignActive() {
|
||||
if !isPaused {
|
||||
pause()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@objc private func applicationWillEnterForeground() {
|
||||
if !isPaused {
|
||||
play()
|
||||
}
|
||||
@objc private func applicationDidBecomeActive() {
|
||||
|
||||
}
|
||||
|
||||
private func performStop(completion: (() -> Void)? = nil) {
|
||||
@@ -509,42 +282,31 @@ class VlcPlayerView: ExpoView {
|
||||
completion?()
|
||||
}
|
||||
|
||||
private func getSubtitleOptions() -> [String: Any] {
|
||||
return [
|
||||
// Text scaling (100 is default, increase for larger text)
|
||||
"sub-text-scale": "105",
|
||||
private func updateVideoProgress() {
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
|
||||
// Text color (RRGGBB format, 16777215 is white)
|
||||
"freetype-color": "16777215",
|
||||
let currentTimeMs = player.time.intValue
|
||||
let durationMs = player.media?.length.intValue ?? 0
|
||||
|
||||
// Outline thickness (reduced from 2 to 1 for less border)
|
||||
"freetype-outline-thickness": "1",
|
||||
|
||||
// Outline color (RRGGBB format, 0 is black)
|
||||
"freetype-outline-color": "0",
|
||||
|
||||
// Text opacity (0-255, 255 is fully opaque)
|
||||
"freetype-opacity": "255",
|
||||
|
||||
// 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)
|
||||
"freetype-shadow-offset": "3",
|
||||
|
||||
// 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",
|
||||
]
|
||||
print("Debug: Current time: \(currentTimeMs)")
|
||||
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"], !name.isEmpty {
|
||||
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
|
||||
self.setSubtitleURL(deliveryUrl, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.onVideoProgress?([
|
||||
"currentTime": currentTimeMs,
|
||||
"duration": durationMs,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Expo Events
|
||||
|
||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
||||
@@ -562,116 +324,64 @@ class VlcPlayerView: ExpoView {
|
||||
}
|
||||
|
||||
extension VlcPlayerView: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.updatePlayerState()
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||
// self?.updateVideoProgress()
|
||||
let timeNow = Date().timeIntervalSince1970
|
||||
if timeNow - lastProgressCall >= 1 {
|
||||
lastProgressCall = timeNow
|
||||
updateVideoProgress()
|
||||
}
|
||||
}
|
||||
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
||||
self.updatePlayerState()
|
||||
}
|
||||
|
||||
private func updatePlayerState() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, let player = self.mediaPlayer else { return }
|
||||
let currentState = player.state
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
let currentState = player.state
|
||||
|
||||
var stateInfo: [String: Any] = [
|
||||
"target": self.reactTag ?? NSNull(),
|
||||
"currentTime": player.time.intValue,
|
||||
"duration": player.media?.length.intValue ?? 0,
|
||||
"error": false,
|
||||
]
|
||||
var stateInfo: [String: Any] = [
|
||||
"target": self.reactTag ?? NSNull(),
|
||||
"currentTime": player.time.intValue,
|
||||
"duration": player.media?.length.intValue ?? 0,
|
||||
"error": false,
|
||||
]
|
||||
|
||||
if player.isPlaying {
|
||||
stateInfo["isPlaying"] = true
|
||||
stateInfo["isBuffering"] = false
|
||||
stateInfo["state"] = "Playing"
|
||||
} else {
|
||||
stateInfo["isPlaying"] = false
|
||||
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)
|
||||
}
|
||||
if player.isPlaying {
|
||||
stateInfo["isPlaying"] = true
|
||||
stateInfo["isBuffering"] = false
|
||||
stateInfo["state"] = "Playing"
|
||||
} else {
|
||||
stateInfo["isPlaying"] = false
|
||||
stateInfo["state"] = "Paused"
|
||||
}
|
||||
}
|
||||
|
||||
// func seekToStartTime() {
|
||||
// DispatchQueue.main.async { [weak self] in
|
||||
// guard let self = self, let player = self.mediaPlayer else { return }
|
||||
|
||||
// if let startPosition = self.startPosition, startPosition > 0 {
|
||||
// print("Debug: Seeking to start position: \(startPosition)")
|
||||
// player.time = VLCTime(int: Int32(startPosition))
|
||||
|
||||
// // Ensure the player continues playing after seeking
|
||||
// if !player.isPlaying {
|
||||
// player.play()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.updateVideoProgress()
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
private func updateVideoProgress() {
|
||||
DispatchQueue.main.async {
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
|
||||
let currentTimeMs = player.time.intValue
|
||||
let durationMs = player.media?.length.intValue ?? 0
|
||||
|
||||
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,
|
||||
])
|
||||
}
|
||||
if self.lastReportedState != currentState
|
||||
|| self.lastReportedIsPlaying != player.isPlaying
|
||||
{
|
||||
self.lastReportedState = currentState
|
||||
self.lastReportedIsPlaying = player.isPlaying
|
||||
self.onVideoStateChange?(stateInfo)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension VlcPlayerView: VLCMediaDelegate {
|
||||
// func mediaMetaDataDidChange(_ aMedia: VLCMedia) {
|
||||
// // Implement if needed
|
||||
// }
|
||||
|
||||
// func mediaDidFinishParsing(_ aMedia: VLCMedia) {
|
||||
// DispatchQueue.main.async {
|
||||
// let duration = aMedia.length.intValue
|
||||
// self.onVideoStateChange?(["type": "MediaParsed", "duration": duration])
|
||||
// }
|
||||
// }
|
||||
// Implement VLCMediaDelegate methods if needed
|
||||
}
|
||||
|
||||
extension VLCMediaPlayerState {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"preset": "jest-expo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "^0.7.1",
|
||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||
"@expo/react-native-action-sheet": "^4.1.0",
|
||||
"@expo/vector-icons": "^14.0.4",
|
||||
@@ -39,6 +40,7 @@
|
||||
"expo-asset": "~10.0.10",
|
||||
"expo-background-fetch": "~12.0.1",
|
||||
"expo-blur": "~13.0.2",
|
||||
"expo-brightness": "~12.0.1",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-dev-client": "~4.0.29",
|
||||
@@ -69,8 +71,9 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "0.74.5",
|
||||
"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-color-matrix-image-filters": "^7.0.1",
|
||||
"react-native-compressor": "^1.9.0",
|
||||
"react-native-device-info": "^14.0.1",
|
||||
"react-native-edge-to-edge": "^1.1.1",
|
||||
@@ -82,6 +85,7 @@
|
||||
"react-native-ios-utilities": "^4.5.1",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.3.0",
|
||||
"react-native-progress": "^5.0.1",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
"react-native-reanimated-carousel": "4.0.0-canary.15",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { useLog, writeToLog } from "@/utils/log";
|
||||
import {
|
||||
cancelAllJobs,
|
||||
cancelJobById,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import axios from "axios";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -46,12 +46,17 @@ import * as Notifications from "expo-notifications";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import useImageStorage from "@/hooks/useImageStorage";
|
||||
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 = {
|
||||
item: Partial<BaseItemDto>;
|
||||
mediaSource: MediaSourceInfo;
|
||||
};
|
||||
|
||||
export const processesAtom = atom<JobStatus[]>([]);
|
||||
|
||||
function onAppStateChange(status: AppStateStatus) {
|
||||
focusManager.setFocused(status === "active");
|
||||
}
|
||||
@@ -65,10 +70,12 @@ function useDownloadProvider() {
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { logs } = useLog();
|
||||
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const { saveImage } = useImageStorage();
|
||||
|
||||
const [processes, setProcesses] = useState<JobStatus[]>([]);
|
||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||
|
||||
const authHeader = useMemo(() => {
|
||||
return api?.accessToken;
|
||||
@@ -258,8 +265,11 @@ function useDownloadProvider() {
|
||||
)
|
||||
);
|
||||
})
|
||||
.done(async () => {
|
||||
await saveDownloadedItemInfo(process.item);
|
||||
.done(async (doneHandler) => {
|
||||
await saveDownloadedItemInfo(
|
||||
process.item,
|
||||
doneHandler.bytesDownloaded
|
||||
);
|
||||
toast.success(`Download completed for ${process.item.Name}`, {
|
||||
duration: 3000,
|
||||
action: {
|
||||
@@ -311,6 +321,7 @@ function useDownloadProvider() {
|
||||
const fileExtension = mediaSource.TranscodingContainer;
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
|
||||
await saveSeriesPrimaryImage(item);
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
@@ -384,19 +395,25 @@ function useDownloadProvider() {
|
||||
);
|
||||
|
||||
const deleteAllFiles = async (): Promise<void> => {
|
||||
try {
|
||||
await deleteLocalFiles();
|
||||
removeDownloadedItemsFromStorage();
|
||||
await cancelAllServerJobs();
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
toast.success("All files, folders, and jobs deleted successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete all files, folders, and jobs:", error);
|
||||
toast.error("An error occurred while deleting files and jobs");
|
||||
}
|
||||
Promise.all([
|
||||
deleteLocalFiles(),
|
||||
removeDownloadedItemsFromStorage(),
|
||||
cancelAllServerJobs(),
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
|
||||
])
|
||||
.then(() =>
|
||||
toast.success("All files, folders, and jobs deleted successfully")
|
||||
)
|
||||
.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;
|
||||
if (!baseDirectory) {
|
||||
throw new Error("Base directory not found");
|
||||
@@ -404,25 +421,31 @@ function useDownloadProvider() {
|
||||
|
||||
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
||||
for (const item of dirContents) {
|
||||
const itemPath = `${baseDirectory}${item}`;
|
||||
const itemInfo = await FileSystem.getInfoAsync(itemPath);
|
||||
// Exclude mmkv directory.
|
||||
// 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.isDirectory) {
|
||||
await FileSystem.deleteAsync(itemPath, { idempotent: true });
|
||||
} else {
|
||||
await FileSystem.deleteAsync(itemPath, { idempotent: true });
|
||||
}
|
||||
callback(itemInfo);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeDownloadedItemsFromStorage = (): void => {
|
||||
try {
|
||||
storage.delete("downloadedItems");
|
||||
} catch (error) {
|
||||
console.error("Failed to remove downloadedItems from storage:", error);
|
||||
throw error;
|
||||
}
|
||||
const deleteLocalFiles = async (): Promise<void> => {
|
||||
await forEveryDirectoryFile(false, (file) => {
|
||||
console.warn("Deleting file", file.uri);
|
||||
FileSystem.deleteAsync(file.uri, { idempotent: true });
|
||||
});
|
||||
};
|
||||
|
||||
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> => {
|
||||
@@ -430,7 +453,8 @@ function useDownloadProvider() {
|
||||
throw new Error("No auth header available");
|
||||
}
|
||||
if (!settings?.optimizedVersionsServerUrl) {
|
||||
throw new Error("No server URL configured");
|
||||
console.error("No server URL configured");
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
@@ -519,7 +577,7 @@ function useDownloadProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
let items: DownloadedItem[] = downloadedItems
|
||||
@@ -546,6 +604,8 @@ function useDownloadProvider() {
|
||||
deleteDownloadItemInfoFromDiskTmp(item.Id!);
|
||||
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
storage.set("downloadedItemSize-" + item.Id, size.toString());
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
refetch();
|
||||
} 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 {
|
||||
processes,
|
||||
startBackgroundDownload,
|
||||
downloadedFiles,
|
||||
deleteAllFiles,
|
||||
deleteFile,
|
||||
deleteItems,
|
||||
saveDownloadedItemInfo,
|
||||
removeProcess,
|
||||
setProcesses,
|
||||
startDownload,
|
||||
getDownloadedItem,
|
||||
deleteFileByType,
|
||||
appSizeUsage,
|
||||
getDownloadedItemSize,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -587,3 +656,16 @@ export function useDownload() {
|
||||
}
|
||||
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(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.21.0" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.22.0" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -91,7 +91,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.21.0"`,
|
||||
}, DeviceId="${deviceId}", Version="0.22.0"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { Bitrate } from "@/components/BitrateSelector";
|
||||
import { settingsAtom } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
PlaybackInfoResponse,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtomValue } from "jotai";
|
||||
@@ -19,7 +16,6 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||
|
||||
export type PlaybackType = {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -124,25 +120,25 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
[api, user, settings, playSettings]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const postCaps = async () => {
|
||||
if (!api) return;
|
||||
await getSessionApi(api).postFullCapabilities({
|
||||
clientCapabilitiesDto: {
|
||||
AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
|
||||
DeviceProfile: native as any,
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
// useEffect(() => {
|
||||
// const postCaps = async () => {
|
||||
// if (!api) return;
|
||||
// await getSessionApi(api).postFullCapabilities({
|
||||
// clientCapabilitiesDto: {
|
||||
// AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
|
||||
// DeviceProfile: native as any,
|
||||
// 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,
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
postCaps();
|
||||
}, [settings, api]);
|
||||
// postCaps();
|
||||
// }, [settings, api]);
|
||||
|
||||
return (
|
||||
<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 { atom, useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import {JobStatus} from "@/utils/optimize-server";
|
||||
import {processesAtom} from "@/providers/DownloadProvider";
|
||||
import {useSettings} from "@/utils/atoms/settings";
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
@@ -13,8 +16,8 @@ export const runningAtom = atom<boolean>(false);
|
||||
export const queueAtom = atom<Job[]>([]);
|
||||
|
||||
export const queueActions = {
|
||||
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
|
||||
const updatedQueue = [...queue, job];
|
||||
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, ...job: Job[]) => {
|
||||
const updatedQueue = [...queue, ...job];
|
||||
console.info("Enqueueing job", job, updatedQueue);
|
||||
setQueue(updatedQueue);
|
||||
},
|
||||
@@ -24,14 +27,17 @@ export const queueActions = {
|
||||
setProcessing: (processing: boolean) => void
|
||||
) => {
|
||||
const [job, ...rest] = queue;
|
||||
setQueue(rest);
|
||||
|
||||
console.info("Processing job", job);
|
||||
|
||||
setProcessing(true);
|
||||
|
||||
// Excute the function assiociated with the job.
|
||||
await job.execute();
|
||||
// Allow job to execute so that it gets added as a processes first BEFORE updating new queue
|
||||
try {
|
||||
await job.execute();
|
||||
} finally {
|
||||
setQueue(rest);
|
||||
}
|
||||
|
||||
console.info("Job done", job);
|
||||
|
||||
@@ -49,11 +55,13 @@ export const queueActions = {
|
||||
export const useJobProcessor = () => {
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [running, setRunning] = useAtom(runningAtom);
|
||||
const [processes] = useAtom<JobStatus[]>(processesAtom);
|
||||
const [settings] = useSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (queue.length > 0 && !running) {
|
||||
if (!running && queue.length > 0 && settings && processes.length < settings?.remuxConcurrentLimit) {
|
||||
console.info("Processing queue", queue);
|
||||
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 * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { storage } from "../mmkv";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export type DownloadQuality = "original" | "high" | "low";
|
||||
|
||||
@@ -74,6 +75,9 @@ export type Settings = {
|
||||
optimizedVersionsServerUrl?: string | null;
|
||||
downloadMethod: "optimized" | "remux";
|
||||
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 => {
|
||||
@@ -103,6 +107,9 @@ const loadSettings = (): Settings => {
|
||||
optimizedVersionsServerUrl: null,
|
||||
downloadMethod: "remux",
|
||||
autoDownload: false,
|
||||
showCustomMenuLinks: false,
|
||||
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
||||
remuxConcurrentLimit: 1,
|
||||
};
|
||||
|
||||
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;
|
||||
bitrate: (typeof BITRATES)[0];
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
audioIndex?: number | null;
|
||||
subtitleIndex?: number | null;
|
||||
audioIndex?: number | undefined;
|
||||
subtitleIndex?: number | undefined;
|
||||
}
|
||||
|
||||
export function getDefaultPlaySettings(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
import { storage } from "./mmkv";
|
||||
import {useQuery} from "@tanstack/react-query";
|
||||
import React, {createContext, useContext} from "react";
|
||||
|
||||
type LogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
@@ -17,6 +19,24 @@ const mmkvStorage = createJSONStorage(() => ({
|
||||
}));
|
||||
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) => {
|
||||
const newEntry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -35,6 +55,9 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => {
|
||||
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[] => {
|
||||
const logs = storage.getString("logs");
|
||||
return logs ? JSON.parse(logs) : [];
|
||||
@@ -44,4 +67,22 @@ export const clearLogs = () => {
|
||||
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;
|
||||
@@ -1,18 +1,25 @@
|
||||
import {
|
||||
DeviceProfile
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
export const chromecastProfile: DeviceProfile = {
|
||||
Name: "Chromecast Video Profile",
|
||||
Id: "chromecast-001",
|
||||
MaxStreamingBitrate: 4000000, // 4 Mbps
|
||||
MaxStaticBitrate: 4000000, // 4 Mbps
|
||||
MaxStreamingBitrate: 8000000, // 8 Mbps
|
||||
MaxStaticBitrate: 8000000, // 8 Mbps
|
||||
MusicStreamingTranscodingBitrate: 384000, // 384 kbps
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: "Video",
|
||||
Codec: "h264",
|
||||
},
|
||||
{
|
||||
Type: "Audio",
|
||||
Codec: "aac,mp3,flac,opus,vorbis",
|
||||
},
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Container: "mp4,webm",
|
||||
Container: "mp4",
|
||||
Type: "Video",
|
||||
VideoCodec: "h264,vp8,vp9",
|
||||
VideoCodec: "h264",
|
||||
AudioCodec: "aac,mp3,opus,vorbis",
|
||||
},
|
||||
{
|
||||
@@ -34,89 +41,32 @@ export const chromecastProfile: DeviceProfile = {
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Container: "ts",
|
||||
Type: "Video",
|
||||
VideoCodec: "h264",
|
||||
AudioCodec: "aac,mp3",
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: "2",
|
||||
MinSegments: 2,
|
||||
BreakOnNonKeyFrames: true,
|
||||
Container: "ts",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3",
|
||||
CopyTimestamps: false,
|
||||
EnableSubtitlesInManifest: true,
|
||||
},
|
||||
{
|
||||
Container: "mp4",
|
||||
Type: "Video",
|
||||
VideoCodec: "h264",
|
||||
AudioCodec: "aac",
|
||||
Type: "Audio",
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
{
|
||||
Container: "mp3",
|
||||
Type: "Audio",
|
||||
AudioCodec: "mp3",
|
||||
Protocol: "http",
|
||||
Context: "Streaming",
|
||||
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: [
|
||||
{
|
||||
Format: "vtt",
|
||||
Method: "Hls",
|
||||
Method: "Encode",
|
||||
},
|
||||
{
|
||||
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 {
|
||||
Name: "1. Vlc Player",
|
||||
MaxStaticBitrate: 20_000_000,
|
||||
MaxStreamingBitrate: 20_000_000,
|
||||
MaxStaticBitrate: 999_999_999,
|
||||
MaxStreamingBitrate: 999_999_999,
|
||||
CodecProfiles: [
|
||||
{
|
||||
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