Compare commits

..

2 Commits

Author SHA1 Message Date
Fredrik Burmester
8b6c7a7603 fix: incorrect matrix 2024-12-09 15:53:53 +01:00
Fredrik Burmester
5a07eccd9b fix: remove black background in some logo images 2024-12-09 15:39:23 +01:00
58 changed files with 1068 additions and 2584 deletions

1
.gitignore vendored
View File

@@ -35,4 +35,3 @@ credentials.json
*.ipa *.ipa
.continuerc.json .continuerc.json
.vscode/

View File

@@ -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"
} }

View File

@@ -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();
}; };

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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}

View File

@@ -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]);

View File

@@ -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>

View File

@@ -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

BIN
bun.lockb

Binary file not shown.

View File

@@ -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;

View File

@@ -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>
); );
}; };

View File

@@ -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" />

View File

@@ -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>
</> </>
) : ( ) : (

View File

@@ -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>

View File

@@ -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];
};

View File

@@ -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() ?? "",

View File

@@ -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>
); );
}; };

View File

@@ -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>
);
};

View File

@@ -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),

View File

@@ -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>
); );
}; };

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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]));

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}; };

View File

@@ -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>
); );
}; };

View File

@@ -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}`}>

View File

@@ -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();

View File

@@ -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,
}; };

View File

@@ -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();

View File

@@ -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;
};

View File

@@ -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");

View File

@@ -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",

View File

@@ -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`;
} }

View File

View File

@@ -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;
};
}

View File

@@ -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);
} }

View File

@@ -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;
};

View File

@@ -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,
}; };
} }

View File

@@ -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,

View File

@@ -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",
}, },
], ],

View File

@@ -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" },

View File

@@ -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 };