diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx
index 8cf6ad48..06669d56 100644
--- a/app/(auth)/(tabs)/(home)/downloads/index.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx
@@ -29,11 +29,10 @@ const formatETA = (seconds: number): string => {
};
const getETA = (download: DownloadInfo): string | null => {
- console.log("getETA", download);
if (
!download.startTime ||
- !download.bytesDownloaded ||
- !download.bytesTotal
+ !download.secondsDownloaded ||
+ !download.secondsTotal
) {
console.log(download);
return null;
@@ -41,12 +40,10 @@ const getETA = (download: DownloadInfo): string | null => {
const elapsed = Date.now() / 1000 - download.startTime; // seconds
- console.log("Elapsed (s):", Number(download.startTime), Date.now(), elapsed);
+ if (elapsed <= 0 || download.secondsDownloaded <= 0) return null;
- if (elapsed <= 0 || download.bytesDownloaded <= 0) return null;
-
- const speed = download.bytesDownloaded / elapsed; // bytes per second
- const remainingBytes = download.bytesTotal - download.bytesDownloaded;
+ const speed = download.secondsDownloaded / elapsed; // downloaded seconds per second
+ const remainingBytes = download.secondsTotal - download.secondsDownloaded;
if (speed <= 0) return null;
@@ -108,8 +105,8 @@ export default function Index() {
{activeDownloads.map((i) => {
const progress =
- i.bytesTotal && i.bytesDownloaded
- ? i.bytesDownloaded / i.bytesTotal
+ i.secondsTotal && i.secondsDownloaded
+ ? i.secondsDownloaded / i.secondsTotal
: 0;
const eta = getETA(i);
const item = i.metadata?.item;
@@ -130,11 +127,6 @@ export default function Index() {
{eta ? `${eta} remaining` : "Calculating time..."}
- {i.state === "DOWNLOADING" && i.bytesTotal ? (
-
- {formatBytes(i.bytesTotal * 100000)}
-
- ) : null}
diff --git a/components/downloads/NativeDownloadButton.tsx b/components/downloads/NativeDownloadButton.tsx
index ec34cbf7..94a184a6 100644
--- a/components/downloads/NativeDownloadButton.tsx
+++ b/components/downloads/NativeDownloadButton.tsx
@@ -15,11 +15,16 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
-import { useFocusEffect } from "expo-router";
+import { useFocusEffect, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
-import { ActivityIndicator, View, ViewProps } from "react-native";
+import {
+ ActivityIndicator,
+ TouchableOpacity,
+ View,
+ ViewProps,
+} from "react-native";
import { toast } from "sonner-native";
import { AudioTrackSelector } from "../AudioTrackSelector";
import { Bitrate, BitrateSelector } from "../BitrateSelector";
@@ -158,6 +163,8 @@ export const NativeDownloadButton: React.FC = ({
[]
);
+ const router = useRouter();
+
const activeDownload = item.Id ? downloads[item.Id] : undefined;
return (
@@ -168,7 +175,11 @@ export const NativeDownloadButton: React.FC = ({
onPress={handlePresentModalPress}
>
{activeDownload ? (
- <>
+ {
+ router.push(`/downloads`);
+ }}
+ >
{activeDownload.state === "PENDING" && (
)}
@@ -193,7 +204,7 @@ export const NativeDownloadButton: React.FC = ({
{activeDownload.state === "DONE" && (
)}
- >
+
) : (
)}
diff --git a/modules/hls-downloader/index.ts b/modules/hls-downloader/index.ts
index 62a2175c..25740d74 100644
--- a/modules/hls-downloader/index.ts
+++ b/modules/hls-downloader/index.ts
@@ -28,7 +28,6 @@ function downloadHLSAsset(
/**
* Checks for existing downloads.
* Returns an array of downloads with additional fields:
- * id, progress, bytesDownloaded, bytesTotal, and state.
*/
async function checkForExistingDownloads(): Promise {
return HlsDownloaderModule.checkForExistingDownloads();
@@ -99,91 +98,9 @@ function useDownloadError(): string | null {
return error;
}
-/**
- * Moves a file from a temporary URI to a permanent location in the document directory.
- * @param tempFileUri The temporary file URI returned by the native module.
- * @param newFilename The desired filename (with extension) for the persisted file.
- * @returns A promise that resolves with the new file URI.
- */
-async function persistDownloadedFile(
- tempFileUri: string,
- newFilename: string
-): Promise {
- const newUri = FileSystem.documentDirectory + newFilename;
- try {
- await FileSystem.moveAsync({
- from: tempFileUri,
- to: newUri,
- });
- console.log("File persisted to:", newUri);
- return newUri;
- } catch (error) {
- console.error("Error moving file:", error);
- throw error;
- }
-}
-
-/**
- * React hook that returns the completion location of the download.
- * If a destinationFileName is provided, the hook will move the downloaded file
- * to the document directory under that name, then return the new URI.
- *
- * @param destinationFileName Optional filename (with extension) to persist the file.
- * @returns The final file URI or null if not completed.
- */
-function useDownloadComplete(destinationFileName?: string): string | null {
- const [location, setLocation] = useState(null);
-
- useEffect(() => {
- console.log("Setting up download complete listener");
-
- const subscription = addCompleteListener(
- async (event: OnCompleteEventPayload) => {
- console.log("Download complete event received:", event);
- console.log("Original download location:", event.location);
-
- if (destinationFileName) {
- console.log(
- "Attempting to persist file with name:",
- destinationFileName
- );
- try {
- const newLocation = await persistDownloadedFile(
- event.location,
- destinationFileName
- );
- console.log("File successfully persisted to:", newLocation);
- setLocation(newLocation);
- } catch (error) {
- console.error("Failed to persist file:", error);
- console.error("Error details:", {
- originalLocation: event.location,
- destinationFileName,
- error: error instanceof Error ? error.message : error,
- });
- }
- } else {
- console.log(
- "No destination filename provided, using original location"
- );
- setLocation(event.location);
- }
- }
- );
-
- return () => {
- console.log("Cleaning up download complete listener");
- subscription.remove();
- };
- }, [destinationFileName]);
-
- return location;
-}
-
export {
downloadHLSAsset,
checkForExistingDownloads,
- useDownloadComplete,
useDownloadError,
useDownloadProgress,
addCompleteListener,
diff --git a/modules/hls-downloader/ios/HlsDownloaderModule.swift b/modules/hls-downloader/ios/HlsDownloaderModule.swift
index 4ee5d215..deb99f58 100644
--- a/modules/hls-downloader/ios/HlsDownloaderModule.swift
+++ b/modules/hls-downloader/ios/HlsDownloaderModule.swift
@@ -38,7 +38,7 @@ public class HlsDownloaderModule: Module {
url: assetURL,
options: [
"AVURLAssetOutOfBandMIMETypeKey": "application/x-mpegURL",
- "AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "YourAppNameHere/1.0"],
+ "AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "Streamyfin/1.0"],
"AVURLAssetAllowsCellularAccessKey": true,
])
@@ -134,8 +134,8 @@ public class HlsDownloaderModule: Module {
downloads.append([
"id": delegate.providedId.isEmpty ? String(id) : delegate.providedId,
"progress": progress,
- "bytesDownloaded": downloaded,
- "bytesTotal": total,
+ "secondsDownloaded": downloaded,
+ "secondsTotal": total,
"state": self.mappedState(for: task),
"metadata": metadata,
"startTime": startTime,
@@ -243,8 +243,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
[
"id": providedId,
"progress": progress,
- "bytesDownloaded": downloaded,
- "bytesTotal": total,
+ "secondsDownloaded": downloaded,
+ "secondsTotal": total,
"state": progress >= 1.0 ? "DONE" : "DOWNLOADING",
"metadata": metadata,
"startTime": startTime,
@@ -263,6 +263,26 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
let newLocation = try module.persistDownloadedFolder(
originalLocation: location, folderName: folderName)
+ // Calculate download size
+ let fileManager = FileManager.default
+ let enumerator = fileManager.enumerator(
+ at: newLocation,
+ includingPropertiesForKeys: [.totalFileAllocatedSizeKey],
+ options: [.skipsHiddenFiles],
+ errorHandler: nil)!
+
+ var totalSize: Int64 = 0
+ while let filePath = enumerator.nextObject() as? URL {
+ do {
+ let resourceValues = try filePath.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
+ if let size = resourceValues.totalFileAllocatedSize {
+ totalSize += Int64(size)
+ }
+ } catch {
+ print("Error calculating size: \(error)")
+ }
+ }
+
if !metadata.isEmpty {
let metadataLocation = newLocation.deletingLastPathComponent().appendingPathComponent(
"\(providedId).json")
@@ -278,6 +298,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
"state": "DONE",
"metadata": metadata,
"startTime": startTime,
+ "bytesDownloaded": totalSize,
])
} catch {
module?.sendEvent(
diff --git a/modules/hls-downloader/src/HlsDownloader.types.ts b/modules/hls-downloader/src/HlsDownloader.types.ts
index 96369888..229c10d7 100644
--- a/modules/hls-downloader/src/HlsDownloader.types.ts
+++ b/modules/hls-downloader/src/HlsDownloader.types.ts
@@ -21,13 +21,13 @@ export type BaseEventPayload = {
id: string;
state: DownloadState;
metadata: DownloadMetadata;
+ startTime?: number;
};
export type OnProgressEventPayload = BaseEventPayload & {
progress: number;
- bytesDownloaded: number;
- bytesTotal: number;
- startTime?: number;
+ secondsDownloaded: number;
+ secondsTotal: number;
};
export type OnErrorEventPayload = BaseEventPayload & {
@@ -38,6 +38,7 @@ export type OnErrorEventPayload = BaseEventPayload & {
export type OnCompleteEventPayload = BaseEventPayload & {
location: string;
+ bytesDownloaded?: number;
};
export type HlsDownloaderModuleEvents = {
@@ -52,8 +53,8 @@ export interface DownloadInfo {
startTime?: number;
progress: number;
state: DownloadState;
- bytesDownloaded?: number;
- bytesTotal?: number;
+ secondsDownloaded?: number;
+ secondsTotal?: number;
location?: string;
error?: string;
metadata: DownloadMetadata;
diff --git a/providers/NativeDownloadProvider.tsx b/providers/NativeDownloadProvider.tsx
index 7c3e9563..f202c832 100644
--- a/providers/NativeDownloadProvider.tsx
+++ b/providers/NativeDownloadProvider.tsx
@@ -136,6 +136,7 @@ export const NativeDownloadProvider: React.FC<{
refetchOnWindowFocus: true,
refetchOnMount: true,
refetchOnReconnect: true,
+ staleTime: 0,
});
useEffect(() => {
@@ -150,8 +151,8 @@ export const NativeDownloadProvider: React.FC<{
id: download.id,
progress: download.progress,
state: download.state,
- bytesDownloaded: download.bytesDownloaded,
- bytesTotal: download.bytesTotal,
+ secondsDownloaded: download.secondsDownloaded,
+ secondsTotal: download.secondsTotal,
metadata: download.metadata,
startTime: download?.startTime,
},
@@ -165,23 +166,25 @@ export const NativeDownloadProvider: React.FC<{
initializeDownloads();
const progressListener = addProgressListener((download) => {
+ console.log("Attempting to add progress listener");
if (!download.metadata) throw new Error("No metadata found in download");
console.log(
"[HLS] Download progress:",
- download.bytesTotal,
- download.bytesDownloaded,
+ download.secondsTotal,
+ download.secondsDownloaded,
download.progress,
download.state
);
+
setDownloads((prev) => ({
...prev,
[download.id]: {
id: download.id,
progress: download.progress,
state: download.state,
- bytesDownloaded: download.bytesDownloaded,
- bytesTotal: download.bytesTotal,
+ secondsDownloaded: download.secondsDownloaded,
+ secondsTotal: download.secondsTotal,
metadata: download.metadata,
startTime: download?.startTime,
},
@@ -189,8 +192,6 @@ export const NativeDownloadProvider: React.FC<{
});
const completeListener = addCompleteListener(async (payload) => {
- if (!payload.id) throw new Error("No id found in payload");
-
try {
await rewriteM3U8Files(payload.location);
await markFileAsDone(payload.id);
@@ -205,21 +206,18 @@ export const NativeDownloadProvider: React.FC<{
toast.success("Download complete ✅");
} catch (error) {
- console.error("Failed to persist file:", error);
+ console.error("Failed to download file:", error);
toast.error("Failed to download ❌");
}
});
const errorListener = addErrorListener((error) => {
- console.error("Download error:", error);
- if (error.id) {
- setDownloads((prev) => {
- const newDownloads = { ...prev };
- delete newDownloads[error.id];
- return newDownloads;
- });
- toast.error("Failed to download ❌");
- }
+ setDownloads((prev) => {
+ const newDownloads = { ...prev };
+ delete newDownloads[error.id];
+ return newDownloads;
+ });
+ toast.error("Failed to download ❌");
});
return () => {
@@ -249,10 +247,10 @@ export const NativeDownloadProvider: React.FC<{
console.log("Found unparsed download:", id);
const p = async () => {
- await markFileAsDone(id);
- rewriteM3U8Files(
+ await rewriteM3U8Files(
FileSystem.documentDirectory + "downloads/" + id
);
+ await markFileAsDone(id);
};
toast.promise(p(), {
error: () => "Failed to download ❌",
diff --git a/utils/movpkg-to-vlc/parse/boot.ts b/utils/movpkg-to-vlc/parse/boot.ts
index 5af8e509..2583ed5b 100644
--- a/utils/movpkg-to-vlc/parse/boot.ts
+++ b/utils/movpkg-to-vlc/parse/boot.ts
@@ -38,7 +38,5 @@ export async function parseBootXML(xml: string): Promise {
parseAttributeValue: true,
});
const jsonObj = parser.parse(xml);
- const b = jsonObj.HLSMoviePackage as Boot;
- console.log(b.Streams);
return jsonObj.HLSMoviePackage as Boot;
}
diff --git a/utils/movpkg-to-vlc/tools.ts b/utils/movpkg-to-vlc/tools.ts
index 354a6647..3a720af5 100644
--- a/utils/movpkg-to-vlc/tools.ts
+++ b/utils/movpkg-to-vlc/tools.ts
@@ -3,6 +3,7 @@ import { parseBootXML } from "./parse/boot";
import { parseStreamInfoXml, StreamInfo } from "./parse/streamInfoBoot";
export async function rewriteM3U8Files(baseDir: string): Promise {
+ console.log(`[1] Rewriting M3U8 files in ${baseDir}`);
const bootData = await loadBootData(baseDir);
if (!bootData) return;
@@ -14,6 +15,7 @@ export async function rewriteM3U8Files(baseDir: string): Promise {
}
async function loadBootData(baseDir: string): Promise {
+ console.log(`[2] Loading boot.xml from ${baseDir}`);
const bootPath = `${baseDir}/boot.xml`;
try {
const bootInfo = await FileSystem.getInfoAsync(bootPath);
@@ -31,15 +33,19 @@ async function processAllStreams(
baseDir: string,
bootData: any
): Promise {
+ console.log(`[3] Processing all streams in ${baseDir}`);
const localPaths: string[] = [];
+ const streams = Array.isArray(bootData.Streams.Stream)
+ ? bootData.Streams.Stream
+ : [bootData.Streams.Stream];
- for (const stream of bootData.Streams.Stream) {
+ for (const stream of streams) {
const streamDir = `${baseDir}/${stream.ID}`;
try {
const streamInfo = await processStream(streamDir);
if (streamInfo && streamInfo.MediaPlaylist.PathToLocalCopy) {
localPaths.push(
- `${streamDir}${streamInfo.MediaPlaylist.PathToLocalCopy}`
+ `${streamDir}/${streamInfo.MediaPlaylist.PathToLocalCopy}`
);
}
} catch (error) {
@@ -84,7 +90,9 @@ export function updatePlaylistWithLocalSegments(
export async function processStream(
streamDir: string
): Promise {
+ console.log(`[4] Processing stream at ${streamDir}`);
const streamInfoPath = `${streamDir}/StreamInfoBoot.xml`;
+ console.log(`Processing stream at ${streamDir}...`);
try {
const streamXML = await FileSystem.readAsStringAsync(streamInfoPath);