diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
index f39db05f..74ae7321 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -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" && (
-
+ {/* */}
+
diff --git a/components/NativeDownloadButton.tsx b/components/NativeDownloadButton.tsx
new file mode 100644
index 00000000..179c8871
--- /dev/null
+++ b/components/NativeDownloadButton.tsx
@@ -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 = ({
+ 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(-1);
+ const [selectedSubtitleStream, setSelectedSubtitleStream] =
+ useState(0);
+ const [maxBitrate, setMaxBitrate] = useState(
+ settings?.defaultBitrate ?? {
+ key: "Max",
+ value: undefined,
+ }
+ );
+
+ const userCanDownload = useMemo(
+ () => user?.Policy?.EnableContentDownloading,
+ [user]
+ );
+ const usingOptimizedServer = useMemo(
+ () => settings?.downloadMethod === DownloadMethod.Optimized,
+ [settings]
+ );
+
+ const bottomSheetModalRef = useRef(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) => (
+
+ ),
+ []
+ );
+
+ const onButtonPress = () => {
+ handlePresentModalPress();
+ };
+
+ return (
+
+
+ {activeDownload &&
+ activeDownload?.bytesTotal > 0 &&
+ activeDownload?.bytesDownloaded > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+ {selectedMediaSource && (
+
+
+
+
+ )}
+
+
+
+
+ {usingOptimizedServer
+ ? t("item_card.download.using_optimized_server")
+ : t("item_card.download.using_default_method")}
+
+
+
+
+
+
+ );
+};
diff --git a/components/ProgressCircle.tsx b/components/ProgressCircle.tsx
index 20c4fbd3..b4d5f5d3 100644
--- a/components/ProgressCircle.tsx
+++ b/components/ProgressCircle.tsx
@@ -24,7 +24,7 @@ const ProgressCircle: React.FC = ({
fill={fill}
tintColor={tintColor}
backgroundColor={backgroundColor}
- rotation={45}
+ rotation={0}
/>
);
};
diff --git a/modules/expo-hls-downloader/example/App.tsx b/modules/expo-hls-downloader/example/App.tsx
index 2f104caf..3835eb3f 100644
--- a/modules/expo-hls-downloader/example/App.tsx
+++ b/modules/expo-hls-downloader/example/App.tsx
@@ -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.
diff --git a/modules/expo-hls-downloader/src/ExpoHlsDownloader.ts b/modules/expo-hls-downloader/index.ts
similarity index 89%
rename from modules/expo-hls-downloader/src/ExpoHlsDownloader.ts
rename to modules/expo-hls-downloader/index.ts
index d3a285d9..d2ef4506 100644
--- a/modules/expo-hls-downloader/src/ExpoHlsDownloader.ts
+++ b/modules/expo-hls-downloader/index.ts
@@ -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 (0–1).
*/
-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(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(null);
useEffect(() => {
@@ -161,3 +159,10 @@ export function useDownloadComplete(
return location;
}
+
+export {
+ downloadHLSAsset,
+ useDownloadComplete,
+ useDownloadError,
+ useDownloadProgress,
+};
diff --git a/modules/expo-hls-downloader/ios/ExpoHlsDownloaderView.swift b/modules/expo-hls-downloader/ios/ExpoHlsDownloaderView.swift
deleted file mode 100644
index 179e76b1..00000000
--- a/modules/expo-hls-downloader/ios/ExpoHlsDownloaderView.swift
+++ /dev/null
@@ -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)
- }
- }
-}
diff --git a/modules/expo-hls-downloader/src/ExpoHlsDownloaderModule.ts b/modules/expo-hls-downloader/src/ExpoHlsDownloaderModule.ts
index 16688bfb..63f0899e 100644
--- a/modules/expo-hls-downloader/src/ExpoHlsDownloaderModule.ts
+++ b/modules/expo-hls-downloader/src/ExpoHlsDownloaderModule.ts
@@ -1,10 +1,3 @@
-import { NativeModule, requireNativeModule } from "expo";
-import { ExpoHlsDownloaderModuleEvents } from "./ExpoHlsDownloader.types";
+import { requireNativeModule } from "expo";
-declare class ExpoHlsDownloaderModule extends NativeModule {
- downloadHLSAsset(url: string, assetTitle: string): void;
-}
-
-export default requireNativeModule(
- "ExpoHlsDownloader"
-);
+export default requireNativeModule("ExpoHlsDownloader");
diff --git a/modules/expo-hls-downloader/src/ExpoHlsDownloaderView.tsx b/modules/expo-hls-downloader/src/ExpoHlsDownloaderView.tsx
deleted file mode 100644
index be3729e8..00000000
--- a/modules/expo-hls-downloader/src/ExpoHlsDownloaderView.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { requireNativeView } from 'expo';
-import * as React from 'react';
-
-import { ExpoHlsDownloaderViewProps } from './ExpoHlsDownloader.types';
-
-const NativeView: React.ComponentType =
- requireNativeView('ExpoHlsDownloader');
-
-export default function ExpoHlsDownloaderView(props: ExpoHlsDownloaderViewProps) {
- return ;
-}
diff --git a/modules/expo-hls-downloader/tsconfig.json b/modules/expo-hls-downloader/tsconfig.json
index cbe9e197..a980e417 100644
--- a/modules/expo-hls-downloader/tsconfig.json
+++ b/modules/expo-hls-downloader/tsconfig.json
@@ -4,6 +4,6 @@
"compilerOptions": {
"outDir": "./build"
},
- "include": ["./src"],
+ "include": ["./src", "index.ts"],
"exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
}