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
}