mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-22 16:54:42 +01:00
Compare commits
2 Commits
v0.22.0
...
feat/remov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6c7a7603 | ||
|
|
5a07eccd9b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,4 +35,3 @@ credentials.json
|
|||||||
*.ipa
|
*.ipa
|
||||||
.continuerc.json
|
.continuerc.json
|
||||||
|
|
||||||
.vscode/
|
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -11,5 +11,7 @@
|
|||||||
},
|
},
|
||||||
"[swift]": {
|
"[swift]": {
|
||||||
"editor.defaultFormatter": "sswg.swift-lang"
|
"editor.defaultFormatter": "sswg.swift-lang"
|
||||||
}
|
},
|
||||||
|
"java.configuration.updateBuildConfiguration": "interactive",
|
||||||
|
"java.compile.nullAnalysis.mode": "automatic"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default function index() {
|
|||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||||
|
|
||||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
const { downloadedFiles } = useDownload();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -107,9 +107,6 @@ export default function index() {
|
|||||||
setIsConnected(state.isConnected);
|
setIsConnected(state.isConnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
cleanCacheDirectory()
|
|
||||||
.then(r => console.log("Cache directory cleaned"))
|
|
||||||
.catch(e => console.error("Something went wrong cleaning cache directory"))
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ import { Button } from "@/components/Button";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListItem } from "@/components/ListItem";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
|
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs, useLog } from "@/utils/log";
|
import {clearLogs, useLog} from "@/utils/log";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import {Alert, ScrollView, View} from "react-native";
|
||||||
import * as Progress from "react-native-progress";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import * as Progress from 'react-native-progress';
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
@@ -25,17 +25,17 @@ export default function settings() {
|
|||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { data: size, isLoading: appSizeLoading } = useQuery({
|
const {data: size , isLoading: appSizeLoading } = useQuery({
|
||||||
queryKey: ["appSize", appSizeUsage],
|
queryKey: ["appSize", appSizeUsage],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const app = await appSizeUsage;
|
const app = await appSizeUsage
|
||||||
|
|
||||||
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
const remaining = await FileSystem.getFreeDiskStorageAsync()
|
||||||
const total = await FileSystem.getTotalDiskCapacityAsync();
|
const total = await FileSystem.getTotalDiskCapacityAsync()
|
||||||
|
|
||||||
return { app, remaining, total, used: (total - remaining) / total };
|
return {app, remaining, total, used: (total - remaining) / total}
|
||||||
},
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
const openQuickConnectAuthCodeInput = () => {
|
const openQuickConnectAuthCodeInput = () => {
|
||||||
Alert.prompt(
|
Alert.prompt(
|
||||||
@@ -69,16 +69,22 @@ export default function settings() {
|
|||||||
const onDeleteClicked = async () => {
|
const onDeleteClicked = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllFiles();
|
await deleteAllFiles();
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Error
|
||||||
|
);
|
||||||
toast.error("Error deleting files");
|
toast.error("Error deleting files");
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const onClearLogsClicked = async () => {
|
const onClearLogsClicked = async () => {
|
||||||
clearLogs();
|
clearLogs();
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,16 +140,19 @@ export default function settings() {
|
|||||||
progress={size?.used}
|
progress={size?.used}
|
||||||
/>
|
/>
|
||||||
{size && (
|
{size && (
|
||||||
<Text>
|
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
|
||||||
Available: {bytesToReadable(size.remaining)}, Total:{" "}
|
|
||||||
{bytesToReadable(size.total)}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Button color="red" onPress={onDeleteClicked}>
|
<Button
|
||||||
|
color="red"
|
||||||
|
onPress={onDeleteClicked}
|
||||||
|
>
|
||||||
Delete all downloaded files
|
Delete all downloaded files
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="red" onPress={onClearLogsClicked}>
|
<Button
|
||||||
|
color="red"
|
||||||
|
onPress={onClearLogsClicked}
|
||||||
|
>
|
||||||
Delete all logs
|
Delete all logs
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ItemContent } from "@/components/ItemContent";
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import {
|
||||||
|
getMediaInfoApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -44,25 +48,20 @@ const Page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fadeOut = (callback: any) => {
|
const fadeOut = (callback: any) => {
|
||||||
setTimeout(() => {
|
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
if (finished) {
|
||||||
if (finished) {
|
runOnJS(callback)();
|
||||||
runOnJS(callback)();
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fadeIn = (callback: any) => {
|
const fadeIn = (callback: any) => {
|
||||||
setTimeout(() => {
|
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
|
||||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
if (finished) {
|
||||||
if (finished) {
|
runOnJS(callback)();
|
||||||
runOnJS(callback)();
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
fadeOut(() => {});
|
fadeOut(() => {});
|
||||||
@@ -85,24 +84,14 @@ const Page: React.FC = () => {
|
|||||||
style={[animatedStyle]}
|
style={[animatedStyle]}
|
||||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||||
>
|
>
|
||||||
<View
|
<View className="h-[350px] bg-transparent rounded-lg mb-4 w-full"></View>
|
||||||
style={{
|
<View className="h-6 bg-neutral-900 rounded mb-1 w-12"></View>
|
||||||
height: item?.Type === "Episode" ? 300 : 450,
|
<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>
|
||||||
className="bg-transparent rounded-lg mb-4 w-full"
|
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||||
></View>
|
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||||
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-1/4"></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>
|
</Animated.View>
|
||||||
{item && <ItemContent item={item} />}
|
{item && <ItemContent item={item} />}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
import {
|
import { Button, Dimensions, ScrollView, View } from "react-native";
|
||||||
Button,
|
|
||||||
Dimensions,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const HOUR_HEIGHT = 30;
|
const HOUR_HEIGHT = 30;
|
||||||
@@ -86,6 +78,8 @@ export default function page() {
|
|||||||
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
|
||||||
|
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
|
||||||
|
|
||||||
const [scrollX, setScrollX] = useState(0);
|
const [scrollX, setScrollX] = useState(0);
|
||||||
|
|
||||||
const handleNextPage = useCallback(() => {
|
const handleNextPage = useCallback(() => {
|
||||||
@@ -106,15 +100,24 @@ export default function page() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<PageButtons
|
<View className="flex flex-row bg-neutral-800 w-full items-end">
|
||||||
currentPage={currentPage}
|
<Button
|
||||||
onPrevPage={handlePrevPage}
|
title="Previous"
|
||||||
onNextPage={handleNextPage}
|
onPress={handlePrevPage}
|
||||||
isNextDisabled={
|
disabled={currentPage === 1}
|
||||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
/>
|
||||||
}
|
<Button
|
||||||
/>
|
title="Next"
|
||||||
|
onPress={handleNextPage}
|
||||||
|
disabled={
|
||||||
|
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row">
|
<View className="flex flex-row">
|
||||||
<View className="flex flex-col w-[64px]">
|
<View className="flex flex-col w-[64px]">
|
||||||
@@ -163,57 +166,3 @@ export default function page() {
|
|||||||
</ScrollView>
|
</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,7 +5,10 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ScrollView, View } from "react-native";
|
import {
|
||||||
|
ScrollView,
|
||||||
|
View
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -24,6 +27,9 @@ export default function page() {
|
|||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
}}
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
@@ -7,14 +6,16 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import {useLocalSearchParams, useNavigation} from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo } from "react";
|
import React, {useEffect} from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import {DownloadItems} from "@/components/DownloadItem";
|
||||||
|
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||||
|
import {getTvShowsApi} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -59,7 +60,7 @@ const page: React.FC = () => {
|
|||||||
[item]
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: allEpisodes, isLoading } = useQuery({
|
const {data: allEpisodes, isLoading} = useQuery({
|
||||||
queryKey: ["AllEpisodes", item?.Id],
|
queryKey: ["AllEpisodes", item?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getTvShowsApi(api!).getEpisodes({
|
const res = await getTvShowsApi(api!).getEpisodes({
|
||||||
@@ -68,38 +69,34 @@ const page: React.FC = () => {
|
|||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
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(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () => (
|
||||||
!isLoading &&
|
(!isLoading && allEpisodes && allEpisodes.length > 0) && (
|
||||||
allEpisodes &&
|
|
||||||
allEpisodes.length > 0 && (
|
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<DownloadItems
|
<DownloadItems
|
||||||
title="Download Series"
|
|
||||||
items={allEpisodes || []}
|
items={allEpisodes || []}
|
||||||
MissingDownloadIconComponent={() => (
|
MissingDownloadIconComponent={() => (
|
||||||
<Ionicons name="download" size={22} color="white" />
|
<MaterialCommunityIcons name="folder-download" size={24} color="white"/>
|
||||||
)}
|
)}
|
||||||
DownloadedIconComponent={() => (
|
DownloadedIconComponent={() => (
|
||||||
<Ionicons
|
<MaterialCommunityIcons name="folder-check" size={26} color="#9333ea"/>
|
||||||
name="checkmark-done-outline"
|
|
||||||
size={24}
|
|
||||||
color="#9333ea"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
),
|
)
|
||||||
});
|
)
|
||||||
|
})
|
||||||
}, [allEpisodes, isLoading]);
|
}, [allEpisodes, isLoading]);
|
||||||
|
|
||||||
if (!item || !backdropUrl) return null;
|
if (!item || !backdropUrl)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParallaxScrollView
|
<ParallaxScrollView
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
getFilterApi,
|
getFilterApi,
|
||||||
@@ -41,7 +40,8 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
|
|
||||||
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -141,18 +141,6 @@ const Page = () => {
|
|||||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
if (!api || !library) return 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({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -167,7 +155,6 @@ const Page = () => {
|
|||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => parseInt(year)),
|
years: selectedYears.map((year) => parseInt(year)),
|
||||||
includeItemTypes: itemType ? [itemType] : undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data || null;
|
return response.data || null;
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
AppState,
|
AppState,
|
||||||
AppStateStatus,
|
AppStateStatus,
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import settings from "../(tabs)/(home)/settings";
|
import settings from "../(tabs)/(home)/settings";
|
||||||
@@ -126,7 +125,14 @@ export default function page() {
|
|||||||
isLoading: isLoadingStreamUrl,
|
isLoading: isLoadingStreamUrl,
|
||||||
isError: isErrorStreamUrl,
|
isError: isErrorStreamUrl,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
queryKey: [
|
||||||
|
"stream-url",
|
||||||
|
itemId,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
bitrateValue,
|
||||||
|
],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log("Offline:", offline);
|
console.log("Offline:", offline);
|
||||||
if (offline) {
|
if (offline) {
|
||||||
@@ -248,7 +254,6 @@ export default function page() {
|
|||||||
videoRef.current?.stop();
|
videoRef.current?.stop();
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
}, [videoRef, reportPlaybackStopped]);
|
||||||
|
|
||||||
// TODO: unused should remove.
|
|
||||||
const reportPlaybackStart = useCallback(async () => {
|
const reportPlaybackStart = useCallback(async () => {
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
|
||||||
@@ -282,12 +287,7 @@ export default function page() {
|
|||||||
|
|
||||||
if (!item?.Id || !stream) return;
|
if (!item?.Id || !stream) return;
|
||||||
|
|
||||||
console.log(
|
console.log("onProgress ~", currentTimeInTicks, isPlaying);
|
||||||
"onProgress ~",
|
|
||||||
currentTimeInTicks,
|
|
||||||
isPlaying,
|
|
||||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
itemId: item.Id,
|
itemId: item.Id,
|
||||||
@@ -300,7 +300,7 @@ export default function page() {
|
|||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
[item?.Id, isPlaying, api, isPlaybackStopped]
|
||||||
);
|
);
|
||||||
|
|
||||||
useOrientation();
|
useOrientation();
|
||||||
@@ -449,7 +449,7 @@ export default function page() {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
|
opacity: showControls ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VlcPlayerView
|
<VlcPlayerView
|
||||||
@@ -502,7 +502,7 @@ export default function page() {
|
|||||||
enableTrickplay={true}
|
enableTrickplay={true}
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||||
offline={offline}
|
offline={false}
|
||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
setAudioTrack={videoRef.current.setAudioTrack}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { View } from "react-native";
|
import { BackHandler, View } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import Video, {
|
import Video, {
|
||||||
OnProgressData,
|
OnProgressData,
|
||||||
@@ -38,7 +38,6 @@ import Video, {
|
|||||||
SelectedTrackType,
|
SelectedTrackType,
|
||||||
VideoRef,
|
VideoRef,
|
||||||
} from "react-native-video";
|
} from "react-native-video";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
|
|
||||||
const Player = () => {
|
const Player = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -54,7 +53,6 @@ const Player = () => {
|
|||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
_setShowControls(show);
|
_setShowControls(show);
|
||||||
@@ -113,14 +111,19 @@ const Player = () => {
|
|||||||
staleTime: 0,
|
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 {
|
const {
|
||||||
data: stream,
|
data: stream,
|
||||||
isLoading: isLoadingStreamUrl,
|
isLoading: isLoadingStreamUrl,
|
||||||
isError: isErrorStreamUrl,
|
isError: isErrorStreamUrl,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
|
queryKey: [
|
||||||
|
"stream-url",
|
||||||
|
itemId,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
bitrateValue,
|
||||||
|
mediaSourceId,
|
||||||
|
],
|
||||||
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) {
|
if (!api) {
|
||||||
@@ -260,13 +263,6 @@ const Player = () => {
|
|||||||
progress.value = ticks;
|
progress.value = ticks;
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
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: Use this when streaming with HLS url, but NOT when direct playing
|
||||||
// TODO: since playable duration is always 0 then.
|
// TODO: since playable duration is always 0 then.
|
||||||
setIsBuffering(data.playableDuration === 0);
|
setIsBuffering(data.playableDuration === 0);
|
||||||
@@ -328,25 +324,25 @@ const Player = () => {
|
|||||||
SelectedTrack | undefined
|
SelectedTrack | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
// Set intial Subtitle Track.
|
||||||
if (selectedTextTrack === undefined) {
|
// We will only select external tracks if they are are text based. Else it should be burned in already.
|
||||||
const subtitleHelper = new SubtitleHelper(
|
const textSubs =
|
||||||
stream?.mediaSource.MediaStreams ?? []
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
);
|
(sub) => sub.Type === "Subtitle" && sub.IsTextSubtitleStream
|
||||||
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
|
) || [];
|
||||||
subtitleIndex!
|
|
||||||
);
|
|
||||||
|
|
||||||
// Most likely the subtitle is burned in.
|
const uniqueTextSubs = Array.from(
|
||||||
if (embeddedTrackIndex === -1) return;
|
new Set(textSubs.map((sub) => sub.DisplayTitle))
|
||||||
console.log(
|
).map((title) => textSubs.find((sub) => sub.DisplayTitle === title));
|
||||||
"Setting selected text track",
|
const chosenSubtitleTrack = textSubs.find(
|
||||||
subtitleIndex,
|
(sub) => sub.Index === subtitleIndex
|
||||||
embeddedTrackIndex
|
);
|
||||||
);
|
useEffect(() => {
|
||||||
|
if (chosenSubtitleTrack && selectedTextTrack === undefined) {
|
||||||
|
console.log("Setting selected text track", chosenSubtitleTrack);
|
||||||
setSelectedTextTrack({
|
setSelectedTextTrack({
|
||||||
type: SelectedTrackType.INDEX,
|
type: SelectedTrackType.INDEX,
|
||||||
value: embeddedTrackIndex,
|
value: uniqueTextSubs.indexOf(chosenSubtitleTrack),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [embededTextTracks]);
|
}, [embededTextTracks]);
|
||||||
|
|||||||
@@ -336,11 +336,7 @@ function Layout() {
|
|||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="login"
|
name="login"
|
||||||
options={{
|
options={{ headerShown: false, title: "Login" }}
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</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 { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
@@ -66,23 +65,6 @@ const Login: React.FC = () => {
|
|||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_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 [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
@@ -121,19 +103,37 @@ const Login: React.FC = () => {
|
|||||||
* - Logs errors and timeout information to the console.
|
* - Logs errors and timeout information to the console.
|
||||||
*/
|
*/
|
||||||
async function checkUrl(url: string) {
|
async function checkUrl(url: string) {
|
||||||
|
url = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
setLoadingServerCheck(true);
|
setLoadingServerCheck(true);
|
||||||
|
|
||||||
|
const protocols = ["https://", "http://"];
|
||||||
|
const timeout = 2000; // 2 seconds timeout for long 404 responses
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${url}/System/Info/Public`, {
|
for (const protocol of protocols) {
|
||||||
mode: "cors",
|
const controller = new AbortController();
|
||||||
});
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
if (response.ok) {
|
try {
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
const response = await fetch(`${protocol}${url}/System/Info/Public`, {
|
||||||
setServerName(data.ServerName || "");
|
mode: "cors",
|
||||||
return url;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingServerCheck(false);
|
setLoadingServerCheck(false);
|
||||||
@@ -159,7 +159,9 @@ const Login: React.FC = () => {
|
|||||||
const handleConnect = async (url: string) => {
|
const handleConnect = async (url: string) => {
|
||||||
url = url.trim();
|
url = url.trim();
|
||||||
|
|
||||||
const result = await checkUrl(url);
|
const result = await checkUrl(
|
||||||
|
url.startsWith("http") ? new URL(url).host : url
|
||||||
|
);
|
||||||
|
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -169,7 +171,7 @@ const Login: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setServer({ address: url });
|
setServer({ address: result });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
@@ -194,21 +196,38 @@ const Login: React.FC = () => {
|
|||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
style={{ flex: 1, height: "100%" }}
|
style={{ flex: 1, height: "100%" }}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col h-full relative items-center justify-center">
|
<View className="flex flex-col w-full h-full relative items-center justify-center">
|
||||||
<View className="px-4 -mt-20 w-full">
|
<View className="px-4 -mt-20">
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="mb-4">
|
||||||
<Text className="text-2xl font-bold -mb-2">
|
<Text className="text-3xl font-bold mb-1">
|
||||||
Log in
|
{serverName || "Streamyfin"}
|
||||||
<>
|
|
||||||
{serverName ? (
|
|
||||||
<>
|
|
||||||
{" to "}
|
|
||||||
<Text className="text-purple-600">{serverName}</Text>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-neutral-400">{serverURL}</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 space-y-2">
|
||||||
|
<Text className="text-2xl font-bold">Log in</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
@@ -282,7 +301,7 @@ const Login: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||||
<Text className="text-neutral-500">
|
<Text className="text-neutral-500">
|
||||||
Enter the URL to your Jellyfin server
|
Connect to your Jellyfin server
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Server URL"
|
placeholder="Server URL"
|
||||||
@@ -294,9 +313,6 @@ const Login: React.FC = () => {
|
|||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<Text className="text-xs text-neutral-500">
|
|
||||||
Make sure to include http or https
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
|||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
export interface ButtonProps
|
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
textClassName?: string;
|
textClassName?: string;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import GoogleCast, {
|
|||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { RoundButton } from "./RoundButton";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -54,30 +53,51 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
|
|
||||||
if (background === "transparent")
|
if (background === "transparent")
|
||||||
return (
|
return (
|
||||||
<RoundButton
|
<>
|
||||||
size="large"
|
<TouchableOpacity
|
||||||
className="mr-2"
|
onPress={() => {
|
||||||
background={false}
|
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
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
else CastContext.showCastDialog();
|
else CastContext.showCastDialog();
|
||||||
}}
|
}}
|
||||||
|
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RoundButton
|
<TouchableOpacity
|
||||||
size="large"
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
else CastContext.showCastDialog();
|
else CastContext.showCastDialog();
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<BlurView
|
||||||
</RoundButton>
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
import { Href, router, useFocusEffect } from "expo-router";
|
import { Href, router, useFocusEffect } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, View, ViewProps } from "react-native";
|
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
@@ -30,25 +30,18 @@ import { Text } from "./common/Text";
|
|||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { RoundButton } from "./RoundButton";
|
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
items: BaseItemDto[];
|
items: BaseItemDto[];
|
||||||
MissingDownloadIconComponent: () => React.ReactElement;
|
MissingDownloadIconComponent: () => React.ReactElement;
|
||||||
DownloadedIconComponent: () => React.ReactElement;
|
DownloadedIconComponent: () => React.ReactElement;
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
size?: "default" | "large";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadItems: React.FC<DownloadProps> = ({
|
export const DownloadItems: React.FC<DownloadProps> = ({
|
||||||
items,
|
items,
|
||||||
MissingDownloadIconComponent,
|
MissingDownloadIconComponent,
|
||||||
DownloadedIconComponent,
|
DownloadedIconComponent,
|
||||||
title = "Download",
|
|
||||||
subtitle = "",
|
|
||||||
size = "default",
|
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -78,6 +71,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
[settings]
|
[settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet
|
||||||
|
*/
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const handlePresentModalPress = useCallback(() => {
|
const handlePresentModalPress = useCallback(() => {
|
||||||
@@ -90,18 +86,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// region computed
|
||||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||||
|
const pendingItems = useMemo(
|
||||||
const itemsNotDownloaded = useMemo(
|
|
||||||
() =>
|
() =>
|
||||||
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
||||||
[items, downloadedFiles]
|
[items, downloadedFiles]
|
||||||
);
|
);
|
||||||
|
const isDownloaded = useMemo(() => {
|
||||||
|
if (!downloadedFiles) return false;
|
||||||
|
return pendingItems.length == 0;
|
||||||
|
}, [downloadedFiles, pendingItems]);
|
||||||
|
|
||||||
const allItemsDownloaded = useMemo(() => {
|
|
||||||
if (items.length === 0) return false;
|
|
||||||
return itemsNotDownloaded.length === 0;
|
|
||||||
}, [items, itemsNotDownloaded]);
|
|
||||||
const itemsProcesses = useMemo(
|
const itemsProcesses = useMemo(
|
||||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
||||||
[processes, itemIds]
|
[processes, itemIds]
|
||||||
@@ -120,10 +116,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
const itemsQueued = useMemo(() => {
|
const itemsQueued = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
itemsNotDownloaded.length > 0 &&
|
pendingItems.length > 0 &&
|
||||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
|
pendingItems.every((p) => queue.some((q) => p.Id == q.item.Id))
|
||||||
);
|
);
|
||||||
}, [queue, itemsNotDownloaded]);
|
}, [queue, pendingItems]);
|
||||||
|
// endregion computed
|
||||||
|
|
||||||
|
// region helper functions
|
||||||
const navigateToDownloads = () => router.push("/downloads");
|
const navigateToDownloads = () => router.push("/downloads");
|
||||||
|
|
||||||
const onDownloadedPress = () => {
|
const onDownloadedPress = () => {
|
||||||
@@ -142,17 +141,17 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
const acceptDownloadOptions = useCallback(() => {
|
const acceptDownloadOptions = useCallback(() => {
|
||||||
if (userCanDownload === true) {
|
if (userCanDownload === true) {
|
||||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
if (pendingItems.some((i) => !i.Id)) {
|
||||||
throw new Error("No item id");
|
throw new Error("No item id");
|
||||||
}
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|
||||||
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
if (usingOptimizedServer) initiateDownload(...pendingItems);
|
||||||
else {
|
else {
|
||||||
queueActions.enqueue(
|
queueActions.enqueue(
|
||||||
queue,
|
queue,
|
||||||
setQueue,
|
setQueue,
|
||||||
...itemsNotDownloaded.map((item) => ({
|
...pendingItems.map((item) => ({
|
||||||
id: item.Id!,
|
id: item.Id!,
|
||||||
execute: async () => await initiateDownload(item),
|
execute: async () => await initiateDownload(item),
|
||||||
item,
|
item,
|
||||||
@@ -165,22 +164,27 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
}, [
|
}, [
|
||||||
queue,
|
queue,
|
||||||
setQueue,
|
setQueue,
|
||||||
itemsNotDownloaded,
|
pendingItems,
|
||||||
usingOptimizedServer,
|
usingOptimizedServer,
|
||||||
userCanDownload,
|
userCanDownload,
|
||||||
|
|
||||||
|
// Need to be reference at the time async lambda is created for initiateDownload
|
||||||
maxBitrate,
|
maxBitrate,
|
||||||
selectedMediaSource,
|
selectedMediaSource,
|
||||||
selectedAudioStream,
|
selectedAudioStream,
|
||||||
selectedSubtitleStream,
|
selectedSubtitleStream,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start download
|
||||||
|
*/
|
||||||
const initiateDownload = useCallback(
|
const initiateDownload = useCallback(
|
||||||
async (...items: BaseItemDto[]) => {
|
async (...items: BaseItemDto[]) => {
|
||||||
if (
|
if (
|
||||||
!api ||
|
!api ||
|
||||||
!user?.Id ||
|
!user?.Id ||
|
||||||
items.some((p) => !p.Id) ||
|
items.some((p) => !p.Id) ||
|
||||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
(pendingItems.length === 1 && !selectedMediaSource?.Id)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||||
@@ -191,7 +195,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (itemsNotDownloaded.length > 1) {
|
if (pendingItems.length > 1) {
|
||||||
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
||||||
item,
|
item,
|
||||||
settings!
|
settings!
|
||||||
@@ -227,14 +231,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
if (usingOptimizedServer) {
|
if (usingOptimizedServer) {
|
||||||
await startBackgroundDownload(url, item, source);
|
await startBackgroundDownload(url, item, source);
|
||||||
} else {
|
} else {
|
||||||
await startRemuxing(item, url, source);
|
await startRemuxing(item, url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
api,
|
api,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
itemsNotDownloaded,
|
pendingItems,
|
||||||
selectedMediaSource,
|
selectedMediaSource,
|
||||||
selectedAudioStream,
|
selectedAudioStream,
|
||||||
selectedSubtitleStream,
|
selectedSubtitleStream,
|
||||||
@@ -256,61 +260,58 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
),
|
),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
// endregion helper functions
|
||||||
|
|
||||||
|
// Allow to select & set settings for single download
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
if (itemsNotDownloaded.length !== 1) return;
|
if (pendingItems.length !== 1) return;
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
getDefaultPlaySettings(items[0], settings);
|
getDefaultPlaySettings(items[0], settings);
|
||||||
|
|
||||||
|
// 4. Set states
|
||||||
setSelectedMediaSource(mediaSource ?? undefined);
|
setSelectedMediaSource(mediaSource ?? undefined);
|
||||||
setSelectedAudioStream(audioIndex ?? 0);
|
setSelectedAudioStream(audioIndex ?? 0);
|
||||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||||
setMaxBitrate(bitrate);
|
setMaxBitrate(bitrate);
|
||||||
}, [items, itemsNotDownloaded, settings])
|
}, [items, pendingItems, settings])
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderButtonContent = () => {
|
|
||||||
if (processes && itemsProcesses.length > 0) {
|
|
||||||
return progress === 0 ? (
|
|
||||||
<Loader />
|
|
||||||
) : (
|
|
||||||
<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 (
|
return (
|
||||||
<View {...props}>
|
<View
|
||||||
<RoundButton size={size} onPress={onButtonPress}>
|
className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center"
|
||||||
{renderButtonContent()}
|
{...props}
|
||||||
</RoundButton>
|
>
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity onPress={handlePresentModalPress}>
|
||||||
|
{MissingDownloadIconComponent()}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
enableDynamicSizing
|
enableDynamicSizing
|
||||||
@@ -325,21 +326,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||||
<View>
|
<Text className="font-bold text-2xl text-neutral-10">
|
||||||
<Text className="font-bold text-2xl text-neutral-100">
|
Download options
|
||||||
{title}
|
</Text>
|
||||||
</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">
|
<View className="flex flex-col space-y-2 w-full items-start">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
inverted
|
inverted
|
||||||
onChange={setMaxBitrate}
|
onChange={setMaxBitrate}
|
||||||
selected={maxBitrate}
|
selected={maxBitrate}
|
||||||
/>
|
/>
|
||||||
{itemsNotDownloaded.length === 1 && (
|
{pendingItems.length === 1 && (
|
||||||
<>
|
<>
|
||||||
<MediaSourceSelector
|
<MediaSourceSelector
|
||||||
item={items[0]}
|
item={items[0]}
|
||||||
@@ -384,15 +380,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DownloadSingleItem: React.FC<{
|
export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({
|
||||||
size?: "default" | "large";
|
item,
|
||||||
item: BaseItemDto;
|
}) => {
|
||||||
}> = ({ item, size = "default" }) => {
|
|
||||||
return (
|
return (
|
||||||
<DownloadItems
|
<DownloadItems
|
||||||
size={size}
|
|
||||||
title="Download Episode"
|
|
||||||
subtitle={item.Name!}
|
|
||||||
items={[item]}
|
items={[item]}
|
||||||
MissingDownloadIconComponent={() => (
|
MissingDownloadIconComponent={() => (
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
|||||||
<View className="mt-2 flex flex-col">
|
<View className="mt-2 flex flex-col">
|
||||||
{item.Type === "Episode" ? (
|
{item.Type === "Episode" ? (
|
||||||
<>
|
<>
|
||||||
<Text numberOfLines={1} className="">
|
<Text numberOfLines={2} className="">
|
||||||
{item.Name}
|
{item.SeriesName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
||||||
{" - "}
|
{item.Name}
|
||||||
{item.SeriesName}
|
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"
|
|||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
MediaStream,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
@@ -33,8 +32,16 @@ import { Chromecast } from "./Chromecast";
|
|||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
import {
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
brightness,
|
||||||
|
ColorMatrix,
|
||||||
|
colorTone,
|
||||||
|
concatColorMatrices,
|
||||||
|
contrast,
|
||||||
|
saturate,
|
||||||
|
sepia,
|
||||||
|
tint,
|
||||||
|
} from "react-native-color-matrix-image-filters";
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -52,7 +59,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
useImageColors({ item });
|
useImageColors({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(false);
|
||||||
const [headerHeight, setHeaderHeight] = useState(350);
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
@@ -90,7 +97,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<Chromecast background="blur" width={22} height={22} />
|
<Chromecast background="blur" width={22} height={22} />
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<DownloadSingleItem item={item} size="large" />
|
<DownloadSingleItem item={item} />
|
||||||
<PlayedStatus item={item} />
|
<PlayedStatus item={item} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -112,36 +119,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
return Boolean(logoUrl && loadingLogo);
|
return Boolean(logoUrl && loadingLogo);
|
||||||
}, [loadingLogo, logoUrl]);
|
}, [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;
|
if (!selectedOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -172,18 +149,45 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
logo={
|
logo={
|
||||||
<>
|
<>
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<Image
|
<ColorMatrix
|
||||||
source={{
|
matrix={[
|
||||||
uri: logoUrl,
|
1,
|
||||||
}}
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0, // Red channel remains unchanged
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0, // Green channel remains unchanged
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0, // Blue channel remains unchanged
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
-1, // Make black (R=0, G=0, B=0) transparent
|
||||||
|
]}
|
||||||
style={{
|
style={{
|
||||||
height: 130,
|
height: 130,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
resizeMode: "contain",
|
|
||||||
}}
|
}}
|
||||||
onLoad={() => setLoadingLogo(false)}
|
>
|
||||||
onError={() => setLoadingLogo(false)}
|
<Image
|
||||||
/>
|
source={{
|
||||||
|
uri: logoUrl,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: 130,
|
||||||
|
width: "100%",
|
||||||
|
resizeMode: "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ColorMatrix>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -232,7 +236,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
selected={selectedOptions.audioIndex}
|
selected={selectedOptions.audioIndex}
|
||||||
/>
|
/>
|
||||||
<SubtitleTrackSelector
|
<SubtitleTrackSelector
|
||||||
isTranscoding={isTranscoding}
|
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
@@ -259,9 +262,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
<OverviewText text={item.Overview} className="px-4 my-4" />
|
||||||
<OverviewText text={item.Overview} className="px-4 mb-4" />
|
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<>
|
<>
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
@@ -286,6 +287,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<SimilarItems itemId={item.Id} />
|
<SimilarItems itemId={item.Id} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<View className="h-16"></View>
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
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,7 +32,6 @@ import Animated, {
|
|||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { SelectedOptions } from "./ItemContent";
|
import { SelectedOptions } from "./ItemContent";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -79,8 +78,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { RoundButton } from "./RoundButton";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidateQueries = () => {
|
const invalidateQueries = () => {
|
||||||
@@ -39,16 +46,44 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View
|
||||||
<RoundButton
|
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||||
fillColor={item.UserData?.Played ? "primary" : undefined}
|
{...props}
|
||||||
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
|
>
|
||||||
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
|
{item.UserData?.Played ? (
|
||||||
size="large"
|
<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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
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,34 +1,26 @@
|
|||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
selected?: number | undefined;
|
selected?: number | undefined;
|
||||||
isTranscoding?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||||
source,
|
source,
|
||||||
onChange,
|
onChange,
|
||||||
selected,
|
selected,
|
||||||
isTranscoding,
|
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(
|
||||||
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
() => source?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||||
|
[source]
|
||||||
if (isTranscoding && Platform.OS === "ios") {
|
);
|
||||||
return subtitleHelper.getUniqueSubtitles();
|
|
||||||
}
|
|
||||||
|
|
||||||
return subtitleHelper.getSubtitles();
|
|
||||||
}, [source, isTranscoding]);
|
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
() => subtitleStreams.find((x) => x.Index === selected),
|
() => subtitleStreams.find((x) => x.Index === selected),
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -47,10 +45,6 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
|||||||
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
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}`;
|
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,82 +58,18 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<TouchableOpacity
|
||||||
<ContextMenu.Trigger>
|
onPress={() => {
|
||||||
<TouchableOpacity
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
onPress={() => {
|
const url = itemRouter(item, from);
|
||||||
const url = itemRouter(item, from);
|
// @ts-ignore
|
||||||
// @ts-ignore
|
router.push(url);
|
||||||
router.push(url);
|
}}
|
||||||
}}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
</TouchableOpacity>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
|||||||
<Text className="text-xs text-neutral-500">
|
<Text className="text-xs text-neutral-500">
|
||||||
{runtimeTicksToSeconds(item.RunTimeTicks)}
|
{runtimeTicksToSeconds(item.RunTimeTicks)}
|
||||||
</Text>
|
</Text>
|
||||||
<DownloadSize items={[item]} />
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -97,9 +97,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className="w-28">
|
<ItemCardText item={item} />
|
||||||
<ItemCardText item={item} />
|
|
||||||
</View>
|
|
||||||
<DownloadSize items={[item]} />
|
<DownloadSize items={[item]} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -125,14 +125,21 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<DownloadItems
|
<DownloadItems
|
||||||
title="Download Season"
|
|
||||||
className="ml-2"
|
className="ml-2"
|
||||||
items={episodes || []}
|
items={episodes || []}
|
||||||
MissingDownloadIconComponent={() => (
|
MissingDownloadIconComponent={() => (
|
||||||
<Ionicons name="download" size={20} color="white" />
|
<MaterialCommunityIcons
|
||||||
|
name="download-multiple"
|
||||||
|
size={20}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
DownloadedIconComponent={() => (
|
DownloadedIconComponent={() => (
|
||||||
<Ionicons name="download" size={20} color="#9333ea" />
|
<MaterialCommunityIcons
|
||||||
|
name="check-all"
|
||||||
|
size={20}
|
||||||
|
color="#9333ea"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
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,6 +1,9 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
import { LANGUAGES } from "@/constants/Languages";
|
||||||
|
import { TextInput } from "react-native-gesture-handler";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -13,6 +16,152 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2">Media</Text>
|
<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-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
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import {
|
|||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
userAtom,
|
userAtom,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {ScreenOrientationEnum, Settings, useSettings} from "@/utils/atoms/settings";
|
||||||
ScreenOrientationEnum,
|
|
||||||
Settings,
|
|
||||||
useSettings,
|
|
||||||
} from "@/utils/atoms/settings";
|
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
registerBackgroundFetchAsync,
|
registerBackgroundFetchAsync,
|
||||||
@@ -21,7 +17,7 @@ import * as BackgroundFetch from "expo-background-fetch";
|
|||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import * as TaskManager from "expo-task-manager";
|
import * as TaskManager from "expo-task-manager";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -36,10 +32,7 @@ import { Input } from "../common/Input";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { MediaToggles } from "./MediaToggles";
|
import { MediaToggles } from "./MediaToggles";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import {Stepper} from "@/components/inputs/Stepper";
|
||||||
import { MediaProvider } from "./MediaContext";
|
|
||||||
import { SubtitleToggles } from "./SubtitleToggles";
|
|
||||||
import { AudioToggles } from "./AudioToggles";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -127,11 +120,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
</View> */}
|
</View> */}
|
||||||
|
|
||||||
<MediaProvider>
|
<MediaToggles />
|
||||||
<MediaToggles />
|
|
||||||
<AudioToggles />
|
|
||||||
<SubtitleToggles />
|
|
||||||
</MediaProvider>
|
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2">Other</Text>
|
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||||
@@ -258,21 +247,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="shrink">
|
|
||||||
<Text className="font-semibold">Safe area in controls</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Enable safe area in video player controls
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.safeAreaInControlsEnabled}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ safeAreaInControlsEnabled: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
@@ -435,24 +409,19 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<View className="shrink">
|
<View className="shrink">
|
||||||
<Text className="font-semibold">Show Custom Menu Links</Text>
|
<Text className="font-semibold">Show Custom Menu Links</Text>
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
Show custom menu links defined inside your Jellyfin web
|
Show custom menu links defined inside your Jellyfin web config.json file
|
||||||
config.json file
|
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
Linking.openURL(
|
Linking.openURL("https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links")
|
||||||
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text className="text-xs text-purple-600">More info</Text>
|
<Text className="text-xs text-purple-600">More info</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.showCustomMenuLinks}
|
value={settings.showCustomMenuLinks}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => updateSettings({ showCustomMenuLinks: value })}
|
||||||
updateSettings({ showCustomMenuLinks: value })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -522,16 +491,15 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
className={`
|
className={`
|
||||||
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
||||||
${
|
${
|
||||||
settings.downloadMethod === "remux"
|
settings.downloadMethod === "remux"
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-50"
|
: "opacity-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Remux max download</Text>
|
<Text className="font-semibold">Remux max download</Text>
|
||||||
<Text className="text-xs opacity-50 shrink">
|
<Text className="text-xs opacity-50 shrink">
|
||||||
This is the total media you want to be able to download at the
|
This is the total media you want to be able to download at the same time.
|
||||||
same time.
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Stepper
|
<Stepper
|
||||||
@@ -539,12 +507,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
step={1}
|
step={1}
|
||||||
min={1}
|
min={1}
|
||||||
max={4}
|
max={4}
|
||||||
onUpdate={(value) =>
|
onUpdate={(value) => updateSettings({remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"]})}
|
||||||
updateSettings({
|
|
||||||
remuxConcurrentLimit:
|
|
||||||
value as Settings["remuxConcurrentLimit"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
@@ -554,10 +517,10 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
className={`
|
className={`
|
||||||
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
||||||
${
|
${
|
||||||
settings.downloadMethod === "optimized"
|
settings.downloadMethod === "optimized"
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-50"
|
: "opacity-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Auto download</Text>
|
<Text className="font-semibold">Auto download</Text>
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
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,15 +1,6 @@
|
|||||||
import { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
|
||||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||||
import { ParamListBase, RouteProp } from "@react-navigation/native";
|
|
||||||
|
|
||||||
type ICommonScreenOptions =
|
const commonScreenOptions = {
|
||||||
| NativeStackNavigationOptions
|
|
||||||
| ((prop: {
|
|
||||||
route: RouteProp<ParamListBase, string>;
|
|
||||||
navigation: any;
|
|
||||||
}) => NativeStackNavigationOptions);
|
|
||||||
|
|
||||||
const commonScreenOptions: ICommonScreenOptions = {
|
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerTransparent: true,
|
headerTransparent: true,
|
||||||
@@ -26,5 +17,5 @@ const routes = [
|
|||||||
"series/[id]",
|
"series/[id]",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
|
export const nestedTabPageScreenOptions: { [key: string]: any } =
|
||||||
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
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,4 +1,3 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||||
@@ -9,13 +8,8 @@ import {
|
|||||||
TrackInfo,
|
TrackInfo,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
getDefaultPlaySettings,
|
|
||||||
previousIndexes,
|
|
||||||
} from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import {
|
import {
|
||||||
formatTimeString,
|
formatTimeString,
|
||||||
@@ -29,12 +23,16 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { debounce } from "lodash";
|
import {
|
||||||
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
|
Dimensions,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import {
|
import {
|
||||||
runOnJS,
|
runOnJS,
|
||||||
@@ -47,15 +45,19 @@ import {
|
|||||||
useSafeAreaInsets,
|
useSafeAreaInsets,
|
||||||
} from "react-native-safe-area-context";
|
} from "react-native-safe-area-context";
|
||||||
import { VideoRef } from "react-native-video";
|
import { VideoRef } from "react-native-video";
|
||||||
import AudioSlider from "./AudioSlider";
|
|
||||||
import BrightnessSlider from "./BrightnessSlider";
|
|
||||||
import { ControlProvider } from "./contexts/ControlContext";
|
import { ControlProvider } from "./contexts/ControlContext";
|
||||||
import { VideoProvider } from "./contexts/VideoContext";
|
import { VideoProvider } from "./contexts/VideoContext";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
|
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
|
||||||
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
|
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
|
||||||
import { EpisodeList } from "./EpisodeList";
|
import BrightnessSlider from "./BrightnessSlider";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
|
||||||
import SkipButton from "./SkipButton";
|
import SkipButton from "./SkipButton";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { EpisodeList } from "./EpisodeList";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -82,7 +84,7 @@ interface Props {
|
|||||||
setSubtitleURL?: (url: string, customName: string) => void;
|
setSubtitleURL?: (url: string, customName: string) => void;
|
||||||
setSubtitleTrack?: (index: number) => void;
|
setSubtitleTrack?: (index: number) => void;
|
||||||
setAudioTrack?: (index: number) => void;
|
setAudioTrack?: (index: number) => void;
|
||||||
stop: (() => Promise<void>) | (() => void);
|
stop?: (() => Promise<void>) | (() => void);
|
||||||
isVlc?: boolean;
|
isVlc?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +129,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
} = useTrickplay(item, !offline && enableTrickplay);
|
} = useTrickplay(item, !offline && enableTrickplay);
|
||||||
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [remainingTime, setRemainingTime] = useState(Infinity);
|
const [remainingTime, setRemainingTime] = useState(0);
|
||||||
|
|
||||||
const min = useSharedValue(0);
|
const min = useSharedValue(0);
|
||||||
const max = useSharedValue(item.RunTimeTicks || 0);
|
const max = useSharedValue(item.RunTimeTicks || 0);
|
||||||
@@ -135,12 +137,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const wasPlayingRef = useRef(false);
|
const wasPlayingRef = useRef(false);
|
||||||
const lastProgressRef = useRef<number>(0);
|
const lastProgressRef = useRef<number>(0);
|
||||||
|
|
||||||
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
|
|
||||||
bitrateValue: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
offline ? undefined : item.Id,
|
offline ? undefined : item.Id,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -162,76 +158,50 @@ export const Controls: React.FC<Props> = ({
|
|||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
getDefaultPlaySettings(previousItem, settings);
|
||||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
mediaSource: newMediaSource,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
|
||||||
} = getDefaultPlaySettings(
|
|
||||||
previousItem,
|
|
||||||
settings,
|
|
||||||
previousIndexes,
|
|
||||||
mediaSource ?? undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: previousItem.Id ?? "", // Ensure itemId is a string
|
itemId: previousItem.Id ?? "", // Ensure itemId is a string
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||||
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
bitrateValue: bitrateValue.toString(),
|
bitrateValue: bitrate.toString(),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
if (!bitrateValue) {
|
if (!bitrate.value) {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
}, [previousItem, settings, subtitleIndex, audioIndex]);
|
}, [previousItem, settings]);
|
||||||
|
|
||||||
const goToNextItem = useCallback(() => {
|
const goToNextItem = useCallback(() => {
|
||||||
if (!nextItem || !settings) return;
|
if (!nextItem || !settings) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
getDefaultPlaySettings(nextItem, settings);
|
||||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
mediaSource: newMediaSource,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
|
||||||
} = getDefaultPlaySettings(
|
|
||||||
nextItem,
|
|
||||||
settings,
|
|
||||||
previousIndexes,
|
|
||||||
mediaSource ?? undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: nextItem.Id ?? "", // Ensure itemId is a string
|
itemId: nextItem.Id ?? "", // Ensure itemId is a string
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||||
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
bitrateValue: bitrateValue.toString(),
|
bitrateValue: bitrate.toString(),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
if (!bitrateValue) {
|
if (!bitrate.value) {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
}, [nextItem, settings, subtitleIndex, audioIndex]);
|
}, [nextItem, settings]);
|
||||||
|
|
||||||
const updateTimes = useCallback(
|
const updateTimes = useCallback(
|
||||||
(currentProgress: number, maxValue: number) => {
|
(currentProgress: number, maxValue: number) => {
|
||||||
@@ -240,10 +210,15 @@ export const Controls: React.FC<Props> = ({
|
|||||||
? maxValue - currentProgress
|
? maxValue - currentProgress
|
||||||
: ticksToSeconds(maxValue - currentProgress);
|
: ticksToSeconds(maxValue - currentProgress);
|
||||||
|
|
||||||
console.log("remaining: ", remaining);
|
|
||||||
|
|
||||||
setCurrentTime(current);
|
setCurrentTime(current);
|
||||||
setRemainingTime(remaining);
|
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]
|
[goToNextItem, isVlc]
|
||||||
);
|
);
|
||||||
@@ -255,6 +230,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
isSeeking: isSeeking.value,
|
isSeeking: isSeeking.value,
|
||||||
}),
|
}),
|
||||||
(result) => {
|
(result) => {
|
||||||
|
// console.log("Progress changed", result);
|
||||||
if (result.isSeeking === false) {
|
if (result.isSeeking === false) {
|
||||||
runOnJS(updateTimes)(result.progress, result.max);
|
runOnJS(updateTimes)(result.progress, result.max);
|
||||||
}
|
}
|
||||||
@@ -276,14 +252,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
prefetchAllTrickplayImages();
|
prefetchAllTrickplayImages();
|
||||||
}, []);
|
}, []);
|
||||||
const toggleControls = () => {
|
const toggleControls = () => setShowControls(!showControls);
|
||||||
if (showControls) {
|
|
||||||
setShowAudioSlider(false);
|
|
||||||
setShowControls(false);
|
|
||||||
} else {
|
|
||||||
setShowControls(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSliderStart = useCallback(() => {
|
const handleSliderStart = useCallback(() => {
|
||||||
if (showControls === false) return;
|
if (showControls === false) return;
|
||||||
@@ -314,14 +283,16 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||||
const handleSliderChange = useCallback(
|
const handleSliderChange = useCallback(
|
||||||
debounce((value: number) => {
|
debounce((value: number) => {
|
||||||
const progressInTicks = isVlc ? msToTicks(value) : value;
|
const progressInTicks = msToTicks(value);
|
||||||
|
console.log("Progress in ticks", progressInTicks);
|
||||||
calculateTrickplayUrl(progressInTicks);
|
calculateTrickplayUrl(progressInTicks);
|
||||||
|
|
||||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||||
const seconds = progressInSeconds % 60;
|
const seconds = progressInSeconds % 60;
|
||||||
setTime({ hours, minutes, seconds });
|
setTime({ hours, minutes, seconds });
|
||||||
}, 3),
|
}, 10),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -436,57 +407,35 @@ export const Controls: React.FC<Props> = ({
|
|||||||
|
|
||||||
const switchOnEpisodeMode = () => {
|
const switchOnEpisodeMode = () => {
|
||||||
setEpisodeView(true);
|
setEpisodeView(true);
|
||||||
if (isPlaying) togglePlay();
|
if (isPlaying) togglePlay(progress.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToItem = useCallback(
|
const gotoEpisode = async (itemId: string) => {
|
||||||
async (itemId: string) => {
|
const item = await getItemById(api, itemId);
|
||||||
try {
|
console.log("Item", item);
|
||||||
const gotoItem = await getItemById(api, itemId);
|
if (!settings || !item) return;
|
||||||
if (!settings || !gotoItem) return;
|
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
getDefaultPlaySettings(item, settings);
|
||||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
const queryParams = new URLSearchParams({
|
||||||
mediaSource: newMediaSource,
|
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||||
} = getDefaultPlaySettings(
|
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
gotoItem,
|
bitrateValue: bitrate.toString(),
|
||||||
settings,
|
}).toString();
|
||||||
previousIndexes,
|
|
||||||
mediaSource ?? undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
if (!bitrate.value) {
|
||||||
itemId: gotoItem.Id ?? "", // Ensure itemId is a string
|
// @ts-expect-error
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
return;
|
||||||
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
}
|
||||||
bitrateValue: bitrateValue.toString(),
|
// @ts-expect-error
|
||||||
}).toString();
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
|
};
|
||||||
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 (
|
return (
|
||||||
<ControlProvider
|
<ControlProvider
|
||||||
@@ -495,11 +444,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={isVideoLoaded}
|
||||||
>
|
>
|
||||||
{EpisodeView ? (
|
{EpisodeView ? (
|
||||||
<EpisodeList
|
<EpisodeList item={item} close={() => setEpisodeView(false)} />
|
||||||
item={item}
|
|
||||||
close={() => setEpisodeView(false)}
|
|
||||||
goToItem={goToItem}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<VideoProvider
|
<VideoProvider
|
||||||
@@ -509,23 +454,11 @@ export const Controls: React.FC<Props> = ({
|
|||||||
setSubtitleTrack={setSubtitleTrack}
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
setSubtitleURL={setSubtitleURL}
|
setSubtitleURL={setSubtitleURL}
|
||||||
>
|
>
|
||||||
<View
|
{!mediaSource?.TranscodingUrl ? (
|
||||||
style={[
|
<DropdownViewDirect showControls={showControls} />
|
||||||
{
|
) : (
|
||||||
position: "absolute",
|
<DropdownViewTranscoding showControls={showControls} />
|
||||||
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
)}
|
||||||
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
zIndex: 1000,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{!mediaSource?.TranscodingUrl ? (
|
|
||||||
<DropdownViewDirect showControls={showControls} />
|
|
||||||
) : (
|
|
||||||
<DropdownViewTranscoding showControls={showControls} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</VideoProvider>
|
</VideoProvider>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -543,15 +476,15 @@ export const Controls: React.FC<Props> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
top: 0,
|
||||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
right: 0,
|
||||||
opacity: showControls ? 1 : 0,
|
opacity: showControls ? 1 : 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||||
>
|
>
|
||||||
{item?.Type === "Episode" && !offline && (
|
{item?.Type === "Episode" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
switchOnEpisodeMode();
|
switchOnEpisodeMode();
|
||||||
@@ -561,7 +494,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
<Ionicons name="list" size={24} color="white" />
|
<Ionicons name="list" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{previousItem && !offline && (
|
{previousItem && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={goToPreviousItem}
|
onPress={goToPreviousItem}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
@@ -570,7 +503,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{nextItem && !offline && (
|
{nextItem && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={goToNextItem}
|
onPress={goToNextItem}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
@@ -593,7 +526,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
@@ -606,13 +538,14 @@ export const Controls: React.FC<Props> = ({
|
|||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "50%", // Center vertically
|
top: "50%", // Center vertically
|
||||||
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
left: 0,
|
||||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
right: 0,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
|
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
|
||||||
paddingHorizontal: "28%", // Add some padding to the left and right
|
paddingHorizontal: "28%", // Add some padding to the left and right
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
pointerEvents={showControls ? "box-none" : "none"}
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
>
|
>
|
||||||
@@ -621,9 +554,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
|
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
|
||||||
left: 0,
|
|
||||||
bottom: 30,
|
bottom: 30,
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BrightnessSlider />
|
<BrightnessSlider />
|
||||||
@@ -634,7 +565,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@@ -669,9 +599,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
name={isPlaying ? "pause" : "play"}
|
name={isPlaying ? "pause" : "play"}
|
||||||
size={50}
|
size={50}
|
||||||
color="white"
|
color="white"
|
||||||
style={{
|
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Loader size={"large"} />
|
<Loader size={"large"} />
|
||||||
@@ -684,7 +611,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="refresh-outline" size={50} color="white" />
|
<Ionicons name="refresh-outline" size={50} color="white" />
|
||||||
@@ -701,30 +627,19 @@ export const Controls: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</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={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
right: 0,
|
||||||
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
left: 0,
|
||||||
bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0,
|
bottom: 0,
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
className={`flex flex-col p-4`}
|
className={`flex flex-col p-4`}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -738,9 +653,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
style={{
|
style={{
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignSelf: "flex-end", // Shrink height based on content
|
alignSelf: "flex-end", // Shrink height based on content
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
}}
|
}}
|
||||||
pointerEvents={showControls ? "box-none" : "none"}
|
|
||||||
>
|
>
|
||||||
<Text className="font-bold">{item?.Name}</Text>
|
<Text className="font-bold">{item?.Name}</Text>
|
||||||
{item?.Type === "Episode" && (
|
{item?.Type === "Episode" && (
|
||||||
@@ -755,7 +668,13 @@ export const Controls: React.FC<Props> = ({
|
|||||||
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-row space-x-2">
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
marginRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
@@ -766,25 +685,10 @@ export const Controls: React.FC<Props> = ({
|
|||||||
onPress={skipCredit}
|
onPress={skipCredit}
|
||||||
buttonText="Skip Credits"
|
buttonText="Skip Credits"
|
||||||
/>
|
/>
|
||||||
<NextEpisodeCountDownButton
|
|
||||||
show={
|
|
||||||
!nextItem
|
|
||||||
? false
|
|
||||||
: isVlc
|
|
||||||
? remainingTime < 10000
|
|
||||||
: remainingTime < 10
|
|
||||||
}
|
|
||||||
onFinish={goToNextItem}
|
|
||||||
onPress={goToNextItem}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
className={`flex flex-col-reverse py-4 pb-1 px-4 rounded-lg items-center bg-neutral-800`}
|
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`}>
|
<View className={`flex flex-col w-full shrink`}>
|
||||||
<Slider
|
<Slider
|
||||||
|
|||||||
@@ -17,23 +17,28 @@ import {
|
|||||||
HorizontalScroll,
|
HorizontalScroll,
|
||||||
HorizontalScrollRef,
|
HorizontalScrollRef,
|
||||||
} from "@/components/common/HorrizontalScroll";
|
} from "@/components/common/HorrizontalScroll";
|
||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
SeasonDropdown,
|
SeasonDropdown,
|
||||||
SeasonIndexState,
|
SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { Item } from "zeego/dropdown-menu";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
goToItem: (itemId: string) => Promise<void>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||||
|
|
||||||
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
export const EpisodeList: React.FC<Props> = ({ item, close }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets(); // Get safe area insets
|
const insets = useSafeAreaInsets(); // Get safe area insets
|
||||||
|
const [settings] = useSettings();
|
||||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
||||||
const scrollToIndex = (index: number) => {
|
const scrollToIndex = (index: number) => {
|
||||||
@@ -150,6 +155,36 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
}
|
}
|
||||||
}, [episodes, item.Id]);
|
}, [episodes, item.Id]);
|
||||||
|
|
||||||
|
const { audioIndex, subtitleIndex, bitrateValue } = useLocalSearchParams<{
|
||||||
|
audioIndex: string;
|
||||||
|
subtitleIndex: string;
|
||||||
|
mediaSourceId: string;
|
||||||
|
bitrateValue: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const gotoEpisode = async (itemId: string) => {
|
||||||
|
const item = await getItemById(api, itemId);
|
||||||
|
if (!settings || !item) return;
|
||||||
|
|
||||||
|
const { mediaSource } = getDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||||
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
|
bitrateValue: bitrateValue,
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
if (!bitrateValue) {
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
|
};
|
||||||
|
|
||||||
if (!episodes) {
|
if (!episodes) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
@@ -207,7 +242,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
goToItem(_item.Id);
|
gotoEpisode(_item.Id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster
|
<ContinueWatchingPoster
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
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 React from "react";
|
||||||
import { View, TouchableOpacity, Text, ViewProps } from "react-native";
|
import { View, TouchableOpacity, Text, StyleSheet } from "react-native";
|
||||||
|
|
||||||
interface SkipButtonProps extends ViewProps {
|
interface SkipButtonProps {
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
showButton: boolean;
|
showButton: boolean;
|
||||||
buttonText: string;
|
buttonText: string;
|
||||||
@@ -11,18 +11,29 @@ const SkipButton: React.FC<SkipButtonProps> = ({
|
|||||||
onPress,
|
onPress,
|
||||||
showButton,
|
showButton,
|
||||||
buttonText,
|
buttonText,
|
||||||
...props
|
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className={showButton ? "flex" : "hidden"} {...props}>
|
<View style={{ display: showButton ? "flex" : "none" }}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={onPress} style={styles.button}>
|
||||||
onPress={onPress}
|
<Text style={styles.text}>{buttonText}</Text>
|
||||||
className="bg-black/60 rounded-md px-3 py-3 border border-neutral-900"
|
|
||||||
>
|
|
||||||
<Text className="text-white font-bold">{buttonText}</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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;
|
export default SkipButton;
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { TrackInfo } from "@/modules/vlc-player";
|
import { TrackInfo } from '@/modules/vlc-player';
|
||||||
import {
|
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||||
BaseItemDto,
|
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import React, { createContext, useContext, useState, ReactNode } from "react";
|
|
||||||
|
|
||||||
interface ControlContextProps {
|
interface ControlContextProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -11,9 +8,7 @@ interface ControlContextProps {
|
|||||||
isVideoLoaded: boolean | undefined;
|
isVideoLoaded: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ControlContext = createContext<ControlContextProps | undefined>(
|
const ControlContext = createContext<ControlContextProps | undefined>(undefined);
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
interface ControlProviderProps {
|
interface ControlProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -22,12 +17,7 @@ interface ControlProviderProps {
|
|||||||
isVideoLoaded: boolean | undefined;
|
isVideoLoaded: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ControlProvider: React.FC<ControlProviderProps> = ({
|
export const ControlProvider: React.FC<ControlProviderProps> = ({ children, item, mediaSource, isVideoLoaded }) => {
|
||||||
children,
|
|
||||||
item,
|
|
||||||
mediaSource,
|
|
||||||
isVideoLoaded,
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
||||||
{children}
|
{children}
|
||||||
@@ -38,7 +28,7 @@ export const ControlProvider: React.FC<ControlProviderProps> = ({
|
|||||||
export const useControlContext = () => {
|
export const useControlContext = () => {
|
||||||
const context = useContext(ControlContext);
|
const context = useContext(ControlContext);
|
||||||
if (context === undefined) {
|
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;
|
return context;
|
||||||
};
|
};
|
||||||
@@ -7,7 +7,7 @@ import { useVideoContext } from "../contexts/VideoContext";
|
|||||||
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
interface DropdownViewDirectProps {
|
interface DropdownViewDirectProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
@@ -71,94 +71,110 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
|||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
return (
|
const [selectedSubtitleIndex, setSelectedSubtitleIndex] = useState<Number>(
|
||||||
<DropdownMenu.Root>
|
parseInt(subtitleIndex)
|
||||||
<DropdownMenu.Trigger>
|
);
|
||||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
const [selectedAudioIndex, setSelectedAudioIndex] = useState<Number>(
|
||||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
parseInt(audioIndex)
|
||||||
</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(
|
return (
|
||||||
"Set external subtitle: ",
|
<View
|
||||||
api?.basePath + sub.deliveryUrl
|
style={{
|
||||||
);
|
position: "absolute",
|
||||||
} else {
|
zIndex: 1000,
|
||||||
console.log("Set sub index: ", sub.index);
|
opacity: showControls ? 1 : 0,
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
}}
|
||||||
}
|
className="p-4"
|
||||||
router.setParams({
|
>
|
||||||
subtitleIndex: sub.index.toString(),
|
<DropdownMenu.Root>
|
||||||
});
|
<DropdownMenu.Trigger>
|
||||||
console.log("Subtitle: ", sub);
|
<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.ItemTitle key={`subtitle-item-title-${idx}`}>
|
</DropdownMenu.Trigger>
|
||||||
{sub.name}
|
<DropdownMenu.Content
|
||||||
</DropdownMenu.ItemTitle>
|
loop={true}
|
||||||
</DropdownMenu.CheckboxItem>
|
side="bottom"
|
||||||
))}
|
align="start"
|
||||||
</DropdownMenu.SubContent>
|
alignOffset={0}
|
||||||
</DropdownMenu.Sub>
|
avoidCollisions={true}
|
||||||
<DropdownMenu.Sub>
|
collisionPadding={8}
|
||||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
sideOffset={8}
|
||||||
Audio
|
>
|
||||||
</DropdownMenu.SubTrigger>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubContent
|
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
||||||
alignOffset={-10}
|
Subtitle
|
||||||
avoidCollisions={true}
|
</DropdownMenu.SubTrigger>
|
||||||
collisionPadding={0}
|
<DropdownMenu.SubContent
|
||||||
loop={true}
|
alignOffset={-10}
|
||||||
sideOffset={10}
|
avoidCollisions={true}
|
||||||
>
|
collisionPadding={0}
|
||||||
{audioTracks?.map((track, idx: number) => (
|
loop={true}
|
||||||
<DropdownMenu.CheckboxItem
|
sideOffset={10}
|
||||||
key={`audio-item-${idx}`}
|
>
|
||||||
value={audioIndex === track.index.toString()}
|
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
||||||
onValueChange={() => {
|
<DropdownMenu.CheckboxItem
|
||||||
setAudioTrack && setAudioTrack(track.index);
|
key={`subtitle-item-${idx}`}
|
||||||
router.setParams({
|
value={selectedSubtitleIndex === sub.index}
|
||||||
audioIndex: track.index.toString(),
|
onValueChange={() => {
|
||||||
});
|
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||||
}}
|
setSubtitleURL &&
|
||||||
>
|
setSubtitleURL(
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
api?.basePath + sub.deliveryUrl,
|
||||||
{track.name}
|
sub.name
|
||||||
</DropdownMenu.ItemTitle>
|
);
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
))}
|
console.log(
|
||||||
</DropdownMenu.SubContent>
|
"Set external subtitle: ",
|
||||||
</DropdownMenu.Sub>
|
api?.basePath + sub.deliveryUrl
|
||||||
</DropdownMenu.Content>
|
);
|
||||||
</DropdownMenu.Root>
|
} 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ import { TranscodedSubtitle } from "../types";
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
|
|
||||||
interface DropdownViewProps {
|
interface DropdownViewProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
offline?: boolean; // used to disable external subs for downloads
|
offline?: boolean; // used to disable external subs for downloads
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
const DropdownView: React.FC<DropdownViewProps> = ({
|
||||||
|
showControls,
|
||||||
|
offline = false,
|
||||||
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const ControlContext = useControlContext();
|
const ControlContext = useControlContext();
|
||||||
@@ -35,20 +37,32 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
|
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
|
||||||
|
const isOnTextSubtitle =
|
||||||
const isOnTextSubtitle = useMemo(() => {
|
mediaSource?.MediaStreams?.find(
|
||||||
const res = Boolean(
|
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
|
||||||
mediaSource?.MediaStreams?.find(
|
) || subtitleIndex === "-1";
|
||||||
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
|
|
||||||
) || subtitleIndex === "-1"
|
|
||||||
);
|
|
||||||
return res;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const allSubs =
|
const allSubs =
|
||||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
|
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
|
||||||
|
const textBasedSubs = allSubs.filter((x) => x.IsTextSubtitleStream);
|
||||||
|
|
||||||
const subtitleHelper = new SubtitleHelper(mediaSource?.MediaStreams ?? []);
|
// 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 allSubtitleTracksForTranscodingStream = useMemo(() => {
|
const allSubtitleTracksForTranscodingStream = useMemo(() => {
|
||||||
const disableSubtitle = {
|
const disableSubtitle = {
|
||||||
@@ -64,9 +78,39 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
IsTextSubtitleStream: true,
|
IsTextSubtitleStream: true,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
console.log("sortedSubtitles", sortedSubtitles);
|
|
||||||
return [disableSubtitle, ...sortedSubtitles];
|
return [disableSubtitle, ...sortedSubtitles];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +123,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
return [disableSubtitle, ...transcodedSubtitle];
|
return [disableSubtitle, ...transcodedSubtitle];
|
||||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
||||||
|
|
||||||
const changeToImageBasedSub = useCallback(
|
const ChangeTranscodingSubtitle = useCallback(
|
||||||
(subtitleIndex: number) => {
|
(subtitleIndex: number) => {
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||||
@@ -101,14 +145,26 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
name: x.DisplayTitle!,
|
name: x.DisplayTitle!,
|
||||||
index: x.Index!,
|
index: x.Index!,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const ChangeTranscodingAudio = useCallback(
|
const ChangeTranscodingAudio = useCallback(
|
||||||
(audioIndex: number) => {
|
(audioIndex: number, currentSelectedSubtitleIndex: number) => {
|
||||||
console.log("ChangeTranscodingAudio", subtitleIndex, audioIndex);
|
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 queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
subtitleIndex: newSubtitleIndex?.toString() ?? "",
|
||||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
bitrateValue: bitrateValue,
|
bitrateValue: bitrateValue,
|
||||||
}).toString();
|
}).toString();
|
||||||
@@ -116,7 +172,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
},
|
},
|
||||||
[mediaSource, subtitleIndex, audioIndex]
|
[mediaSource]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -157,38 +213,18 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
{allSubtitleTracksForTranscodingStream?.map(
|
{allSubtitleTracksForTranscodingStream?.map(
|
||||||
(sub, idx: number) => (
|
(sub, idx: number) => (
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
value={
|
value={selectedSubtitleIndex === sub.index}
|
||||||
subtitleIndex ===
|
|
||||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
|
||||||
? subtitleHelper
|
|
||||||
.getSourceSubtitleIndex(sub.index)
|
|
||||||
.toString()
|
|
||||||
: sub?.index.toString())
|
|
||||||
}
|
|
||||||
key={`subtitle-item-${idx}`}
|
key={`subtitle-item-${idx}`}
|
||||||
onValueChange={() => {
|
onValueChange={() => {
|
||||||
console.log("sub", sub);
|
console.log("sub", sub);
|
||||||
if (
|
if (selectedSubtitleIndex === sub?.index) return;
|
||||||
subtitleIndex ===
|
setSelectedSubtitleIndex(sub.index);
|
||||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
|
||||||
? subtitleHelper
|
|
||||||
.getSourceSubtitleIndex(sub.index)
|
|
||||||
.toString()
|
|
||||||
: sub?.index.toString())
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
router.setParams({
|
|
||||||
subtitleIndex: subtitleHelper
|
|
||||||
.getSourceSubtitleIndex(sub.index)
|
|
||||||
.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
changeToImageBasedSub(sub.index);
|
|
||||||
|
ChangeTranscodingSubtitle(sub.index);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||||
@@ -213,14 +249,11 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
{allAudio?.map((track, idx: number) => (
|
{allAudio?.map((track, idx: number) => (
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key={`audio-item-${idx}`}
|
key={`audio-item-${idx}`}
|
||||||
value={audioIndex === track.index.toString()}
|
value={selectedAudioIndex === track.index}
|
||||||
onValueChange={() => {
|
onValueChange={() => {
|
||||||
if (audioIndex === track.index.toString()) return;
|
if (selectedAudioIndex === track.index) return;
|
||||||
console.log("Setting audio track to: ", track.index);
|
setSelectedAudioIndex(track.index);
|
||||||
router.setParams({
|
ChangeTranscodingAudio(track.index, selectedSubtitleIndex);
|
||||||
audioIndex: track.index.toString(),
|
|
||||||
});
|
|
||||||
ChangeTranscodingAudio(track.index);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface CreditTimestamps {
|
interface CreditTimestamps {
|
||||||
Introduction: {
|
Introduction: {
|
||||||
@@ -79,7 +78,6 @@ export const useCreditSkipper = (
|
|||||||
if (!creditTimestamps) return;
|
if (!creditTimestamps) return;
|
||||||
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
||||||
try {
|
try {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
wrappedSeek(creditTimestamps.Credits.End);
|
wrappedSeek(creditTimestamps.Credits.End);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
play();
|
play();
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
// Used only for intial play settings.
|
|
||||||
const useDefaultPlaySettings = (
|
const useDefaultPlaySettings = (
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
settings: Settings | null
|
settings: Settings | null
|
||||||
@@ -18,23 +17,34 @@ const useDefaultPlaySettings = (
|
|||||||
// 2. Get default or preferred audio
|
// 2. Get default or preferred audio
|
||||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||||
(x) =>
|
(x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage
|
||||||
x.Type === "Audio" &&
|
|
||||||
x.Language ===
|
|
||||||
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName
|
|
||||||
)?.Index;
|
)?.Index;
|
||||||
|
|
||||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||||
(x) => x.Type === "Audio"
|
(x) => x.Type === "Audio"
|
||||||
)?.Index;
|
)?.Index;
|
||||||
|
|
||||||
|
// 3. Get default or preferred subtitle
|
||||||
|
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||||
|
(x) =>
|
||||||
|
x.Type === "Subtitle" &&
|
||||||
|
x.Language === settings?.defaultSubtitleLanguage?.value
|
||||||
|
)?.Index;
|
||||||
|
|
||||||
|
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||||
|
(stream) => stream.Type === "Subtitle" && stream.IsDefault
|
||||||
|
)?.Index;
|
||||||
|
|
||||||
// 4. Get default bitrate
|
// 4. Get default bitrate
|
||||||
const bitrate = BITRATES[0];
|
const bitrate = BITRATES[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultAudioIndex:
|
defaultAudioIndex:
|
||||||
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
||||||
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex || -1,
|
defaultSubtitleIndex:
|
||||||
|
preferedSubtitleIndex !== undefined
|
||||||
|
? preferedSubtitleIndex
|
||||||
|
: defaultSubtitleIndex || undefined,
|
||||||
defaultMediaSource: mediaSource || undefined,
|
defaultMediaSource: mediaSource || undefined,
|
||||||
defaultBitrate: bitrate || undefined,
|
defaultBitrate: bitrate || undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface IntroTimestamps {
|
interface IntroTimestamps {
|
||||||
EpisodeId: string;
|
EpisodeId: string;
|
||||||
@@ -79,7 +78,6 @@ export const useIntroSkipper = (
|
|||||||
console.log("skipIntro");
|
console.log("skipIntro");
|
||||||
if (!introTimestamps) return;
|
if (!introTimestamps) return;
|
||||||
try {
|
try {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
wrappedSeek(introTimestamps.IntroEnd);
|
wrappedSeek(introTimestamps.IntroEnd);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
play();
|
play();
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -53,7 +53,7 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { saveImage } = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||||
const { saveDownloadedItemInfo, setProcesses, processes, APP_CACHE_DOWNLOAD_DIRECTORY } = useDownload();
|
const { saveDownloadedItemInfo, setProcesses, processes } = useDownload();
|
||||||
|
|
||||||
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
||||||
await saveSeriesPrimaryImage(item);
|
await saveSeriesPrimaryImage(item);
|
||||||
@@ -71,30 +71,59 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
const completeCallback = useCallback(
|
const completeCallback = useCallback(
|
||||||
async (session: FFmpegSession, item: BaseItemDto) => {
|
async (session: FFmpegSession, item: BaseItemDto) => {
|
||||||
try {
|
try {
|
||||||
console.log("completeCallback");
|
let endTime;
|
||||||
const returnCode = await session.getReturnCode();
|
const returnCode = await session.getReturnCode();
|
||||||
|
const startTime = new Date();
|
||||||
|
|
||||||
if (returnCode.isValueSuccess()) {
|
if (returnCode.isValueSuccess()) {
|
||||||
|
endTime = new Date();
|
||||||
const stat = await session.getLastReceivedStatistics();
|
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({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["downloadedItems"],
|
queryKey: ["downloadedItems"],
|
||||||
});
|
});
|
||||||
|
|
||||||
saveDownloadedItemInfo(item, stat.getSize());
|
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");
|
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) => {
|
setProcesses((prev) => {
|
||||||
return prev.filter((process) => process.itemId !== item.Id);
|
return prev.filter((process) => process.itemId !== item.Id);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
const error = e as Error;
|
||||||
|
writeErrorLog(
|
||||||
|
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name},
|
||||||
|
Error: ${error.message}, Stack: ${error.stack}`.replace(/^ +/g, "")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("completeCallback ~ end");
|
|
||||||
},
|
},
|
||||||
[processes, setProcesses]
|
[processes, setProcesses]
|
||||||
);
|
);
|
||||||
@@ -130,14 +159,8 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
async (item: BaseItemDto, url: string) => {
|
||||||
const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY);
|
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||||
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 (!api) throw new Error("API is not defined");
|
||||||
if (!item.Id) throw new Error("Item must have an Id");
|
if (!item.Id) throw new Error("Item must have an Id");
|
||||||
|
|
||||||
|
|||||||
@@ -71,8 +71,9 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-native": "0.74.5",
|
"react-native": "0.74.5",
|
||||||
"react-native-awesome-slider": "^2.5.6",
|
"react-native-awesome-slider": "^2.5.6",
|
||||||
"react-native-bottom-tabs": "0.7.1",
|
"react-native-bottom-tabs": "^0.7.3",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
|
"react-native-color-matrix-image-filters": "^7.0.1",
|
||||||
"react-native-compressor": "^1.9.0",
|
"react-native-compressor": "^1.9.0",
|
||||||
"react-native-device-info": "^14.0.1",
|
"react-native-device-info": "^14.0.1",
|
||||||
"react-native-edge-to-edge": "^1.1.1",
|
"react-native-edge-to-edge": "^1.1.1",
|
||||||
@@ -95,7 +96,6 @@
|
|||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-uuid": "^2.0.2",
|
"react-native-uuid": "^2.0.2",
|
||||||
"react-native-video": "^6.7.0",
|
"react-native-video": "^6.7.0",
|
||||||
"react-native-volume-manager": "^1.10.0",
|
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"sonner-native": "^0.14.2",
|
"sonner-native": "^0.14.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {AppState, AppStateStatus, Platform} from "react-native";
|
import { AppState, AppStateStatus } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { apiAtom } from "./JellyfinProvider";
|
import { apiAtom } from "./JellyfinProvider";
|
||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
@@ -49,7 +49,6 @@ import { storage } from "@/utils/mmkv";
|
|||||||
import useDownloadHelper from "@/utils/download";
|
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 Haptics from "expo-haptics";
|
||||||
import * as Application from "expo-application";
|
|
||||||
|
|
||||||
export type DownloadedItem = {
|
export type DownloadedItem = {
|
||||||
item: Partial<BaseItemDto>;
|
item: Partial<BaseItemDto>;
|
||||||
@@ -195,8 +194,6 @@ function useDownloadProvider() {
|
|||||||
[settings?.optimizedVersionsServerUrl, authHeader]
|
[settings?.optimizedVersionsServerUrl, authHeader]
|
||||||
);
|
);
|
||||||
|
|
||||||
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`
|
|
||||||
|
|
||||||
const startDownload = useCallback(
|
const startDownload = useCallback(
|
||||||
async (process: JobStatus) => {
|
async (process: JobStatus) => {
|
||||||
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
||||||
@@ -413,9 +410,8 @@ function useDownloadProvider() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const forEveryDocumentDirFile = async (
|
const forEveryDirectoryFile = async (
|
||||||
includeMMKV: boolean = true,
|
includeMMKV: boolean = true,
|
||||||
ignoreList: string[] = [],
|
|
||||||
callback: (file: FileInfo) => void
|
callback: (file: FileInfo) => void
|
||||||
) => {
|
) => {
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
@@ -423,35 +419,20 @@ function useDownloadProvider() {
|
|||||||
throw new Error("Base directory not found");
|
throw new Error("Base directory not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`ignoreList length: ${ignoreList?.length}`);
|
|
||||||
|
|
||||||
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
||||||
for (const item of dirContents) {
|
for (const item of dirContents) {
|
||||||
// Exclude mmkv directory.
|
// Exclude mmkv directory.
|
||||||
// Deleting this deletes all user information as well. Logout should handle this.
|
// Deleting this deletes all user information as well. Logout should handle this.
|
||||||
if (
|
if (item == "mmkv" && !includeMMKV) continue;
|
||||||
(item == "mmkv" && !includeMMKV) ||
|
const itemInfo = await FileSystem.getInfoAsync(`${baseDirectory}${item}`);
|
||||||
ignoreList.some(i => item.includes(i))
|
if (itemInfo.exists) {
|
||||||
) {
|
callback(itemInfo);
|
||||||
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> => {
|
const deleteLocalFiles = async (): Promise<void> => {
|
||||||
await forEveryDocumentDirFile(false, [], (file) => {
|
await forEveryDirectoryFile(false, (file) => {
|
||||||
console.warn("Deleting file", file.uri);
|
console.warn("Deleting file", file.uri);
|
||||||
FileSystem.deleteAsync(file.uri, { idempotent: true });
|
FileSystem.deleteAsync(file.uri, { idempotent: true });
|
||||||
});
|
});
|
||||||
@@ -544,30 +525,6 @@ function useDownloadProvider() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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(
|
await Promise.all(
|
||||||
downloadedFiles
|
downloadedFiles
|
||||||
@@ -583,23 +540,13 @@ function useDownloadProvider() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const appSizeUsage = useMemo(async () => {
|
const appSizeUsage = useMemo(async () => {
|
||||||
const sizes: number[] = downloadedFiles?.map(d => {
|
const sizes: number[] = [];
|
||||||
return getDownloadedItemSize(d.item.Id!!)
|
await forEveryDirectoryFile(true, (file) => {
|
||||||
}) || [];
|
if (file.exists) sizes.push(file.size);
|
||||||
|
});
|
||||||
await forEveryDocumentDirFile(
|
|
||||||
true,
|
|
||||||
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);
|
return sizes.reduce((sum, size) => sum + size, 0);
|
||||||
}, [logs, downloadedFiles, forEveryDocumentDirFile]);
|
}, [logs, downloadedFiles]);
|
||||||
|
|
||||||
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
||||||
try {
|
try {
|
||||||
@@ -689,8 +636,6 @@ function useDownloadProvider() {
|
|||||||
deleteFileByType,
|
deleteFileByType,
|
||||||
appSizeUsage,
|
appSizeUsage,
|
||||||
getDownloadedItemSize,
|
getDownloadedItemSize,
|
||||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
|
||||||
cleanCacheDirectory
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,10 +662,10 @@ export function bytesToReadable(bytes: number): string {
|
|||||||
|
|
||||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||||
|
|
||||||
const mb = bytes / 1024.0 / 1024.0;
|
const mb = bytes / 1024 / 1024;
|
||||||
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
||||||
|
|
||||||
const kb = bytes / 1024.0;
|
const kb = bytes / 1024;
|
||||||
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
||||||
return `${bytes.toFixed(2)} B`;
|
return `${bytes.toFixed(2)} B`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,6 @@ import { useEffect } from "react";
|
|||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { storage } from "../mmkv";
|
import { storage } from "../mmkv";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import {
|
|
||||||
CultureDto,
|
|
||||||
SubtitlePlaybackMode,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
|
|
||||||
export type DownloadQuality = "original" | "high" | "low";
|
export type DownloadQuality = "original" | "high" | "low";
|
||||||
|
|
||||||
@@ -70,12 +66,8 @@ export type Settings = {
|
|||||||
openInVLC?: boolean;
|
openInVLC?: boolean;
|
||||||
downloadQuality?: DownloadOption;
|
downloadQuality?: DownloadOption;
|
||||||
libraryOptions: LibraryOptions;
|
libraryOptions: LibraryOptions;
|
||||||
defaultAudioLanguage: CultureDto | null;
|
defaultSubtitleLanguage: DefaultLanguageOption | null;
|
||||||
playDefaultAudioTrack: boolean;
|
defaultAudioLanguage: DefaultLanguageOption | null;
|
||||||
rememberAudioSelections: boolean;
|
|
||||||
defaultSubtitleLanguage: CultureDto | null;
|
|
||||||
subtitleMode: SubtitlePlaybackMode;
|
|
||||||
rememberSubtitleSelections: boolean;
|
|
||||||
showHomeTitles: boolean;
|
showHomeTitles: boolean;
|
||||||
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
||||||
forwardSkipTime: number;
|
forwardSkipTime: number;
|
||||||
@@ -85,8 +77,7 @@ export type Settings = {
|
|||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
showCustomMenuLinks: boolean;
|
showCustomMenuLinks: boolean;
|
||||||
subtitleSize: number;
|
subtitleSize: number;
|
||||||
remuxConcurrentLimit: 1 | 2 | 3 | 4;
|
remuxConcurrentLimit: 1 | 2 | 3 | 4; // TODO: Maybe let people choose their own limit? 4 seems like a safe max?
|
||||||
safeAreaInControlsEnabled: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettings = (): Settings => {
|
const loadSettings = (): Settings => {
|
||||||
@@ -108,11 +99,7 @@ const loadSettings = (): Settings => {
|
|||||||
showStats: true,
|
showStats: true,
|
||||||
},
|
},
|
||||||
defaultAudioLanguage: null,
|
defaultAudioLanguage: null,
|
||||||
playDefaultAudioTrack: true,
|
|
||||||
rememberAudioSelections: true,
|
|
||||||
defaultSubtitleLanguage: null,
|
defaultSubtitleLanguage: null,
|
||||||
subtitleMode: SubtitlePlaybackMode.Default,
|
|
||||||
rememberSubtitleSelections: true,
|
|
||||||
showHomeTitles: true,
|
showHomeTitles: true,
|
||||||
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
||||||
forwardSkipTime: 30,
|
forwardSkipTime: 30,
|
||||||
@@ -123,7 +110,6 @@ const loadSettings = (): Settings => {
|
|||||||
showCustomMenuLinks: false,
|
showCustomMenuLinks: false,
|
||||||
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
||||||
remuxConcurrentLimit: 1,
|
remuxConcurrentLimit: 1,
|
||||||
safeAreaInControlsEnabled: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -158,7 +144,6 @@ export const useSettings = () => {
|
|||||||
const updateSettings = (update: Partial<Settings>) => {
|
const updateSettings = (update: Partial<Settings>) => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
const newSettings = { ...settings, ...update };
|
const newSettings = { ...settings, ...update };
|
||||||
|
|
||||||
setSettings(newSettings);
|
setSettings(newSettings);
|
||||||
saveSettings(newSettings);
|
saveSettings(newSettings);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -4,12 +4,7 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Settings, useSettings } from "../atoms/settings";
|
import { Settings } from "../atoms/settings";
|
||||||
import {
|
|
||||||
AudioStreamRanker,
|
|
||||||
StreamRanker,
|
|
||||||
SubtitleStreamRanker,
|
|
||||||
} from "../streamRanker";
|
|
||||||
|
|
||||||
interface PlaySettings {
|
interface PlaySettings {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -19,22 +14,9 @@ interface PlaySettings {
|
|||||||
subtitleIndex?: number | undefined;
|
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(
|
export function getDefaultPlaySettings(
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
settings: Settings,
|
settings: Settings
|
||||||
previousIndexes?: previousIndexes,
|
|
||||||
previousSource?: MediaSourceInfo
|
|
||||||
): PlaySettings {
|
): PlaySettings {
|
||||||
if (item.Type === "Program") {
|
if (item.Type === "Program") {
|
||||||
return {
|
return {
|
||||||
@@ -53,44 +35,19 @@ export function getDefaultPlaySettings(
|
|||||||
// 2. Get default or preferred audio
|
// 2. Get default or preferred audio
|
||||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||||
(x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage
|
(x) => x.Language === settings?.defaultAudioLanguage
|
||||||
)?.Index;
|
)?.Index;
|
||||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||||
(x) => x.Type === "Audio"
|
(x) => x.Type === "Audio"
|
||||||
)?.Index;
|
)?.Index;
|
||||||
|
|
||||||
// We prefer the previous track over the default track.
|
// 3. Get default or preferred subtitle
|
||||||
let trackOptions: TrackOptions = {
|
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||||
DefaultAudioStreamIndex: defaultAudioIndex ?? -1,
|
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
|
||||||
DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
)?.Index;
|
||||||
};
|
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||||
|
(stream) => stream.Type === "Subtitle" && stream.IsDefault
|
||||||
const mediaStreams = mediaSource?.MediaStreams ?? [];
|
)?.Index;
|
||||||
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
|
// 4. Get default bitrate
|
||||||
const bitrate = BITRATES.sort(
|
const bitrate = BITRATES.sort(
|
||||||
@@ -101,7 +58,7 @@ export function getDefaultPlaySettings(
|
|||||||
item,
|
item,
|
||||||
bitrate,
|
bitrate,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
audioIndex: trackOptions.DefaultAudioStreamIndex,
|
audioIndex: preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex,
|
||||||
subtitleIndex: trackOptions.DefaultSubtitleStreamIndex,
|
subtitleIndex: preferedSubtitleIndex ?? defaultSubtitleIndex ?? -1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export const getStreamUrl = async ({
|
|||||||
|
|
||||||
if (item.MediaType === "Video") {
|
if (item.MediaType === "Video") {
|
||||||
if (mediaSource?.TranscodingUrl) {
|
if (mediaSource?.TranscodingUrl) {
|
||||||
|
|
||||||
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
|
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
|
||||||
|
|
||||||
// If there is no subtitle stream index, add it to the URL.
|
// If there is no subtitle stream index, add it to the URL.
|
||||||
@@ -123,7 +124,10 @@ export const getStreamUrl = async ({
|
|||||||
// Get the updated URL
|
// Get the updated URL
|
||||||
const transcodeUrl = urlObj.toString();
|
const transcodeUrl = urlObj.toString();
|
||||||
|
|
||||||
console.log("Video has transcoding URL:", `${transcodeUrl}`);
|
console.log(
|
||||||
|
"Video has transcoding URL:",
|
||||||
|
`${transcodeUrl}`
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
url: transcodeUrl,
|
url: transcodeUrl,
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
|
|||||||
@@ -41,39 +41,21 @@ export const chromecastProfile: DeviceProfile = {
|
|||||||
],
|
],
|
||||||
TranscodingProfiles: [
|
TranscodingProfiles: [
|
||||||
{
|
{
|
||||||
Container: "ts",
|
|
||||||
Type: "Video",
|
Type: "Video",
|
||||||
VideoCodec: "h264",
|
Context: "Streaming",
|
||||||
AudioCodec: "aac,mp3",
|
|
||||||
Protocol: "hls",
|
Protocol: "hls",
|
||||||
Context: "Streaming",
|
Container: "ts",
|
||||||
MaxAudioChannels: "2",
|
VideoCodec: "h264, hevc",
|
||||||
MinSegments: 2,
|
AudioCodec: "aac,mp3,ac3",
|
||||||
BreakOnNonKeyFrames: true,
|
CopyTimestamps: false,
|
||||||
|
EnableSubtitlesInManifest: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Container: "mp4",
|
Type: "Audio",
|
||||||
Type: "Video",
|
Context: "Streaming",
|
||||||
VideoCodec: "h264",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Protocol: "http",
|
Protocol: "http",
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "mp3",
|
Container: "mp3",
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "mp3",
|
AudioCodec: "mp3",
|
||||||
Protocol: "http",
|
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "aac",
|
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Protocol: "http",
|
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: "2",
|
MaxAudioChannels: "2",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import MediaTypes from "../../constants/MediaTypes";
|
import MediaTypes from "../../constants/MediaTypes";
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Name: "Vlc Player for HLS streams.",
|
Name: "Vlc Player for HLS streams.",
|
||||||
MaxStaticBitrate: 20_000_000,
|
MaxStaticBitrate: 20_000_000,
|
||||||
@@ -39,7 +40,7 @@ export default {
|
|||||||
Type: MediaTypes.Video,
|
Type: MediaTypes.Video,
|
||||||
Context: "Streaming",
|
Context: "Streaming",
|
||||||
Protocol: "hls",
|
Protocol: "hls",
|
||||||
Container: "fmp4",
|
Container: "ts",
|
||||||
VideoCodec: "h264, hevc",
|
VideoCodec: "h264, hevc",
|
||||||
AudioCodec: "aac,mp3,ac3",
|
AudioCodec: "aac,mp3,ac3",
|
||||||
CopyTimestamps: false,
|
CopyTimestamps: false,
|
||||||
@@ -77,6 +78,7 @@ export default {
|
|||||||
{ Format: "vtt", Method: "Hls" },
|
{ Format: "vtt", Method: "Hls" },
|
||||||
{ Format: "webvtt", Method: "Hls" },
|
{ Format: "webvtt", Method: "Hls" },
|
||||||
|
|
||||||
|
|
||||||
// Image based subs use encode.
|
// Image based subs use encode.
|
||||||
{ Format: "dvdsub", Method: "Encode" },
|
{ Format: "dvdsub", Method: "Encode" },
|
||||||
{ Format: "pgs", Method: "Encode" },
|
{ Format: "pgs", Method: "Encode" },
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
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