This commit is contained in:
Fredrik Burmester
2025-02-15 17:20:46 +01:00
parent 4644d2ab58
commit a25c01b7a8
9 changed files with 384 additions and 73 deletions

View File

@@ -36,6 +36,8 @@ import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { AddToFavorites } from "./AddToFavorites";
import { NativeDownloadButton } from "./NativeDownloadButton";
import { Ionicons } from "@expo/vector-icons";
export type SelectedOptions = {
bitrate: Bitrate;
@@ -95,7 +97,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
/>
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<DownloadSingleItem item={item} size="large" />
{/* <DownloadSingleItem item={item} size="large" /> */}
<NativeDownloadButton
size={"large"}
title={"Download"}
subtitle={item.Name!}
item={item}
/>
<PlayedStatus items={[item]} size="large" />
<AddToFavorites item={item} type="item" />
</View>

View File

@@ -0,0 +1,354 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
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 { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
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,
} from "@/modules/expo-hls-downloader";
interface NativeDownloadButton extends ViewProps {
item: BaseItemDto;
title?: string;
subtitle?: string;
size?: "default" | "large";
}
type DownloadState = {
id: string;
bytesDownloaded: number;
bytesTotal: number;
state: DownloadTaskState;
metadata?: {};
};
export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
item,
title = "Download",
subtitle = "",
size = "default",
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings] = useSettings();
const [activeDownload, setActiveDownload] = useState<
DownloadState | undefined
>(undefined);
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined | null
>(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
settings?.defaultBitrate ?? {
key: "Max",
value: undefined,
}
);
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,
[user]
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings]
);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const handlePresentModalPress = useCallback(() => {
bottomSheetModalRef.current?.present();
}, []);
const handleSheetChanges = useCallback((index: number) => {}, []);
const closeModal = useCallback(() => {
bottomSheetModalRef.current?.dismiss();
}, []);
const progress = useDownloadProgress();
const complete = useDownloadComplete("download");
const downloadError = useDownloadError();
const acceptDownloadOptions = useCallback(async () => {
if (userCanDownload === true) {
closeModal();
console.log({
selectedAudioStream,
selectedMediaSource,
selectedSubtitleStream,
maxBitrate,
item,
});
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
downloadHLSAsset(
res.url,
`${FileSystem.documentDirectory}${item.Name}.mkv`
);
console.log("TODO: Download with custom native module");
} 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,
bytesDownloaded: 0,
bytesTotal: expectedBytes,
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);
}
}
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files")
);
}
closeModal();
}, [
userCanDownload,
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
]);
useEffect(() => {
console.log(progress);
}, [progress]);
useEffect(() => {
RNBackgroundDownloader.checkForExistingDownloads().then((downloads) => {
console.log("checkForExistingDownloads ~", downloads);
const e = downloads?.[0];
setActiveDownload({
id: e?.id,
bytesDownloaded: e?.bytesDownloaded,
bytesTotal: e?.bytesTotal,
state: e?.state,
});
e.progress(({ bytesDownloaded, bytesTotal }) => {
console.log(`Downloaded: ${bytesDownloaded} of ${bytesTotal}`);
setActiveDownload({
id: e?.id,
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;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(item, settings);
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [item, settings])
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
const onButtonPress = () => {
handlePresentModalPress();
};
return (
<View {...props}>
<RoundButton
disabled={userCanDownload === false || activeDownload?.id !== undefined}
size={size}
onPress={onButtonPress}
>
{activeDownload &&
activeDownload?.bytesTotal > 0 &&
activeDownload?.bytesDownloaded > 0 ? (
<ProgressCircle
size={24}
fill={
(activeDownload.bytesDownloaded / activeDownload.bytesTotal) * 100
}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
) : (
<Ionicons name="cloud-download-outline" size={24} color="white" />
)}
</RoundButton>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">
{title}
</Text>
</View>
<View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector
inverted
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}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
)}
</View>
<Button
className="mt-auto"
onPress={acceptDownloadOptions}
color="purple"
>
{t("item_card.download.download_button")}
</Button>
<View className="opacity-70 text-center w-full flex items-center">
<Text className="text-xs">
{usingOptimizedServer
? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")}
</Text>
</View>
</View>
</BottomSheetView>
</BottomSheetModal>
</View>
);
};

View File

@@ -24,7 +24,7 @@ const ProgressCircle: React.FC<ProgressCircleProps> = ({
fill={fill}
tintColor={tintColor}
backgroundColor={backgroundColor}
rotation={45}
rotation={0}
/>
);
};

View File

@@ -14,7 +14,7 @@ import {
useDownloadComplete,
useDownloadError,
useDownloadProgress,
} from "../src/ExpoHlsDownloader";
} from "..";
/**
* Parses boot.xml in the root download directory to extract the stream folder name.

View File

@@ -6,15 +6,15 @@ import type {
OnCompleteEventPayload,
OnErrorEventPayload,
OnProgressEventPayload,
} from "./ExpoHlsDownloader.types";
import ExpoHlsDownloaderModule from "./ExpoHlsDownloaderModule";
} from "./src/ExpoHlsDownloader.types";
import ExpoHlsDownloaderModule from "./src/ExpoHlsDownloaderModule";
/**
* Initiates an HLS download.
* @param url - The HLS stream URL.
* @param assetTitle - A title for the asset.
*/
export function downloadHLSAsset(url: string, assetTitle: string): void {
function downloadHLSAsset(url: string, assetTitle: string): void {
ExpoHlsDownloaderModule.downloadHLSAsset(url, assetTitle);
}
@@ -23,7 +23,7 @@ export function downloadHLSAsset(url: string, assetTitle: string): void {
* @param listener A callback invoked with progress updates.
* @returns A subscription that can be removed.
*/
export function addProgressListener(
function addProgressListener(
listener: (event: OnProgressEventPayload) => void
): EventSubscription {
return ExpoHlsDownloaderModule.addListener("onProgress", listener);
@@ -34,7 +34,7 @@ export function addProgressListener(
* @param listener A callback invoked with error details.
* @returns A subscription that can be removed.
*/
export function addErrorListener(
function addErrorListener(
listener: (event: OnErrorEventPayload) => void
): EventSubscription {
return ExpoHlsDownloaderModule.addListener("onError", listener);
@@ -45,7 +45,7 @@ export function addErrorListener(
* @param listener A callback invoked when the download completes.
* @returns A subscription that can be removed.
*/
export function addCompleteListener(
function addCompleteListener(
listener: (event: OnCompleteEventPayload) => void
): EventSubscription {
return ExpoHlsDownloaderModule.addListener("onComplete", listener);
@@ -54,7 +54,7 @@ export function addCompleteListener(
/**
* React hook that returns the current download progress (01).
*/
export function useDownloadProgress(): number {
function useDownloadProgress(): number {
const [progress, setProgress] = useState(0);
useEffect(() => {
const subscription = addProgressListener((event) => {
@@ -68,7 +68,7 @@ export function useDownloadProgress(): number {
/**
* React hook that returns the latest download error (or null if none).
*/
export function useDownloadError(): string | null {
function useDownloadError(): string | null {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const subscription = addErrorListener((event) => {
@@ -111,9 +111,7 @@ async function persistDownloadedFile(
* @param destinationFileName Optional filename (with extension) to persist the file.
* @returns The final file URI or null if not completed.
*/
export function useDownloadComplete(
destinationFileName?: string
): string | null {
function useDownloadComplete(destinationFileName?: string): string | null {
const [location, setLocation] = useState<string | null>(null);
useEffect(() => {
@@ -161,3 +159,10 @@ export function useDownloadComplete(
return location;
}
export {
downloadHLSAsset,
useDownloadComplete,
useDownloadError,
useDownloadProgress,
};

View File

@@ -1,38 +0,0 @@
import ExpoModulesCore
import WebKit
// This view will be used as a native component. Make sure to inherit from `ExpoView`
// to apply the proper styling (e.g. border radius and shadows).
class ExpoHlsDownloaderView: ExpoView {
let webView = WKWebView()
let onLoad = EventDispatcher()
var delegate: WebViewDelegate?
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
clipsToBounds = true
delegate = WebViewDelegate { url in
self.onLoad(["url": url])
}
webView.navigationDelegate = delegate
addSubview(webView)
}
override func layoutSubviews() {
webView.frame = bounds
}
}
class WebViewDelegate: NSObject, WKNavigationDelegate {
let onUrlChange: (String) -> Void
init(onUrlChange: @escaping (String) -> Void) {
self.onUrlChange = onUrlChange
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation) {
if let url = webView.url {
onUrlChange(url.absoluteString)
}
}
}

View File

@@ -1,10 +1,3 @@
import { NativeModule, requireNativeModule } from "expo";
import { ExpoHlsDownloaderModuleEvents } from "./ExpoHlsDownloader.types";
import { requireNativeModule } from "expo";
declare class ExpoHlsDownloaderModule extends NativeModule<ExpoHlsDownloaderModuleEvents> {
downloadHLSAsset(url: string, assetTitle: string): void;
}
export default requireNativeModule<ExpoHlsDownloaderModule>(
"ExpoHlsDownloader"
);
export default requireNativeModule("ExpoHlsDownloader");

View File

@@ -1,11 +0,0 @@
import { requireNativeView } from 'expo';
import * as React from 'react';
import { ExpoHlsDownloaderViewProps } from './ExpoHlsDownloader.types';
const NativeView: React.ComponentType<ExpoHlsDownloaderViewProps> =
requireNativeView('ExpoHlsDownloader');
export default function ExpoHlsDownloaderView(props: ExpoHlsDownloaderViewProps) {
return <NativeView {...props} />;
}

View File

@@ -4,6 +4,6 @@
"compilerOptions": {
"outDir": "./build"
},
"include": ["./src"],
"include": ["./src", "index.ts"],
"exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
}