Merge branch 'pr/233'

This commit is contained in:
Fredrik Burmester
2024-12-01 21:01:59 +01:00
8 changed files with 285 additions and 174 deletions

View File

@@ -83,8 +83,8 @@ export default function page() {
</View>
</View>}
<ScrollView key={seasonIndex}>
{groupBySeason.map((episode) => (
<View className="px-4 flex flex-col my-4">
{groupBySeason.map((episode, index) => (
<View className="px-4 flex flex-col my-4" key={index}>
<EpisodeCard item={episode}/>
</View>
))}

View File

@@ -63,12 +63,13 @@ export default function page() {
Queue and downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q) => (
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
key={index}
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>

View File

@@ -8,13 +8,17 @@ import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import {useLocalSearchParams, useNavigation} from "expo-router";
import { useAtom } from "jotai";
import React from "react";
import { useEffect, useMemo } from "react";
import React, {useEffect} from "react";
import { useMemo } from "react";
import { View } from "react-native";
import {DownloadItems} from "@/components/DownloadItem";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {getTvShowsApi} from "@jellyfin/sdk/lib/utils/api";
const page: React.FC = () => {
const navigation = useNavigation();
const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as {
id: string;
@@ -56,7 +60,43 @@ const page: React.FC = () => {
[item]
);
if (!item || !backdropUrl) return null;
const {data: allEpisodes, isLoading} = useQuery({
queryKey: ["AllEpisodes", item?.Id],
queryFn: async () => {
const res = await getTvShowsApi(api!).getEpisodes({
seriesId: item?.Id!,
userId: user?.Id!,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
});
return res?.data.Items || []
},
enabled: !!api && !!user?.Id && !!item?.Id
});
useEffect(() => {
navigation.setOptions({
headerRight: () => (
(!isLoading && allEpisodes && allEpisodes.length > 0) && (
<View className="flex flex-row items-center space-x-2">
<DownloadItems
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<MaterialCommunityIcons name="folder-download" size={24} color="white"/>
)}
DownloadedIconComponent={() => (
<MaterialCommunityIcons name="folder-check" size={26} color="#9333ea"/>
)}
/>
</View>
)
)
})
}, [allEpisodes, isLoading]);
if (!item || !backdropUrl)
return null;
return (
<ParallaxScrollView

View File

@@ -6,7 +6,6 @@ import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
import native from "@/utils/profiles/native";
import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons";
import {
@@ -21,7 +20,7 @@ import {
} from "@jellyfin/sdk/lib/generated-client/models";
import {Href, router, useFocusEffect} from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
@@ -34,45 +33,34 @@ import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
items: BaseItemDto[];
MissingDownloadIconComponent: () => React.ReactElement;
DownloadedIconComponent: () => React.ReactElement;
}
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
export const DownloadItems: React.FC<DownloadProps> = ({
items,
MissingDownloadIconComponent,
DownloadedIconComponent,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { processes, startBackgroundDownload } = useDownload();
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined | null
>(undefined);
const [selectedMediaSource, setSelectedMediaSource] = useState<MediaSourceInfo | undefined | null>(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [selectedSubtitleStream, setSelectedSubtitleStream] = useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
useFocusEffect(
useCallback(() => {
if (!settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(item, settings);
// 4. Set states
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [item, settings])
);
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
const userCanDownload = useMemo(() => user?.Policy?.EnableContentDownloading, [user]);
const usingOptimizedServer = useMemo(() => settings?.downloadMethod === "optimized", [settings]);
/**
* Bottom sheet
@@ -89,70 +77,154 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
bottomSheetModalRef.current?.dismiss();
}, []);
// region computed
const itemIds = useMemo(() => items.map(i => i.Id), [items]);
const pendingItems = useMemo(() =>
items.filter(i => !downloadedFiles?.some(f => f.item.Id === i.Id)),
[items, downloadedFiles]
);
const isDownloaded = useMemo(() => {
if (!downloadedFiles)
return false;
return pendingItems.length == 0;
}, [downloadedFiles, pendingItems]);
const itemsProcesses = useMemo(() =>
processes?.filter(p => itemIds.includes(p.item.Id)),
[processes, itemIds]
);
const progress = useMemo(() => {
if (itemIds.length == 1)
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0)
return ((itemIds.length - queue.filter(q => itemIds.includes(q.item.Id)).length) / itemIds.length) * 100
},
[queue, itemsProcesses, itemIds]
);
const itemsQueued = useMemo(() => {
return pendingItems.length > 0 && pendingItems.every(p => queue.some(q => p.Id == q.item.Id))
},
[queue, pendingItems]
);
// endregion computed
// region helper functions
const navigateToDownloads = () => router.push("/downloads");
const onDownloadedPress = () => {
const firstItem = items?.[0]
router.push(
firstItem.Type !== "Episode"
? "/downloads"
: {
pathname: `/downloads/${firstItem.SeriesId}`,
params: {
episodeSeasonIndex: firstItem.ParentIndexNumber
}
} as Href
);
}
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (pendingItems.some(i => !i.Id)) {
throw new Error("No item id");
}
closeModal();
if (usingOptimizedServer)
initiateDownload(...pendingItems);
else {
queueActions.enqueue(
queue,
setQueue,
...pendingItems.map(item => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
}))
)
}
} else {
toast.error("You are not allowed to download files.");
}
}, [
queue,
setQueue,
pendingItems,
usingOptimizedServer,
userCanDownload,
// Need to be reference at the time async lambda is created for initiateDownload
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream
])
/**
* Start download
*/
const initiateDownload = useCallback(async () => {
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
);
const initiateDownload = useCallback(async (...items: BaseItemDto[]) => {
if (!api || !user?.Id || items.some(p => !p.Id) || (pendingItems.length === 1 && !selectedMediaSource?.Id)) {
throw new Error("DownloadItem ~ initiateDownload: No api or user or item");
}
let mediaSource = selectedMediaSource
let audioIndex: number | undefined = selectedAudioStream
let subtitleIndex: number | undefined = selectedSubtitleStream
const res = await getStreamUrl({
api,
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: selectedAudioStream,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: selectedMediaSource.Id,
subtitleStreamIndex: selectedSubtitleStream,
deviceProfile: download,
});
for (const item of items) {
if (pendingItems.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(item, settings!));
}
if (!res) {
Alert.alert(
"Something went wrong",
"Could not get stream url from Jellyfin"
);
return;
}
const res = await getStreamUrl({
api,
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
});
const { mediaSource, url } = res;
if (!res) {
Alert.alert(
"Something went wrong",
"Could not get stream url from Jellyfin"
);
continue;
}
if (!url || !mediaSource) throw new Error("No url");
const {mediaSource: source, url} = res;
saveDownloadItemInfoToDiskTmp(item, mediaSource, url);
if (!url || !source) throw new Error("No url");
if (settings?.downloadMethod === "optimized") {
return await startBackgroundDownload(url, item, mediaSource);
} else {
return await startRemuxing(item, url, mediaSource);
saveDownloadItemInfoToDiskTmp(item, source, url);
if (usingOptimizedServer) {
await startBackgroundDownload(url, item, source);
} else {
await startRemuxing(item, url, source);
}
}
}, [
api,
item,
startBackgroundDownload,
user?.Id,
pendingItems,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings,
maxBitrate,
settings?.downloadMethod,
usingOptimizedServer,
startBackgroundDownload,
startRemuxing,
]);
/**
* Check if item is downloaded
*/
const { downloadedFiles } = useDownload();
const isDownloaded = useMemo(() => {
if (!downloadedFiles) return false;
return downloadedFiles.some((file) => file.item.Id === item.Id);
}, [downloadedFiles, item.Id]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -163,31 +235,38 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
),
[]
);
// endregion helper functions
const process = useMemo(() => {
if (!processes) return null;
// Allow to select & set settings for single download
useFocusEffect(
useCallback(() => {
if (!settings) return;
if (pendingItems.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings);
return processes.find((process) => process?.item?.Id === item.Id);
}, [processes, item.Id]);
// 4. Set states
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, pendingItems, settings])
);
return (
<View
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
{...props}
>
{process && process?.item.Id === item.Id ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
{process.progress === 0 ? (
{processes && itemsProcesses.length > 0 ? (
<TouchableOpacity onPress={navigateToDownloads}>
{progress === 0 ? (
<Loader />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={process.progress}
fill={progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
@@ -195,34 +274,17 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
</View>
)}
</TouchableOpacity>
) : queue.some((i) => i.id === item.Id) ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
) : itemsQueued ? (
<TouchableOpacity onPress={navigateToDownloads}>
<Ionicons name="hourglass" size={24} color="white" />
</TouchableOpacity>
) : isDownloaded ? (
<TouchableOpacity
onPress={() => {
router.push(
item.Type !== "Episode"
? "/downloads"
: {
pathname: `/downloads/${item.SeriesId}`,
params: {
episodeSeasonIndex: item.ParentIndexNumber
}
} as Href
);
}}
>
<Ionicons name="cloud-download" size={26} color="#9333ea" />
<TouchableOpacity onPress={onDownloadedPress}>
{DownloadedIconComponent()}
</TouchableOpacity>
) : (
<TouchableOpacity onPress={handlePresentModalPress}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
{MissingDownloadIconComponent()}
</TouchableOpacity>
)}
<BottomSheetModal
@@ -245,62 +307,44 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
<View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector
inverted
onChange={(val) => setMaxBitrate(val)}
onChange={setMaxBitrate}
selected={maxBitrate}
/>
<MediaSourceSelector
item={item}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<View className="flex flex-col space-y-2">
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
{pendingItems.length === 1 && (
<>
<MediaSourceSelector
item={items[0]}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
{selectedMediaSource && (
<View className="flex flex-col space-y-2">
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
)}
</>
)}
</View>
<Button
className="mt-auto"
onPress={() => {
if (userCanDownload === true) {
if (!item.Id) {
throw new Error("No item id");
}
closeModal();
if (settings?.downloadMethod === "remux") {
queueActions.enqueue(queue, setQueue, {
id: item.Id,
execute: async () => {
await initiateDownload();
},
item,
});
} else {
initiateDownload();
}
} else {
toast.error("You are not allowed to download files.");
}
}}
onPress={acceptDownloadOptions}
color="purple"
>
Download
</Button>
<View className="opacity-70 text-center w-full flex items-center">
{settings?.downloadMethod === "optimized" ? (
<Text className="text-xs">Using optimized server</Text>
) : (
<Text className="text-xs">Using default method</Text>
)}
<Text className="text-xs">
{usingOptimizedServer ? "Using optimized server" : "Using default method"}
</Text>
</View>
</View>
</BottomSheetView>
@@ -308,3 +352,17 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
</View>
);
};
export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<DownloadItems
items={[item]}
MissingDownloadIconComponent={() => (
<Ionicons name="cloud-download-outline" size={24} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="cloud-download" size={26} color="#9333ea" />
)}
/>
)
}

View File

@@ -1,6 +1,6 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadItem } from "@/components/DownloadItem";
import {DownloadSingleItem} from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlayButton } from "@/components/PlayButton";
@@ -87,7 +87,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<DownloadItem item={item} />
<DownloadSingleItem item={item} />
<PlayedStatus item={item} />
</View>
)}

View File

@@ -6,13 +6,14 @@ import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { View } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { DownloadItem } from "../DownloadItem";
import {DownloadItems, DownloadSingleItem} from "../DownloadItem";
import { Loader } from "../Loader";
import { Text } from "../common/Text";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import {SeasonDropdown, SeasonIndexState} from "@/components/series/SeasonDropdown";
import {Ionicons, MaterialCommunityIcons} from "@expo/vector-icons";
type Props = {
item: BaseItemDto;
@@ -108,16 +109,27 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
minHeight: 144 * nrOfEpisodes,
}}
>
<SeasonDropdown
item={item}
seasons={seasons}
state={seasonIndexState}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
}));
}} />
<View className="flex flex-row justify-start items-center">
<SeasonDropdown
item={item}
seasons={seasons}
state={seasonIndexState}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
}));
}} />
<DownloadItems
items={episodes || []}
MissingDownloadIconComponent={() => (
<MaterialCommunityIcons name="download-multiple" size={24} color="white"/>
)}
DownloadedIconComponent={() => (
<MaterialCommunityIcons name="check-all" size={24} color="#9333ea"/>
)}
/>
</View>
<View className="px-4 flex flex-col my-4">
{isFetching ? (
<View
@@ -155,7 +167,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
</Text>
</View>
<View className="self-start ml-auto -mt-0.5">
<DownloadItem item={e} />
<DownloadSingleItem item={e} />
</View>
</View>

View File

@@ -13,8 +13,8 @@ export const runningAtom = atom<boolean>(false);
export const queueAtom = atom<Job[]>([]);
export const queueActions = {
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
const updatedQueue = [...queue, job];
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, ...job: Job[]) => {
const updatedQueue = [...queue, ...job];
console.info("Enqueueing job", job, updatedQueue);
setQueue(updatedQueue);
},

View File

@@ -10,8 +10,8 @@ interface PlaySettings {
item: BaseItemDto;
bitrate: (typeof BITRATES)[0];
mediaSource?: MediaSourceInfo | null;
audioIndex?: number | null;
subtitleIndex?: number | null;
audioIndex?: number | undefined;
subtitleIndex?: number | undefined;
}
export function getDefaultPlaySettings(