react-native-nitro-player 1.4.3 → 1.4.4-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -57,6 +57,10 @@ class TrackPlayerCore: NSObject {
|
|
|
57
57
|
internal let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
|
|
58
58
|
internal var didRequestUrlsForCurrentItem = false
|
|
59
59
|
|
|
60
|
+
// Follows http→https redirects that AVURLAsset otherwise silently drops (issue #111).
|
|
61
|
+
// Attached as the resource-loader delegate for cleartext-http assets only.
|
|
62
|
+
internal let redirectResolver = TrackPlayerRedirectResolver()
|
|
63
|
+
|
|
60
64
|
// MARK: - Stall & network recovery
|
|
61
65
|
// Whether the user/app wants playback ongoing (true after play(), false after pause()).
|
|
62
66
|
internal var intendedToPlay = false
|
|
@@ -216,6 +220,7 @@ class TrackPlayerCore: NSObject {
|
|
|
216
220
|
self.pathMonitor = nil
|
|
217
221
|
self.preloadedAssets.removeAll()
|
|
218
222
|
self.failedItemRetryCounts.removeAll()
|
|
223
|
+
self.redirectResolver.clear()
|
|
219
224
|
}
|
|
220
225
|
}
|
|
221
226
|
|
|
@@ -389,6 +389,20 @@ extension TrackPlayerCore {
|
|
|
389
389
|
return options
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
+
/// Builds the AVURLAsset for a track URL. Cleartext-`http` remote URLs are routed through
|
|
393
|
+
/// `redirectResolver` so AVFoundation follows http→https redirects it would otherwise drop
|
|
394
|
+
/// (issue #111). HTTPS and local URLs load natively, unchanged.
|
|
395
|
+
func makeAsset(for track: TrackItem, url: URL, isLocal: Bool) -> AVURLAsset {
|
|
396
|
+
let options = assetOptions(for: track, isLocal: isLocal)
|
|
397
|
+
guard !isLocal, let wrappedURL = TrackPlayerRedirectResolver.wrap(url) else {
|
|
398
|
+
return AVURLAsset(url: url, options: options)
|
|
399
|
+
}
|
|
400
|
+
redirectResolver.registerHeaders(httpHeaders(for: track), for: wrappedURL)
|
|
401
|
+
let asset = AVURLAsset(url: wrappedURL, options: options)
|
|
402
|
+
asset.resourceLoader.setDelegate(redirectResolver, queue: redirectResolver.queue)
|
|
403
|
+
return asset
|
|
404
|
+
}
|
|
405
|
+
|
|
392
406
|
/// Creates a gapless-optimized AVPlayerItem with proper buffering configuration
|
|
393
407
|
func createGaplessPlayerItem(for track: TrackItem, isPreload: Bool = false) -> AVPlayerItem? {
|
|
394
408
|
let effectiveUrlString = DownloadManagerCore.shared.getEffectiveUrl(track: track)
|
|
@@ -427,7 +441,7 @@ extension TrackPlayerCore {
|
|
|
427
441
|
asset = preloadedAsset
|
|
428
442
|
NitroPlayerLogger.log("TrackPlayerCore", "🚀 Using preloaded asset for \(track.title)")
|
|
429
443
|
} else {
|
|
430
|
-
asset =
|
|
444
|
+
asset = makeAsset(for: track, url: url, isLocal: isLocal)
|
|
431
445
|
}
|
|
432
446
|
|
|
433
447
|
let item = AVPlayerItem(asset: asset)
|
|
@@ -492,7 +506,7 @@ extension TrackPlayerCore {
|
|
|
492
506
|
url = remoteUrl
|
|
493
507
|
}
|
|
494
508
|
|
|
495
|
-
let asset =
|
|
509
|
+
let asset = self.makeAsset(for: track, url: url, isLocal: isLocal)
|
|
496
510
|
|
|
497
511
|
asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) { [weak self] in
|
|
498
512
|
var allKeysLoaded = true
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
//
|
|
2
|
+
// TrackPlayerRedirectResolver.swift
|
|
3
|
+
// NitroPlayer
|
|
4
|
+
//
|
|
5
|
+
// Created by Ritesh Shukla.
|
|
6
|
+
//
|
|
7
|
+
// AVURLAsset does not reliably follow HTTP→HTTPS redirects: when a cleartext `http://`
|
|
8
|
+
// track URL answers with a 30x (e.g. a universal `308 → https://...` upgrade), AVFoundation
|
|
9
|
+
// silently drops the redirect and the item fails to load (issue #111). Unlike ExoPlayer's
|
|
10
|
+
// `setAllowCrossProtocolRedirects(true)`, AVFoundation exposes no such flag.
|
|
11
|
+
//
|
|
12
|
+
// We work around it by giving such assets a sentinel URL scheme so AVFoundation has to ask
|
|
13
|
+
// this resource-loader delegate to load them. We then resolve the redirect chain ourselves
|
|
14
|
+
// with URLSession (which follows redirects) and hand AVFoundation a redirect to the final
|
|
15
|
+
// URL — after which it loads the media natively from the resolved location.
|
|
16
|
+
//
|
|
17
|
+
// Only cleartext-`http` URLs are routed here. HTTPS assets are left untouched: AVFoundation
|
|
18
|
+
// follows same-scheme redirects on its own, so only the failing http path pays the extra
|
|
19
|
+
// resolution request. The app must still permit the initial cleartext connection via ATS
|
|
20
|
+
// (`NSAppTransportSecurity`); this delegate only fixes the redirect-following, not ATS.
|
|
21
|
+
//
|
|
22
|
+
|
|
23
|
+
import AVFoundation
|
|
24
|
+
import Foundation
|
|
25
|
+
|
|
26
|
+
final class TrackPlayerRedirectResolver: NSObject, AVAssetResourceLoaderDelegate {
|
|
27
|
+
|
|
28
|
+
/// Sentinel scheme prefix. A wrapped URL looks like `nitroredirect+http://host/path`.
|
|
29
|
+
private static let schemePrefix = "nitroredirect+"
|
|
30
|
+
|
|
31
|
+
/// Serial queue the resource-loader delegate methods (and our task bookkeeping) run on.
|
|
32
|
+
let queue = DispatchQueue(label: "com.nitroplayer.redirect", qos: .userInitiated)
|
|
33
|
+
|
|
34
|
+
private let session: URLSession
|
|
35
|
+
|
|
36
|
+
// In-flight resolution tasks, keyed by loading request, so a cancelled request cancels its
|
|
37
|
+
// task. Touched only on `queue` (delegate callbacks + the task completion is hopped onto it).
|
|
38
|
+
private var tasksByRequest: [ObjectIdentifier: URLSessionDataTask] = [:]
|
|
39
|
+
|
|
40
|
+
// Custom request headers (e.g. `Authorization`) per wrapped-URL, so the resolution request can
|
|
41
|
+
// authenticate when a server requires auth even to return the redirect. Written on the player
|
|
42
|
+
// queue (registerHeaders), read on `queue`; guarded by `headersLock`.
|
|
43
|
+
private var headersByURL: [String: [String: String]] = [:]
|
|
44
|
+
private let headersLock = NSLock()
|
|
45
|
+
|
|
46
|
+
override init() {
|
|
47
|
+
let config = URLSessionConfiguration.ephemeral
|
|
48
|
+
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
49
|
+
session = URLSession(configuration: config)
|
|
50
|
+
super.init()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// MARK: - URL wrapping
|
|
54
|
+
|
|
55
|
+
/// Wraps a cleartext-`http` URL in the sentinel scheme so AVFoundation routes it here.
|
|
56
|
+
/// Returns nil for anything else (https, file, custom schemes) — those load natively.
|
|
57
|
+
static func wrap(_ url: URL) -> URL? {
|
|
58
|
+
guard url.scheme?.lowercased() == "http",
|
|
59
|
+
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
60
|
+
else { return nil }
|
|
61
|
+
components.scheme = schemePrefix + "http"
|
|
62
|
+
return components.url
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Restores the real URL from a sentinel-wrapped URL.
|
|
66
|
+
private static func unwrap(_ url: URL) -> URL? {
|
|
67
|
+
guard let scheme = url.scheme?.lowercased(), scheme.hasPrefix(schemePrefix),
|
|
68
|
+
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
69
|
+
else { return nil }
|
|
70
|
+
components.scheme = String(scheme.dropFirst(schemePrefix.count))
|
|
71
|
+
return components.url
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// MARK: - Header registry
|
|
75
|
+
|
|
76
|
+
func registerHeaders(_ headers: [String: String]?, for wrappedURL: URL) {
|
|
77
|
+
guard let headers, !headers.isEmpty else { return }
|
|
78
|
+
headersLock.lock()
|
|
79
|
+
defer { headersLock.unlock() }
|
|
80
|
+
headersByURL[wrappedURL.absoluteString] = headers
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Releases all registered headers. Call when the player is torn down.
|
|
84
|
+
func clear() {
|
|
85
|
+
headersLock.lock()
|
|
86
|
+
defer { headersLock.unlock() }
|
|
87
|
+
headersByURL.removeAll()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private func headers(for wrappedURL: URL) -> [String: String]? {
|
|
91
|
+
headersLock.lock()
|
|
92
|
+
defer { headersLock.unlock() }
|
|
93
|
+
return headersByURL[wrappedURL.absoluteString]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// MARK: - AVAssetResourceLoaderDelegate
|
|
97
|
+
|
|
98
|
+
func resourceLoader(
|
|
99
|
+
_ resourceLoader: AVAssetResourceLoader,
|
|
100
|
+
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
|
|
101
|
+
) -> Bool {
|
|
102
|
+
guard let wrappedURL = loadingRequest.request.url,
|
|
103
|
+
let realURL = Self.unwrap(wrappedURL)
|
|
104
|
+
else { return false }
|
|
105
|
+
|
|
106
|
+
var request = URLRequest(url: realURL)
|
|
107
|
+
// HEAD is enough: we only need `response.url` after URLSession follows the redirect chain.
|
|
108
|
+
// Even if the resolved endpoint rejects HEAD (405), the post-redirect URL is still reported.
|
|
109
|
+
request.httpMethod = "HEAD"
|
|
110
|
+
headers(for: wrappedURL)?.forEach { request.setValue($1, forHTTPHeaderField: $0) }
|
|
111
|
+
loadingRequest.request.allHTTPHeaderFields?.forEach { request.setValue($1, forHTTPHeaderField: $0) }
|
|
112
|
+
|
|
113
|
+
let key = ObjectIdentifier(loadingRequest)
|
|
114
|
+
let task = session.dataTask(with: request) { [weak self] _, response, error in
|
|
115
|
+
self?.queue.async {
|
|
116
|
+
guard let self else { return }
|
|
117
|
+
self.tasksByRequest.removeValue(forKey: key)
|
|
118
|
+
guard !loadingRequest.isCancelled, !loadingRequest.isFinished else { return }
|
|
119
|
+
|
|
120
|
+
if let error {
|
|
121
|
+
NitroPlayerLogger.log("TrackPlayerCore", "❌ Redirect resolution failed for \(realURL): \(error.localizedDescription)")
|
|
122
|
+
loadingRequest.finishLoading(with: error)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// `response.url` is the final URL after URLSession followed all redirects.
|
|
127
|
+
let finalURL = response?.url ?? realURL
|
|
128
|
+
if finalURL != realURL {
|
|
129
|
+
NitroPlayerLogger.log("TrackPlayerCore", "↪️ Resolved redirect \(realURL) → \(finalURL)")
|
|
130
|
+
}
|
|
131
|
+
var redirect = URLRequest(url: finalURL)
|
|
132
|
+
self.headers(for: wrappedURL)?.forEach { redirect.setValue($1, forHTTPHeaderField: $0) }
|
|
133
|
+
loadingRequest.redirect = redirect
|
|
134
|
+
loadingRequest.response = HTTPURLResponse(
|
|
135
|
+
url: finalURL, statusCode: 302, httpVersion: "HTTP/1.1", headerFields: nil)
|
|
136
|
+
loadingRequest.finishLoading()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
tasksByRequest[key] = task
|
|
140
|
+
task.resume()
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func resourceLoader(
|
|
145
|
+
_ resourceLoader: AVAssetResourceLoader,
|
|
146
|
+
didCancel loadingRequest: AVAssetResourceLoadingRequest
|
|
147
|
+
) {
|
|
148
|
+
tasksByRequest.removeValue(forKey: ObjectIdentifier(loadingRequest))?.cancel()
|
|
149
|
+
}
|
|
150
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-nitro-player",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4-alpha.0",
|
|
4
4
|
"description": "A powerful audio player library for React Native with playlist management, playback controls, and support for Android Auto and CarPlay",
|
|
5
5
|
"main": "lib/index",
|
|
6
6
|
"module": "lib/index",
|