forked from Ninjalama/streamyfin_mirror
Compare commits
111 Commits
fix/global
...
feat/conte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90930d478c | ||
|
|
8608ad02f7 | ||
|
|
030947fc38 | ||
|
|
9b18188b32 | ||
|
|
d86853dec9 | ||
|
|
0750acdc13 | ||
|
|
d8231f5b80 | ||
|
|
41d17499bb | ||
|
|
60f1217cae | ||
|
|
834de10e34 | ||
|
|
51f17f983d | ||
|
|
ba4a2c0b79 | ||
|
|
a32eb710ec | ||
|
|
cb05da782a | ||
|
|
5a680a4392 | ||
|
|
8a44d2ff15 | ||
|
|
f3f260625f | ||
|
|
6908620f4e | ||
|
|
9932266203 | ||
|
|
cb2268e39c | ||
|
|
bf9be278d3 | ||
|
|
584fcc09d6 | ||
|
|
7a26b5004b | ||
|
|
ae92692ea0 | ||
|
|
92e4b3b8cf | ||
|
|
127ec1391b | ||
|
|
0ac4f826bc | ||
|
|
6190f2e602 | ||
|
|
24fdd071af | ||
|
|
be3122caac | ||
|
|
39a220bbed | ||
|
|
e3bdbb5cbd | ||
|
|
b6ad05d980 | ||
|
|
0360b5cbd5 | ||
|
|
a9b1d9fb0a | ||
|
|
4291ef55b9 | ||
|
|
655060fb40 | ||
|
|
0e29b8b671 | ||
|
|
72f64c71dd | ||
|
|
ddfd9f6ce3 | ||
|
|
67fb339d40 | ||
|
|
9e0a7f047c | ||
|
|
aab806bbf4 | ||
|
|
4a53b20618 | ||
|
|
45299a5c5d | ||
|
|
65ad4effca | ||
|
|
35fcb5ca0c | ||
|
|
5dc0066370 | ||
|
|
3fb20a8ca2 | ||
|
|
180ed54fed | ||
|
|
72859b4ae3 | ||
|
|
bfe96edb29 | ||
|
|
46f4acdad0 | ||
|
|
da1aa9f48c | ||
|
|
1d0d99c79b | ||
|
|
33a6295b20 | ||
|
|
72cc381087 | ||
|
|
c4bfaf2d56 | ||
|
|
487ac398e5 | ||
|
|
84fd0edc49 | ||
|
|
0e1583c440 | ||
|
|
6459e5f323 | ||
|
|
319e1fd53f | ||
|
|
93bd817eaf | ||
|
|
d9f21e6824 | ||
|
|
d287f5d082 | ||
|
|
ecd2fa386e | ||
|
|
7c022bbaff | ||
|
|
5d79ee34cf | ||
|
|
b0adad8dc4 | ||
|
|
c3d3f538d7 | ||
|
|
6b6dedf303 | ||
|
|
8d22e4c075 | ||
|
|
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 | ||
|
|
a6b49c42cf | ||
|
|
65d3da155f | ||
|
|
d616574232 | ||
|
|
b8b083abe2 | ||
|
|
49a1bffcf5 | ||
|
|
cb6c716830 | ||
|
|
a725af114c | ||
|
|
5b290fd667 | ||
|
|
de4f60f564 | ||
|
|
a4cd3ea600 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ credentials.json
|
||||
*.ipa
|
||||
.continuerc.json
|
||||
|
||||
.vscode/
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -11,7 +11,5 @@
|
||||
},
|
||||
"[swift]": {
|
||||
"editor.defaultFormatter": "sswg.swift-lang"
|
||||
},
|
||||
"java.configuration.updateBuildConfiguration": "interactive",
|
||||
"java.compile.nullAnalysis.mode": "automatic"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,48 @@
|
||||
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} 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";
|
||||
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 { seriesId, episodeSeasonIndex } = local as {
|
||||
seriesId: string;
|
||||
episodeSeasonIndex: number | string | undefined;
|
||||
};
|
||||
|
||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>({});
|
||||
const {downloadedFiles, deleteItems} = useDownload();
|
||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||
{}
|
||||
);
|
||||
const { downloadedFiles, deleteItems } = useDownload();
|
||||
|
||||
const series = useMemo(() => {
|
||||
try {
|
||||
return downloadedFiles
|
||||
return (
|
||||
downloadedFiles
|
||||
?.filter((f) => f.item.SeriesId == seriesId)
|
||||
?.sort((a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!)
|
||||
|| [];
|
||||
?.sort(
|
||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
|
||||
) || []
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const seasonIndex = seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || episodeSeasonIndex || "";
|
||||
const seasonIndex =
|
||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||
episodeSeasonIndex ||
|
||||
"";
|
||||
|
||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||
const seasons: Record<string, BaseItemDto[]> = {};
|
||||
@@ -43,13 +54,16 @@ export default function page() {
|
||||
|
||||
seasons[episode.item.ParentIndexNumber!].push(episode.item);
|
||||
});
|
||||
return seasons[seasonIndex]
|
||||
?.sort((a, b) => a.IndexNumber! - b.IndexNumber!)
|
||||
?? []
|
||||
return (
|
||||
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
|
||||
[]
|
||||
);
|
||||
}, [series, seasonIndex]);
|
||||
|
||||
const initialSeasonIndex = useMemo(() =>
|
||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ?? series?.[0]?.item?.ParentIndexNumber,
|
||||
const initialSeasonIndex = useMemo(
|
||||
() =>
|
||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||
series?.[0]?.item?.ParentIndexNumber,
|
||||
[groupBySeason]
|
||||
);
|
||||
|
||||
@@ -58,49 +72,61 @@ export default function page() {
|
||||
navigation.setOptions({
|
||||
title: series[0].item.SeriesName,
|
||||
});
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
storage.delete(seriesId);
|
||||
router.back();
|
||||
}
|
||||
}, [series]);
|
||||
|
||||
const deleteSeries = useCallback(
|
||||
async () => deleteItems(groupBySeason),
|
||||
[groupBySeason]
|
||||
);
|
||||
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 (
|
||||
<>
|
||||
{series.length > 0 && <View className="my-4 flex flex-row items-center justify-start">
|
||||
<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="flex flex-row items-center justify-between w-72">
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
|
||||
</View>
|
||||
<View className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center">
|
||||
<TouchableOpacity onPress={deleteSeries}>
|
||||
<Ionicons name="trash" size={22} color="white"/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<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>}
|
||||
<ScrollView key={seasonIndex}>
|
||||
<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) => (
|
||||
<View className="px-4 flex flex-col my-4" key={index}>
|
||||
<EpisodeCard item={episode}/>
|
||||
</View>
|
||||
<EpisodeCard key={index} item={episode} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function page() {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all movies");
|
||||
});
|
||||
const deleteShows = () => deleteFileByType("Movie")
|
||||
const deleteShows = () => deleteFileByType("Episode")
|
||||
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function index() {
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||
|
||||
const { downloadedFiles } = useDownload();
|
||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -107,6 +107,9 @@ export default function index() {
|
||||
setIsConnected(state.isConnected);
|
||||
});
|
||||
|
||||
cleanCacheDirectory()
|
||||
.then(r => console.log("Cache directory cleaned"))
|
||||
.catch(e => console.error("Something went wrong cleaning cache directory"))
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import {
|
||||
getMediaInfoApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -48,20 +44,25 @@ const Page: React.FC = () => {
|
||||
});
|
||||
|
||||
const fadeOut = (callback: any) => {
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const fadeIn = (callback: any) => {
|
||||
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
fadeOut(() => {});
|
||||
@@ -84,14 +85,24 @@ const Page: React.FC = () => {
|
||||
style={[animatedStyle]}
|
||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||
>
|
||||
<View className="h-[350px] bg-transparent rounded-lg mb-4 w-full"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-1 w-12"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-1/2"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg w-2/3 mb-10"></View>
|
||||
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-1/4"></View>
|
||||
<View
|
||||
style={{
|
||||
height: item?.Type === "Episode" ? 300 : 450,
|
||||
}}
|
||||
className="bg-transparent rounded-lg mb-4 w-full"
|
||||
></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
||||
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
||||
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
|
||||
<View className="flex flex-row space-x-1 mb-8">
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
</View>
|
||||
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
|
||||
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
</Animated.View>
|
||||
{item && <ItemContent item={item} />}
|
||||
</View>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Button, Dimensions, ScrollView, View } from "react-native";
|
||||
import {
|
||||
Button,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const HOUR_HEIGHT = 30;
|
||||
@@ -78,8 +86,6 @@ export default function page() {
|
||||
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
|
||||
|
||||
const [scrollX, setScrollX] = useState(0);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
@@ -100,24 +106,15 @@ export default function page() {
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
style={{
|
||||
marginBottom: TAB_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-row bg-neutral-800 w-full items-end">
|
||||
<Button
|
||||
title="Previous"
|
||||
onPress={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
/>
|
||||
<Button
|
||||
title="Next"
|
||||
onPress={handleNextPage}
|
||||
disabled={
|
||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<PageButtons
|
||||
currentPage={currentPage}
|
||||
onPrevPage={handlePrevPage}
|
||||
onNextPage={handleNextPage}
|
||||
isNextDisabled={
|
||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||
}
|
||||
/>
|
||||
|
||||
<View className="flex flex-row">
|
||||
<View className="flex flex-col w-[64px]">
|
||||
@@ -166,3 +163,57 @@ export default function page() {
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageButtonsProps {
|
||||
currentPage: number;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: () => void;
|
||||
isNextDisabled: boolean;
|
||||
}
|
||||
|
||||
const PageButtons: React.FC<PageButtonsProps> = ({
|
||||
currentPage,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
isNextDisabled,
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
||||
<TouchableOpacity
|
||||
onPress={onPrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={24}
|
||||
color={currentPage === 1 ? "gray" : "white"}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-1 ${
|
||||
currentPage === 1 ? "text-gray-500" : "text-white"
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-white">Page {currentPage}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onNextPage}
|
||||
disabled={isNextDisabled}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Text
|
||||
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||
>
|
||||
Next
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={24}
|
||||
color={isNextDisabled ? "gray" : "white"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,10 +5,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
View
|
||||
} from "react-native";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
@@ -27,9 +24,6 @@ export default function page() {
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
}}
|
||||
style={{
|
||||
marginBottom: TAB_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<ScrollingCollectionList
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DownloadItems } from "@/components/DownloadItem";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||
@@ -6,16 +7,14 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import {useLocalSearchParams, useNavigation} from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {useEffect} from "react";
|
||||
import { useMemo } from "react";
|
||||
import React, { useEffect, 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();
|
||||
@@ -60,7 +59,7 @@ const page: React.FC = () => {
|
||||
[item]
|
||||
);
|
||||
|
||||
const {data: allEpisodes, isLoading} = useQuery({
|
||||
const { data: allEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["AllEpisodes", item?.Id],
|
||||
queryFn: async () => {
|
||||
const res = await getTvShowsApi(api!).getEpisodes({
|
||||
@@ -69,34 +68,38 @@ const page: React.FC = () => {
|
||||
enableUserData: true,
|
||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||
});
|
||||
return res?.data.Items || []
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!item?.Id
|
||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
(!isLoading && allEpisodes && allEpisodes.length > 0) && (
|
||||
headerRight: () =>
|
||||
!isLoading &&
|
||||
allEpisodes &&
|
||||
allEpisodes.length > 0 && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<DownloadItems
|
||||
title="Download Series"
|
||||
items={allEpisodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<MaterialCommunityIcons name="folder-download" size={24} color="white"/>
|
||||
<Ionicons name="download" size={22} color="white" />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<MaterialCommunityIcons name="folder-check" size={26} color="#9333ea"/>
|
||||
<Ionicons
|
||||
name="checkmark-done-outline"
|
||||
size={24}
|
||||
color="#9333ea"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
)
|
||||
})
|
||||
),
|
||||
});
|
||||
}, [allEpisodes, isLoading]);
|
||||
|
||||
if (!item || !backdropUrl)
|
||||
return null;
|
||||
|
||||
if (!item || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
@@ -40,8 +41,7 @@ import {
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -141,6 +141,18 @@ const Page = () => {
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !library) return null;
|
||||
|
||||
console.log("[libraryId] ~", library);
|
||||
|
||||
let itemType: BaseItemKind | undefined;
|
||||
|
||||
// This fix makes sure to only return 1 type of items, if defined.
|
||||
// This is because the underlying directory some times contains other types, and we don't want to show them.
|
||||
if (library.CollectionType === "movies") {
|
||||
itemType = "Movie";
|
||||
} else if (library.CollectionType === "tvshows") {
|
||||
itemType = "Series";
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
@@ -155,6 +167,7 @@ const Page = () => {
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
includeItemTypes: itemType ? [itemType] : undefined,
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
View,
|
||||
AppState,
|
||||
AppStateStatus,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import settings from "../(tabs)/(home)/settings";
|
||||
@@ -125,14 +126,7 @@ export default function page() {
|
||||
isLoading: isLoadingStreamUrl,
|
||||
isError: isErrorStreamUrl,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"stream-url",
|
||||
itemId,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
bitrateValue,
|
||||
],
|
||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||
queryFn: async () => {
|
||||
console.log("Offline:", offline);
|
||||
if (offline) {
|
||||
@@ -254,6 +248,7 @@ export default function page() {
|
||||
videoRef.current?.stop();
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
// TODO: unused should remove.
|
||||
const reportPlaybackStart = useCallback(async () => {
|
||||
if (offline) return;
|
||||
|
||||
@@ -287,7 +282,12 @@ export default function page() {
|
||||
|
||||
if (!item?.Id || !stream) return;
|
||||
|
||||
console.log("onProgress ~", currentTimeInTicks, isPlaying);
|
||||
console.log(
|
||||
"onProgress ~",
|
||||
currentTimeInTicks,
|
||||
isPlaying,
|
||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
||||
);
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
@@ -300,7 +300,7 @@ export default function page() {
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
},
|
||||
[item?.Id, isPlaying, api, isPlaybackStopped]
|
||||
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
||||
);
|
||||
|
||||
useOrientation();
|
||||
@@ -342,22 +342,11 @@ 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");
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
@@ -460,7 +449,7 @@ export default function page() {
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
opacity: showControls ? 0.5 : 1,
|
||||
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
|
||||
}}
|
||||
>
|
||||
<VlcPlayerView
|
||||
@@ -513,7 +502,7 @@ export default function page() {
|
||||
enableTrickplay={true}
|
||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||
offline={false}
|
||||
offline={offline}
|
||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||
setAudioTrack={videoRef.current.setAudioTrack}
|
||||
|
||||
@@ -30,7 +30,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { BackHandler, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, {
|
||||
OnProgressData,
|
||||
@@ -38,6 +38,7 @@ import Video, {
|
||||
SelectedTrackType,
|
||||
VideoRef,
|
||||
} from "react-native-video";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
|
||||
const Player = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
@@ -53,6 +54,7 @@ const Player = () => {
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
|
||||
const setShowControls = useCallback((show: boolean) => {
|
||||
_setShowControls(show);
|
||||
@@ -111,19 +113,14 @@ const Player = () => {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
|
||||
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
|
||||
const {
|
||||
data: stream,
|
||||
isLoading: isLoadingStreamUrl,
|
||||
isError: isErrorStreamUrl,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"stream-url",
|
||||
itemId,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
bitrateValue,
|
||||
mediaSourceId,
|
||||
],
|
||||
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
|
||||
|
||||
queryFn: async () => {
|
||||
if (!api) {
|
||||
@@ -263,6 +260,13 @@ const Player = () => {
|
||||
progress.value = ticks;
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
|
||||
console.log(
|
||||
"onProgress ~",
|
||||
ticks,
|
||||
isPlaying,
|
||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
||||
);
|
||||
|
||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||
// TODO: since playable duration is always 0 then.
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
@@ -324,25 +328,25 @@ const Player = () => {
|
||||
SelectedTrack | undefined
|
||||
>(undefined);
|
||||
|
||||
// Set intial Subtitle Track.
|
||||
// We will only select external tracks if they are are text based. Else it should be burned in already.
|
||||
const textSubs =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(sub) => sub.Type === "Subtitle" && sub.IsTextSubtitleStream
|
||||
) || [];
|
||||
|
||||
const uniqueTextSubs = Array.from(
|
||||
new Set(textSubs.map((sub) => sub.DisplayTitle))
|
||||
).map((title) => textSubs.find((sub) => sub.DisplayTitle === title));
|
||||
const chosenSubtitleTrack = textSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex
|
||||
);
|
||||
useEffect(() => {
|
||||
if (chosenSubtitleTrack && selectedTextTrack === undefined) {
|
||||
console.log("Setting selected text track", chosenSubtitleTrack);
|
||||
if (selectedTextTrack === undefined) {
|
||||
const subtitleHelper = new SubtitleHelper(
|
||||
stream?.mediaSource.MediaStreams ?? []
|
||||
);
|
||||
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
|
||||
subtitleIndex!
|
||||
);
|
||||
|
||||
// Most likely the subtitle is burned in.
|
||||
if (embeddedTrackIndex === -1) return;
|
||||
console.log(
|
||||
"Setting selected text track",
|
||||
subtitleIndex,
|
||||
embeddedTrackIndex
|
||||
);
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: uniqueTextSubs.indexOf(chosenSubtitleTrack),
|
||||
value: embeddedTrackIndex,
|
||||
});
|
||||
}
|
||||
}, [embededTextTracks]);
|
||||
@@ -362,23 +366,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();
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
|
||||
@@ -336,7 +336,11 @@ function Layout() {
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
|
||||
110
app/login.tsx
110
app/login.tsx
@@ -6,7 +6,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
@@ -65,6 +66,23 @@ const Login: React.FC = () => {
|
||||
})();
|
||||
}, [_apiUrl, _username, _password]);
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerTitle: serverName,
|
||||
headerLeft: () =>
|
||||
api?.basePath ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
) : null,
|
||||
});
|
||||
}, [serverName, navigation, api?.basePath]);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
@@ -103,37 +121,19 @@ const Login: React.FC = () => {
|
||||
* - Logs errors and timeout information to the console.
|
||||
*/
|
||||
async function checkUrl(url: string) {
|
||||
url = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
setLoadingServerCheck(true);
|
||||
|
||||
const protocols = ["https://", "http://"];
|
||||
const timeout = 2000; // 2 seconds timeout for long 404 responses
|
||||
|
||||
try {
|
||||
for (const protocol of protocols) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
const response = await fetch(`${url}/System/Info/Public`, {
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${protocol}${url}/System/Info/Public`, {
|
||||
mode: "cors",
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
setServerName(data.ServerName || "");
|
||||
return `${protocol}${url}`;
|
||||
}
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
if (error.name === "AbortError") {
|
||||
console.error(`Request to ${protocol}${url} timed out`);
|
||||
} else {
|
||||
console.error(`Error checking ${protocol}${url}:`, error);
|
||||
}
|
||||
}
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
setServerName(data.ServerName || "");
|
||||
return url;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} finally {
|
||||
setLoadingServerCheck(false);
|
||||
@@ -159,9 +159,7 @@ const Login: React.FC = () => {
|
||||
const handleConnect = async (url: string) => {
|
||||
url = url.trim();
|
||||
|
||||
const result = await checkUrl(
|
||||
url.startsWith("http") ? new URL(url).host : url
|
||||
);
|
||||
const result = await checkUrl(url);
|
||||
|
||||
if (result === undefined) {
|
||||
Alert.alert(
|
||||
@@ -171,7 +169,7 @@ const Login: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setServer({ address: result });
|
||||
setServer({ address: url });
|
||||
};
|
||||
|
||||
const handleQuickConnect = async () => {
|
||||
@@ -196,38 +194,21 @@ const Login: React.FC = () => {
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1, height: "100%" }}
|
||||
>
|
||||
<View className="flex flex-col w-full h-full relative items-center justify-center">
|
||||
<View className="px-4 -mt-20">
|
||||
<View className="mb-4">
|
||||
<Text className="text-3xl font-bold mb-1">
|
||||
{serverName || "Streamyfin"}
|
||||
</Text>
|
||||
<View className="bg-neutral-900 rounded-xl p-4 mb-2 flex flex-row items-center justify-between">
|
||||
<Text className="">URL</Text>
|
||||
<Text numberOfLines={1} className="shrink">
|
||||
{api.basePath}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
justify="between"
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name="arrow-back-outline"
|
||||
size={18}
|
||||
color={"white"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Change server
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col h-full relative items-center justify-center">
|
||||
<View className="px-4 -mt-20 w-full">
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold">Log in</Text>
|
||||
<Text className="text-2xl font-bold -mb-2">
|
||||
Log in
|
||||
<>
|
||||
{serverName ? (
|
||||
<>
|
||||
{" to "}
|
||||
<Text className="text-purple-600">{serverName}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400">{serverURL}</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
@@ -301,7 +282,7 @@ const Login: React.FC = () => {
|
||||
/>
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="text-neutral-500">
|
||||
Connect to your Jellyfin server
|
||||
Enter the URL to your Jellyfin server
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Server URL"
|
||||
@@ -313,6 +294,9 @@ const Login: React.FC = () => {
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
Make sure to include http or https
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||
<Button
|
||||
|
||||
@@ -3,7 +3,8 @@ import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
onPress?: () => void;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
|
||||
@@ -10,6 +10,7 @@ import GoogleCast, {
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
width?: number;
|
||||
@@ -53,51 +54,30 @@ export const Chromecast: React.FC<Props> = ({
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
<AndroidCastButton />
|
||||
</>
|
||||
);
|
||||
|
||||
if (Platform.OS === "android")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<RoundButton
|
||||
size="large"
|
||||
className="mr-2"
|
||||
background={false}
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
</RoundButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<RoundButton
|
||||
size="large"
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<BlurView
|
||||
intensity={100}
|
||||
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</BlurView>
|
||||
<AndroidCastButton />
|
||||
</TouchableOpacity>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -18,10 +18,10 @@ import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {Href, router, useFocusEffect} from "expo-router";
|
||||
import { Href, router, useFocusEffect } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Alert, View, ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
@@ -30,18 +30,25 @@ import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
MissingDownloadIconComponent: () => React.ReactElement;
|
||||
DownloadedIconComponent: () => React.ReactElement;
|
||||
MissingDownloadIconComponent: () => React.ReactElement;
|
||||
DownloadedIconComponent: () => React.ReactElement;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
items,
|
||||
MissingDownloadIconComponent,
|
||||
DownloadedIconComponent,
|
||||
title = "Download",
|
||||
subtitle = "",
|
||||
size = "default",
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -51,20 +58,26 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||
const { startRemuxing } = useRemuxHlsToMp4();
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<MediaSourceInfo | undefined | null>(undefined);
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined | null
|
||||
>(undefined);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] = useState<number>(0);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const userCanDownload = useMemo(() => user?.Policy?.EnableContentDownloading, [user]);
|
||||
const usingOptimizedServer = useMemo(() => settings?.downloadMethod === "optimized", [settings]);
|
||||
const userCanDownload = useMemo(
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
[user]
|
||||
);
|
||||
const usingOptimizedServer = useMemo(
|
||||
() => settings?.downloadMethod === "optimized",
|
||||
[settings]
|
||||
);
|
||||
|
||||
/**
|
||||
* Bottom sheet
|
||||
*/
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const handlePresentModalPress = useCallback(() => {
|
||||
@@ -77,74 +90,74 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}, []);
|
||||
|
||||
// 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)),
|
||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||
|
||||
const itemsNotDownloaded = 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 itemsProcesses = useMemo(() =>
|
||||
processes?.filter(p => itemIds.includes(p.item.Id)),
|
||||
const allItemsDownloaded = useMemo(() => {
|
||||
if (items.length === 0) return false;
|
||||
return itemsNotDownloaded.length === 0;
|
||||
}, [items, itemsNotDownloaded]);
|
||||
const itemsProcesses = useMemo(
|
||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
||||
[processes, itemIds]
|
||||
);
|
||||
|
||||
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]
|
||||
);
|
||||
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 itemsQueued = useMemo(() => {
|
||||
return pendingItems.length > 0 && pendingItems.every(p => queue.some(q => p.Id == q.item.Id))
|
||||
},
|
||||
[queue, pendingItems]
|
||||
);
|
||||
// endregion computed
|
||||
|
||||
// region helper functions
|
||||
return (
|
||||
itemsNotDownloaded.length > 0 &&
|
||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
|
||||
);
|
||||
}, [queue, itemsNotDownloaded]);
|
||||
const navigateToDownloads = () => router.push("/downloads");
|
||||
|
||||
const onDownloadedPress = () => {
|
||||
const firstItem = items?.[0]
|
||||
const firstItem = items?.[0];
|
||||
router.push(
|
||||
firstItem.Type !== "Episode"
|
||||
? "/downloads"
|
||||
: {
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber
|
||||
}
|
||||
} as Href
|
||||
: ({
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
},
|
||||
} as Href)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptDownloadOptions = useCallback(() => {
|
||||
if (userCanDownload === true) {
|
||||
if (pendingItems.some(i => !i.Id)) {
|
||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
closeModal();
|
||||
|
||||
if (usingOptimizedServer)
|
||||
initiateDownload(...pendingItems);
|
||||
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
||||
else {
|
||||
queueActions.enqueue(
|
||||
queue,
|
||||
setQueue,
|
||||
...pendingItems.map(item => ({
|
||||
...itemsNotDownloaded.map((item) => ({
|
||||
id: item.Id!,
|
||||
execute: async () => await initiateDownload(item),
|
||||
item,
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error("You are not allowed to download files.");
|
||||
@@ -152,79 +165,87 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
}, [
|
||||
queue,
|
||||
setQueue,
|
||||
pendingItems,
|
||||
itemsNotDownloaded,
|
||||
usingOptimizedServer,
|
||||
userCanDownload,
|
||||
|
||||
// Need to be reference at the time async lambda is created for initiateDownload
|
||||
maxBitrate,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream
|
||||
])
|
||||
|
||||
/**
|
||||
* Start download
|
||||
*/
|
||||
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
|
||||
|
||||
for (const item of items) {
|
||||
if (pendingItems.length > 1) {
|
||||
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(item, settings!));
|
||||
}
|
||||
|
||||
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, source);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
pendingItems,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
maxBitrate,
|
||||
usingOptimizedServer,
|
||||
startBackgroundDownload,
|
||||
startRemuxing,
|
||||
]);
|
||||
|
||||
const initiateDownload = useCallback(
|
||||
async (...items: BaseItemDto[]) => {
|
||||
if (
|
||||
!api ||
|
||||
!user?.Id ||
|
||||
items.some((p) => !p.Id) ||
|
||||
(itemsNotDownloaded.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;
|
||||
|
||||
for (const item of items) {
|
||||
if (itemsNotDownloaded.length > 1) {
|
||||
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
||||
item,
|
||||
settings!
|
||||
));
|
||||
}
|
||||
|
||||
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, source);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
itemsNotDownloaded,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
maxBitrate,
|
||||
usingOptimizedServer,
|
||||
startBackgroundDownload,
|
||||
startRemuxing,
|
||||
]
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
@@ -235,58 +256,61 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
),
|
||||
[]
|
||||
);
|
||||
// endregion helper functions
|
||||
|
||||
// Allow to select & set settings for single download
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
if (pendingItems.length !== 1) return;
|
||||
if (itemsNotDownloaded.length !== 1) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(items[0], settings);
|
||||
|
||||
// 4. Set states
|
||||
setSelectedMediaSource(mediaSource ?? undefined);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [items, pendingItems, settings])
|
||||
}, [items, itemsNotDownloaded, settings])
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
{processes && itemsProcesses.length > 0 ? (
|
||||
<TouchableOpacity onPress={navigateToDownloads}>
|
||||
{progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={progress}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : itemsQueued ? (
|
||||
<TouchableOpacity onPress={navigateToDownloads}>
|
||||
<Ionicons name="hourglass" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
) : isDownloaded ? (
|
||||
<TouchableOpacity onPress={onDownloadedPress}>
|
||||
{DownloadedIconComponent()}
|
||||
</TouchableOpacity>
|
||||
const renderButtonContent = () => {
|
||||
if (processes && itemsProcesses.length > 0) {
|
||||
return progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<TouchableOpacity onPress={handlePresentModalPress}>
|
||||
{MissingDownloadIconComponent()}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={progress}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else if (itemsQueued) {
|
||||
return <Ionicons name="hourglass" size={24} color="white" />;
|
||||
} else if (allItemsDownloaded) {
|
||||
return <DownloadedIconComponent />;
|
||||
} else {
|
||||
return <MissingDownloadIconComponent />;
|
||||
}
|
||||
};
|
||||
|
||||
const onButtonPress = () => {
|
||||
if (processes && itemsProcesses.length > 0) {
|
||||
navigateToDownloads();
|
||||
} else if (itemsQueued) {
|
||||
navigateToDownloads();
|
||||
} else if (allItemsDownloaded) {
|
||||
onDownloadedPress();
|
||||
} else {
|
||||
handlePresentModalPress();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton size={size} onPress={onButtonPress}>
|
||||
{renderButtonContent()}
|
||||
</RoundButton>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
@@ -301,16 +325,21 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<Text className="font-bold text-2xl text-neutral-10">
|
||||
Download options
|
||||
</Text>
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">
|
||||
{title}
|
||||
</Text>
|
||||
<Text className="text-neutral-300">
|
||||
{subtitle || `Download ${itemsNotDownloaded.length} items`}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2 w-full items-start">
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={setMaxBitrate}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
{pendingItems.length === 1 && (
|
||||
{itemsNotDownloaded.length === 1 && (
|
||||
<>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
@@ -343,7 +372,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
</Button>
|
||||
<View className="opacity-70 text-center w-full flex items-center">
|
||||
<Text className="text-xs">
|
||||
{usingOptimizedServer ? "Using optimized server" : "Using default method"}
|
||||
{usingOptimizedServer
|
||||
? "Using optimized server"
|
||||
: "Using default method"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -353,9 +384,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
export const DownloadSingleItem: React.FC<{
|
||||
size?: "default" | "large";
|
||||
item: BaseItemDto;
|
||||
}> = ({ item, size = "default" }) => {
|
||||
return (
|
||||
<DownloadItems
|
||||
size={size}
|
||||
title="Download Episode"
|
||||
subtitle={item.Name!}
|
||||
items={[item]}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
@@ -364,5 +401,5 @@ export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({ item }) =>
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,12 +13,13 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||
<View className="mt-2 flex flex-col">
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} className="">
|
||||
{item.SeriesName}
|
||||
<Text numberOfLines={1} className="">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
||||
{item.Name}
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
{item.SeriesName}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import {DownloadSingleItem} from "@/components/DownloadItem";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
@@ -20,6 +20,7 @@ import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
@@ -32,6 +33,8 @@ import { Chromecast } from "./Chromecast";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
@@ -87,7 +90,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">
|
||||
<DownloadSingleItem item={item} />
|
||||
<DownloadSingleItem item={item} size="large" />
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
)}
|
||||
@@ -109,6 +112,36 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
|
||||
const [isTranscoding, setIsTranscoding] = useState(false);
|
||||
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
|
||||
useState<number | undefined>(selectedOptions?.subtitleIndex);
|
||||
|
||||
useEffect(() => {
|
||||
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
|
||||
if (isTranscoding) {
|
||||
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
|
||||
const subHelper = new SubtitleHelper(
|
||||
selectedOptions?.mediaSource?.MediaStreams ?? []
|
||||
);
|
||||
|
||||
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
|
||||
selectedOptions?.subtitleIndex
|
||||
);
|
||||
|
||||
setSelectedOptions((prev) => ({
|
||||
...prev!,
|
||||
subtitleIndex: newSubtitleIndex ?? -1,
|
||||
}));
|
||||
}
|
||||
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
|
||||
setSelectedOptions((prev) => ({
|
||||
...prev!,
|
||||
subtitleIndex: previouslyChosenSubtitleIndex,
|
||||
}));
|
||||
}
|
||||
setIsTranscoding(isTranscoding);
|
||||
}, [selectedOptions?.bitrate]);
|
||||
|
||||
if (!selectedOptions) return null;
|
||||
|
||||
return (
|
||||
@@ -199,6 +232,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
isTranscoding={isTranscoding}
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
@@ -225,9 +259,15 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
<OverviewText text={item.Overview} className="px-4 my-4" />
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
<OverviewText text={item.Overview} className="px-4 mb-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,15 +283,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
</View>
|
||||
)}
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className="mb-4" />
|
||||
)}
|
||||
|
||||
<SimilarItems itemId={item.Id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<View className="h-16"></View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
|
||||
236
components/ItemTechnicalDetails.tsx
Normal file
236
components/ItemTechnicalDetails.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
MediaSourceInfo,
|
||||
type MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Badge } from "./Badge";
|
||||
import { Text } from "./common/Text";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetView,
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface Props {
|
||||
source?: MediaSourceInfo;
|
||||
}
|
||||
|
||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
return (
|
||||
<View className="px-4 mt-2 mb-4">
|
||||
<Text className="text-lg font-bold mb-4">Video</Text>
|
||||
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||
<View className="flex flex-row space-x-2">
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
<Text className="text-purple-600">More details</Text>
|
||||
</TouchableOpacity>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
snapPoints={["80%"]}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<BottomSheetScrollView>
|
||||
<View className="flex flex-col space-y-2 p-4 mb-4">
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-4">Video</Text>
|
||||
<View className="flex flex-row space-x-2">
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-2">Audio</Text>
|
||||
<AudioStreamInfo
|
||||
audioStreams={
|
||||
source?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Audio"
|
||||
) || []
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-2">Subtitles</Text>
|
||||
<SubtitleStreamInfo
|
||||
subtitleStreams={
|
||||
source?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Subtitle"
|
||||
) || []
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const SubtitleStreamInfo = ({
|
||||
subtitleStreams,
|
||||
}: {
|
||||
subtitleStreams: MediaStream[];
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
{subtitleStreams.map((stream, index) => (
|
||||
<View key={stream.Index} className="flex flex-col">
|
||||
<Text className="text-xs mb-3 text-neutral-400">
|
||||
{stream.DisplayTitle}
|
||||
</Text>
|
||||
<View className="flex flex-row flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="language-outline" size={16} color="white" />
|
||||
}
|
||||
text={stream.Language}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
text={stream.Codec}
|
||||
iconLeft={
|
||||
<Ionicons name="layers-outline" size={16} color="white" />
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
{audioStreams.map((audioStreams, index) => (
|
||||
<View key={index} className="flex flex-col">
|
||||
<Text className="mb-3 text-neutral-400 text-xs">
|
||||
{audioStreams.DisplayTitle}
|
||||
</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="language-outline" size={16} color="white" />
|
||||
}
|
||||
text={audioStreams.Language}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name="musical-notes-outline"
|
||||
size={16}
|
||||
color="white"
|
||||
/>
|
||||
}
|
||||
text={audioStreams.Codec}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
|
||||
text={audioStreams.ChannelLayout}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||
}
|
||||
text={formatBitrate(audioStreams.BitRate)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
if (!source) return null;
|
||||
|
||||
const videoStream = useMemo(() => {
|
||||
return source.MediaStreams?.find(
|
||||
(stream) => stream.Type === "Video"
|
||||
) as MediaStream;
|
||||
}, [source.MediaStreams]);
|
||||
|
||||
return (
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||
text={formatFileSize(source.Size)}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="color-palette-outline" size={16} color="white" />
|
||||
}
|
||||
text={videoStream.VideoRange}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="code-working-outline" size={16} color="white" />
|
||||
}
|
||||
text={videoStream.Codec}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||
}
|
||||
text={formatBitrate(videoStream.BitRate)}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
|
||||
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number | null) => {
|
||||
if (!bytes) return "N/A";
|
||||
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
if (bytes === 0) return "0 Byte";
|
||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
|
||||
const formatBitrate = (bitrate?: number | null) => {
|
||||
if (!bitrate) return "N/A";
|
||||
|
||||
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
|
||||
if (bitrate === 0) return "0 bps";
|
||||
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
|
||||
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
@@ -32,6 +32,7 @@ import Animated, {
|
||||
import { Button } from "./Button";
|
||||
import { SelectedOptions } from "./ItemContent";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
@@ -78,6 +79,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const onPress = useCallback(async () => {
|
||||
if (!item) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
||||
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidateQueries = () => {
|
||||
@@ -46,44 +39,16 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||
|
||||
return (
|
||||
<View
|
||||
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
{item.UserData?.Played ? (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
await markAsNotPlayed({
|
||||
api: api,
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="checkmark-circle" size={24} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
await markAsPlayed({
|
||||
api: api,
|
||||
item: item,
|
||||
userId: user?.Id,
|
||||
});
|
||||
invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
fillColor={item.UserData?.Played ? "primary" : undefined}
|
||||
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
|
||||
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
|
||||
size="large"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
114
components/RoundButton.tsx
Normal file
114
components/RoundButton.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { PropsWithChildren } from "react";
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
} from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
onPress: () => void;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
background?: boolean;
|
||||
size?: "default" | "large";
|
||||
fillColor?: "primary";
|
||||
hapticFeedback?: boolean;
|
||||
}
|
||||
|
||||
export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
background = true,
|
||||
icon,
|
||||
onPress,
|
||||
children,
|
||||
size = "default",
|
||||
fillColor,
|
||||
hapticFeedback = true,
|
||||
...props
|
||||
}) => {
|
||||
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
|
||||
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
|
||||
|
||||
const handlePress = () => {
|
||||
if (hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
onPress();
|
||||
};
|
||||
|
||||
if (fillColor)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (background === false)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (Platform.OS === "android")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||
fillColor ? fillColorClass : "bg-neutral-800/80"
|
||||
}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} {...props}>
|
||||
<BlurView
|
||||
intensity={90}
|
||||
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</BlurView>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -1,26 +1,34 @@
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected?: number | undefined;
|
||||
isTranscoding?: boolean;
|
||||
}
|
||||
|
||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
isTranscoding,
|
||||
...props
|
||||
}) => {
|
||||
const subtitleStreams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||
[source]
|
||||
);
|
||||
const subtitleStreams = useMemo(() => {
|
||||
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
||||
|
||||
if (isTranscoding && Platform.OS === "ios") {
|
||||
return subtitleHelper.getUniqueSubtitles();
|
||||
}
|
||||
|
||||
return subtitleHelper.getSubtitles();
|
||||
}, [source, isTranscoding]);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
() => subtitleStreams.find((x) => x.Index === selected),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
@@ -45,6 +47,10 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "Playlist") {
|
||||
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
||||
}
|
||||
|
||||
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
||||
};
|
||||
|
||||
@@ -58,18 +64,82 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
const from = segments[2];
|
||||
|
||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
avoidCollisions
|
||||
alignOffset={0}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
key={"content"}
|
||||
>
|
||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||
<ContextMenu.Item
|
||||
key="item-1"
|
||||
onSelect={() => {
|
||||
markAsPlayedStatus(true);
|
||||
}}
|
||||
shouldDismissMenuOnSelect
|
||||
>
|
||||
<ContextMenu.ItemTitle key="item-1-title">
|
||||
Mark as watched
|
||||
</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
ios={{
|
||||
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
|
||||
pointSize: 18,
|
||||
weight: "semibold",
|
||||
scale: "medium",
|
||||
hierarchicalColor: {
|
||||
dark: "green", // Changed to green for "watched"
|
||||
light: "green",
|
||||
},
|
||||
}}
|
||||
androidIconName="checkmark-circle"
|
||||
></ContextMenu.ItemIcon>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
key="item-2"
|
||||
onSelect={() => {
|
||||
markAsPlayedStatus(false);
|
||||
}}
|
||||
shouldDismissMenuOnSelect
|
||||
destructive
|
||||
>
|
||||
<ContextMenu.ItemTitle key="item-2-title">
|
||||
Mark as not watched
|
||||
</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
ios={{
|
||||
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
|
||||
pointSize: 18, // Adjusted for better visibility
|
||||
weight: "semibold",
|
||||
scale: "medium",
|
||||
hierarchicalColor: {
|
||||
dark: "red", // Changed to red for "not watched"
|
||||
light: "red",
|
||||
},
|
||||
// Removed paletteColors as it's not necessary in this case
|
||||
}}
|
||||
androidIconName="eye-slash"
|
||||
></ContextMenu.ItemIcon>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,62 +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 {Text} from "@/components/common/Text";
|
||||
import useDownloadHelper from "@/utils/download";
|
||||
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
|
||||
import {TextProps} from "react-native";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { TextProps } from "react-native";
|
||||
|
||||
interface DownloadSizeProps extends TextProps {
|
||||
items: BaseItemDto[];
|
||||
}
|
||||
|
||||
interface DownloadSizes {
|
||||
knownSize: number;
|
||||
itemsNeedingSize: BaseItemDto[];
|
||||
}
|
||||
|
||||
export const DownloadSize: React.FC<DownloadSizeProps> = ({ items, ...props }) => {
|
||||
const { downloadedFiles, saveDownloadedItemInfo } = useDownload();
|
||||
const { getDownloadSize } = useDownloadHelper();
|
||||
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])
|
||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadedFiles)
|
||||
return
|
||||
if (!downloadedFiles) return;
|
||||
|
||||
const {knownSize, itemsNeedingSize} = downloadedFiles
|
||||
.filter(f => itemIds.includes(f.item.Id))
|
||||
?.reduce<DownloadSizes>((acc, file) => {
|
||||
if (file?.size && file.size > 0)
|
||||
acc.knownSize += file.size
|
||||
else
|
||||
acc.itemsNeedingSize.push(file.item)
|
||||
return acc
|
||||
}, {
|
||||
knownSize: 0,
|
||||
itemsNeedingSize: []
|
||||
})
|
||||
let s = 0;
|
||||
|
||||
getDownloadSize(
|
||||
(item, size) => saveDownloadedItemInfo(item, size),
|
||||
...itemsNeedingSize
|
||||
).then(sizeSum => {
|
||||
setSize(bytesToReadable((sizeSum + knownSize)))
|
||||
})
|
||||
},
|
||||
[items, itemIds]
|
||||
);
|
||||
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 "reading size..."
|
||||
return size
|
||||
}, [size])
|
||||
if (!size) return "...";
|
||||
return size;
|
||||
}, [size]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="text-xs text-neutral-500" {...props}>{sizeText}</Text>
|
||||
<Text className="text-xs text-neutral-500" {...props}>
|
||||
{sizeText}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, {useCallback, useMemo} 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 {useDownload} from "@/providers/DownloadProvider";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Image } from "expo-image";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {Text} from "@/components/common/Text";
|
||||
import {runtimeTicksToSeconds} from "@/utils/time";
|
||||
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
||||
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();
|
||||
@@ -77,35 +74,14 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
onLongPress={showActionSheet}
|
||||
className="flex flex-col mr-2"
|
||||
key={item.Id}
|
||||
className="flex flex-col mb-4"
|
||||
>
|
||||
<View className="flex flex-row items-start mb-2">
|
||||
<View className="mr-2">
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
|
||||
</View>
|
||||
<View className="w-56 flex flex-col">
|
||||
<View className="shrink">
|
||||
<Text numberOfLines={2} className="">
|
||||
{item.Name}
|
||||
</Text>
|
||||
@@ -118,7 +94,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
<DownloadSize items={[item]} />
|
||||
</View>
|
||||
</View>
|
||||
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">{item.Overview}</Text>
|
||||
|
||||
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
||||
{item.Overview}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -97,7 +97,9 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ItemCardText item={item} />
|
||||
<View className="w-28">
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
<DownloadSize items={[item]} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {useEffect, useMemo} from "react";
|
||||
import {TouchableOpacity, View} from "react-native";
|
||||
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";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
seasons: BaseItemDto[];
|
||||
initialSeasonIndex?: number;
|
||||
state: SeasonIndexState;
|
||||
onSelect: (season: BaseItemDto) => void
|
||||
onSelect: (season: BaseItemDto) => void;
|
||||
};
|
||||
|
||||
type SeasonKeys = {
|
||||
id: keyof BaseItemDto,
|
||||
title: keyof BaseItemDto,
|
||||
index: keyof BaseItemDto
|
||||
}
|
||||
id: keyof BaseItemDto;
|
||||
title: keyof BaseItemDto;
|
||||
index: keyof BaseItemDto;
|
||||
};
|
||||
|
||||
export type SeasonIndexState = {
|
||||
[seriesId: string]: number | null | undefined;
|
||||
@@ -27,21 +27,28 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
seasons,
|
||||
initialSeasonIndex,
|
||||
state,
|
||||
onSelect
|
||||
onSelect,
|
||||
}) => {
|
||||
const keys = useMemo<SeasonKeys>(() =>
|
||||
item.Type === "Episode" ? {
|
||||
id: "ParentId",
|
||||
title: "SeasonName",
|
||||
index: "ParentIndexNumber"
|
||||
}
|
||||
: {
|
||||
id: "Id",
|
||||
title: "Name",
|
||||
index: "IndexNumber"
|
||||
}, [item]
|
||||
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]
|
||||
);
|
||||
const seasonIndex = useMemo(() => state[item[keys.id] ?? ""], [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||
@@ -62,28 +69,28 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
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)
|
||||
onSelect(firstSeason);
|
||||
}
|
||||
|
||||
if (initialIndex !== undefined) {
|
||||
const initialSeason = seasons.find((season: any) =>
|
||||
season[keys.index] === initialIndex
|
||||
)
|
||||
const initialSeason = seasons.find(
|
||||
(season: any) => season[keys.index] === initialIndex
|
||||
);
|
||||
|
||||
if (initialSeason) onSelect(initialSeason!)
|
||||
else throw Error("Initial index could not be found!")
|
||||
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) => a[keys.index] - b[keys.index];
|
||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
||||
Number(a[keys.index]) - Number(b[keys.index]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
@@ -103,7 +110,9 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
key={season[keys.title]}
|
||||
onSelect={() => onSelect(season)}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{season[keys.title]}</DropdownMenu.ItemTitle>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{season[keys.title]}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
|
||||
@@ -6,14 +6,17 @@ import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import {DownloadItems, DownloadSingleItem} 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 { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import {SeasonDropdown, SeasonIndexState} from "@/components/series/SeasonDropdown";
|
||||
import {Ionicons, MaterialCommunityIcons} from "@expo/vector-icons";
|
||||
import {
|
||||
SeasonDropdown,
|
||||
SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
@@ -109,7 +112,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
minHeight: 144 * nrOfEpisodes,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-row justify-start items-center">
|
||||
<View className="flex flex-row justify-start items-center px-4">
|
||||
<SeasonDropdown
|
||||
item={item}
|
||||
seasons={seasons}
|
||||
@@ -119,14 +122,17 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
...prev,
|
||||
[item.Id ?? ""]: season.IndexNumber,
|
||||
}));
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
<DownloadItems
|
||||
title="Download Season"
|
||||
className="ml-2"
|
||||
items={episodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<MaterialCommunityIcons name="download-multiple" size={24} color="white"/>
|
||||
<Ionicons name="download" size={20} color="white" />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<MaterialCommunityIcons name="check-all" size={24} color="#9333ea"/>
|
||||
<Ionicons name="download" size={20} color="#9333ea" />
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
114
components/settings/AudioToggles.tsx
Normal file
114
components/settings/AudioToggles.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { useMedia } from "./MediaContext";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const media = useMedia();
|
||||
const { settings, updateSettings } = media;
|
||||
const cultures = media.cultures;
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Audio</Text>
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<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">Audio language</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default audio language.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{settings?.defaultAudioLanguage?.DisplayName || "None"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"none-audio"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{cultures?.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{l.DisplayName}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">Use Default Audio</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Play default audio track regardless of language.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.playDefaultAudioTrack}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ playDefaultAudioTrack: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">
|
||||
Set Audio Track From Previous Item
|
||||
</Text>
|
||||
<Text className="text-xs opacity-50 min max-w-[85%]">
|
||||
Try to set the audio track to the closest match to the last
|
||||
video.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.rememberAudioSelections}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ rememberAudioSelections: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
154
components/settings/MediaContext.tsx
Normal file
154
components/settings/MediaContext.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getLocalizationApi, getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import {
|
||||
CultureDto,
|
||||
UserDto,
|
||||
UserConfiguration,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface MediaContextType {
|
||||
settings: Settings | null;
|
||||
updateSettings: (update: Partial<Settings>) => void;
|
||||
user: UserDto | undefined;
|
||||
cultures: CultureDto[];
|
||||
}
|
||||
|
||||
const MediaContext = createContext<MediaContextType | undefined>(undefined);
|
||||
|
||||
export const useMedia = () => {
|
||||
const context = useContext(MediaContext);
|
||||
if (!context) {
|
||||
throw new Error("useMedia must be used within a MediaProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const MediaProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateSetingsWrapper = (update: Partial<Settings>) => {
|
||||
const updateUserConfiguration = async (
|
||||
update: Partial<UserConfiguration>
|
||||
) => {
|
||||
if (api && user) {
|
||||
try {
|
||||
await getUserApi(api).updateUserConfiguration({
|
||||
userConfiguration: {
|
||||
...user.Configuration,
|
||||
...update,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["authUser"] });
|
||||
} catch (error) {}
|
||||
}
|
||||
};
|
||||
|
||||
updateSettings(update);
|
||||
|
||||
console.log("update", update);
|
||||
|
||||
let updatePayload = {
|
||||
SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode,
|
||||
PlayDefaultAudioTrack:
|
||||
update?.playDefaultAudioTrack ?? settings?.playDefaultAudioTrack,
|
||||
RememberAudioSelections:
|
||||
update?.rememberAudioSelections ?? settings?.rememberAudioSelections,
|
||||
RememberSubtitleSelections:
|
||||
update?.rememberSubtitleSelections ??
|
||||
settings?.rememberSubtitleSelections,
|
||||
} as Partial<UserConfiguration>;
|
||||
|
||||
updatePayload.AudioLanguagePreference =
|
||||
update?.defaultAudioLanguage === null
|
||||
? ""
|
||||
: update?.defaultAudioLanguage?.ThreeLetterISOLanguageName ||
|
||||
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName ||
|
||||
"";
|
||||
|
||||
updatePayload.SubtitleLanguagePreference =
|
||||
update?.defaultSubtitleLanguage === null
|
||||
? ""
|
||||
: update?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
|
||||
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
|
||||
"";
|
||||
|
||||
console.log("updatePayload", updatePayload);
|
||||
|
||||
updateUserConfiguration(updatePayload);
|
||||
};
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ["authUser"],
|
||||
queryFn: async () => {
|
||||
if (!api) return;
|
||||
const userApi = await getUserApi(api).getCurrentUser();
|
||||
return userApi.data;
|
||||
},
|
||||
enabled: !!api,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: cultures = [], isFetched: isCulturesFetched } = useQuery({
|
||||
queryKey: ["cultures"],
|
||||
queryFn: async () => {
|
||||
if (!api) return [];
|
||||
const localizationApi = await getLocalizationApi(api).getCultures();
|
||||
const cultures = localizationApi.data;
|
||||
return cultures;
|
||||
},
|
||||
enabled: !!api,
|
||||
staleTime: 43200000, // 12 hours
|
||||
});
|
||||
|
||||
// Set default settings from user configuration.s
|
||||
useEffect(() => {
|
||||
if (!user || cultures.length === 0) return;
|
||||
const userSubtitlePreference =
|
||||
user?.Configuration?.SubtitleLanguagePreference;
|
||||
const userAudioPreference = user?.Configuration?.AudioLanguagePreference;
|
||||
|
||||
const subtitlePreference = cultures.find(
|
||||
(x) => x.ThreeLetterISOLanguageName === userSubtitlePreference
|
||||
);
|
||||
const audioPreference = cultures.find(
|
||||
(x) => x.ThreeLetterISOLanguageName === userAudioPreference
|
||||
);
|
||||
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: subtitlePreference,
|
||||
defaultAudioLanguage: audioPreference,
|
||||
subtitleMode: user?.Configuration?.SubtitleMode,
|
||||
playDefaultAudioTrack: user?.Configuration?.PlayDefaultAudioTrack,
|
||||
rememberAudioSelections: user?.Configuration?.RememberAudioSelections,
|
||||
rememberSubtitleSelections:
|
||||
user?.Configuration?.RememberSubtitleSelections,
|
||||
});
|
||||
}, [user, isCulturesFetched]);
|
||||
|
||||
if (!api) return null;
|
||||
|
||||
return (
|
||||
<MediaContext.Provider
|
||||
value={{
|
||||
settings,
|
||||
updateSettings: updateSetingsWrapper,
|
||||
user,
|
||||
cultures,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MediaContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { LANGUAGES } from "@/constants/Languages";
|
||||
import { TextInput } from "react-native-gesture-handler";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -16,152 +13,6 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Media</Text>
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<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">Audio language</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default audio language.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings?.defaultAudioLanguage?.label || "None"}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"none-audio"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{LANGUAGES.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l.value}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Subtitle language</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default subtitle language.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{settings?.defaultSubtitleLanguage?.label || "None"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"none-subs"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{LANGUAGES.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l.value}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<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,11 @@ import {
|
||||
getOrSetDeviceId,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import {ScreenOrientationEnum, Settings, useSettings} from "@/utils/atoms/settings";
|
||||
import {
|
||||
ScreenOrientationEnum,
|
||||
Settings,
|
||||
useSettings,
|
||||
} from "@/utils/atoms/settings";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
registerBackgroundFetchAsync,
|
||||
@@ -17,7 +21,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,7 +36,10 @@ import { Input } from "../common/Input";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
import { MediaToggles } from "./MediaToggles";
|
||||
import {Stepper} from "@/components/inputs/Stepper";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import { MediaProvider } from "./MediaContext";
|
||||
import { SubtitleToggles } from "./SubtitleToggles";
|
||||
import { AudioToggles } from "./AudioToggles";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -120,7 +127,11 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
</View>
|
||||
</View> */}
|
||||
|
||||
<MediaToggles />
|
||||
<MediaProvider>
|
||||
<MediaToggles />
|
||||
<AudioToggles />
|
||||
<SubtitleToggles />
|
||||
</MediaProvider>
|
||||
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||
@@ -409,19 +420,24 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
<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
|
||||
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")
|
||||
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 })}
|
||||
value={settings.showCustomMenuLinks}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ showCustomMenuLinks: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -491,15 +507,16 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
className={`
|
||||
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
||||
${
|
||||
settings.downloadMethod === "remux"
|
||||
? "opacity-100"
|
||||
: "opacity-50"
|
||||
}`}
|
||||
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.
|
||||
This is the total media you want to be able to download at the
|
||||
same time.
|
||||
</Text>
|
||||
</View>
|
||||
<Stepper
|
||||
@@ -507,7 +524,12 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
step={1}
|
||||
min={1}
|
||||
max={4}
|
||||
onUpdate={(value) => updateSettings({remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"]})}
|
||||
onUpdate={(value) =>
|
||||
updateSettings({
|
||||
remuxConcurrentLimit:
|
||||
value as Settings["remuxConcurrentLimit"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
@@ -517,10 +539,10 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
className={`
|
||||
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
||||
${
|
||||
settings.downloadMethod === "optimized"
|
||||
? "opacity-100"
|
||||
: "opacity-50"
|
||||
}`}
|
||||
settings.downloadMethod === "optimized"
|
||||
? "opacity-100"
|
||||
: "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Auto download</Text>
|
||||
|
||||
191
components/settings/SubtitleToggles.tsx
Normal file
191
components/settings/SubtitleToggles.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { useMedia } from "./MediaContext";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const media = useMedia();
|
||||
const { settings, updateSettings } = media;
|
||||
const cultures = media.cultures;
|
||||
if (!settings) return null;
|
||||
|
||||
const subtitleModes = [
|
||||
SubtitlePlaybackMode.Default,
|
||||
SubtitlePlaybackMode.Smart,
|
||||
SubtitlePlaybackMode.OnlyForced,
|
||||
SubtitlePlaybackMode.Always,
|
||||
SubtitlePlaybackMode.None,
|
||||
];
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Subtitle</Text>
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<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 language</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default subtitle language.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"none-subs"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{cultures?.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{l.DisplayName}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Subtitle Mode</Text>
|
||||
<Text className="text-xs opacity-50 mr-2">
|
||||
Subtitles are loaded based on the default and forced flags in the
|
||||
embedded metadata. Language preferences are considered when
|
||||
multiple options are available.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings?.subtitleMode || "Loading"}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Subtitle Mode</DropdownMenu.Label>
|
||||
{subtitleModes?.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
subtitleMode: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{l}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">
|
||||
Set Subtitle Track From Previous Item
|
||||
</Text>
|
||||
<Text className="text-xs opacity-50 min max-w-[85%]">
|
||||
Try to set the subtitle track to the closest match to the last
|
||||
video.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.rememberSubtitleSelections}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ rememberSubtitleSelections: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Subtitle Size</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default subtitle size for direct play (only works for
|
||||
some subtitle formats).
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-row items-center">
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
updateSettings({
|
||||
subtitleSize: Math.max(0, settings.subtitleSize - 5),
|
||||
})
|
||||
}
|
||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||
>
|
||||
<Text>-</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||
{settings.subtitleSize}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||
onPress={() =>
|
||||
updateSettings({
|
||||
subtitleSize: Math.min(120, settings.subtitleSize + 5),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,15 @@
|
||||
import { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||
import { ParamListBase, RouteProp } from "@react-navigation/native";
|
||||
|
||||
const commonScreenOptions = {
|
||||
type ICommonScreenOptions =
|
||||
| NativeStackNavigationOptions
|
||||
| ((prop: {
|
||||
route: RouteProp<ParamListBase, string>;
|
||||
navigation: any;
|
||||
}) => NativeStackNavigationOptions);
|
||||
|
||||
const commonScreenOptions: ICommonScreenOptions = {
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
@@ -17,5 +26,5 @@ const routes = [
|
||||
"series/[id]",
|
||||
];
|
||||
|
||||
export const nestedTabPageScreenOptions: { [key: string]: any } =
|
||||
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
|
||||
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
||||
|
||||
114
components/video-player/controls/AudioSlider.tsx
Normal file
114
components/video-player/controls/AudioSlider.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { VolumeManager } from "react-native-volume-manager";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
interface AudioSliderProps {
|
||||
setVisibility: (show: boolean) => void;
|
||||
}
|
||||
|
||||
const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
||||
const volume = useSharedValue<number>(50); // Explicitly type as number
|
||||
const min = useSharedValue<number>(0); // Explicitly type as number
|
||||
const max = useSharedValue<number>(100); // Explicitly type as number
|
||||
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null); // Use a ref to store the timeout ID
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInitialVolume = async () => {
|
||||
try {
|
||||
const { volume: initialVolume } = await VolumeManager.getVolume();
|
||||
console.log("initialVolume", initialVolume);
|
||||
volume.value = initialVolume * 100;
|
||||
} catch (error) {
|
||||
console.error("Error fetching initial volume:", error);
|
||||
}
|
||||
};
|
||||
fetchInitialVolume();
|
||||
|
||||
// Disable the native volume UI when the component mounts
|
||||
VolumeManager.showNativeVolumeUI({ enabled: false });
|
||||
|
||||
return () => {
|
||||
// Re-enable the native volume UI when the component unmounts
|
||||
VolumeManager.showNativeVolumeUI({ enabled: true });
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleValueChange = async (value: number) => {
|
||||
volume.value = value;
|
||||
console.log("volume through slider", value);
|
||||
await VolumeManager.setVolume(value / 100);
|
||||
|
||||
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
|
||||
VolumeManager.showNativeVolumeUI({ enabled: false });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const volumeListener = VolumeManager.addVolumeListener((result) => {
|
||||
console.log("Volume through device", result.volume);
|
||||
volume.value = result.volume * 100;
|
||||
setVisibility(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set a new timeout to hide the visibility after 2 seconds
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setVisibility(false);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
return () => {
|
||||
volumeListener.remove();
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [volume]);
|
||||
|
||||
return (
|
||||
<View style={styles.sliderContainer}>
|
||||
<Slider
|
||||
progress={volume}
|
||||
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="volume-high"
|
||||
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 AudioSlider;
|
||||
@@ -1,3 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
@@ -8,8 +9,13 @@ import {
|
||||
TrackInfo,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import {
|
||||
getDefaultPlaySettings,
|
||||
previousIndexes,
|
||||
} from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
formatTimeString,
|
||||
@@ -23,16 +29,12 @@ import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { debounce } from "lodash";
|
||||
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
runOnJS,
|
||||
@@ -45,14 +47,15 @@ import {
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
import { VideoRef } from "react-native-video";
|
||||
import AudioSlider from "./AudioSlider";
|
||||
import BrightnessSlider from "./BrightnessSlider";
|
||||
import { ControlProvider } from "./contexts/ControlContext";
|
||||
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 { EpisodeList } from "./EpisodeList";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
@@ -79,7 +82,7 @@ interface Props {
|
||||
setSubtitleURL?: (url: string, customName: string) => void;
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setAudioTrack?: (index: number) => void;
|
||||
stop?: (() => Promise<void>) | (() => void);
|
||||
stop: (() => Promise<void>) | (() => void);
|
||||
isVlc?: boolean;
|
||||
}
|
||||
|
||||
@@ -113,6 +116,7 @@ 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 {
|
||||
@@ -123,7 +127,7 @@ export const Controls: React.FC<Props> = ({
|
||||
} = useTrickplay(item, !offline && enableTrickplay);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(Infinity);
|
||||
|
||||
const min = useSharedValue(0);
|
||||
const max = useSharedValue(item.RunTimeTicks || 0);
|
||||
@@ -131,6 +135,12 @@ export const Controls: React.FC<Props> = ({
|
||||
const wasPlayingRef = useRef(false);
|
||||
const lastProgressRef = useRef<number>(0);
|
||||
|
||||
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||
bitrateValue: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
}>();
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
currentTime,
|
||||
@@ -152,50 +162,76 @@ export const Controls: React.FC<Props> = ({
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(previousItem, settings);
|
||||
const previousIndexes: previousIndexes = {
|
||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(
|
||||
previousItem,
|
||||
settings,
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: previousItem.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrate.toString(),
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrateValue.toString(),
|
||||
}).toString();
|
||||
|
||||
if (!bitrate.value) {
|
||||
if (!bitrateValue) {
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
return;
|
||||
}
|
||||
// @ts-expect-error
|
||||
router.replace(`player/transcoding-player?${queryParams}`);
|
||||
}, [previousItem, settings]);
|
||||
}, [previousItem, settings, subtitleIndex, audioIndex]);
|
||||
|
||||
const goToNextItem = useCallback(() => {
|
||||
if (!nextItem || !settings) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(nextItem, settings);
|
||||
const previousIndexes: previousIndexes = {
|
||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(
|
||||
nextItem,
|
||||
settings,
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: nextItem.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrate.toString(),
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrateValue.toString(),
|
||||
}).toString();
|
||||
|
||||
if (!bitrate.value) {
|
||||
if (!bitrateValue) {
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
return;
|
||||
}
|
||||
// @ts-expect-error
|
||||
router.replace(`player/transcoding-player?${queryParams}`);
|
||||
}, [nextItem, settings]);
|
||||
}, [nextItem, settings, subtitleIndex, audioIndex]);
|
||||
|
||||
const updateTimes = useCallback(
|
||||
(currentProgress: number, maxValue: number) => {
|
||||
@@ -204,15 +240,10 @@ export const Controls: React.FC<Props> = ({
|
||||
? maxValue - currentProgress
|
||||
: ticksToSeconds(maxValue - currentProgress);
|
||||
|
||||
console.log("remaining: ", remaining);
|
||||
|
||||
setCurrentTime(current);
|
||||
setRemainingTime(remaining);
|
||||
|
||||
// Currently doesm't work in VLC because of some corrupted timestamps, will need to find a workaround.
|
||||
if (currentProgress === maxValue) {
|
||||
setShowControls(true);
|
||||
// Automatically play the next item if it exists
|
||||
goToNextItem();
|
||||
}
|
||||
},
|
||||
[goToNextItem, isVlc]
|
||||
);
|
||||
@@ -224,7 +255,6 @@ export const Controls: React.FC<Props> = ({
|
||||
isSeeking: isSeeking.value,
|
||||
}),
|
||||
(result) => {
|
||||
// console.log("Progress changed", result);
|
||||
if (result.isSeeking === false) {
|
||||
runOnJS(updateTimes)(result.progress, result.max);
|
||||
}
|
||||
@@ -246,7 +276,14 @@ export const Controls: React.FC<Props> = ({
|
||||
useEffect(() => {
|
||||
prefetchAllTrickplayImages();
|
||||
}, []);
|
||||
const toggleControls = () => setShowControls(!showControls);
|
||||
const toggleControls = () => {
|
||||
if (showControls) {
|
||||
setShowAudioSlider(false);
|
||||
setShowControls(false);
|
||||
} else {
|
||||
setShowControls(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSliderStart = useCallback(() => {
|
||||
if (showControls === false) return;
|
||||
@@ -277,16 +314,14 @@ 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);
|
||||
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 });
|
||||
}, 20), // 100ms debounce delay
|
||||
}, 3),
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -397,292 +432,410 @@ export const Controls: React.FC<Props> = ({
|
||||
);
|
||||
}, [trickPlayUrl, trickplayInfo, time]);
|
||||
|
||||
const [EpisodeView, setEpisodeView] = useState(false);
|
||||
|
||||
const switchOnEpisodeMode = () => {
|
||||
setEpisodeView(true);
|
||||
if (isPlaying) togglePlay();
|
||||
};
|
||||
|
||||
const goToItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
try {
|
||||
const gotoItem = await getItemById(api, itemId);
|
||||
if (!settings || !gotoItem) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
const previousIndexes: previousIndexes = {
|
||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(
|
||||
gotoItem,
|
||||
settings,
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: gotoItem.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrateValue.toString(),
|
||||
}).toString();
|
||||
|
||||
if (!bitrateValue) {
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
return;
|
||||
}
|
||||
// @ts-expect-error
|
||||
router.replace(`player/transcoding-player?${queryParams}`);
|
||||
} catch (error) {
|
||||
console.error("Error in gotoEpisode:", error);
|
||||
}
|
||||
},
|
||||
[settings, subtitleIndex, audioIndex]
|
||||
);
|
||||
|
||||
// Used when user changes audio through audio button on device.
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
|
||||
return (
|
||||
<ControlProvider
|
||||
item={item}
|
||||
mediaSource={mediaSource}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
>
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
{!mediaSource?.TranscodingUrl ? (
|
||||
<DropdownViewDirect showControls={showControls} />
|
||||
) : (
|
||||
<DropdownViewTranscoding showControls={showControls} />
|
||||
)}
|
||||
</VideoProvider>
|
||||
|
||||
<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 `}
|
||||
>
|
||||
{previousItem && (
|
||||
<TouchableOpacity
|
||||
onPress={goToPreviousItem}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
{EpisodeView ? (
|
||||
<EpisodeList
|
||||
item={item}
|
||||
close={() => setEpisodeView(false)}
|
||||
goToItem={goToItem}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: insets.top,
|
||||
left: insets.left,
|
||||
opacity: showControls ? 1 : 0,
|
||||
zIndex: 1000,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{!mediaSource?.TranscodingUrl ? (
|
||||
<DropdownViewDirect showControls={showControls} />
|
||||
) : (
|
||||
<DropdownViewTranscoding showControls={showControls} />
|
||||
)}
|
||||
</View>
|
||||
</VideoProvider>
|
||||
|
||||
{nextItem && (
|
||||
<TouchableOpacity
|
||||
onPress={goToNextItem}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
<Pressable
|
||||
onPressIn={() => {
|
||||
toggleControls();
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: Dimensions.get("window").width,
|
||||
height: Dimensions.get("window").height,
|
||||
}}
|
||||
></Pressable>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: insets.top,
|
||||
right: insets.right,
|
||||
opacity: showControls ? 1 : 0,
|
||||
},
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{item?.Type === "Episode" && !offline && (
|
||||
<TouchableOpacity
|
||||
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 && !offline && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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 () => {
|
||||
if (stop) await stop();
|
||||
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>
|
||||
{nextItem && !offline && (
|
||||
<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 () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
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",
|
||||
position: "absolute",
|
||||
top: "50%", // Center vertically
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
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
|
||||
}}
|
||||
pointerEvents={showControls ? "box-none" : "none"}
|
||||
>
|
||||
<Ionicons
|
||||
name="refresh-outline"
|
||||
size={50}
|
||||
color="white"
|
||||
style={{
|
||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
alignItems: "center",
|
||||
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
|
||||
left: 0,
|
||||
bottom: 30,
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{settings?.rewindSkipTime}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
togglePlay();
|
||||
}}
|
||||
>
|
||||
{!isBuffering ? (
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={50}
|
||||
color="white"
|
||||
/>
|
||||
) : (
|
||||
<Loader size={"large"} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<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
|
||||
<BrightnessSlider />
|
||||
</View>
|
||||
<TouchableOpacity onPress={handleSkipBackward}>
|
||||
<View
|
||||
style={{
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="refresh-outline"
|
||||
size={50}
|
||||
color="white"
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
left: -2,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#fff",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
cache={cacheProgress}
|
||||
onSlidingStart={handleSliderStart}
|
||||
onSlidingComplete={handleSliderComplete}
|
||||
onValueChange={handleSliderChange}
|
||||
containerStyle={{
|
||||
borderRadius: 100,
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
{settings?.rewindSkipTime}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
togglePlay();
|
||||
}}
|
||||
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>
|
||||
>
|
||||
{!isBuffering ? (
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={50}
|
||||
color="white"
|
||||
style={{
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Loader size={"large"} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={handleSkipForward}>
|
||||
<View
|
||||
style={{
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<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
|
||||
style={{
|
||||
position: "absolute",
|
||||
alignItems: "center",
|
||||
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
|
||||
bottom: 30,
|
||||
right: 0,
|
||||
opacity: showAudioSlider || showControls ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<AudioSlider setVisibility={setShowAudioSlider} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
right: insets.right,
|
||||
left: insets.left,
|
||||
bottom: insets.bottom,
|
||||
},
|
||||
]}
|
||||
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
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
pointerEvents={showControls ? "box-none" : "none"}
|
||||
>
|
||||
<Text className="font-bold">{item?.Name}</Text>
|
||||
{item?.Type === "Episode" && (
|
||||
<Text className="opacity-50">{item.SeriesName}</Text>
|
||||
)}
|
||||
{item?.Type === "Movie" && (
|
||||
<Text className="text-xs opacity-50">
|
||||
{item?.ProductionYear}
|
||||
</Text>
|
||||
)}
|
||||
{item?.Type === "Audio" && (
|
||||
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex flex-row space-x-2">
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText="Skip Intro"
|
||||
/>
|
||||
<SkipButton
|
||||
showButton={showSkipCreditButton}
|
||||
onPress={skipCredit}
|
||||
buttonText="Skip Credits"
|
||||
/>
|
||||
<NextEpisodeCountDownButton
|
||||
show={
|
||||
!nextItem
|
||||
? false
|
||||
: isVlc
|
||||
? remainingTime < 10000
|
||||
: remainingTime < 10
|
||||
}
|
||||
onFinish={goToNextItem}
|
||||
onPress={goToNextItem}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
className={`flex flex-col-reverse py-4 pb-1 px-4 rounded-lg items-center bg-neutral-800`}
|
||||
style={{
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
pointerEvents={showControls ? "box-none" : "none"}
|
||||
>
|
||||
<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={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
left: -2,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#fff",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</ControlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
254
components/video-player/controls/EpisodeList.tsx
Normal file
254
components/video-player/controls/EpisodeList.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
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 {
|
||||
SeasonDropdown,
|
||||
SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
close: () => void;
|
||||
goToItem: (itemId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||
|
||||
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets(); // Get safe area insets
|
||||
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]);
|
||||
|
||||
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={() => {
|
||||
goToItem(_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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
Easing,
|
||||
runOnJS,
|
||||
} from "react-native-reanimated";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
|
||||
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
|
||||
onFinish?: () => void;
|
||||
onPress?: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
||||
onFinish,
|
||||
onPress,
|
||||
show,
|
||||
...props
|
||||
}) => {
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
progress.value = 0;
|
||||
progress.value = withTiming(
|
||||
1,
|
||||
{
|
||||
duration: 10000, // 10 seconds
|
||||
easing: Easing.linear,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished && onFinish) {
|
||||
console.log("finish");
|
||||
runOnJS(onFinish)();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [show, onFinish]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: `${progress.value * 100}%`,
|
||||
backgroundColor: Colors.primary,
|
||||
};
|
||||
});
|
||||
|
||||
const handlePress = () => {
|
||||
if (onPress) {
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900"
|
||||
{...props}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<Animated.View style={animatedStyle} />
|
||||
<View className="px-3 py-3">
|
||||
<Text className="text-center font-bold">Next Episode</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextEpisodeCountDownButton;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { View, TouchableOpacity, Text, StyleSheet } from "react-native";
|
||||
import { View, TouchableOpacity, Text, ViewProps } from "react-native";
|
||||
|
||||
interface SkipButtonProps {
|
||||
interface SkipButtonProps extends ViewProps {
|
||||
onPress: () => void;
|
||||
showButton: boolean;
|
||||
buttonText: string;
|
||||
@@ -11,29 +11,18 @@ const SkipButton: React.FC<SkipButtonProps> = ({
|
||||
onPress,
|
||||
showButton,
|
||||
buttonText,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<View style={{ display: showButton ? "flex" : "none" }}>
|
||||
<TouchableOpacity onPress={onPress} style={styles.button}>
|
||||
<Text style={styles.text}>{buttonText}</Text>
|
||||
<View className={showButton ? "flex" : "hidden"} {...props}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
className="bg-black/60 rounded-md px-3 py-3 border border-neutral-900"
|
||||
>
|
||||
<Text className="text-white font-bold">{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;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { TrackInfo } from '@/modules/vlc-player';
|
||||
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { TrackInfo } from "@/modules/vlc-player";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import React, { createContext, useContext, useState, ReactNode } from "react";
|
||||
|
||||
interface ControlContextProps {
|
||||
item: BaseItemDto;
|
||||
@@ -8,7 +11,9 @@ interface ControlContextProps {
|
||||
isVideoLoaded: boolean | undefined;
|
||||
}
|
||||
|
||||
const ControlContext = createContext<ControlContextProps | undefined>(undefined);
|
||||
const ControlContext = createContext<ControlContextProps | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
interface ControlProviderProps {
|
||||
children: ReactNode;
|
||||
@@ -17,7 +22,12 @@ interface ControlProviderProps {
|
||||
isVideoLoaded: boolean | undefined;
|
||||
}
|
||||
|
||||
export const ControlProvider: React.FC<ControlProviderProps> = ({ children, item, mediaSource, isVideoLoaded }) => {
|
||||
export const ControlProvider: React.FC<ControlProviderProps> = ({
|
||||
children,
|
||||
item,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
}) => {
|
||||
return (
|
||||
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
||||
{children}
|
||||
@@ -28,7 +38,7 @@ export const ControlProvider: React.FC<ControlProviderProps> = ({ children, item
|
||||
export const useControlContext = () => {
|
||||
const context = useContext(ControlContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useControlContext must be used within a ControlProvider');
|
||||
throw new Error("useControlContext must be used within a ControlProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useVideoContext } from "../contexts/VideoContext";
|
||||
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
|
||||
interface DropdownViewDirectProps {
|
||||
showControls: boolean;
|
||||
@@ -71,110 +71,94 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
const [selectedSubtitleIndex, setSelectedSubtitleIndex] = useState<Number>(
|
||||
parseInt(subtitleIndex)
|
||||
);
|
||||
const [selectedAudioIndex, setSelectedAudioIndex] = useState<Number>(
|
||||
parseInt(audioIndex)
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 1000,
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
className="p-4"
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
||||
Subtitle
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`subtitle-item-${idx}`}
|
||||
value={selectedSubtitleIndex === sub.index}
|
||||
onValueChange={() => {
|
||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||
setSubtitleURL &&
|
||||
setSubtitleURL(
|
||||
api?.basePath + sub.deliveryUrl,
|
||||
sub.name
|
||||
);
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
||||
Subtitle
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`subtitle-item-${idx}`}
|
||||
value={subtitleIndex === sub.index.toString()}
|
||||
onValueChange={() => {
|
||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||
setSubtitleURL &&
|
||||
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
|
||||
|
||||
console.log(
|
||||
"Set external subtitle: ",
|
||||
api?.basePath + sub.deliveryUrl
|
||||
);
|
||||
} else {
|
||||
console.log("Set sub index: ", sub.index);
|
||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||
}
|
||||
|
||||
setSelectedSubtitleIndex(sub.index);
|
||||
console.log("Subtitle: ", sub);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||
{sub.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
||||
Audio
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{audioTracks?.map((track, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`audio-item-${idx}`}
|
||||
value={selectedAudioIndex === track.index}
|
||||
onValueChange={() => {
|
||||
setSelectedAudioIndex(track.index);
|
||||
setAudioTrack && setAudioTrack(track.index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{track.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
console.log(
|
||||
"Set external subtitle: ",
|
||||
api?.basePath + sub.deliveryUrl
|
||||
);
|
||||
} else {
|
||||
console.log("Set sub index: ", sub.index);
|
||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||
}
|
||||
router.setParams({
|
||||
subtitleIndex: sub.index.toString(),
|
||||
});
|
||||
console.log("Subtitle: ", sub);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||
{sub.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
||||
Audio
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{audioTracks?.map((track, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`audio-item-${idx}`}
|
||||
value={audioIndex === track.index.toString()}
|
||||
onValueChange={() => {
|
||||
setAudioTrack && setAudioTrack(track.index);
|
||||
router.setParams({
|
||||
audioIndex: track.index.toString(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{track.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,16 +8,14 @@ import { TranscodedSubtitle } from "../types";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
|
||||
interface DropdownViewProps {
|
||||
showControls: boolean;
|
||||
offline?: boolean; // used to disable external subs for downloads
|
||||
}
|
||||
|
||||
const DropdownView: React.FC<DropdownViewProps> = ({
|
||||
showControls,
|
||||
offline = false,
|
||||
}) => {
|
||||
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
||||
const router = useRouter();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const ControlContext = useControlContext();
|
||||
@@ -37,32 +35,20 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
||||
}>();
|
||||
|
||||
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
|
||||
const isOnTextSubtitle =
|
||||
mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
|
||||
) || subtitleIndex === "-1";
|
||||
|
||||
const isOnTextSubtitle = useMemo(() => {
|
||||
const res = Boolean(
|
||||
mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
|
||||
) || subtitleIndex === "-1"
|
||||
);
|
||||
return res;
|
||||
}, []);
|
||||
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
|
||||
const textBasedSubs = allSubs.filter((x) => x.IsTextSubtitleStream);
|
||||
|
||||
// This is used in the case where it is transcoding stream.
|
||||
const chosenSubtitle = textBasedSubs.find(
|
||||
(x) => x.Index === parseInt(subtitleIndex)
|
||||
);
|
||||
|
||||
let initialSubtitleIndex = -1;
|
||||
if (!isOnTextSubtitle) {
|
||||
initialSubtitleIndex = parseInt(subtitleIndex);
|
||||
} else if (chosenSubtitle) {
|
||||
initialSubtitleIndex = textBasedSubs.indexOf(chosenSubtitle);
|
||||
}
|
||||
|
||||
const [selectedSubtitleIndex, setSelectedSubtitleIndex] =
|
||||
useState<number>(initialSubtitleIndex);
|
||||
const [selectedAudioIndex, setSelectedAudioIndex] = useState<number>(
|
||||
parseInt(audioIndex)
|
||||
);
|
||||
const subtitleHelper = new SubtitleHelper(mediaSource?.MediaStreams ?? []);
|
||||
|
||||
const allSubtitleTracksForTranscodingStream = useMemo(() => {
|
||||
const disableSubtitle = {
|
||||
@@ -78,39 +64,9 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
||||
IsTextSubtitleStream: true,
|
||||
})) || [];
|
||||
|
||||
const imageSubtitles = allSubs
|
||||
.filter((x) => !x.IsTextSubtitleStream)
|
||||
.map(
|
||||
(x) =>
|
||||
({
|
||||
name: x.DisplayTitle!,
|
||||
index: x.Index!,
|
||||
IsTextSubtitleStream: x.IsTextSubtitleStream,
|
||||
} as TranscodedSubtitle)
|
||||
);
|
||||
|
||||
const textSubtitlesMap = new Map(textSubtitles.map((s) => [s.name, s]));
|
||||
const imageSubtitlesMap = new Map(imageSubtitles.map((s) => [s.name, s]));
|
||||
|
||||
const sortedSubtitles = Array.from(
|
||||
new Set(
|
||||
allSubs
|
||||
.map((sub) => {
|
||||
const displayTitle = sub.DisplayTitle ?? "";
|
||||
if (textSubtitlesMap.has(displayTitle)) {
|
||||
return textSubtitlesMap.get(displayTitle);
|
||||
}
|
||||
if (imageSubtitlesMap.has(displayTitle)) {
|
||||
return imageSubtitlesMap.get(displayTitle);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(
|
||||
(subtitle): subtitle is TranscodedSubtitle => subtitle !== null
|
||||
)
|
||||
)
|
||||
);
|
||||
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
|
||||
|
||||
console.log("sortedSubtitles", sortedSubtitles);
|
||||
return [disableSubtitle, ...sortedSubtitles];
|
||||
}
|
||||
|
||||
@@ -123,7 +79,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
||||
return [disableSubtitle, ...transcodedSubtitle];
|
||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
||||
|
||||
const ChangeTranscodingSubtitle = useCallback(
|
||||
const changeToImageBasedSub = useCallback(
|
||||
(subtitleIndex: number) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||
@@ -145,26 +101,14 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
||||
name: x.DisplayTitle!,
|
||||
index: x.Index!,
|
||||
})) || [];
|
||||
const ChangeTranscodingAudio = useCallback(
|
||||
(audioIndex: number, currentSelectedSubtitleIndex: number) => {
|
||||
let newSubtitleIndex: number;
|
||||
|
||||
if (!isOnTextSubtitle) {
|
||||
newSubtitleIndex = parseInt(subtitleIndex);
|
||||
} else if (
|
||||
currentSelectedSubtitleIndex >= 0 &&
|
||||
currentSelectedSubtitleIndex < textBasedSubs.length
|
||||
) {
|
||||
console.log("setHere SubtitleIndex", currentSelectedSubtitleIndex);
|
||||
newSubtitleIndex = textBasedSubs[currentSelectedSubtitleIndex].Index!;
|
||||
console.log("newSubtitleIndex", newSubtitleIndex);
|
||||
} else {
|
||||
newSubtitleIndex = -1;
|
||||
}
|
||||
const ChangeTranscodingAudio = useCallback(
|
||||
(audioIndex: number) => {
|
||||
console.log("ChangeTranscodingAudio", subtitleIndex, audioIndex);
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: newSubtitleIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrateValue,
|
||||
}).toString();
|
||||
@@ -172,7 +116,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
||||
// @ts-expect-error
|
||||
router.replace(`player/transcoding-player?${queryParams}`);
|
||||
},
|
||||
[mediaSource]
|
||||
[mediaSource, subtitleIndex, audioIndex]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -213,18 +157,38 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
||||
{allSubtitleTracksForTranscodingStream?.map(
|
||||
(sub, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
value={selectedSubtitleIndex === sub.index}
|
||||
value={
|
||||
subtitleIndex ===
|
||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||
? subtitleHelper
|
||||
.getSourceSubtitleIndex(sub.index)
|
||||
.toString()
|
||||
: sub?.index.toString())
|
||||
}
|
||||
key={`subtitle-item-${idx}`}
|
||||
onValueChange={() => {
|
||||
console.log("sub", sub);
|
||||
if (selectedSubtitleIndex === sub?.index) return;
|
||||
setSelectedSubtitleIndex(sub.index);
|
||||
if (
|
||||
subtitleIndex ===
|
||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||
? subtitleHelper
|
||||
.getSourceSubtitleIndex(sub.index)
|
||||
.toString()
|
||||
: sub?.index.toString())
|
||||
)
|
||||
return;
|
||||
|
||||
router.setParams({
|
||||
subtitleIndex: subtitleHelper
|
||||
.getSourceSubtitleIndex(sub.index)
|
||||
.toString(),
|
||||
});
|
||||
|
||||
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||
return;
|
||||
}
|
||||
|
||||
ChangeTranscodingSubtitle(sub.index);
|
||||
changeToImageBasedSub(sub.index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||
@@ -249,11 +213,14 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
||||
{allAudio?.map((track, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`audio-item-${idx}`}
|
||||
value={selectedAudioIndex === track.index}
|
||||
value={audioIndex === track.index.toString()}
|
||||
onValueChange={() => {
|
||||
if (selectedAudioIndex === track.index) return;
|
||||
setSelectedAudioIndex(track.index);
|
||||
ChangeTranscodingAudio(track.index, selectedSubtitleIndex);
|
||||
if (audioIndex === track.index.toString()) return;
|
||||
console.log("Setting audio track to: ", track.index);
|
||||
router.setParams({
|
||||
audioIndex: track.index.toString(),
|
||||
});
|
||||
ChangeTranscodingAudio(track.index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface CreditTimestamps {
|
||||
Introduction: {
|
||||
@@ -78,6 +79,7 @@ export const useCreditSkipper = (
|
||||
if (!creditTimestamps) return;
|
||||
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
||||
try {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
wrappedSeek(creditTimestamps.Credits.End);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
|
||||
// Used only for intial play settings.
|
||||
const useDefaultPlaySettings = (
|
||||
item: BaseItemDto,
|
||||
settings: Settings | null
|
||||
@@ -17,18 +18,14 @@ const useDefaultPlaySettings = (
|
||||
// 2. Get default or preferred audio
|
||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Language === settings?.defaultAudioLanguage
|
||||
)?.Index;
|
||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Type === "Audio"
|
||||
(x) =>
|
||||
x.Type === "Audio" &&
|
||||
x.Language ===
|
||||
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName
|
||||
)?.Index;
|
||||
|
||||
// 3. Get default or preferred subtitle
|
||||
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
|
||||
)?.Index;
|
||||
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||
(stream) => stream.Type === "Subtitle" && stream.IsDefault
|
||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Type === "Audio"
|
||||
)?.Index;
|
||||
|
||||
// 4. Get default bitrate
|
||||
@@ -37,8 +34,7 @@ const useDefaultPlaySettings = (
|
||||
return {
|
||||
defaultAudioIndex:
|
||||
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
||||
defaultSubtitleIndex:
|
||||
preferedSubtitleIndex || defaultSubtitleIndex || undefined,
|
||||
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex || -1,
|
||||
defaultMediaSource: mediaSource || undefined,
|
||||
defaultBitrate: bitrate || undefined,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface IntroTimestamps {
|
||||
EpisodeId: string;
|
||||
@@ -78,6 +79,7 @@ export const useIntroSkipper = (
|
||||
console.log("skipIntro");
|
||||
if (!introTimestamps) return;
|
||||
try {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
wrappedSeek(introTimestamps.IntroEnd);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
|
||||
88
hooks/useMarkAsPlayed.ts
Normal file
88
hooks/useMarkAsPlayed.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
||||
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
export const useMarkAsPlayed = (item: BaseItemDto) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidateQueries = () => {
|
||||
const queriesToInvalidate = [
|
||||
["item", item.Id],
|
||||
["resumeItems"],
|
||||
["continueWatching"],
|
||||
["nextUp-all"],
|
||||
["nextUp"],
|
||||
["episodes"],
|
||||
["seasons"],
|
||||
["home"],
|
||||
];
|
||||
|
||||
queriesToInvalidate.forEach((queryKey) => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
});
|
||||
};
|
||||
|
||||
const markAsPlayedStatus = async (played: boolean) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
// Optimistic update
|
||||
queryClient.setQueryData(
|
||||
["item", item.Id],
|
||||
(oldData: BaseItemDto | undefined) => {
|
||||
if (oldData) {
|
||||
return {
|
||||
...oldData,
|
||||
UserData: {
|
||||
...oldData.UserData,
|
||||
Played: !played,
|
||||
},
|
||||
};
|
||||
}
|
||||
return oldData;
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
if (played) {
|
||||
await markAsNotPlayed({
|
||||
api: api,
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
} else {
|
||||
await markAsPlayed({
|
||||
api: api,
|
||||
item: item,
|
||||
userId: user?.Id,
|
||||
});
|
||||
}
|
||||
invalidateQueries();
|
||||
} catch (error) {
|
||||
// Revert optimistic update on error
|
||||
queryClient.setQueryData(
|
||||
["item", item.Id],
|
||||
(oldData: BaseItemDto | undefined) => {
|
||||
if (oldData) {
|
||||
return {
|
||||
...oldData,
|
||||
UserData: {
|
||||
...oldData.UserData,
|
||||
Played: played,
|
||||
},
|
||||
};
|
||||
}
|
||||
return oldData;
|
||||
}
|
||||
);
|
||||
console.error("Error updating played status:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return markAsPlayedStatus;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import {writeErrorLog, writeInfoLog, writeToLog} from "@/utils/log";
|
||||
import { writeErrorLog, writeInfoLog, writeToLog } from "@/utils/log";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
@@ -9,34 +9,34 @@ import {
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
import {FFmpegKit, FFmpegSession, Statistics} from "ffmpeg-kit-react-native";
|
||||
import {useAtomValue} from "jotai";
|
||||
import {useCallback} from "react";
|
||||
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";
|
||||
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
|
||||
"-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
|
||||
"-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
|
||||
]
|
||||
"-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.
|
||||
@@ -51,9 +51,9 @@ export const useRemuxHlsToMp4 = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [settings] = useSettings();
|
||||
const {saveImage} = useImageStorage();
|
||||
const {saveSeriesPrimaryImage} = useDownloadHelper();
|
||||
const {saveDownloadedItemInfo, setProcesses, processes} = useDownload();
|
||||
const { saveImage } = useImageStorage();
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const { saveDownloadedItemInfo, setProcesses, processes, APP_CACHE_DOWNLOAD_DIRECTORY } = useDownload();
|
||||
|
||||
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
||||
await saveSeriesPrimaryImage(item);
|
||||
@@ -66,90 +66,78 @@ export const useRemuxHlsToMp4 = () => {
|
||||
});
|
||||
|
||||
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();
|
||||
const completeCallback = useCallback(
|
||||
async (session: FFmpegSession, item: BaseItemDto) => {
|
||||
try {
|
||||
console.log("completeCallback");
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
endTime = new Date();
|
||||
const stat = await session.getLastReceivedStatistics();
|
||||
await queryClient.invalidateQueries({queryKey: ["downloadedItems"]});
|
||||
if (returnCode.isValueSuccess()) {
|
||||
const stat = await session.getLastReceivedStatistics();
|
||||
await FileSystem.moveAsync({
|
||||
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
||||
to: `${FileSystem.documentDirectory}${item.Id}.mp4`
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["downloadedItems"],
|
||||
});
|
||||
saveDownloadedItemInfo(item, stat.getSize());
|
||||
toast.success("Download completed");
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
console.log("completeCallback ~ end");
|
||||
},
|
||||
[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.filter((process) => process.itemId !== item.Id);
|
||||
return prev.map((process) => {
|
||||
if (process.itemId === item.Id) {
|
||||
return {
|
||||
...process,
|
||||
id: statistics.getSessionId().toString(),
|
||||
progress: percentage,
|
||||
speed: Math.max(speed, 0),
|
||||
};
|
||||
}
|
||||
return process;
|
||||
});
|
||||
});
|
||||
} 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]);
|
||||
},
|
||||
[setProcesses, completeCallback]
|
||||
);
|
||||
|
||||
const startRemuxing = useCallback(
|
||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||
const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY);
|
||||
if (!cacheDir.exists) {
|
||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {intermediates: true})
|
||||
}
|
||||
|
||||
const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`
|
||||
|
||||
if (!api) throw new Error("API is not defined");
|
||||
if (!item.Id) throw new Error("Item must have an Id");
|
||||
|
||||
@@ -177,17 +165,17 @@ export const useRemuxHlsToMp4 = () => {
|
||||
progress: 0,
|
||||
status: "downloading",
|
||||
timestamp: new Date(),
|
||||
}
|
||||
};
|
||||
|
||||
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
||||
setProcesses((prev) => [...prev, job]);
|
||||
|
||||
await FFmpegKit.executeAsync(
|
||||
createFFmpegCommand(url, output).join(" "),
|
||||
session => completeCallback(session, item),
|
||||
(session) => completeCallback(session, item),
|
||||
undefined,
|
||||
s => statisticsCallback(s, item)
|
||||
)
|
||||
(s) => statisticsCallback(s, item)
|
||||
);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error("Failed to remux:", error);
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-awesome-slider": "^2.5.6",
|
||||
"react-native-bottom-tabs": "^0.7.1",
|
||||
"react-native-bottom-tabs": "0.7.1",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
"react-native-compressor": "^1.9.0",
|
||||
"react-native-device-info": "^14.0.1",
|
||||
@@ -95,6 +95,7 @@
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"react-native-video": "^6.7.0",
|
||||
"react-native-volume-manager": "^1.10.0",
|
||||
"react-native-web": "~0.19.13",
|
||||
"sonner-native": "^0.14.2",
|
||||
"tailwindcss": "3.3.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import {useLog, 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 {atom, useAtom} from "jotai";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -39,7 +39,7 @@ import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AppState, AppStateStatus } from "react-native";
|
||||
import {AppState, AppStateStatus, Platform} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { apiAtom } from "./JellyfinProvider";
|
||||
import * as Notifications from "expo-notifications";
|
||||
@@ -47,16 +47,16 @@ 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 { FileInfo } from "expo-file-system";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import * as Application from "expo-application";
|
||||
|
||||
export type DownloadedItem = {
|
||||
item: Partial<BaseItemDto>;
|
||||
mediaSource: MediaSourceInfo;
|
||||
size: number | undefined;
|
||||
};
|
||||
|
||||
export const processesAtom = atom<JobStatus[]>([])
|
||||
export const processesAtom = atom<JobStatus[]>([]);
|
||||
|
||||
function onAppStateChange(status: AppStateStatus) {
|
||||
focusManager.setFocused(status === "active");
|
||||
@@ -73,7 +73,7 @@ function useDownloadProvider() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { logs } = useLog();
|
||||
|
||||
const {saveSeriesPrimaryImage} = useDownloadHelper();
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const { saveImage } = useImageStorage();
|
||||
|
||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||
@@ -195,6 +195,8 @@ function useDownloadProvider() {
|
||||
[settings?.optimizedVersionsServerUrl, authHeader]
|
||||
);
|
||||
|
||||
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`
|
||||
|
||||
const startDownload = useCallback(
|
||||
async (process: JobStatus) => {
|
||||
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
||||
@@ -267,7 +269,10 @@ function useDownloadProvider() {
|
||||
);
|
||||
})
|
||||
.done(async (doneHandler) => {
|
||||
await saveDownloadedItemInfo(process.item, doneHandler.bytesDownloaded);
|
||||
await saveDownloadedItemInfo(
|
||||
process.item,
|
||||
doneHandler.bytesDownloaded
|
||||
);
|
||||
toast.success(`Download completed for ${process.item.Name}`, {
|
||||
duration: 3000,
|
||||
action: {
|
||||
@@ -397,53 +402,69 @@ function useDownloadProvider() {
|
||||
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");
|
||||
});
|
||||
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 forEveryDirectoryFile = async (includeMMKV: boolean = true, callback: (file: FileInfo) => void) => {
|
||||
const forEveryDocumentDirFile = async (
|
||||
includeMMKV: boolean = true,
|
||||
ignoreList: string[] = [],
|
||||
callback: (file: FileInfo) => void
|
||||
) => {
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
if (!baseDirectory) {
|
||||
throw new Error("Base directory not found");
|
||||
}
|
||||
|
||||
console.log(`ignoreList length: ${ignoreList?.length}`);
|
||||
|
||||
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
||||
for (const item of dirContents) {
|
||||
// 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) {
|
||||
callback(itemInfo)
|
||||
if (
|
||||
(item == "mmkv" && !includeMMKV) ||
|
||||
ignoreList.some(i => item.includes(i))
|
||||
) {
|
||||
console.log("Skipping read for item", item)
|
||||
continue;
|
||||
}
|
||||
await FileSystem.getInfoAsync(`${baseDirectory}${item}`)
|
||||
.then((itemInfo) => {
|
||||
console.log("Loading itemInfo", itemInfo);
|
||||
|
||||
if (itemInfo.exists && !itemInfo.isDirectory) {
|
||||
callback(itemInfo);
|
||||
}
|
||||
})
|
||||
.catch(e =>
|
||||
console.error(e)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteLocalFiles = async (): Promise<void> => {
|
||||
await forEveryDirectoryFile(false, (file) => {
|
||||
console.warn("Deleting file", file.uri)
|
||||
FileSystem.deleteAsync(file.uri, {idempotent: true})
|
||||
}
|
||||
)
|
||||
await forEveryDocumentDirFile(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
|
||||
})
|
||||
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> => {
|
||||
@@ -452,7 +473,7 @@ function useDownloadProvider() {
|
||||
}
|
||||
if (!settings?.optimizedVersionsServerUrl) {
|
||||
console.error("No server URL configured");
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
@@ -513,41 +534,72 @@ function useDownloadProvider() {
|
||||
};
|
||||
|
||||
const deleteItems = async (items: BaseItemDto[]) => {
|
||||
Promise.all(items.map(i => {
|
||||
if (i.Id)
|
||||
return deleteFile(i.Id)
|
||||
return
|
||||
})).then(() =>
|
||||
Promise.all(
|
||||
items.map((i) => {
|
||||
if (i.Id) return deleteFile(i.Id);
|
||||
return;
|
||||
})
|
||||
).then(() =>
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const cleanCacheDirectory = async () => {
|
||||
const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY);
|
||||
if (cacheDir.exists) {
|
||||
const cachedFiles = await FileSystem.readDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY)
|
||||
let position = 0
|
||||
const batchSize = 3
|
||||
|
||||
// batching promise.all to avoid OOM
|
||||
while (position < cachedFiles.length) {
|
||||
const itemsForBatch = cachedFiles.slice(position, position + batchSize)
|
||||
await Promise.all(itemsForBatch.map(async file => {
|
||||
const info = await FileSystem.getInfoAsync(`${APP_CACHE_DOWNLOAD_DIRECTORY}${file}`)
|
||||
if (info.exists) {
|
||||
await FileSystem.deleteAsync(info.uri, { idempotent: true })
|
||||
return Promise.resolve(file)
|
||||
}
|
||||
return Promise.reject()
|
||||
}))
|
||||
|
||||
position += batchSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFileByType = async (type: BaseItemDto['Type']) => {
|
||||
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
|
||||
await Promise.all(
|
||||
downloadedFiles
|
||||
?.filter(file => file.item.Type == type)
|
||||
?.flatMap(file => {
|
||||
?.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!))
|
||||
promises.push(deleteFile(file.item.SeriesId));
|
||||
promises.push(deleteFile(file.item.Id!));
|
||||
return promises;
|
||||
})
|
||||
|| []
|
||||
}) || []
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const appSizeUsage = useMemo(async () => {
|
||||
const sizes: number[] = [];
|
||||
await forEveryDirectoryFile(
|
||||
const sizes: number[] = downloadedFiles?.map(d => {
|
||||
return getDownloadedItemSize(d.item.Id!!)
|
||||
}) || [];
|
||||
|
||||
await forEveryDocumentDirFile(
|
||||
true,
|
||||
file => {
|
||||
if (file.exists) sizes.push(file.size)
|
||||
}
|
||||
)
|
||||
getAllDownloadedItems().map(d => d.item.Id!!),
|
||||
(file) => {
|
||||
if (file.exists) {
|
||||
sizes.push(file.size);
|
||||
}
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
|
||||
return sizes.reduce((sum, size) => sum + size, 0);
|
||||
}, [logs, downloadedFiles])
|
||||
}, [logs, downloadedFiles, forEveryDocumentDirFile]);
|
||||
|
||||
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
||||
try {
|
||||
@@ -594,7 +646,7 @@ function useDownloadProvider() {
|
||||
"Media source not found in tmp storage. Did you forget to save it before starting download?"
|
||||
);
|
||||
|
||||
const newItem = { item, size, mediaSource: data.mediaSource };
|
||||
const newItem = { item, mediaSource: data.mediaSource };
|
||||
|
||||
if (existingItemIndex !== -1) {
|
||||
items[existingItemIndex] = newItem;
|
||||
@@ -605,6 +657,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) {
|
||||
@@ -615,6 +669,11 @@ function useDownloadProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
function getDownloadedItemSize(itemId: string): number {
|
||||
const size = storage.getString("downloadedItemSize-" + itemId);
|
||||
return size ? parseInt(size) : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
processes,
|
||||
startBackgroundDownload,
|
||||
@@ -628,7 +687,10 @@ function useDownloadProvider() {
|
||||
startDownload,
|
||||
getDownloadedItem,
|
||||
deleteFileByType,
|
||||
appSizeUsage
|
||||
appSizeUsage,
|
||||
getDownloadedItemSize,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
cleanCacheDirectory
|
||||
};
|
||||
}
|
||||
|
||||
@@ -653,15 +715,12 @@ export function useDownload() {
|
||||
export function bytesToReadable(bytes: number): string {
|
||||
const gb = bytes / 1e9;
|
||||
|
||||
if (gb >= 1)
|
||||
return `${gb.toFixed(2)} GB`
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
|
||||
const mb = bytes / 1024 / 1024
|
||||
if (mb >= 1)
|
||||
return `${mb.toFixed(2)} MB`
|
||||
const mb = bytes / 1024.0 / 1024.0;
|
||||
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`
|
||||
}
|
||||
const kb = bytes / 1024.0;
|
||||
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
||||
return `${bytes.toFixed(2)} B`;
|
||||
}
|
||||
|
||||
0
svenska_kyrkan.sql
Normal file
0
svenska_kyrkan.sql
Normal file
134
utils/SubtitleHelper.ts
Normal file
134
utils/SubtitleHelper.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { TranscodedSubtitle } from "@/components/video-player/controls/types";
|
||||
import { TrackInfo } from "@/modules/vlc-player";
|
||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const disableSubtitle = {
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
IsTextSubtitleStream: true,
|
||||
} as TranscodedSubtitle;
|
||||
|
||||
export class SubtitleHelper {
|
||||
private mediaStreams: MediaStream[];
|
||||
|
||||
constructor(mediaStreams: MediaStream[]) {
|
||||
this.mediaStreams = mediaStreams.filter((x) => x.Type === "Subtitle");
|
||||
}
|
||||
|
||||
getSubtitles(): MediaStream[] {
|
||||
return this.mediaStreams;
|
||||
}
|
||||
|
||||
getUniqueSubtitles(): MediaStream[] {
|
||||
const uniqueSubs: MediaStream[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
this.mediaStreams.forEach((x) => {
|
||||
if (!seen.has(x.DisplayTitle!)) {
|
||||
seen.add(x.DisplayTitle!);
|
||||
uniqueSubs.push(x);
|
||||
}
|
||||
});
|
||||
|
||||
return uniqueSubs;
|
||||
}
|
||||
|
||||
getCurrentSubtitle(subtitleIndex?: number): MediaStream | undefined {
|
||||
return this.mediaStreams.find((x) => x.Index === subtitleIndex);
|
||||
}
|
||||
|
||||
getMostCommonSubtitleByName(
|
||||
subtitleIndex: number | undefined
|
||||
): number | undefined {
|
||||
if (subtitleIndex === undefined) -1;
|
||||
const uniqueSubs = this.getUniqueSubtitles();
|
||||
const currentSub = this.getCurrentSubtitle(subtitleIndex);
|
||||
|
||||
return uniqueSubs.find((x) => x.DisplayTitle === currentSub?.DisplayTitle)
|
||||
?.Index;
|
||||
}
|
||||
|
||||
getTextSubtitles(): MediaStream[] {
|
||||
return this.mediaStreams.filter((x) => x.IsTextSubtitleStream);
|
||||
}
|
||||
|
||||
getImageSubtitles(): MediaStream[] {
|
||||
return this.mediaStreams.filter((x) => !x.IsTextSubtitleStream);
|
||||
}
|
||||
|
||||
getEmbeddedTrackIndex(sourceSubtitleIndex: number): number {
|
||||
if (Platform.OS === "android") {
|
||||
const textSubs = this.getTextSubtitles();
|
||||
const matchingSubtitle = textSubs.find(
|
||||
(sub) => sub.Index === sourceSubtitleIndex
|
||||
);
|
||||
|
||||
if (!matchingSubtitle) return -1;
|
||||
return textSubs.indexOf(matchingSubtitle);
|
||||
}
|
||||
|
||||
// Get unique text-based subtitles because react-native-video removes hls text tracks duplicates. (iOS)
|
||||
const uniqueTextSubs = this.getUniqueTextBasedSubtitles();
|
||||
const matchingSubtitle = uniqueTextSubs.find(
|
||||
(sub) => sub.Index === sourceSubtitleIndex
|
||||
);
|
||||
|
||||
if (!matchingSubtitle) return -1;
|
||||
return uniqueTextSubs.indexOf(matchingSubtitle);
|
||||
}
|
||||
|
||||
sortSubtitles(
|
||||
textSubs: TranscodedSubtitle[],
|
||||
allSubs: MediaStream[]
|
||||
): TranscodedSubtitle[] {
|
||||
let textIndex = 0; // To track position in textSubtitles
|
||||
// Merge text and image subtitles in the order of allSubs
|
||||
const sortedSubtitles = allSubs.map((sub) => {
|
||||
if (sub.IsTextSubtitleStream) {
|
||||
if (textSubs.length === 0) return disableSubtitle;
|
||||
const textSubtitle = textSubs[textIndex];
|
||||
if (!textSubtitle) return disableSubtitle;
|
||||
textIndex++;
|
||||
return textSubtitle;
|
||||
} else {
|
||||
return {
|
||||
name: sub.DisplayTitle!,
|
||||
index: sub.Index!,
|
||||
IsTextSubtitleStream: sub.IsTextSubtitleStream,
|
||||
} as TranscodedSubtitle;
|
||||
}
|
||||
});
|
||||
|
||||
return sortedSubtitles;
|
||||
}
|
||||
|
||||
getSortedSubtitles(subtitleTracks: TrackInfo[]): TranscodedSubtitle[] {
|
||||
const textSubtitles =
|
||||
subtitleTracks.map((s) => ({
|
||||
name: s.name,
|
||||
index: s.index,
|
||||
IsTextSubtitleStream: true,
|
||||
})) || [];
|
||||
|
||||
const sortedSubs =
|
||||
Platform.OS === "android"
|
||||
? this.sortSubtitles(textSubtitles, this.mediaStreams)
|
||||
: this.sortSubtitles(textSubtitles, this.getUniqueSubtitles());
|
||||
|
||||
return sortedSubs;
|
||||
}
|
||||
|
||||
getUniqueTextBasedSubtitles(): MediaStream[] {
|
||||
return this.getUniqueSubtitles().filter((x) => x.IsTextSubtitleStream);
|
||||
}
|
||||
|
||||
// HLS stream indexes are not the same as the actual source indexes.
|
||||
// This function aims to get the source subtitle index from the embedded track index.
|
||||
getSourceSubtitleIndex = (embeddedTrackIndex: number): number => {
|
||||
if (Platform.OS === "android") {
|
||||
return this.getTextSubtitles()[embeddedTrackIndex]?.Index ?? -1;
|
||||
}
|
||||
return this.getUniqueTextBasedSubtitles()[embeddedTrackIndex]?.Index ?? -1;
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,11 @@ 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";
|
||||
import {
|
||||
CultureDto,
|
||||
SubtitlePlaybackMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
export type DownloadQuality = "original" | "high" | "low";
|
||||
|
||||
@@ -65,8 +70,12 @@ export type Settings = {
|
||||
openInVLC?: boolean;
|
||||
downloadQuality?: DownloadOption;
|
||||
libraryOptions: LibraryOptions;
|
||||
defaultSubtitleLanguage: DefaultLanguageOption | null;
|
||||
defaultAudioLanguage: DefaultLanguageOption | null;
|
||||
defaultAudioLanguage: CultureDto | null;
|
||||
playDefaultAudioTrack: boolean;
|
||||
rememberAudioSelections: boolean;
|
||||
defaultSubtitleLanguage: CultureDto | null;
|
||||
subtitleMode: SubtitlePlaybackMode;
|
||||
rememberSubtitleSelections: boolean;
|
||||
showHomeTitles: boolean;
|
||||
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
||||
forwardSkipTime: number;
|
||||
@@ -98,7 +107,11 @@ const loadSettings = (): Settings => {
|
||||
showStats: true,
|
||||
},
|
||||
defaultAudioLanguage: null,
|
||||
playDefaultAudioTrack: true,
|
||||
rememberAudioSelections: true,
|
||||
defaultSubtitleLanguage: null,
|
||||
subtitleMode: SubtitlePlaybackMode.Default,
|
||||
rememberSubtitleSelections: true,
|
||||
showHomeTitles: true,
|
||||
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
||||
forwardSkipTime: 30,
|
||||
@@ -107,7 +120,7 @@ const loadSettings = (): Settings => {
|
||||
downloadMethod: "remux",
|
||||
autoDownload: false,
|
||||
showCustomMenuLinks: false,
|
||||
subtitleSize: 60,
|
||||
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
||||
remuxConcurrentLimit: 1,
|
||||
};
|
||||
|
||||
@@ -143,6 +156,7 @@ export const useSettings = () => {
|
||||
const updateSettings = (update: Partial<Settings>) => {
|
||||
if (settings) {
|
||||
const newSettings = { ...settings, ...update };
|
||||
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
}
|
||||
|
||||
53
utils/collectionTypeToItemType.ts
Normal file
53
utils/collectionTypeToItemType.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
BaseItemKind,
|
||||
CollectionType,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
/**
|
||||
* Converts a ColletionType to a BaseItemKind (also called ItemType)
|
||||
*
|
||||
* CollectionTypes
|
||||
* readonly Unknown: "unknown";
|
||||
readonly Movies: "movies";
|
||||
readonly Tvshows: "tvshows";
|
||||
readonly Music: "music";
|
||||
readonly Musicvideos: "musicvideos";
|
||||
readonly Trailers: "trailers";
|
||||
readonly Homevideos: "homevideos";
|
||||
readonly Boxsets: "boxsets";
|
||||
readonly Books: "books";
|
||||
readonly Photos: "photos";
|
||||
readonly Livetv: "livetv";
|
||||
readonly Playlists: "playlists";
|
||||
readonly Folders: "folders";
|
||||
*/
|
||||
export const colletionTypeToItemType = (
|
||||
collectionType?: CollectionType | null
|
||||
): BaseItemKind | undefined => {
|
||||
if (!collectionType) return undefined;
|
||||
|
||||
switch (collectionType) {
|
||||
case CollectionType.Movies:
|
||||
return BaseItemKind.Movie;
|
||||
case CollectionType.Tvshows:
|
||||
return BaseItemKind.Series;
|
||||
case CollectionType.Homevideos:
|
||||
return BaseItemKind.Video;
|
||||
case CollectionType.Musicvideos:
|
||||
return BaseItemKind.MusicVideo;
|
||||
case CollectionType.Books:
|
||||
return BaseItemKind.Book;
|
||||
case CollectionType.Playlists:
|
||||
return BaseItemKind.Playlist;
|
||||
case CollectionType.Folders:
|
||||
return BaseItemKind.Folder;
|
||||
case CollectionType.Photos:
|
||||
return BaseItemKind.Photo;
|
||||
case CollectionType.Trailers:
|
||||
return BaseItemKind.Trailer;
|
||||
case CollectionType.Playlists:
|
||||
return BaseItemKind.Playlist;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,49 +1,33 @@
|
||||
import {getPrimaryImageUrlById} from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client";
|
||||
import useImageStorage from "@/hooks/useImageStorage";
|
||||
import {apiAtom} from "@/providers/JellyfinProvider";
|
||||
import {useAtom} from "jotai";
|
||||
import {storage} from "@/utils/mmkv";
|
||||
import {getDownloadedFileUrl} from "@/hooks/useDownloadedFileOpener";
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import {FileInfo} from "expo-file-system";
|
||||
|
||||
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 { saveImage } = useImageStorage();
|
||||
|
||||
const saveSeriesPrimaryImage = async (item: BaseItemDto) => {
|
||||
if (item.Type === "Episode" && item.SeriesId && !storage.getString(item.SeriesId)) {
|
||||
await saveImage(item.SeriesId, getPrimaryImageUrlById({ api, id: item.SeriesId }))
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getDownloadSize = async (
|
||||
onNewItemSizeFetched: (item: BaseItemDto, size: number) => void,
|
||||
...items: BaseItemDto[]
|
||||
) => {
|
||||
const sizes: number[] = [];
|
||||
return { saveSeriesPrimaryImage };
|
||||
};
|
||||
|
||||
await Promise.all(items.map(item => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const url = await getDownloadedFileUrl(item.Id!);
|
||||
if (url) {
|
||||
const fileInfo: FileInfo = await FileSystem.getInfoAsync(url);
|
||||
if (fileInfo.exists) {
|
||||
onNewItemSizeFetched(item, fileInfo.size)
|
||||
sizes.push(fileInfo.size);
|
||||
resolve(sizes)
|
||||
}
|
||||
}
|
||||
reject();
|
||||
})
|
||||
}));
|
||||
|
||||
return sizes.reduce((sum, size) => sum + size, 0);
|
||||
}
|
||||
|
||||
return { saveSeriesPrimaryImage, getDownloadSize }
|
||||
}
|
||||
|
||||
export default useDownloadHelper;
|
||||
export default useDownloadHelper;
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Settings } from "../atoms/settings";
|
||||
import { Settings, useSettings } from "../atoms/settings";
|
||||
import {
|
||||
AudioStreamRanker,
|
||||
StreamRanker,
|
||||
SubtitleStreamRanker,
|
||||
} from "../streamRanker";
|
||||
|
||||
interface PlaySettings {
|
||||
item: BaseItemDto;
|
||||
@@ -14,9 +19,22 @@ interface PlaySettings {
|
||||
subtitleIndex?: number | undefined;
|
||||
}
|
||||
|
||||
export interface previousIndexes {
|
||||
audioIndex?: number;
|
||||
subtitleIndex?: number;
|
||||
}
|
||||
|
||||
interface TrackOptions {
|
||||
DefaultAudioStreamIndex: number | undefined;
|
||||
DefaultSubtitleStreamIndex: number | undefined;
|
||||
}
|
||||
|
||||
// Used getting default values for the next player.
|
||||
export function getDefaultPlaySettings(
|
||||
item: BaseItemDto,
|
||||
settings: Settings
|
||||
settings: Settings,
|
||||
previousIndexes?: previousIndexes,
|
||||
previousSource?: MediaSourceInfo
|
||||
): PlaySettings {
|
||||
if (item.Type === "Program") {
|
||||
return {
|
||||
@@ -35,19 +53,44 @@ export function getDefaultPlaySettings(
|
||||
// 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
|
||||
)?.Index;
|
||||
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||
(stream) => stream.Type === "Subtitle" && stream.IsDefault
|
||||
)?.Index;
|
||||
// We prefer the previous track over the default track.
|
||||
let trackOptions: TrackOptions = {
|
||||
DefaultAudioStreamIndex: defaultAudioIndex ?? -1,
|
||||
DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
||||
};
|
||||
|
||||
const mediaStreams = mediaSource?.MediaStreams ?? [];
|
||||
if (settings?.rememberSubtitleSelections && previousIndexes) {
|
||||
if (previousIndexes.subtitleIndex !== undefined && previousSource) {
|
||||
const subtitleRanker = new SubtitleStreamRanker();
|
||||
const ranker = new StreamRanker(subtitleRanker);
|
||||
ranker.rankStream(
|
||||
previousIndexes.subtitleIndex,
|
||||
previousSource,
|
||||
mediaStreams,
|
||||
trackOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings?.rememberAudioSelections && previousIndexes) {
|
||||
if (previousIndexes.audioIndex !== undefined && previousSource) {
|
||||
const audioRanker = new AudioStreamRanker();
|
||||
const ranker = new StreamRanker(audioRanker);
|
||||
ranker.rankStream(
|
||||
previousIndexes.audioIndex,
|
||||
previousSource,
|
||||
mediaStreams,
|
||||
trackOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Get default bitrate
|
||||
const bitrate = BITRATES.sort(
|
||||
@@ -58,7 +101,7 @@ export function getDefaultPlaySettings(
|
||||
item,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex: preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex,
|
||||
subtitleIndex: preferedSubtitleIndex ?? defaultSubtitleIndex ?? -1,
|
||||
audioIndex: trackOptions.DefaultAudioStreamIndex,
|
||||
subtitleIndex: trackOptions.DefaultSubtitleStreamIndex,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +109,6 @@ export const getStreamUrl = async ({
|
||||
|
||||
if (item.MediaType === "Video") {
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
|
||||
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
|
||||
|
||||
// If there is no subtitle stream index, add it to the URL.
|
||||
@@ -124,10 +123,7 @@ export const getStreamUrl = async ({
|
||||
// Get the updated URL
|
||||
const transcodeUrl = urlObj.toString();
|
||||
|
||||
console.log(
|
||||
"Video has transcoding URL:",
|
||||
`${transcodeUrl}`
|
||||
);
|
||||
console.log("Video has transcoding URL:", `${transcodeUrl}`);
|
||||
return {
|
||||
url: transcodeUrl,
|
||||
sessionId: sessionId,
|
||||
|
||||
@@ -41,21 +41,39 @@ export const chromecastProfile: DeviceProfile = {
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: "Video",
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "ts",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3",
|
||||
CopyTimestamps: false,
|
||||
EnableSubtitlesInManifest: true,
|
||||
Type: "Video",
|
||||
VideoCodec: "h264",
|
||||
AudioCodec: "aac,mp3",
|
||||
Protocol: "hls",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: "2",
|
||||
MinSegments: 2,
|
||||
BreakOnNonKeyFrames: true,
|
||||
},
|
||||
{
|
||||
Type: "Audio",
|
||||
Context: "Streaming",
|
||||
Container: "mp4",
|
||||
Type: "Video",
|
||||
VideoCodec: "h264",
|
||||
AudioCodec: "aac",
|
||||
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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -10,21 +10,12 @@ import MediaTypes from "../../constants/MediaTypes";
|
||||
*/
|
||||
export default {
|
||||
Name: "1. Vlc Player",
|
||||
MaxStaticBitrate: 9999_000_000,
|
||||
MaxStreamingBitrate: 9999_000_000,
|
||||
MaxStaticBitrate: 999_999_999,
|
||||
MaxStreamingBitrate: 999_999_999,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||
// TODO: Fix dolby vision playback
|
||||
// Conditions: [
|
||||
// {
|
||||
// Condition: "NotEquals",
|
||||
// Property: "VideoProfile",
|
||||
// Value: "dolby-vision|dvhe|dvh1|dvhe.05|dvhe.07",
|
||||
// IsRequired: false,
|
||||
// },
|
||||
// ],
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
|
||||
|
||||
export default {
|
||||
Name: "Vlc Player for HLS streams.",
|
||||
MaxStaticBitrate: 20_000_000,
|
||||
@@ -40,7 +39,7 @@ export default {
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "ts",
|
||||
Container: "fmp4",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3",
|
||||
CopyTimestamps: false,
|
||||
@@ -78,11 +77,10 @@ export default {
|
||||
{ Format: "vtt", Method: "Hls" },
|
||||
{ Format: "webvtt", Method: "Hls" },
|
||||
|
||||
|
||||
// Image based subs use encode.
|
||||
{ Format: "dvdsub", Method: "Encode" },
|
||||
{ Format: "pgs", Method: "Encode" },
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
{ Format: "xsub", Method: "Encode" },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
147
utils/streamRanker.ts
Normal file
147
utils/streamRanker.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
MediaSourceInfo,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
abstract class StreamRankerStrategy {
|
||||
abstract streamType: string;
|
||||
|
||||
abstract rankStream(
|
||||
prevIndex: number,
|
||||
prevSource: MediaSourceInfo,
|
||||
mediaStreams: MediaStream[],
|
||||
trackOptions: any
|
||||
): void;
|
||||
|
||||
protected rank(
|
||||
prevIndex: number,
|
||||
prevSource: MediaSourceInfo,
|
||||
mediaStreams: MediaStream[],
|
||||
trackOptions: any
|
||||
): void {
|
||||
if (prevIndex == -1) {
|
||||
console.debug(`AutoSet Subtitle - No Stream Set`);
|
||||
trackOptions[`Default${this.streamType}StreamIndex`] = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prevSource.MediaStreams || !mediaStreams) {
|
||||
console.debug(`AutoSet ${this.streamType} - No MediaStreams`);
|
||||
return;
|
||||
}
|
||||
|
||||
let bestStreamIndex = null;
|
||||
let bestStreamScore = 0;
|
||||
|
||||
const prevStream = prevSource.MediaStreams[prevIndex];
|
||||
|
||||
if (!prevStream) {
|
||||
console.debug(`AutoSet ${this.streamType} - No prevStream`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`AutoSet ${this.streamType} - Previous was ${prevStream.Index} - ${prevStream.DisplayTitle}`
|
||||
);
|
||||
|
||||
let prevRelIndex = 0;
|
||||
for (const stream of prevSource.MediaStreams) {
|
||||
if (stream.Type != this.streamType) continue;
|
||||
|
||||
if (stream.Index == prevIndex) break;
|
||||
|
||||
prevRelIndex += 1;
|
||||
}
|
||||
|
||||
let newRelIndex = 0;
|
||||
for (const stream of mediaStreams) {
|
||||
if (stream.Type != this.streamType) continue;
|
||||
|
||||
let score = 0;
|
||||
|
||||
if (prevStream.Codec == stream.Codec) score += 1;
|
||||
if (prevRelIndex == newRelIndex) score += 1;
|
||||
if (
|
||||
prevStream.DisplayTitle &&
|
||||
prevStream.DisplayTitle == stream.DisplayTitle
|
||||
)
|
||||
score += 2;
|
||||
if (
|
||||
prevStream.Language &&
|
||||
prevStream.Language != "und" &&
|
||||
prevStream.Language == stream.Language
|
||||
)
|
||||
score += 2;
|
||||
|
||||
console.debug(
|
||||
`AutoSet ${this.streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`
|
||||
);
|
||||
if (score > bestStreamScore && score >= 3) {
|
||||
bestStreamScore = score;
|
||||
bestStreamIndex = stream.Index;
|
||||
}
|
||||
|
||||
newRelIndex += 1;
|
||||
}
|
||||
|
||||
if (bestStreamIndex != null) {
|
||||
console.debug(
|
||||
`AutoSet ${this.streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`
|
||||
);
|
||||
trackOptions[`Default${this.streamType}StreamIndex`] = bestStreamIndex;
|
||||
} else {
|
||||
console.debug(
|
||||
`AutoSet ${this.streamType} - Threshold not met. Using default.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SubtitleStreamRanker extends StreamRankerStrategy {
|
||||
streamType = "Subtitle";
|
||||
|
||||
rankStream(
|
||||
prevIndex: number,
|
||||
prevSource: MediaSourceInfo,
|
||||
mediaStreams: MediaStream[],
|
||||
trackOptions: any
|
||||
): void {
|
||||
super.rank(prevIndex, prevSource, mediaStreams, trackOptions);
|
||||
}
|
||||
}
|
||||
|
||||
class AudioStreamRanker extends StreamRankerStrategy {
|
||||
streamType = "Audio";
|
||||
|
||||
rankStream(
|
||||
prevIndex: number,
|
||||
prevSource: MediaSourceInfo,
|
||||
mediaStreams: MediaStream[],
|
||||
trackOptions: any
|
||||
): void {
|
||||
super.rank(prevIndex, prevSource, mediaStreams, trackOptions);
|
||||
}
|
||||
}
|
||||
|
||||
class StreamRanker {
|
||||
private strategy: StreamRankerStrategy;
|
||||
|
||||
constructor(strategy: StreamRankerStrategy) {
|
||||
this.strategy = strategy;
|
||||
}
|
||||
|
||||
setStrategy(strategy: StreamRankerStrategy) {
|
||||
this.strategy = strategy;
|
||||
}
|
||||
|
||||
rankStream(
|
||||
prevIndex: number,
|
||||
prevSource: MediaSourceInfo,
|
||||
mediaStreams: MediaStream[],
|
||||
trackOptions: any
|
||||
) {
|
||||
this.strategy.rankStream(prevIndex, prevSource, mediaStreams, trackOptions);
|
||||
}
|
||||
}
|
||||
|
||||
export { StreamRanker, SubtitleStreamRanker, AudioStreamRanker };
|
||||
Reference in New Issue
Block a user