forked from Ninjalama/streamyfin_mirror
wip
This commit is contained in:
@@ -47,6 +47,7 @@ import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "react-native-reanimated";
|
||||
import { Toaster } from "sonner-native";
|
||||
import { NativeDownloadProvider } from "@/providers/NativeDownloadProvider";
|
||||
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
@@ -321,52 +322,54 @@ function Layout() {
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style="light" hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
<NativeDownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style="light" hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</NativeDownloadProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
|
||||
@@ -14,20 +14,11 @@ import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import RNBackgroundDownloader, {
|
||||
DownloadTaskState,
|
||||
} from "@kesha-antonov/react-native-background-downloader";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { ActivityIndicator, View, ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
@@ -36,20 +27,8 @@ import { Text } from "./common/Text";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
|
||||
import {
|
||||
downloadHLSAsset,
|
||||
useDownloadProgress,
|
||||
useDownloadError,
|
||||
useDownloadComplete,
|
||||
addCompleteListener,
|
||||
addErrorListener,
|
||||
addProgressListener,
|
||||
checkForExistingDownloads,
|
||||
} from "@/modules/hls-downloader";
|
||||
import { useNativeDownloads } from "@/providers/NativeDownloadProvider";
|
||||
|
||||
interface NativeDownloadButton extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
@@ -58,13 +37,6 @@ interface NativeDownloadButton extends ViewProps {
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
type DownloadState = {
|
||||
id: string;
|
||||
progress: number;
|
||||
state: DownloadTaskState;
|
||||
metadata?: {};
|
||||
};
|
||||
|
||||
export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
|
||||
item,
|
||||
title = "Download",
|
||||
@@ -75,10 +47,7 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [settings] = useSettings();
|
||||
|
||||
const [activeDownload, setActiveDownload] = useState<
|
||||
DownloadState | undefined
|
||||
>(undefined);
|
||||
const { downloads, startDownload } = useNativeDownloads();
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined | null
|
||||
@@ -118,69 +87,27 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
|
||||
if (userCanDownload === true) {
|
||||
closeModal();
|
||||
|
||||
console.log({
|
||||
selectedAudioStream,
|
||||
selectedMediaSource,
|
||||
selectedSubtitleStream,
|
||||
maxBitrate,
|
||||
item,
|
||||
});
|
||||
try {
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: 0,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: selectedAudioStream,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
mediaSourceId: selectedMediaSource?.Id,
|
||||
subtitleStreamIndex: selectedSubtitleStream,
|
||||
deviceProfile: download,
|
||||
});
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: 0,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: selectedAudioStream,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
mediaSourceId: selectedMediaSource?.Id,
|
||||
subtitleStreamIndex: selectedSubtitleStream,
|
||||
deviceProfile: download,
|
||||
});
|
||||
|
||||
console.log("acceptDownloadOptions ~", res);
|
||||
|
||||
if (!res?.url) throw new Error("No url found");
|
||||
|
||||
if (res.url.includes("master.m3u8")) {
|
||||
// TODO: Download with custom native module
|
||||
console.log("TODO: Download with custom native module");
|
||||
if (!res?.url) throw new Error("No url found");
|
||||
if (!item.Id || !item.Name) throw new Error("No item id found");
|
||||
downloadHLSAsset(item.Id, res.url, item.Name);
|
||||
} else {
|
||||
// Download with reac-native-background-downloader
|
||||
const destination = `${FileSystem.documentDirectory}${item.Name}.mkv`;
|
||||
const jobId = item.Id!;
|
||||
|
||||
try {
|
||||
RNBackgroundDownloader.download({
|
||||
id: jobId,
|
||||
url: res.url,
|
||||
destination,
|
||||
})
|
||||
.begin(({ expectedBytes, headers }) => {
|
||||
console.log(`Starting download of ${expectedBytes} bytes`);
|
||||
toast.success("Download started");
|
||||
setActiveDownload({
|
||||
id: jobId,
|
||||
progress: 0,
|
||||
state: "DOWNLOADING",
|
||||
});
|
||||
})
|
||||
.progress(({ bytesDownloaded, bytesTotal }) =>
|
||||
console.log(`Downloaded: ${bytesDownloaded} of ${bytesTotal}`)
|
||||
)
|
||||
.done(({ bytesDownloaded, bytesTotal }) => {
|
||||
console.log("Download completed:", bytesDownloaded, bytesTotal);
|
||||
|
||||
RNBackgroundDownloader.completeHandler(jobId);
|
||||
})
|
||||
.error(({ error, errorCode }) =>
|
||||
console.error("Download error:", error)
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error ~", error);
|
||||
}
|
||||
await startDownload(item, res.url);
|
||||
toast.success("Download started");
|
||||
} catch (error) {
|
||||
console.error("Download error:", error);
|
||||
toast.error("Failed to start download");
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
@@ -195,87 +122,11 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
item,
|
||||
user,
|
||||
api,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const progressListener = addProgressListener((_item) => {
|
||||
console.log("progress ~", item);
|
||||
if (item.Id !== _item.id) return;
|
||||
setActiveDownload((prev) => {
|
||||
if (!prev) return undefined;
|
||||
return {
|
||||
...prev,
|
||||
progress: _item.progress,
|
||||
state: _item.state,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
checkForExistingDownloads().then((downloads) => {
|
||||
console.log(
|
||||
"AVAssetDownloadURLSession ~ checkForExistingDownloads ~",
|
||||
downloads
|
||||
);
|
||||
|
||||
const firstDownload = downloads?.[0];
|
||||
|
||||
if (!firstDownload) return;
|
||||
if (firstDownload.id !== item.Id) return;
|
||||
|
||||
setActiveDownload({
|
||||
id: firstDownload?.id,
|
||||
progress: firstDownload?.progress,
|
||||
state: firstDownload?.state,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
progressListener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// console.log(progress);
|
||||
|
||||
// // setActiveDownload({
|
||||
// // id: activeDownload?.id!,
|
||||
// // progress,
|
||||
// // state: "DOWNLOADING",
|
||||
// // });
|
||||
// }, [progress]);
|
||||
|
||||
useEffect(() => {
|
||||
RNBackgroundDownloader.checkForExistingDownloads().then((downloads) => {
|
||||
console.log(
|
||||
"RNBackgroundDownloader ~ checkForExistingDownloads ~",
|
||||
downloads
|
||||
);
|
||||
const e = downloads?.[0];
|
||||
setActiveDownload({
|
||||
id: e?.id,
|
||||
progress: e?.bytesDownloaded / e?.bytesTotal,
|
||||
state: e?.state,
|
||||
});
|
||||
|
||||
e.progress(({ bytesDownloaded, bytesTotal }) => {
|
||||
console.log(`Downloaded: ${bytesDownloaded} of ${bytesTotal}`);
|
||||
setActiveDownload({
|
||||
id: e?.id,
|
||||
progress: bytesDownloaded / bytesTotal,
|
||||
state: e?.state,
|
||||
});
|
||||
});
|
||||
e.done(({ bytesDownloaded, bytesTotal }) => {
|
||||
console.log("Download completed:", bytesDownloaded, bytesTotal);
|
||||
setActiveDownload(undefined);
|
||||
});
|
||||
e.error(({ error, errorCode }) => {
|
||||
console.error("Download error:", error);
|
||||
setActiveDownload(undefined);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
@@ -300,25 +151,30 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
|
||||
[]
|
||||
);
|
||||
|
||||
const onButtonPress = () => {
|
||||
handlePresentModalPress();
|
||||
};
|
||||
const activeDownload = item.Id ? downloads[item.Id] : undefined;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
disabled={userCanDownload === false || activeDownload?.id !== undefined}
|
||||
disabled={userCanDownload === false || activeDownload !== undefined}
|
||||
size={size}
|
||||
onPress={onButtonPress}
|
||||
onPress={handlePresentModalPress}
|
||||
>
|
||||
{activeDownload && activeDownload?.progress > 0 ? (
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={activeDownload.progress * 100}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
{activeDownload ? (
|
||||
<>
|
||||
{activeDownload.state === "PENDING" && (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
)}
|
||||
{activeDownload.state === "DOWNLOADING" && (
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={activeDownload.progress * 100}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type EventSubscription } from "expo-modules-core";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type {
|
||||
DownloadMetadata,
|
||||
OnCompleteEventPayload,
|
||||
OnErrorEventPayload,
|
||||
OnProgressEventPayload,
|
||||
@@ -14,9 +15,15 @@ import HlsDownloaderModule from "./src/HlsDownloaderModule";
|
||||
* @param id - A unique identifier for the download.
|
||||
* @param url - The HLS stream URL.
|
||||
* @param assetTitle - A title for the asset.
|
||||
* @param destination - The destination path for the downloaded asset.
|
||||
*/
|
||||
function downloadHLSAsset(id: string, url: string, assetTitle: string): void {
|
||||
HlsDownloaderModule.downloadHLSAsset(id, url, assetTitle);
|
||||
function downloadHLSAsset(
|
||||
id: string,
|
||||
url: string,
|
||||
assetTitle: string,
|
||||
metadata: DownloadMetadata
|
||||
): void {
|
||||
HlsDownloaderModule.downloadHLSAsset(id, url, assetTitle, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,63 +1,72 @@
|
||||
// ios/HlsDownloaderModule.swift
|
||||
import ExpoModulesCore
|
||||
import AVFoundation
|
||||
|
||||
public class HlsDownloaderModule: Module {
|
||||
var activeDownloads: [Int: (task: AVAssetDownloadTask, delegate: HLSDownloadDelegate)] = [:]
|
||||
var activeDownloads: [Int: (task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any])] = [:]
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("HlsDownloader")
|
||||
|
||||
|
||||
Events("onProgress", "onError", "onComplete")
|
||||
|
||||
Function("downloadHLSAsset") { (providedId: String, url: String, assetTitle: String) -> Void in
|
||||
print("Starting download - ID: \(providedId), URL: \(url), Title: \(assetTitle)")
|
||||
|
||||
Function("downloadHLSAsset") { (providedId: String, url: String, assetTitle: String, metadata: [String: Any]?) -> Void in
|
||||
print("Starting download - ID: \(providedId), URL: \(url), Title: \(assetTitle), Metadata: \(String(describing: metadata))")
|
||||
|
||||
guard let assetURL = URL(string: url) else {
|
||||
self.sendEvent("onError", ["id": providedId, "error": "Invalid URL", "state": "FAILED"])
|
||||
self.sendEvent("onError", [
|
||||
"id": providedId,
|
||||
"error": "Invalid URL",
|
||||
"state": "FAILED",
|
||||
"metadata": metadata ?? [:]
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let asset = AVURLAsset(url: assetURL)
|
||||
let configuration = URLSessionConfiguration.background(withIdentifier: "com.example.hlsdownload")
|
||||
let delegate = HLSDownloadDelegate(module: self)
|
||||
delegate.providedId = providedId
|
||||
|
||||
let downloadSession = AVAssetDownloadURLSession(
|
||||
configuration: configuration,
|
||||
assetDownloadDelegate: delegate,
|
||||
delegateQueue: OperationQueue.main
|
||||
)
|
||||
|
||||
|
||||
guard let task = downloadSession.makeAssetDownloadTask(
|
||||
asset: asset,
|
||||
assetTitle: assetTitle,
|
||||
assetArtworkData: nil,
|
||||
options: nil
|
||||
) else {
|
||||
self.sendEvent("onError", ["id": providedId, "error": "Failed to create download task", "state": "FAILED"])
|
||||
self.sendEvent("onError", [
|
||||
"id": providedId,
|
||||
"error": "Failed to create download task",
|
||||
"state": "FAILED",
|
||||
"metadata": metadata ?? [:]
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
delegate.taskIdentifier = task.taskIdentifier
|
||||
self.activeDownloads[task.taskIdentifier] = (task, delegate)
|
||||
|
||||
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:])
|
||||
self.sendEvent("onProgress", [
|
||||
"id": providedId,
|
||||
"id": providedId,
|
||||
"progress": 0.0,
|
||||
"state": "PENDING"
|
||||
])
|
||||
|
||||
"state": "PENDING",
|
||||
"metadata": metadata ?? [:]
|
||||
])
|
||||
|
||||
task.resume()
|
||||
print("Download task started with identifier: \(task.taskIdentifier)")
|
||||
}
|
||||
|
||||
|
||||
Function("checkForExistingDownloads") {
|
||||
() -> [[String: Any]] in
|
||||
var downloads: [[String: Any]] = []
|
||||
for (id, pair) in self.activeDownloads {
|
||||
let task = pair.task
|
||||
let delegate = pair.delegate
|
||||
let metadata = pair.metadata
|
||||
let downloaded = delegate.downloadedSeconds
|
||||
let total = delegate.totalSeconds
|
||||
let progress = total > 0 ? downloaded / total : 0
|
||||
@@ -66,20 +75,21 @@ public class HlsDownloaderModule: Module {
|
||||
"progress": progress,
|
||||
"bytesDownloaded": downloaded,
|
||||
"bytesTotal": total,
|
||||
"state": self.mappedState(for: task)
|
||||
])
|
||||
}
|
||||
"state": self.mappedState(for: task),
|
||||
"metadata": metadata
|
||||
])
|
||||
}
|
||||
return downloads
|
||||
}
|
||||
|
||||
|
||||
OnStartObserving { }
|
||||
OnStopObserving { }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func removeDownload(with id: Int) {
|
||||
activeDownloads.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
|
||||
func mappedState(for task: URLSessionTask, errorOccurred: Bool = false) -> String {
|
||||
if errorOccurred { return "FAILED" }
|
||||
switch task.state {
|
||||
@@ -98,54 +108,55 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
||||
var providedId: String = ""
|
||||
var downloadedSeconds: Double = 0
|
||||
var totalSeconds: Double = 0
|
||||
|
||||
init(module: HlsDownloaderModule) {
|
||||
self.module = module
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
|
||||
didLoad timeRange: CMTimeRange,
|
||||
totalTimeRangesLoaded loadedTimeRanges: [NSValue],
|
||||
timeRangeExpectedToLoad: CMTimeRange) {
|
||||
var loadedSeconds = 0.0
|
||||
for value in loadedTimeRanges {
|
||||
loadedSeconds += CMTimeGetSeconds(value.timeRangeValue.duration)
|
||||
|
||||
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) {
|
||||
let downloaded = loadedTimeRanges.reduce(0.0) { total, value in
|
||||
let timeRange = value.timeRangeValue
|
||||
return total + CMTimeGetSeconds(timeRange.duration)
|
||||
}
|
||||
|
||||
let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
|
||||
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
|
||||
|
||||
self.downloadedSeconds = downloaded
|
||||
self.totalSeconds = total
|
||||
|
||||
let progress = total > 0 ? downloaded / total : 0
|
||||
|
||||
module?.sendEvent("onProgress", [
|
||||
"id": providedId,
|
||||
"progress": progress,
|
||||
"bytesDownloaded": downloaded,
|
||||
"bytesTotal": total,
|
||||
"state": progress >= 1.0 ? "DONE" : "DOWNLOADING",
|
||||
"metadata": metadata
|
||||
])
|
||||
}
|
||||
let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
|
||||
downloadedSeconds = loadedSeconds
|
||||
totalSeconds = total
|
||||
let progress = total > 0 ? loadedSeconds / total : 0
|
||||
let state = module?.mappedState(for: assetDownloadTask) ?? "PENDING"
|
||||
|
||||
module?.sendEvent("onProgress", [
|
||||
"id": providedId,
|
||||
"progress": progress,
|
||||
"bytesDownloaded": loadedSeconds,
|
||||
"bytesTotal": total,
|
||||
"state": state
|
||||
])
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let error = error {
|
||||
let state = module?.mappedState(for: task, errorOccurred: true) ?? "FAILED"
|
||||
module?.sendEvent("onError", [
|
||||
"id": providedId,
|
||||
"error": error.localizedDescription,
|
||||
"state": state
|
||||
])
|
||||
|
||||
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
|
||||
module?.sendEvent("onComplete", [
|
||||
"id": providedId,
|
||||
"location": location.absoluteString,
|
||||
"state": "DONE",
|
||||
"metadata": metadata
|
||||
])
|
||||
module?.removeDownload(with: assetDownloadTask.taskIdentifier)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let error = error {
|
||||
let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:]
|
||||
module?.sendEvent("onError", [
|
||||
"id": providedId,
|
||||
"error": error.localizedDescription,
|
||||
"state": "FAILED",
|
||||
"metadata": metadata
|
||||
])
|
||||
module?.removeDownload(with: taskIdentifier)
|
||||
}
|
||||
}
|
||||
module?.removeDownload(with: task.taskIdentifier)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
|
||||
didFinishDownloadingTo location: URL) {
|
||||
let state = module?.mappedState(for: assetDownloadTask) ?? "DONE"
|
||||
module?.sendEvent("onComplete", [
|
||||
"id": providedId,
|
||||
"location": location.absoluteString,
|
||||
"state": state
|
||||
])
|
||||
module?.removeDownload(with: assetDownloadTask.taskIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
export type OnProgressEventPayload = {
|
||||
progress: number;
|
||||
state: "PENDING" | "DOWNLOADING" | "PAUSED" | "DONE" | "FAILED" | "STOPPED";
|
||||
export type DownloadState =
|
||||
| "PENDING"
|
||||
| "DOWNLOADING"
|
||||
| "PAUSED"
|
||||
| "DONE"
|
||||
| "FAILED"
|
||||
| "STOPPED";
|
||||
|
||||
export interface DownloadMetadata {
|
||||
Name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type BaseEventPayload = {
|
||||
id: string;
|
||||
state: DownloadState;
|
||||
metadata?: DownloadMetadata;
|
||||
};
|
||||
|
||||
export type OnProgressEventPayload = BaseEventPayload & {
|
||||
progress: number;
|
||||
bytesDownloaded: number;
|
||||
bytesTotal: number;
|
||||
};
|
||||
|
||||
export type OnErrorEventPayload = {
|
||||
export type OnErrorEventPayload = BaseEventPayload & {
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type OnCompleteEventPayload = {
|
||||
export type OnCompleteEventPayload = BaseEventPayload & {
|
||||
location: string;
|
||||
};
|
||||
|
||||
@@ -19,3 +36,15 @@ export type HlsDownloaderModuleEvents = {
|
||||
onError: (params: OnErrorEventPayload) => void;
|
||||
onComplete: (params: OnCompleteEventPayload) => void;
|
||||
};
|
||||
|
||||
// Export a common interface that can be used by both HLS and regular downloads
|
||||
export interface DownloadInfo {
|
||||
id: string;
|
||||
progress: number;
|
||||
state: DownloadState;
|
||||
bytesDownloaded?: number;
|
||||
bytesTotal?: number;
|
||||
location?: string;
|
||||
error?: string;
|
||||
metadata?: DownloadMetadata;
|
||||
}
|
||||
|
||||
255
providers/NativeDownloadProvider.tsx
Normal file
255
providers/NativeDownloadProvider.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import RNBackgroundDownloader, {
|
||||
DownloadTaskState,
|
||||
} from "@kesha-antonov/react-native-background-downloader";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
addCompleteListener,
|
||||
addErrorListener,
|
||||
addProgressListener,
|
||||
checkForExistingDownloads,
|
||||
downloadHLSAsset,
|
||||
} from "@/modules/hls-downloader";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types";
|
||||
|
||||
type DownloadContextType = {
|
||||
downloads: Record<string, DownloadInfo>;
|
||||
startDownload: (item: BaseItemDto, url: string) => Promise<void>;
|
||||
cancelDownload: (id: string) => void;
|
||||
};
|
||||
|
||||
const DownloadContext = createContext<DownloadContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const persistDownloadedFile = async (
|
||||
originalLocation: string,
|
||||
fileName: string
|
||||
) => {
|
||||
const destinationDir = `${FileSystem.documentDirectory}downloads/`;
|
||||
const newLocation = `${destinationDir}${fileName}`;
|
||||
|
||||
try {
|
||||
// Ensure the downloads directory exists
|
||||
await FileSystem.makeDirectoryAsync(destinationDir, {
|
||||
intermediates: true,
|
||||
});
|
||||
|
||||
// Move the file to its final destination
|
||||
await FileSystem.moveAsync({
|
||||
from: originalLocation,
|
||||
to: newLocation,
|
||||
});
|
||||
|
||||
return newLocation;
|
||||
} catch (error) {
|
||||
console.error("Error persisting file:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const NativeDownloadProvider: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const [downloads, setDownloads] = useState<Record<string, DownloadInfo>>({});
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize downloads from both HLS and regular downloads
|
||||
const initializeDownloads = async () => {
|
||||
// Check HLS downloads
|
||||
const hlsDownloads = await checkForExistingDownloads();
|
||||
const hlsDownloadStates = hlsDownloads.reduce(
|
||||
(acc, download) => ({
|
||||
...acc,
|
||||
[download.id]: {
|
||||
id: download.id,
|
||||
progress: download.progress,
|
||||
state: download.state,
|
||||
},
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
// Check regular downloads
|
||||
const regularDownloads =
|
||||
await RNBackgroundDownloader.checkForExistingDownloads();
|
||||
const regularDownloadStates = regularDownloads.reduce(
|
||||
(acc, download) => ({
|
||||
...acc,
|
||||
[download.id]: {
|
||||
id: download.id,
|
||||
progress: download.bytesDownloaded / download.bytesTotal,
|
||||
state: download.state,
|
||||
},
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
setDownloads({ ...hlsDownloadStates, ...regularDownloadStates });
|
||||
};
|
||||
|
||||
initializeDownloads();
|
||||
|
||||
// Set up HLS download listeners
|
||||
const progressListener = addProgressListener((download) => {
|
||||
console.log("[HLS] Download progress:", download);
|
||||
setDownloads((prev) => ({
|
||||
...prev,
|
||||
[download.id]: {
|
||||
id: download.id,
|
||||
progress: download.progress,
|
||||
state: download.state,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const completeListener = addCompleteListener(async (payload) => {
|
||||
if (typeof payload === "string") {
|
||||
// Handle string ID (old HLS downloads)
|
||||
setDownloads((prev) => {
|
||||
const newDownloads = { ...prev };
|
||||
delete newDownloads[payload];
|
||||
return newDownloads;
|
||||
});
|
||||
} else {
|
||||
// Handle OnCompleteEventPayload (with location)
|
||||
console.log("Download complete event received:", payload);
|
||||
console.log("Original download location:", payload.location);
|
||||
|
||||
try {
|
||||
// Get the download info from our state
|
||||
const downloadInfo = downloads[payload.id];
|
||||
if (downloadInfo?.metadata?.Name) {
|
||||
const newLocation = await persistDownloadedFile(
|
||||
payload.location,
|
||||
downloadInfo.metadata.Name
|
||||
);
|
||||
console.log("File successfully persisted to:", newLocation);
|
||||
} else {
|
||||
console.log("No filename in metadata, using original location");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to persist file:", error);
|
||||
}
|
||||
|
||||
setDownloads((prev) => {
|
||||
const newDownloads = { ...prev };
|
||||
delete newDownloads[payload.id];
|
||||
return newDownloads;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const errorListener = addErrorListener((error) => {
|
||||
console.error("Download error:", error);
|
||||
if (error.id) {
|
||||
setDownloads((prev) => {
|
||||
const newDownloads = { ...prev };
|
||||
delete newDownloads[error.id];
|
||||
return newDownloads;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
progressListener.remove();
|
||||
completeListener.remove();
|
||||
errorListener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startDownload = async (item: BaseItemDto, url: string) => {
|
||||
if (!item.Id || !item.Name) throw new Error("Item ID or Name is missing");
|
||||
const jobId = item.Id;
|
||||
|
||||
if (url.includes("master.m3u8")) {
|
||||
// HLS download
|
||||
downloadHLSAsset(jobId, url, item.Name, {
|
||||
Name: item.Name,
|
||||
});
|
||||
} else {
|
||||
// Regular download
|
||||
try {
|
||||
const task = RNBackgroundDownloader.download({
|
||||
id: jobId,
|
||||
url: url,
|
||||
destination: `${FileSystem.documentDirectory}${jobId}`,
|
||||
});
|
||||
|
||||
task.begin(({ expectedBytes }) => {
|
||||
setDownloads((prev) => ({
|
||||
...prev,
|
||||
[jobId]: {
|
||||
id: jobId,
|
||||
progress: 0,
|
||||
state: "DOWNLOADING",
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
task.progress(({ bytesDownloaded, bytesTotal }) => {
|
||||
console.log(
|
||||
"[Normal] Download progress:",
|
||||
bytesDownloaded,
|
||||
bytesTotal
|
||||
);
|
||||
setDownloads((prev) => ({
|
||||
...prev,
|
||||
[jobId]: {
|
||||
id: jobId,
|
||||
progress: bytesDownloaded / bytesTotal,
|
||||
state: "DOWNLOADING",
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
task.done(() => {
|
||||
setDownloads((prev) => {
|
||||
const newDownloads = { ...prev };
|
||||
delete newDownloads[jobId];
|
||||
return newDownloads;
|
||||
});
|
||||
});
|
||||
|
||||
task.error(({ error }) => {
|
||||
console.error("Download error:", error);
|
||||
setDownloads((prev) => {
|
||||
const newDownloads = { ...prev };
|
||||
delete newDownloads[jobId];
|
||||
return newDownloads;
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error starting download:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDownload = (id: string) => {
|
||||
// Implement cancel logic here
|
||||
setDownloads((prev) => {
|
||||
const newDownloads = { ...prev };
|
||||
delete newDownloads[id];
|
||||
return newDownloads;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DownloadContext.Provider
|
||||
value={{ downloads, startDownload, cancelDownload }}
|
||||
>
|
||||
{children}
|
||||
</DownloadContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNativeDownloads = () => {
|
||||
const context = useContext(DownloadContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useDownloads must be used within a NativeDownloadProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
Reference in New Issue
Block a user