This commit is contained in:
Alex Kim
2025-07-19 00:37:28 +10:00
parent d0b1c51fac
commit 4aea5c0155
11 changed files with 139 additions and 128 deletions

View File

@@ -69,10 +69,15 @@ const page: React.FC = () => {
seriesId: item?.Id!,
userId: user?.Id!,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
// Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
return res?.data.Items || [];
},
select: (data) =>
[...(data || [])].sort(
(a, b) => (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0)
),
staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id,
});

View File

@@ -14,7 +14,7 @@ import { t } from "i18next";
import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
import { toast } from "sonner-native";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -55,6 +55,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
@@ -96,6 +97,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[items, downloadedFiles],
);
const itemsToDownload = useMemo(() => {
if (downloadUnwatchedOnly) {
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
}
return itemsNotDownloaded;
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
const allItemsDownloaded = useMemo(() => {
if (items.length === 0) return false;
return itemsNotDownloaded.length === 0;
@@ -138,30 +146,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
};
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsNotDownloaded.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
initiateDownload(...itemsNotDownloaded);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [
queue,
setQueue,
itemsNotDownloaded,
userCanDownload,
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
]);
const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => {
if (
@@ -174,18 +158,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
"DownloadItem ~ initiateDownload: No api or user or item",
);
}
let mediaSource = selectedMediaSource;
let audioIndex: number | undefined = selectedAudioStream;
let subtitleIndex: number | undefined = selectedSubtitleStream;
for (const item of items) {
if (itemsNotDownloaded.length > 1) {
const defaults = getDefaultPlaySettings(item, settings!);
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
}
const downloadDetailsPromises = items.map(async (item) => {
const { mediaSource, audioIndex, subtitleIndex } =
itemsNotDownloaded.length > 1
? getDefaultPlaySettings(item, settings!)
: {
mediaSource: selectedMediaSource,
audioIndex: selectedAudioStream,
subtitleIndex: selectedSubtitleStream,
};
const res = await getStreamUrl({
api,
item,
@@ -198,7 +179,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
deviceProfile: download,
download: true,
});
return { res, item };
});
const downloadDetails = await Promise.all(downloadDetailsPromises);
for (const { res, item } of downloadDetails) {
if (!res) {
Alert.alert(
t("home.downloads.something_went_wrong"),
@@ -206,11 +190,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
continue;
}
const { mediaSource: source, url } = res;
if (!url || !source) throw new Error("No url");
if (!url || !source) {
console.error(`Could not get download URL for ${item.Name}`);
toast.error(
t("Could not get download URL for {{itemName}}", {
itemName: item.Name,
}),
);
continue;
}
await startBackgroundDownload(url, item, source, maxBitrate);
}
},
@@ -227,6 +216,26 @@ export const DownloadItems: React.FC<DownloadProps> = ({
],
);
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsToDownload.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
initiateDownload(...itemsToDownload);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [
closeModal,
initiateDownload,
itemsToDownload,
userCanDownload,
]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -316,7 +325,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
<Text className='text-neutral-300'>
{subtitle ||
t("item_card.download.download_x_item", {
item_count: itemsNotDownloaded.length,
item_count: itemsToDownload.length,
})}
</Text>
</View>
@@ -326,6 +335,17 @@ export const DownloadItems: React.FC<DownloadProps> = ({
onChange={setMaxBitrate}
selected={maxBitrate}
/>
{itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'>
<Text>
{t("item_card.download.download_unwatched_only")}
</Text>
<Switch
onValueChange={setDownloadUnwatchedOnly}
value={downloadUnwatchedOnly}
/>
</View>
)}
{itemsNotDownloaded.length === 1 && (
<>
<MediaSourceSelector
@@ -350,6 +370,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
</>
)}
</View>
<Button
className='mt-auto'
onPress={acceptDownloadOptions}

View File

@@ -18,7 +18,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
selected,
...props
}) => {
if (Platform.isTV) return null;
const { t } = useTranslation();
const subtitleStreams = useMemo(() => {
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]);
@@ -28,9 +28,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
[subtitleStreams, selected],
);
if (subtitleStreams?.length === 0) return null;
const { t } = useTranslation();
if (Platform.isTV || subtitleStreams?.length === 0) return null;
return (
<View

View File

@@ -65,24 +65,23 @@ interface DownloadCardProps extends TouchableOpacityProps {
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, removeProcess } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const cancelJobMutation = useMutation({
mutationFn: async (id: string) => {
if (!process) throw new Error("No active download");
const tasks = await BackGroundDownloader.checkForExistingDownloads();
try {
const tasks = await BackGroundDownloader.checkForExistingDownloads();
for (const task of tasks) {
if (task.id === id) {
task.stop();
}
for (const task of tasks) {
if (task.id === id) {
await task.stop();
}
} finally {
await removeProcess(id);
}
removeProcess(id);
},
onSuccess: () => {
toast.success(t("home.downloads.toasts.download_cancelled"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
},
onError: (e) => {
console.error(e);

View File

@@ -86,7 +86,8 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
userId: user.Id,
seasonId: selectedSeasonId,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
// Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
if (res.data.TotalRecordCount === 0)
@@ -97,6 +98,10 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
return res.data.Items;
},
select: (data) =>
[...(data || [])].sort(
(a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});

View File

@@ -527,9 +527,8 @@ export const Controls: FC<Props> = ({
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${time.minutes < 10 ? `0${time.minutes}` : time.minutes}:${
time.seconds < 10 ? `0${time.seconds}` : time.seconds
}`}
{`${time.hours > 0 ? `${time.hours}:` : ""}${time.minutes < 10 ? `0${time.minutes}` : time.minutes}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds
}`}
</Text>
</View>
);
@@ -644,10 +643,9 @@ export const Controls: FC<Props> = ({
color='white'
/>
</TouchableOpacity>
{/* )} */}
<TouchableOpacity
onPress={onClose}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
className='aspect-square flex flex-col l items-center justify-center p-2'
>
<Ionicons name='close' size={24} color='white' />
</TouchableOpacity>
@@ -822,19 +820,19 @@ export const Controls: FC<Props> = ({
/>
{(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount <
settings.maxAutoPlayEpisodeCount.value) && (
<NextEpisodeCountDownButton
show={
!nextItem
? false
: isVlc
? remainingTime < 10000
: remainingTime < 10
}
onFinish={handleNextEpisodeAutoPlay}
onPress={handleNextEpisodeManual}
/>
)}
settings.maxAutoPlayEpisodeCount.value) && (
<NextEpisodeCountDownButton
show={
!nextItem
? false
: isVlc
? remainingTime < 10000
: remainingTime < 10
}
onFinish={handleNextEpisodeAutoPlay}
onPress={handleNextEpisodeManual}
/>
)}
</View>
</View>
<View

View File

@@ -6,6 +6,7 @@ import { useGlobalSearchParams } from "expo-router";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import {
HorizontalScroll,
@@ -183,7 +184,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
}
return (
<View
<SafeAreaView
style={{
position: "absolute",
backgroundColor: "black",
@@ -211,10 +212,8 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
/>
)}
<TouchableOpacity
onPress={async () => {
close();
}}
className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2'
onPress={close}
className='aspect-square flex flex-col l items-center justify-center p-2'
>
<Ionicons name='close' size={24} color='white' />
</TouchableOpacity>
@@ -228,9 +227,8 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
<View
key={_item.Id}
style={{}}
className={`flex flex-col w-44 ${
item.Id !== _item.Id ? "opacity-75" : ""
}`}
className={`flex flex-col w-44 ${item.Id !== _item.Id ? "opacity-75" : ""
}`}
>
<TouchableOpacity
onPress={() => {
@@ -260,11 +258,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
{runtimeTicksToSeconds(_item.RunTimeTicks)}
</Text>
</View>
{!isOffline && (
<View className='self-start mt-2'>
<DownloadSingleItem item={_item} />
</View>
)}
<Text numberOfLines={5} className='text-xs text-neutral-500 shrink'>
{_item.Overview}
</Text>
@@ -274,6 +267,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
estimatedItemSize={200}
showsHorizontalScrollIndicator={false}
/>
</View>
</SafeAreaView>
);
};

View File

@@ -13,23 +13,18 @@ export const useItemQuery = (itemId: string, isOffline: boolean) => {
queryKey: ["item", itemId],
queryFn: async () => {
if (isOffline) {
const downloadedItem = downloadedFiles?.find(
(item) => item.item.Id === itemId,
);
const downloadedItem = downloadedFiles?.find((item) => item.item.Id === itemId);
if (downloadedItem) return downloadedItem.item;
return null;
}
if (!api || !user || !itemId) return null;
const res = await getUserLibraryApi(api).getItem({
itemId: itemId,
userId: user?.Id,
});
const res = await getUserLibraryApi(api).getItem({ itemId: itemId, userId: user?.Id });
return res.data;
},
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
networkMode: isOffline ? "online" : "always",
networkMode: "always",
});
};

View File

@@ -124,6 +124,8 @@ export const usePlaybackManager = () => {
UserData: {
...localItem.item.UserData,
Played: true,
PlaybackPositionTicks: 0,
PlayedPercentage: 0,
LastPlayedDate: new Date().toISOString(),
},
},
@@ -169,6 +171,7 @@ export const usePlaybackManager = () => {
...localItem.item.UserData,
Played: false,
PlaybackPositionTicks: 0,
PlayedPercentage: 0,
LastPlayedDate: new Date().toISOString(), // Keep track of when it was marked unplayed
},
},

View File

@@ -9,13 +9,13 @@ import * as FileSystem from "expo-file-system";
import * as Notifications from "expo-notifications";
import { router } from "expo-router";
import { atom, useAtom } from "jotai";
import type React from "react";
import {
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { AppState, type AppStateStatus } from "react-native";
@@ -76,9 +76,6 @@ const calculateSpeed = (
export const processesAtom = atom<JobStatus[]>([]);
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
function onAppStateChange(status: AppStateStatus) {
focusManager.setFocused(status === "active");
}
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
@@ -108,12 +105,11 @@ function useDownloadProvider() {
});
const currentProcesses = [...processes, ...missingProcesses];
const updatedProcesses = currentProcesses.map((p) => {
// fallback. Doesn't really work for transcodes as they may be a lot smaller.
// We make an wild guess by comparing bitrates
const task = tasks.find((s) => s.id === p.id);
if (task) {
if (task && p.status === "downloading") {
const estimatedSize = calculateEstimatedSize(p);
let progress = p.progress;
if (estimatedSize > 0) {
@@ -168,9 +164,9 @@ function useDownloadProvider() {
prev.map((p) =>
p.id === processId
? {
...p,
...newStatus,
}
...p,
...newStatus,
}
: p,
),
);
@@ -205,18 +201,9 @@ function useDownloadProvider() {
networkMode: "always",
});
useEffect(() => {
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => subscription.remove();
}, []);
const removeProcess = useCallback(
async (id: string) => {
setProcesses((prev) => prev.filter((process) => process.id !== id));
},
[setProcesses],
);
const removeProcess = useCallback((id: string) => {
setProcesses((prev) => prev.filter((process) => process.id !== id));
}, [setProcesses]);
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
@@ -309,7 +296,7 @@ function useDownloadProvider() {
});
BackGroundDownloader?.setConfig({
isLogsEnabled: true,
isLogsEnabled: false,
progressInterval: 500,
headers: {
Authorization: authHeader,
@@ -331,11 +318,10 @@ function useDownloadProvider() {
})
.progress((data) => {
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
console.log("Progress:", percent);
updateProcess(process.id, {
speed: undefined,
status: "downloading",
progress: 50,
progress: percent,
});
})
.done(async () => {
@@ -409,11 +395,19 @@ function useDownloadProvider() {
);
BackGroundDownloader.completeHandler(process.id);
removeProcess(process.id);
const itemName =
process.item.Type === "Episode" &&
process.item.SeriesName &&
process.item.ParentIndexNumber != null &&
process.item.IndexNumber != null
? `${process.item.SeriesName} - S${String(process.item.ParentIndexNumber).padStart(2, "0")}E${String(process.item.IndexNumber).padStart(2, "0")} - ${process.item.Name}`
: process.item.Name;
await Notifications.scheduleNotificationAsync({
content: {
title: t("home.downloads.toasts.download_completed"),
body: t("home.downloads.toasts.download_completed_for_item", {
item: process.item.Name,
item: itemName,
}),
data: {
url: `/items/${process.item.Id}`,
@@ -436,10 +430,10 @@ function useDownloadProvider() {
);
const manageDownloadQueue = useCallback(() => {
const activeDownloads = processes.filter(
(p) => p.status === "downloading",
).length;
const activeDownloads = processes.filter((p) => p.status === "downloading").length;
const concurrentLimit = settings?.remuxConcurrentLimit || 1;
console.log("processes", processes.map((p) => p.status));
if (activeDownloads < concurrentLimit) {
const queuedDownload = processes.find((p) => p.status === "queued");
if (queuedDownload) {
@@ -582,7 +576,7 @@ function useDownloadProvider() {
const deleteItems = async (items: BaseItemDto[]) => {
for (const item of items) {
if (item.Id && (item.Type === "Movie" || item.Type === "Episode")) {
deleteFile(item.Id, item.Type);
await deleteFile(item.Id, item.Type);
}
}
};
@@ -671,7 +665,6 @@ function useDownloadProvider() {
deleteFile,
deleteItems,
removeProcess,
setProcesses,
startDownload,
deleteFileByType,
getDownloadedItemSize,

View File

@@ -408,6 +408,7 @@
"download_episode": "Download Episode",
"download_movie": "Download Movie",
"download_x_item": "Download {{item_count}} items",
"download_unwatched_only": "Unwatched Only",
"download_button": "Download",
"using_optimized_server": "Using optimized server",
"using_default_method": "Using default method"