diff --git a/Example/app/jsx/App.tsx b/Example/app/jsx/App.tsx index 48ef5d4..34ff7b5 100644 --- a/Example/app/jsx/App.tsx +++ b/Example/app/jsx/App.tsx @@ -18,6 +18,7 @@ import SourcesExample from './screens/SourcesExample'; import YoutubeExample from './screens/YoutubeExample'; import PlayerInModal from './screens/PlayerInModal'; import GlobalPlayerExample from './screens/GlobalPlayerExample'; +import OfflineDownloadExample from './screens/OfflineDownloadExample'; const Stack = createNativeStackNavigator(); @@ -40,6 +41,7 @@ export default class App extends Component { + diff --git a/Example/app/jsx/modules/JWPlayerOffline.ts b/Example/app/jsx/modules/JWPlayerOffline.ts new file mode 100644 index 0000000..4f7d51a --- /dev/null +++ b/Example/app/jsx/modules/JWPlayerOffline.ts @@ -0,0 +1,103 @@ +/** + * JWPlayerOffline Module + * + * Native module for downloading and playing offline DRM-protected videos + */ + +import { NativeModules, NativeEventEmitter } from 'react-native'; + +const JWPlayerOfflineModule = NativeModules.RNJWPlayerOfflineModule; + +export interface OfflineVideoConfig { + mediaId: string; + file: string; + processSpcUrl?: string; + certificateUrl?: string; +} + +export interface OfflinePlaylistItem { + mediaId: string; + file: string; + localURL: string; +} + +export interface DownloadProgressEvent { + mediaId: string; + progress: number; +} + +export interface DownloadCompleteEvent { + mediaId: string; + url: string; +} + +export interface DownloadErrorEvent { + mediaId: string; + error: string; +} + +class JWPlayerOffline { + private eventEmitter: NativeEventEmitter; + + constructor() { + this.eventEmitter = new NativeEventEmitter(JWPlayerOfflineModule); + } + + /** + * Start downloading a video for offline playback + */ + downloadVideo(config: OfflineVideoConfig): Promise { + return JWPlayerOfflineModule.downloadVideo(config); + } + + /** + * Check if a video is already downloaded + */ + isDownloaded(mediaId: string): Promise { + return JWPlayerOfflineModule.isDownloaded(mediaId); + } + + /** + * Get list of all downloaded videos + */ + getDownloads(): Promise> { + return JWPlayerOfflineModule.getDownloads(); + } + + /** + * Delete a downloaded video + */ + deleteDownload(mediaId: string): Promise { + return JWPlayerOfflineModule.deleteDownload(mediaId); + } + + /** + * Get the playlist item for offline playback + */ + getOfflinePlaylistItem(mediaId: string): Promise { + return JWPlayerOfflineModule.getOfflinePlaylistItem(mediaId); + } + + /** + * Listen for download progress events + */ + onDownloadProgress(callback: (event: DownloadProgressEvent) => void) { + return this.eventEmitter.addListener('onDownloadProgress', callback); + } + + /** + * Listen for download complete events + */ + onDownloadComplete(callback: (event: DownloadCompleteEvent) => void) { + return this.eventEmitter.addListener('onDownloadComplete', callback); + } + + /** + * Listen for download error events + */ + onDownloadError(callback: (event: DownloadErrorEvent) => void) { + return this.eventEmitter.addListener('onDownloadError', callback); + } +} + +export default new JWPlayerOffline(); diff --git a/Example/app/jsx/screens/Home.js b/Example/app/jsx/screens/Home.js index 254dfd7..2989aa5 100644 --- a/Example/app/jsx/screens/Home.js +++ b/Example/app/jsx/screens/Home.js @@ -4,7 +4,7 @@ import Icons from 'react-native-vector-icons/FontAwesome5'; import {useNavigation} from '@react-navigation/native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; -const SCREENS = ['TypeScript Example', 'Single', 'On Before Next Playlist Item', 'Modal', 'List', 'DRM', 'Local', 'Sources', 'Youtube', 'Global Player']; +const SCREENS = ['TypeScript Example', 'Single', 'On Before Next Playlist Item', 'Modal', 'List', 'DRM', 'Offline Download', 'Local', 'Sources', 'Youtube', 'Global Player']; export default () => { const navigation = useNavigation(); diff --git a/Example/app/jsx/screens/OfflineDownloadExample.tsx b/Example/app/jsx/screens/OfflineDownloadExample.tsx new file mode 100644 index 0000000..b5b1ccd --- /dev/null +++ b/Example/app/jsx/screens/OfflineDownloadExample.tsx @@ -0,0 +1,421 @@ +/** + * Offline Download Example + * + * Test screen for downloading and playing DRM-protected videos offline. + * Update the TEST_VIDEOS array below with your DRM content to test. + */ + +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + Dimensions, +} from 'react-native'; +import Player from '../components/Player'; +import JWPlayerOffline, { OfflineVideoConfig } from '../modules/JWPlayerOffline'; + +// --------------------------------------------------------------------------- +// Test Videos +// +// Offline DRM downloads are currently implemented for iOS only (FairPlay). +// Add your FairPlay DRM-protected videos here for testing. +// --------------------------------------------------------------------------- +const TEST_VIDEOS: OfflineVideoConfig[] = [ + // { + // mediaId: 'my-video', + // file: 'https://content.jwplatform.com/v2/media/MEDIA_ID/playlist.m3u8?policy_id=POLICY&version=v2&token=TOKEN', + // processSpcUrl: 'https://content.jwplatform.com/v2/media/MEDIA_ID/license?drm=fairplay&policy_id=POLICY&version=v2&token=TOKEN', + // certificateUrl: 'https://content.jwplatform.com/v2/fairplay-streaming/certificate?policy_id=POLICY&version=v2&token=TOKEN', + // }, + // + // Non-DRM example (omit processSpcUrl / certificateUrl): + // { + // mediaId: 'non-drm-test', + // file: 'https://cdn.jwplayer.com/manifests/example.m3u8', + // }, +]; + +// --------------------------------------------------------------------------- + +interface DownloadStatus { + mediaId: string; + isDownloaded: boolean; + progress: number; + isDownloading: boolean; +} + +const OfflineDownloadExample: React.FC = () => { + const [downloads, setDownloads] = useState([]); + const [selectedVideo, setSelectedVideo] = useState(null); + const [offlineFile, setOfflineFile] = useState(null); + const [selectedDrmConfig, setSelectedDrmConfig] = useState(null); + + useEffect(() => { + loadDownloadStatus(); + + const progressListener = JWPlayerOffline.onDownloadProgress((event) => { + console.log('Download progress:', event); + setDownloads(prev => prev.map(d => + d.mediaId === event.mediaId + ? { ...d, progress: event.progress, isDownloading: true } + : d + )); + }); + + const completeListener = JWPlayerOffline.onDownloadComplete((event) => { + console.log('Download complete:', event); + setDownloads(prev => prev.map(d => + d.mediaId === event.mediaId + ? { ...d, isDownloaded: true, isDownloading: false, progress: 100 } + : d + )); + Alert.alert('Success', `Video ${event.mediaId} downloaded successfully!`); + }); + + const errorListener = JWPlayerOffline.onDownloadError((event) => { + console.error('Download error:', event); + setDownloads(prev => prev.map(d => + d.mediaId === event.mediaId + ? { ...d, isDownloading: false } + : d + )); + Alert.alert('Error', `Download failed: ${event.error}`); + }); + + return () => { + progressListener.remove(); + completeListener.remove(); + errorListener.remove(); + }; + }, []); + + const loadDownloadStatus = async () => { + const statuses = await Promise.all( + TEST_VIDEOS.map(async (video) => { + const isDownloaded = await JWPlayerOffline.isDownloaded(video.mediaId); + return { + mediaId: video.mediaId, + isDownloaded, + progress: isDownloaded ? 100 : 0, + isDownloading: false, + }; + }) + ); + setDownloads(statuses); + }; + + const startDownload = async (video: OfflineVideoConfig) => { + try { + setDownloads(prev => prev.map(d => + d.mediaId === video.mediaId + ? { ...d, isDownloading: true, progress: 0 } + : d + )); + await JWPlayerOffline.downloadVideo(video); + } catch (error) { + console.error('Failed to start download:', error); + Alert.alert('Error', 'Failed to start download'); + setDownloads(prev => prev.map(d => + d.mediaId === video.mediaId + ? { ...d, isDownloading: false } + : d + )); + } + }; + + const deleteDownload = async (mediaId: string) => { + try { + await JWPlayerOffline.deleteDownload(mediaId); + await loadDownloadStatus(); + if (selectedVideo === mediaId) { + setSelectedVideo(null); + setOfflineFile(null); + } + Alert.alert('Success', 'Download deleted'); + } catch (error) { + console.error('Failed to delete download:', error); + Alert.alert('Error', 'Failed to delete download'); + } + }; + + const playOfflineVideo = async (mediaId: string) => { + try { + const playlistItem = await JWPlayerOffline.getOfflinePlaylistItem(mediaId); + console.log('Playing offline video:', playlistItem); + + if (!playlistItem.file) { + throw new Error('No file URL returned'); + } + + const videoConfig = TEST_VIDEOS.find(v => v.mediaId === mediaId); + + setSelectedVideo(mediaId); + setOfflineFile(playlistItem.file); + setSelectedDrmConfig(videoConfig || null); + + console.log('DRM config for playback:', { + processSpcUrl: videoConfig?.processSpcUrl ? 'SET' : 'NONE', + certificateUrl: videoConfig?.certificateUrl ? 'SET' : 'NONE', + }); + } catch (error) { + console.error('Failed to get offline playlist:', error); + Alert.alert('Error', `Failed to load offline video: ${error.message}`); + } + }; + + const getDownloadStatus = (mediaId: string) => { + return downloads.find(d => d.mediaId === mediaId); + }; + + const renderVideoItem = (video: OfflineVideoConfig, index: number) => { + const status = getDownloadStatus(video.mediaId); + + return ( + + Test Video {index + 1} + {video.mediaId} + + {status?.isDownloading && ( + + + Downloading: {status.progress.toFixed(1)}% + + + + + + )} + + + {!status?.isDownloaded && !status?.isDownloading && ( + startDownload(video)} + > + Download + + )} + + {status?.isDownloaded && ( + <> + playOfflineVideo(video.mediaId)} + > + Play Offline + + + deleteDownload(video.mediaId)} + > + Delete + + + )} + + + ); + }; + + return ( + + + Offline Download Test + + Test downloading and playing DRM-protected videos offline + + + + + Instructions: + + 1. Update TEST_VIDEOS array with your DRM content{'\n'} + 2. Tap "Download" to download video offline{'\n'} + 3. Once downloaded, tap "Play Offline" to test playback{'\n'} + 4. Use "Delete" to remove downloaded content + + + + {offlineFile && ( + + + console.log('Player error:', e)} + onPlay={() => console.log('Playing offline video')} + /> + + + )} + + + Test Videos + {TEST_VIDEOS.map((video, index) => renderVideoItem(video, index))} + + + {TEST_VIDEOS.length === 0 && ( + + + No test videos configured.{'\n'} + Update the TEST_VIDEOS array in OfflineDownloadExample.tsx + + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + header: { + padding: 20, + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + color: '#333', + marginBottom: 8, + }, + subtitle: { + fontSize: 14, + color: '#666', + }, + instructions: { + margin: 16, + padding: 16, + backgroundColor: '#fff3cd', + borderRadius: 8, + borderWidth: 1, + borderColor: '#ffc107', + }, + instructionsTitle: { + fontSize: 16, + fontWeight: 'bold', + color: '#856404', + marginBottom: 8, + }, + instructionsText: { + fontSize: 14, + color: '#856404', + lineHeight: 20, + }, + playerContainer: { + backgroundColor: '#000', + alignItems: 'center', + marginVertical: 16, + }, + playerContainer: { + height: 300, + width: Dimensions.get('window').width - 40, + }, + videoList: { + padding: 16, + }, + sectionTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#333', + marginBottom: 12, + }, + videoItem: { + backgroundColor: '#fff', + padding: 16, + borderRadius: 8, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + videoTitle: { + fontSize: 16, + fontWeight: 'bold', + color: '#333', + marginBottom: 4, + }, + videoId: { + fontSize: 12, + color: '#666', + marginBottom: 12, + }, + progressContainer: { + marginBottom: 12, + }, + progressText: { + fontSize: 14, + color: '#007AFF', + marginBottom: 4, + }, + progressBar: { + height: 4, + backgroundColor: '#e0e0e0', + borderRadius: 2, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + backgroundColor: '#007AFF', + }, + buttonRow: { + flexDirection: 'row', + gap: 8, + }, + button: { + flex: 1, + paddingVertical: 12, + borderRadius: 6, + alignItems: 'center', + }, + downloadButton: { + backgroundColor: '#007AFF', + }, + playButton: { + backgroundColor: '#34C759', + }, + deleteButton: { + backgroundColor: '#FF3B30', + }, + buttonText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + emptyState: { + padding: 40, + alignItems: 'center', + }, + emptyText: { + fontSize: 14, + color: '#666', + textAlign: 'center', + lineHeight: 20, + }, +}); + +export default OfflineDownloadExample; diff --git a/ios/RNJWPlayer/OfflineKeyDataSource.swift b/ios/RNJWPlayer/OfflineKeyDataSource.swift new file mode 100644 index 0000000..3a104b0 --- /dev/null +++ b/ios/RNJWPlayer/OfflineKeyDataSource.swift @@ -0,0 +1,48 @@ +// +// OfflineKeyDataSource.swift +// RNJWPlayer +// +// Key data source for offline DRM content playback. +// + +import Foundation +import JWPlayerKit + +/// Manages DRM certificate and license requests for offline content. +class OfflineKeyDataSource: NSObject, JWDRMContentKeyDataSource { + var certificateURLStr: String? + var processSPCURLStr: String? + + func contentIdentifierForURL(_ url: URL, completionHandler handler: @escaping (Data?) -> Void) { + handler(url.host?.data(using: .utf8)) + } + + func appIdentifierForURL(_ url: URL, completionHandler handler: @escaping (Data?) -> Void) { + guard let certificateURLStr = certificateURLStr, + let certificateURL = URL(string: certificateURLStr) else { + handler(nil) + return + } + + URLSession.shared.dataTask(with: URLRequest(url: certificateURL)) { (data, response, error) in + handler(data) + }.resume() + } + + func contentKeyWithSPCData(_ spcData: Data, completionHandler handler: @escaping (Data?, Date?, String?) -> Void) { + guard let processSPCURLStr = processSPCURLStr, + let processSPCURL = URL(string: processSPCURLStr) else { + handler(nil, nil, nil) + return + } + + var request = URLRequest(url: processSPCURL) + request.httpMethod = "POST" + request.addValue("application/octet-stream", forHTTPHeaderField: "Content-type") + request.httpBody = spcData + + URLSession.shared.dataTask(with: request) { (data, response, error) in + handler(data, nil, "application/octet-stream") + }.resume() + } +} diff --git a/ios/RNJWPlayer/OfflineKeyManager.swift b/ios/RNJWPlayer/OfflineKeyManager.swift new file mode 100644 index 0000000..8daa827 --- /dev/null +++ b/ios/RNJWPlayer/OfflineKeyManager.swift @@ -0,0 +1,120 @@ +// +// OfflineKeyManager.swift +// RNJWPlayer +// +// Manages persistent storage of DRM keys for offline playback. +// Tracks which media IDs are actively downloading so contentKeyTypeFor +// returns .persistable during download (to trigger key persistence) +// and during playback (when keys exist on disk). +// + +import Foundation +import JWPlayerKit + +/// Callback to notify when persistable keys have been written to disk, +/// signaling that stream download can begin. +protocol OfflineKeyManagerDelegate: AnyObject { + func offlineKeyManager(_ manager: OfflineKeyManager, didPersistKeyFor contentKeyIdentifier: String) +} + +class OfflineKeyManager: NSObject, JWDRMContentKeyManager { + + let keyDirectory: URL + weak var delegate: OfflineKeyManagerDelegate? + + private var downloadingKeyIdentifiers: Set = [] + + override init() { + let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + + guard let contentKeyDirectory = documentDirectory?.appendingPathComponent(".jwplayer-keys/", isDirectory: true) else { + fatalError("This device does not have a valid document directory") + } + + if !FileManager.default.fileExists(atPath: contentKeyDirectory.path, isDirectory: nil) { + do { + try FileManager.default.createDirectory( + at: contentKeyDirectory, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + fatalError("Unable to create directory for content keys at path: \(contentKeyDirectory.path)") + } + } + + keyDirectory = contentKeyDirectory + super.init() + } + + // MARK: - Download State Tracking + + func markAsDownloading(_ contentKeyIdentifier: String) { + downloadingKeyIdentifiers.insert(contentKeyIdentifier) + } + + /// Marks all incoming key requests as persistable (used when the + /// content key identifier isn't known ahead of time). + func markDownloadActive() { + downloadingKeyIdentifiers.insert("__download_active__") + } + + func clearDownloadState() { + downloadingKeyIdentifiers.removeAll() + } + + private func isDownloading(_ contentKeyIdentifier: String) -> Bool { + return downloadingKeyIdentifiers.contains(contentKeyIdentifier) || + downloadingKeyIdentifiers.contains("__download_active__") + } + + private func keyURL(for contentKeyIdentifier: String) -> URL { + return keyDirectory.appendingPathExtension(contentKeyIdentifier) + } + + private func keyExistsOnDisk(_ contentKeyIdentifier: String) -> Bool { + return FileManager.default.fileExists(atPath: keyURL(for: contentKeyIdentifier).relativePath) + } + + // MARK: - JWDRMContentKeyManager + + func contentLoader(_ contentLoader: JWDRMContentLoader, writePersistableContentKey contentKey: Data, contentKeyIdentifier: String) { + do { + try contentKey.write(to: keyURL(for: contentKeyIdentifier)) + } catch { + print("Error writing DRM key: \(error.localizedDescription)") + } + } + + func contentLoader(_ contentLoader: JWDRMContentLoader, didWritePersistableContentKey contentKeyIdentifier: String) { + downloadingKeyIdentifiers.remove(contentKeyIdentifier) + delegate?.offlineKeyManager(self, didPersistKeyFor: contentKeyIdentifier) + } + + func contentLoader(_ contentLoader: JWDRMContentLoader, deletePersistableContentKey contentKeyIdentifier: String) { + do { + try FileManager.default.removeItem(at: keyURL(for: contentKeyIdentifier)) + } catch { + print("Error deleting DRM key: \(error.localizedDescription)") + } + } + + func contentLoader(_ contentLoader: JWDRMContentLoader, contentKeyTypeFor contentKeyIdentifier: String) -> JWContentKeyType { + if isDownloading(contentKeyIdentifier) || keyExistsOnDisk(contentKeyIdentifier) { + return .persistable + } + return .nonpersistable + } + + func contentLoader(_ contentLoader: JWDRMContentLoader, contentKeyExistsOnDisk contentKeyIdentifier: String) -> Bool { + return keyExistsOnDisk(contentKeyIdentifier) + } + + func contentLoader(_ contentLoader: JWDRMContentLoader, urlForPersistableContentKey contentKeyIdentifier: String) -> URL { + return keyURL(for: contentKeyIdentifier) + } + + func contentLoader(_ contentLoader: JWDRMContentLoader, failedWithError error: JWError) { + print("JWDRMContentLoader error: \(error.localizedDescription)") + } +} diff --git a/ios/RNJWPlayer/RNJWPlayerOfflineModule.m b/ios/RNJWPlayer/RNJWPlayerOfflineModule.m new file mode 100644 index 0000000..88d4067 --- /dev/null +++ b/ios/RNJWPlayer/RNJWPlayerOfflineModule.m @@ -0,0 +1,39 @@ +// +// RNJWPlayerOfflineModule.m +// RNJWPlayer +// +// React Native bridge for the offline download module. +// + +#if __has_include("React/RCTBridgeModule.h") +#import "React/RCTBridgeModule.h" +#import "React/RCTEventEmitter.h" +#else +#import "RCTBridgeModule.h" +#import "RCTEventEmitter.h" +#endif + +@interface RCT_EXTERN_MODULE(RNJWPlayerOfflineModule, RCTEventEmitter) + +RCT_EXTERN_METHOD(downloadVideo:(NSDictionary *)config + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(isDownloaded:(NSString *)mediaId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getDownloads:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(deleteDownload:(NSString *)mediaId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getOfflinePlaylistItem:(NSString *)mediaId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(supportedEvents) + +@end diff --git a/ios/RNJWPlayer/RNJWPlayerOfflineModule.swift b/ios/RNJWPlayer/RNJWPlayerOfflineModule.swift new file mode 100644 index 0000000..d318701 --- /dev/null +++ b/ios/RNJWPlayer/RNJWPlayerOfflineModule.swift @@ -0,0 +1,375 @@ +// +// RNJWPlayerOfflineModule.swift +// RNJWPlayer +// +// Native module for offline video downloads with FairPlay DRM support. +// Follows the NativeDemo OfflineDRM pattern: +// 1. Acquire and persist DRM keys via contentLoader.load(playlist:) +// 2. Start AVAssetDownloadTask after keys are persisted +// 3. Offline playback uses persisted keys from disk +// + +import Foundation +import JWPlayerKit +import AVFoundation +import React + +@objc(RNJWPlayerOfflineModule) +class RNJWPlayerOfflineModule: RCTEventEmitter, OfflineKeyManagerDelegate { + + // MARK: - Properties + + private let savedDataKeyBase = "jwplayer_offline:" + private func key(for mediaId: String) -> String { savedDataKeyBase + mediaId } + + private var contentLoader: JWDRMContentLoader? + private let keyManager: OfflineKeyManager = OfflineKeyManager() + private let keyDataSource: OfflineKeyDataSource = OfflineKeyDataSource() + + private var assetDownloadURLSession: AVAssetDownloadURLSession! + private let delegateQueue: OperationQueue = { + let q = OperationQueue() + q.maxConcurrentOperationCount = 1 + return q + }() + + private let assetDelegate = OfflineAssetDownloadDelegate() + + /// Pending downloads waiting for DRM key persistence before starting + /// the AVAssetDownloadTask. Keyed by mediaId. + private var pendingDownloads: [String: PendingDownload] = [:] + + private struct PendingDownload { + let mediaId: String + let fileURL: URL + let playlistURL: URL + } + + // MARK: - Initialization + + override init() { + super.init() + keyManager.delegate = self + setupDownloadSession() + } + + private func setupDownloadSession() { + let backgroundConfig = URLSessionConfiguration.background(withIdentifier: "com.jwplayer.offline.download") + assetDownloadURLSession = AVAssetDownloadURLSession( + configuration: backgroundConfig, + assetDownloadDelegate: assetDelegate, + delegateQueue: delegateQueue + ) + + assetDelegate.onProgress = { [weak self] mediaId, progress in + self?.sendEvent(withName: "onDownloadProgress", body: [ + "mediaId": mediaId, + "progress": progress + ]) + } + + assetDelegate.onComplete = { [weak self] mediaId, url in + self?.persist(url: url, for: mediaId) + self?.sendEvent(withName: "onDownloadComplete", body: [ + "mediaId": mediaId, + "url": url.absoluteString + ]) + } + + assetDelegate.onError = { [weak self] mediaId, error in + self?.sendEvent(withName: "onDownloadError", body: [ + "mediaId": mediaId, + "error": error.localizedDescription + ]) + } + } + + // MARK: - Persistence + + private func persist(url: URL, for mediaId: String) { + do { + let bookmark = try url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil) + UserDefaults.standard.set(bookmark, forKey: key(for: mediaId)) + } catch { + print("Failed to create bookmark for mediaId=\(mediaId): \(error)") + } + } + + private func restoredURL(for mediaId: String) -> URL? { + guard let data = UserDefaults.standard.data(forKey: key(for: mediaId)) else { return nil } + var stale = false + do { + let url = try URL(resolvingBookmarkData: data, bookmarkDataIsStale: &stale) + return stale ? nil : url + } catch { + return nil + } + } + + private func removePersistedURL(for mediaId: String) { + UserDefaults.standard.removeObject(forKey: key(for: mediaId)) + } + + // MARK: - Stream Download + + /// Starts the AVAssetDownloadTask for the given pending download. + /// Called after DRM keys have been persisted, or immediately for non-DRM content. + private func startStreamDownload(for pending: PendingDownload) { + let asset = AVURLAsset(url: pending.fileURL) + let preferredMediaSelection = asset.preferredMediaSelection + + guard let task = assetDownloadURLSession.aggregateAssetDownloadTask( + with: asset, + mediaSelections: [preferredMediaSelection], + assetTitle: pending.mediaId, + assetArtworkData: nil, + options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000] + ) else { + sendEvent(withName: "onDownloadError", body: [ + "mediaId": pending.mediaId, + "error": "Failed to create download task" + ]) + return + } + + task.taskDescription = pending.mediaId + assetDelegate.register(task: task, mediaId: pending.mediaId) + task.resume() + } + + // MARK: - OfflineKeyManagerDelegate + + /// Called when DRM keys have been persisted to disk. + /// Now safe to start the actual stream download. + func offlineKeyManager(_ manager: OfflineKeyManager, didPersistKeyFor contentKeyIdentifier: String) { + for (mediaId, pending) in pendingDownloads { + if contentKeyIdentifier.contains(mediaId) || true { + pendingDownloads.removeValue(forKey: mediaId) + startStreamDownload(for: pending) + return + } + } + + if let (mediaId, pending) = pendingDownloads.first { + pendingDownloads.removeValue(forKey: mediaId) + startStreamDownload(for: pending) + } + } + + // MARK: - Download Management + + @objc + func downloadVideo(_ config: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + guard let mediaId = config["mediaId"] as? String, + let fileURLString = config["file"] as? String, + let fileURL = URL(string: fileURLString) else { + rejecter("INVALID_CONFIG", "Missing mediaId or file URL", nil) + return + } + + let hasDRM = config["processSpcUrl"] is String && config["certificateUrl"] is String + + if hasDRM, + let processSpcUrl = config["processSpcUrl"] as? String, + let certificateUrl = config["certificateUrl"] as? String { + + keyDataSource.processSPCURLStr = processSpcUrl + keyDataSource.certificateURLStr = certificateUrl + contentLoader = JWDRMContentLoader(dataSource: keyDataSource, keyManager: keyManager) + + // Check if keys already exist on disk (re-download case) + let existingKeys = keyManager.contentLoader(contentLoader!, contentKeyExistsOnDisk: mediaId) + + if existingKeys { + let pending = PendingDownload(mediaId: mediaId, fileURL: fileURL, playlistURL: fileURL) + startStreamDownload(for: pending) + } else { + // Acquire persistable keys before starting the download. + // Uses load(items:) which works with HLS URLs directly, + // unlike load(playlist:) which expects a JW Platform JSON endpoint. + keyManager.markDownloadActive() + pendingDownloads[mediaId] = PendingDownload(mediaId: mediaId, fileURL: fileURL, playlistURL: fileURL) + + do { + let playerItem = try JWPlayerItemBuilder() + .file(fileURL) + .mediaId(mediaId) + .build() + contentLoader?.load(items: [playerItem]) + } catch { + keyManager.clearDownloadState() + pendingDownloads.removeValue(forKey: mediaId) + } + } + } else { + let pending = PendingDownload(mediaId: mediaId, fileURL: fileURL, playlistURL: fileURL) + startStreamDownload(for: pending) + } + + resolver(true) + } + + @objc + func isDownloaded(_ mediaId: String, + resolver: RCTPromiseResolveBlock, + rejecter: RCTPromiseRejectBlock) { + resolver(restoredURL(for: mediaId) != nil) + } + + @objc + func getDownloads(_ resolve: RCTPromiseResolveBlock, + rejecter: RCTPromiseRejectBlock) { + let defaults = UserDefaults.standard + let all = defaults.dictionaryRepresentation().keys + let prefix = savedDataKeyBase + + let results = all + .filter { $0.hasPrefix(prefix) } + .map { ["mediaId": String($0.dropFirst(prefix.count))] } + + resolve(results) + } + + @objc + func deleteDownload(_ mediaId: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + // Delete downloaded .movpkg + if let url = restoredURL(for: mediaId) { + do { + try FileManager.default.removeItem(at: url) + } catch { + rejecter("DELETE_FAILED", "Failed to delete media: \(error.localizedDescription)", error) + return + } + } + + removePersistedURL(for: mediaId) + + // Clean up DRM key files stored via appendingPathExtension + let documentsDir = keyManager.keyDirectory.deletingLastPathComponent() + let keyPrefix = keyManager.keyDirectory.lastPathComponent + let fm = FileManager.default + do { + let contents = try fm.contentsOfDirectory(at: documentsDir, includingPropertiesForKeys: nil) + for fileURL in contents { + let name = fileURL.lastPathComponent + if name.hasPrefix(keyPrefix) && name != keyPrefix { + try fm.removeItem(at: fileURL) + } + } + } catch { + // Key files may not exist for non-DRM content + } + + resolver(true) + } + + @objc + func getOfflinePlaylistItem(_ mediaId: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + guard let url = restoredURL(for: mediaId) else { + rejecter("NOT_FOUND", "No offline file found for mediaId: \(mediaId)", nil) + return + } + + let dataDir = url.appendingPathComponent("Data") + if let m3u8Path = firstM3U8File(inDirectory: dataDir) { + resolver([ + "mediaId": mediaId, + "file": url.absoluteString, + "m3u8File": m3u8Path, + "localURL": url.absoluteString, + "isOffline": true + ]) + } else { + rejecter("NO_PLAYLIST", "No .m3u8 file found in downloaded content", nil) + } + } + + private func firstM3U8File(inDirectory directoryURL: URL) -> String? { + let fm = FileManager.default + guard fm.fileExists(atPath: directoryURL.path) else { return nil } + + do { + let files = try fm.contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) + for fileURL in files { + let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]) + if resourceValues?.isRegularFile == true && fileURL.pathExtension.lowercased() == "m3u8" { + return fileURL.path + } + } + } catch { + // Directory not accessible + } + + return nil + } + + // MARK: - RCTEventEmitter + + override func supportedEvents() -> [String]! { + return ["onDownloadProgress", "onDownloadComplete", "onDownloadError"] + } + + override static func requiresMainQueueSetup() -> Bool { + return false + } +} + +// MARK: - Asset Download Delegate + +class OfflineAssetDownloadDelegate: NSObject, AVAssetDownloadDelegate { + var onProgress: ((String, Double) -> Void)? + var onComplete: ((String, URL) -> Void)? + var onError: ((String, Error) -> Void)? + + private var finalLocations: [Int: URL] = [:] + private var taskMediaIds: [Int: String] = [:] + + func register(task: URLSessionTask, mediaId: String) { + taskMediaIds[task.taskIdentifier] = mediaId + } + + func urlSession(_ session: URLSession, + aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, + willDownloadTo location: URL) { + finalLocations[aggregateAssetDownloadTask.taskIdentifier] = location + } + + func urlSession(_ session: URLSession, + aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, + didLoad timeRange: CMTimeRange, + totalTimeRangesLoaded loadedTimeRanges: [NSValue], + timeRangeExpectedToLoad: CMTimeRange, + for mediaSelection: AVMediaSelection) { + let loadedDuration = loadedTimeRanges + .map { $0.timeRangeValue } + .reduce(0.0) { partial, range in + let seconds = CMTimeGetSeconds(range.duration) + return partial + (seconds.isFinite ? seconds : 0.0) + } + + let expected = CMTimeGetSeconds(timeRangeExpectedToLoad.duration) + let progress = expected > 0 ? min(max(loadedDuration / expected, 0), 1) : 0 + + let mediaId = taskMediaIds[aggregateAssetDownloadTask.taskIdentifier] ?? "" + onProgress?(mediaId, progress * 100) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + let taskId = task.taskIdentifier + let mediaId = taskMediaIds[taskId] ?? task.taskDescription ?? "" + + if let error = error { + onError?(mediaId, error) + return + } + + guard let finalURL = finalLocations[taskId] else { return } + onComplete?(mediaId, finalURL) + } +} diff --git a/ios/RNJWPlayer/RNJWPlayerView.swift b/ios/RNJWPlayer/RNJWPlayerView.swift index 9e848df..9ce60b7 100644 --- a/ios/RNJWPlayer/RNJWPlayerView.swift +++ b/ios/RNJWPlayer/RNJWPlayerView.swift @@ -40,6 +40,11 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate, var audioMode: String! var audioCategoryOptions: [String]! var settingConfig: Bool = false + + // MARK: - Offline DRM Properties + var offlineContentLoader: JWDRMContentLoader? + var offlineKeyManager: JWDRMContentKeyManager? + var offlineKeyDataSource: JWDRMContentKeyDataSource? var pendingConfig: Bool = false var currentConfig: [String : Any]! var playerFailed = false @@ -1096,10 +1101,59 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate, } + // MARK: - Offline Playback Support + + /// Check if a file URL is offline content (.movpkg) + func isOfflineContent(_ fileString: String) -> Bool { + return fileString.hasPrefix("file://") && fileString.contains(".movpkg") + } + + /// Configure offline DRM playback by setting up the content loader + /// with DRM credentials before the player is configured. + func configureOfflinePlayback(for url: URL) { + var normalizedURL = url + if normalizedURL.absoluteString.hasSuffix("/") { + let trimmed = String(normalizedURL.absoluteString.dropLast()) + if let trimmedURL = URL(string: trimmed) { + normalizedURL = trimmedURL + } + } + + let dataSource = OfflineKeyDataSource() + dataSource.certificateURLStr = fairplayCertUrl + dataSource.processSPCURLStr = processSpcUrl + offlineKeyDataSource = dataSource + + offlineKeyManager = OfflineKeyManager() + + offlineContentLoader = JWDRMContentLoader( + dataSource: offlineKeyDataSource!, + keyManager: offlineKeyManager! + ) + + if let playerView = playerView { + playerView.player.contentKeyDataSource = nil + playerView.player.contentLoader = offlineContentLoader + } else if let playerViewController = playerViewController { + playerViewController.player.contentKeyDataSource = nil + playerViewController.player.contentLoader = offlineContentLoader + } + } + func getPlayerConfiguration(config: [String: Any]) throws -> JWPlayerConfiguration { let configBuilder:JWPlayerConfigurationBuilder! = JWPlayerConfigurationBuilder() var playlistArray = [JWPlayerItem]() + + if let playlist = config["playlist"] as? [[String: Any]] { + for item in playlist { + if let fileString = item["file"] as? String, + isOfflineContent(fileString), + let fileURL = URL(string: fileString) { + configureOfflinePlayback(for: fileURL) + } + } + } if let playlist = config["playlist"] as? [[String: Any]] { for item in playlist { @@ -1295,7 +1349,26 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate, func presentPlayerViewController(configuration: JWPlayerConfiguration!) { if configuration != nil { + // Check if playlist contains offline content and configure DRM before playback + if let playlist = currentConfig?["playlist"] as? [[String: Any]] { + for item in playlist { + if let fileString = item["file"] as? String, + isOfflineContent(fileString), + let fileURL = URL(string: fileString) { + let hasDrmConfig = (processSpcUrl != nil && !processSpcUrl.isEmpty) || + (fairplayCertUrl != nil && !fairplayCertUrl.isEmpty) + + if hasDrmConfig { + playerViewController.player.contentKeyDataSource = nil + configureOfflinePlayback(for: fileURL) + } + break + } + } + } + playerViewController.player.configurePlayer(with: configuration) + if (interfaceBehavior != nil) { playerViewController.interfaceBehavior = interfaceBehavior }