diff --git a/.vscode/settings.json b/.vscode/settings.json index 22480b68..1bf5644b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,6 @@ "editor.formatOnSave": true }, "[swift]": { - "editor.defaultFormatter": "sswg.swift-lang" + "editor.defaultFormatter": "swiftlang.swift-vscode" } } diff --git a/modules/hls-downloader/ios/HlsDownloaderModule.swift b/modules/hls-downloader/ios/HlsDownloaderModule.swift index 9cb28bf1..bad1e320 100644 --- a/modules/hls-downloader/ios/HlsDownloaderModule.swift +++ b/modules/hls-downloader/ios/HlsDownloaderModule.swift @@ -1,29 +1,36 @@ -import ExpoModulesCore import AVFoundation +import ExpoModulesCore public class HlsDownloaderModule: Module { - var activeDownloads: [Int: (task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any])] = [:] + 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, metadata: [String: Any]?) -> Void in - print("Starting download - ID: \(providedId), URL: \(url), Title: \(assetTitle), Metadata: \(String(describing: metadata))") + 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", - "metadata": metadata ?? [:] - ]) + 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 configuration = URLSessionConfiguration.background( + withIdentifier: "com.example.hlsdownload") let delegate = HLSDownloadDelegate(module: self) delegate.providedId = providedId let downloadSession = AVAssetDownloadURLSession( @@ -32,29 +39,35 @@ public class HlsDownloaderModule: Module { delegateQueue: OperationQueue.main ) - guard let task = downloadSession.makeAssetDownloadTask( - asset: asset, - assetTitle: assetTitle, - assetArtworkData: nil, - options: nil - ) else { - self.sendEvent("onError", [ + 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", - "metadata": metadata ?? [:] - ]) + "error": "Failed to create download task", + "state": "FAILED", + "metadata": metadata ?? [:], + ]) return - } + } delegate.taskIdentifier = task.taskIdentifier self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:]) - self.sendEvent("onProgress", [ - "id": providedId, - "progress": 0.0, - "state": "PENDING", - "metadata": metadata ?? [:] - ]) + self.sendEvent( + "onProgress", + [ + "id": providedId, + "progress": 0.0, + "state": "PENDING", + "metadata": metadata ?? [:], + ]) task.resume() print("Download task started with identifier: \(task.taskIdentifier)") @@ -76,20 +89,35 @@ public class HlsDownloaderModule: Module { "bytesDownloaded": downloaded, "bytesTotal": total, "state": self.mappedState(for: task), - "metadata": metadata - ]) - } + "metadata": metadata, + ]) + } return downloads } - OnStartObserving { } - OnStopObserving { } -} + OnStartObserving {} + OnStopObserving {} + } func removeDownload(with id: Int) { activeDownloads.removeValue(forKey: id) } + func persistDownloadedFolder(originalLocation: URL, folderName: String) throws -> URL { + let fileManager = FileManager.default + let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + let destinationDir = documents.appendingPathComponent("downloads", isDirectory: true) + if !fileManager.fileExists(atPath: destinationDir.path) { + try fileManager.createDirectory(at: destinationDir, withIntermediateDirectories: true) + } + let newLocation = destinationDir.appendingPathComponent(folderName, isDirectory: true) + if fileManager.fileExists(atPath: newLocation.path) { + try fileManager.removeItem(at: newLocation) + } + try fileManager.moveItem(at: originalLocation, to: newLocation) + return newLocation + } + func mappedState(for task: URLSessionTask, errorOccurred: Bool = false) -> String { if errorOccurred { return "FAILED" } switch task.state { @@ -112,51 +140,79 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { self.module = module } - 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) - } + 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 ?? [:] + let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration) + let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:] - self.downloadedSeconds = downloaded - self.totalSeconds = total + self.downloadedSeconds = downloaded + self.totalSeconds = total - let progress = total > 0 ? downloaded / total : 0 + 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 + module?.sendEvent( + "onProgress", + [ + "id": providedId, + "progress": progress, + "bytesDownloaded": downloaded, + "bytesTotal": total, + "state": progress >= 1.0 ? "DONE" : "DOWNLOADING", + "metadata": metadata, + ]) + } + + func urlSession( + _ session: URLSession, assetDownloadTask: AVAssetDownloadTask, + didFinishDownloadingTo location: URL + ) { + let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:] + let folderName = providedId // using providedId as the folder name + do { + guard let module = module else { return } + let newLocation = try module.persistDownloadedFolder( + originalLocation: location, folderName: folderName) + + module.sendEvent( + "onComplete", + [ + "id": providedId, + "location": newLocation.absoluteString, + "state": "DONE", + "metadata": metadata, + ]) + } catch { + module?.sendEvent( + "onError", + [ + "id": providedId, + "error": error.localizedDescription, + "state": "FAILED", + "metadata": metadata, ]) } + module?.removeDownload(with: assetDownloadTask.taskIdentifier) + } - 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) - } + 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) } + } } diff --git a/modules/vlc-player/ios/VlcPlayerView.swift b/modules/vlc-player/ios/VlcPlayerView.swift index fe18e709..e3bea0a2 100644 --- a/modules/vlc-player/ios/VlcPlayerView.swift +++ b/modules/vlc-player/ios/VlcPlayerView.swift @@ -2,7 +2,6 @@ import ExpoModulesCore import VLCKit import UIKit - public class VLCPlayerView: UIView { func setupView(parent: UIView) { self.backgroundColor = .black diff --git a/providers/NativeDownloadProvider.tsx b/providers/NativeDownloadProvider.tsx index b2c05f58..b9dbfbac 100644 --- a/providers/NativeDownloadProvider.tsx +++ b/providers/NativeDownloadProvider.tsx @@ -12,7 +12,7 @@ import { } from "@/modules/hls-downloader"; import * as FileSystem from "expo-file-system"; import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types"; -import { processStream } from "@/utils/hls/av-file-parser"; +import { parseBootXML, processStream } from "@/utils/hls/av-file-parser"; type DownloadContextType = { downloads: Record; @@ -50,6 +50,22 @@ const persistDownloadedFile = async ( } }; +/** + * Opens the boot.xml file and parses it to get the streams + */ +const getBootStreams = async (path: string) => { + const b = `${path}/boot.xml`; + const fileInfo = await FileSystem.getInfoAsync(b); + if (fileInfo.exists) { + const boot = await FileSystem.readAsStringAsync(b, { + encoding: FileSystem.EncodingType.UTF8, + }); + return parseBootXML(boot); + } else { + console.log(`No boot.xml found in ${path}`); + } +}; + export const NativeDownloadProvider: React.FC<{ children: React.ReactNode; }> = ({ children }) => { @@ -88,6 +104,11 @@ export const NativeDownloadProvider: React.FC<{ ); setDownloads({ ...hlsDownloadStates, ...regularDownloadStates }); + + console.log("Existing downloads:", { + ...hlsDownloadStates, + ...regularDownloadStates, + }); }; initializeDownloads(); @@ -106,43 +127,30 @@ export const NativeDownloadProvider: React.FC<{ }); 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); + console.log("Download complete to:", payload.location); - try { - if (payload?.metadata?.Name) { - const newLocation = await persistDownloadedFile( - payload.location, - payload.metadata.Name - ); - console.log("File successfully persisted to:", newLocation); + // try { + // if (payload?.id) { + // const newLocation = await persistDownloadedFile( + // payload.location, + // payload.id + // ); + // console.log("File successfully persisted to:", newLocation); + // } else { + // console.log( + // "No filename in metadata, using original location", + // payload + // ); + // } + // } catch (error) { + // console.error("Failed to persist file:", error); + // } - processStream(newLocation); - } else { - console.log( - "No filename in metadata, using original location", - payload - ); - } - } catch (error) { - console.error("Failed to persist file:", error); - } - - setDownloads((prev) => { - const newDownloads = { ...prev }; - delete newDownloads[payload.id]; - return newDownloads; - }); - } + setDownloads((prev) => { + const newDownloads = { ...prev }; + delete newDownloads[payload.id]; + return newDownloads; + }); }); const errorListener = addErrorListener((error) => { diff --git a/utils/hls/av-file-parser.ts b/utils/hls/av-file-parser.ts index 02d56761..2fb973ba 100644 --- a/utils/hls/av-file-parser.ts +++ b/utils/hls/av-file-parser.ts @@ -19,7 +19,31 @@ export interface StreamInfo { segPaths: string[]; } -// 1. Parse boot.xml to extract stream definitions. +// Rewrites m3u8 files with local paths +export async function rewriteM3U8Files(path: string) { + const streams = await getBootStreams(path); + if (!streams) return; + for (const stream of streams) { + const streamDirectory = `${path}/${stream.id}`; + processStream(streamDirectory); + } +} + +// Opens the boot.xml file and parses it to get the streams +const getBootStreams = async (path: string) => { + const b = `${path}/boot.xml`; + const fileInfo = await FileSystem.getInfoAsync(b); + if (fileInfo.exists) { + const boot = await FileSystem.readAsStringAsync(b, { + encoding: FileSystem.EncodingType.UTF8, + }); + return parseBootXML(boot); + } else { + console.log(`No boot.xml found in ${path}`); + } +}; + +// Parse boot.xml to extract stream definitions. export function parseBootXML(xml: string): StreamDefinition[] { const json = parser.parse(xml); const pkg = json.HLSMoviePackage; @@ -33,7 +57,7 @@ export function parseBootXML(xml: string): StreamDefinition[] { })); } -// 2. Parse StreamInfoBoot.xml to extract the local m3u8 path and segment paths. +// Parse StreamInfoBoot.xml to extract the local m3u8 path and segment paths. export function parseStreamInfo(xml: string): StreamInfo { const json = parser.parse(xml); const streamInfo = json.StreamInfo; @@ -56,7 +80,7 @@ export function parseStreamInfo(xml: string): StreamInfo { }; } -// 3. Update the m3u8 playlist content by replacing remote segment URLs with local paths. +// Update the m3u8 playlist content by replacing remote segment URLs with local paths. export function updatePlaylistWithLocalSegments( playlistContent: string, segPaths: string[] @@ -76,29 +100,45 @@ export function updatePlaylistWithLocalSegments( return lines.join("\n"); } -// Example: Process a stream directory using Expo FileSystem. +// Process a stream directory using Expo FileSystem. export async function processStream(streamDir: string): Promise { + console.log(`Processing stream directory: ${streamDir}`); + // Read StreamInfoBoot.xml from the stream directory. const streamInfoPath = `${streamDir}/StreamInfoBoot.xml`; + console.log(`Reading StreamInfoBoot.xml from: ${streamInfoPath}`); + try { const streamInfoXML = await FileSystem.readAsStringAsync(streamInfoPath, { encoding: FileSystem.EncodingType.UTF8, }); + console.log('Successfully read StreamInfoBoot.xml'); const streamInfo = parseStreamInfo(streamInfoXML); + console.log(`Parsed stream info: ${JSON.stringify(streamInfo, null, 2)}`); // Read the local m3u8 file. const playlistPath = `${streamDir}/${streamInfo.localM3U8}`; + console.log(`Reading m3u8 playlist from: ${playlistPath}`); const playlistContent = await FileSystem.readAsStringAsync(playlistPath, { encoding: FileSystem.EncodingType.UTF8, }); + console.log('Successfully read m3u8 playlist'); // Replace remote segment URIs with local segment paths. + console.log('Updating playlist with local segment paths'); const updatedPlaylist = updatePlaylistWithLocalSegments( playlistContent, streamInfo.segPaths ); // Save the updated playlist back to disk. + console.log(`Writing updated playlist back to: ${playlistPath}`); await FileSystem.writeAsStringAsync(playlistPath, updatedPlaylist, { encoding: FileSystem.EncodingType.UTF8, }); + console.log('Successfully wrote updated playlist'); + + } catch (error) { + console.error(`Error processing stream directory ${streamDir}:`, error); + throw error; +} }