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__/*"] }