一个未知长度的资源的AVAssetResourceLoaderDelegate实现

8
我的iOS应用程序使用AVPlayer从服务器播放流音频并将其存储在设备上。 我实现了AVAssetResourceLoaderDelegate,以便拦截流。我更改了我的方案(从http到假方案),以便调用AVAssetResourceLoaderDelegate方法: func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool 我按照这个教程操作:

http://blog.jaredsinclair.com/post/149892449150/implementing-avassetresourceloaderdelegate-a

在那里,我放回了原始方案,并为从服务器拉取音频创建了一个会话。当我的服务器为流式音频文件提供Content-Length(以字节为单位的音频文件大小)头时,一切都运行得很完美。

但有时我要流式传输无法提前知道长度的音频文件(比如直播播客流)。在这种情况下,AVURLAsset将长度设置为-1并失败,显示以下错误:

"Error Domain=AVFoundationErrorDomain Code=-11849 \"Operation Stopped\" UserInfo={NSUnderlyingError=0x61800004abc0 {Error Domain=NSOSStatusErrorDomain Code=-12873 \"(null)\"}, NSLocalizedFailureReason=This media may be damaged., NSLocalizedDescription=Operation Stopped}"

我无法绕过此错误。我试图采用hacky方法,提供虚假的Content-Length: 999999999,但在这种情况下,一旦下载整个音频流,我的会话就会失败,并显示以下内容:

Loaded so far: 10349852 out of 99999999 The request timed out. //音频文件已下载,其大小为10349852 //AVPlayer尝试获取下一个块,然后由于请求超时而失败

有人之前遇到过这个问题吗?

P.S. 如果我在 AVURLAsset 中保留原始的 http 协议,AVPlayer 就知道如何处理这个协议,因此它可以播放音频文件(甚至没有 Content-Length),我不知道它是如何做到不失败的。此外,在这种情况下,我的 AVAssetResourceLoaderDelegate 没有被使用,因此我无法拦截并将音频文件的内容复制到本地存储中。

这是实现方式:

import AVFoundation

@objc protocol CachingPlayerItemDelegate {

    // called when file is fully downloaded
    @objc optional func playerItem(playerItem: CachingPlayerItem, didFinishDownloadingData data: NSData)

    // called every time new portion of data is received
    @objc optional func playerItemDownloaded(playerItem: CachingPlayerItem, didDownloadBytesSoFar bytesDownloaded: Int, outOf bytesExpected: Int)

    // called after prebuffering is finished, so the player item is ready to play. Called only once, after initial pre-buffering
    @objc optional func playerItemReadyToPlay(playerItem: CachingPlayerItem)

    // called when some media did not arrive in time to continue playback
    @objc optional func playerItemDidStopPlayback(playerItem: CachingPlayerItem)

    // called when deinit
    @objc optional func playerItemWillDeinit(playerItem: CachingPlayerItem)

}

extension URL {

    func urlWithCustomScheme(scheme: String) -> URL {
        var components = URLComponents(url: self, resolvingAgainstBaseURL: false)
        components?.scheme = scheme
        return components!.url!
    }

}

class CachingPlayerItem: AVPlayerItem {

    class ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDelegate, URLSessionDataDelegate, URLSessionTaskDelegate {

        var playingFromCache = false
        var mimeType: String? // is used if we play from cache (with NSData)

        var session: URLSession?
        var songData: NSData?
        var response: URLResponse?
        var pendingRequests = Set<AVAssetResourceLoadingRequest>()
        weak var owner: CachingPlayerItem?

        //MARK: AVAssetResourceLoader delegate

        func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {

            if playingFromCache { // if we're playing from cache
                // nothing to do here
            } else if session == nil { // if we're playing from url, we need to download the file
                let interceptedURL = loadingRequest.request.url!.urlWithCustomScheme(scheme: owner!.scheme!).deletingLastPathComponent()
                startDataRequest(withURL: interceptedURL)
            }

            pendingRequests.insert(loadingRequest)
            processPendingRequests()
            return true
        }

        func startDataRequest(withURL url: URL) {
            let request = URLRequest(url: url)
            let configuration = URLSessionConfiguration.default
            configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
            configuration.timeoutIntervalForRequest = 60.0
            configuration.timeoutIntervalForResource = 120.0
            session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
            let task = session?.dataTask(with: request)
            task?.resume()
        }

        func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
            pendingRequests.remove(loadingRequest)
        }

        //MARK: URLSession delegate

        func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
            (songData as! NSMutableData).append(data)
            processPendingRequests()
            owner?.delegate?.playerItemDownloaded?(playerItem: owner!, didDownloadBytesSoFar: songData!.length, outOf: Int(dataTask.countOfBytesExpectedToReceive))
        }

        func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
            completionHandler(URLSession.ResponseDisposition.allow)
            songData = NSMutableData()
            self.response = response
            processPendingRequests()
        }

        func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError err: Error?) {
            if let error = err {
                print(error.localizedDescription)
                return
            }
            processPendingRequests()
            owner?.delegate?.playerItem?(playerItem: owner!, didFinishDownloadingData: songData!)
        }

        //MARK:

        func processPendingRequests() {
            var requestsCompleted = Set<AVAssetResourceLoadingRequest>()
            for loadingRequest in pendingRequests {
                fillInContentInforation(contentInformationRequest: loadingRequest.contentInformationRequest)
                let didRespondCompletely = respondWithDataForRequest(dataRequest: loadingRequest.dataRequest!)
                if didRespondCompletely {
                    requestsCompleted.insert(loadingRequest)
                    loadingRequest.finishLoading()
                }
            }
            for i in requestsCompleted {
                pendingRequests.remove(i)
            }
        }

        func fillInContentInforation(contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
            // if we play from cache we make no URL requests, therefore we have no responses, so we need to fill in contentInformationRequest manually
            if playingFromCache {
                contentInformationRequest?.contentType = self.mimeType
                contentInformationRequest?.contentLength = Int64(songData!.length)
                contentInformationRequest?.isByteRangeAccessSupported = true
                return
            }

            // have no response from the server yet
            if  response == nil {
                return
            }

            let mimeType = response?.mimeType
            contentInformationRequest?.contentType = mimeType
            if response?.expectedContentLength != -1 {
                contentInformationRequest?.contentLength = response!.expectedContentLength
                contentInformationRequest?.isByteRangeAccessSupported = true
            } else {
                contentInformationRequest?.isByteRangeAccessSupported = false
            }
        }

        func respondWithDataForRequest(dataRequest: AVAssetResourceLoadingDataRequest) -> Bool {

            let requestedOffset = Int(dataRequest.requestedOffset)
            let requestedLength = dataRequest.requestedLength
            let startOffset = Int(dataRequest.currentOffset)

            // Don't have any data at all for this request
            if songData == nil || songData!.length < startOffset {
                return false
            }

            // This is the total data we have from startOffset to whatever has been downloaded so far
            let bytesUnread = songData!.length - Int(startOffset)

            // Respond fully or whaterver is available if we can't satisfy the request fully yet
            let bytesToRespond = min(bytesUnread, requestedLength + Int(requestedOffset))
            dataRequest.respond(with: songData!.subdata(with: NSMakeRange(startOffset, bytesToRespond)))

            let didRespondFully = songData!.length >= requestedLength + Int(requestedOffset)
            return didRespondFully

        }

        deinit {
            session?.invalidateAndCancel()
        }

    }

    private var resourceLoaderDelegate = ResourceLoaderDelegate()
    private var scheme: String?
    private var url: URL!

    weak var delegate: CachingPlayerItemDelegate?

    // use this initializer to play remote files
    init(url: URL) {

        self.url = url

        let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
        scheme = components.scheme

        let asset = AVURLAsset(url: url.urlWithCustomScheme(scheme: "fakeScheme").appendingPathComponent("/test.mp3"))
        asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main)
        super.init(asset: asset, automaticallyLoadedAssetKeys: nil)
        resourceLoaderDelegate.owner = self

        self.addObserver(self, forKeyPath: "status", options: NSKeyValueObservingOptions.new, context: nil)

        NotificationCenter.default.addObserver(self, selector: #selector(didStopHandler), name:NSNotification.Name.AVPlayerItemPlaybackStalled, object: self)

    }

    // use this initializer to play local files
    init(data: NSData, mimeType: String, fileExtension: String) {

        self.url = URL(string: "whatever://whatever/file.\(fileExtension)")

        resourceLoaderDelegate.songData = data
        resourceLoaderDelegate.playingFromCache = true
        resourceLoaderDelegate.mimeType = mimeType

        let asset = AVURLAsset(url: url)
        asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main)

        super.init(asset: asset, automaticallyLoadedAssetKeys: nil)
        resourceLoaderDelegate.owner = self

        self.addObserver(self, forKeyPath: "status", options: NSKeyValueObservingOptions.new, context: nil)

        NotificationCenter.default.addObserver(self, selector: #selector(didStopHandler), name:NSNotification.Name.AVPlayerItemPlaybackStalled, object: self)

    }

    func download() {
        if resourceLoaderDelegate.session == nil {
            resourceLoaderDelegate.startDataRequest(withURL: url)
        }
    }

    override init(asset: AVAsset, automaticallyLoadedAssetKeys: [String]?) {
        fatalError("not implemented")
    }

    // MARK: KVO
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        delegate?.playerItemReadyToPlay?(playerItem: self)
    }

    // MARK: Notification handlers

    func didStopHandler() {
        delegate?.playerItemDidStopPlayback?(playerItem: self)
    }

    // MARK:

    deinit {
        NotificationCenter.default.removeObserver(self)
        removeObserver(self, forKeyPath: "status")
        resourceLoaderDelegate.session?.invalidateAndCancel()
        delegate?.playerItemWillDeinit?(playerItem: self)
    }

}

说实话,你的问题帮了我很多。我之前对于如何正确实现AVAssetResourceLoaderDelegate有些迷茫,我想补充一下,博客文章的链接已经更改为→https://jaredsinclair.com/2016/09/03/implementing-avassetresourceload.html - undefined
2个回答

1
对于iOS来说,由于头文件不正确,你无法处理这种情况。系统认为你要播放普通音频文件,但它没有关于该文件的所有信息。你不知道音频持续时间是多少,除非你有一个直播流。在iOS上,直播流使用HTTP直播流协议进行。你的iOS代码是正确的。你需要修改后端并提供m3u8播放列表以进行直播流音频,然后iOS将其接受为直播流,并启动音频播放器轨迹。
一些相关信息可以在这里找到。作为一名具有流媒体音频/视频丰富经验的iOS开发者,我可以告诉你,播放直播/点播的代码是相同的。

这很奇怪。看看我的P.S.:附言:如果我在AVURLAsset中保留原始的http协议,AVPlayer就知道如何处理此协议,因此它可以正常播放音频文件(即使没有Content-Length),我不知道它是如何在不失败的情况下完成的。此外,在这种情况下,我的AVAssetResourceLoaderDelegate从未被使用过,因此我无法拦截并将音频文件的内容复制到本地存储。我仍然发送没有Content-Length头的常规mp3文件,它仍能正常播放。 - yeralin

0
但有时我会流式传输音频文件,而我无法提前提供它们的长度(比如直播播客流)。在这种情况下,AVURLAsset将长度设置为-1并失败。
在这种情况下,您应该让播放器稍后重新请求此数据,并为给定部分的contentInformationRequest属性设置renewalDate属性,以便在未来某个时间点可用时使用。
如果只是一个无限的直播流,您始终可以提供已获取部分的长度,并为下一个更新周期设置新的renewDate(根据我的观察,本地AVPlayer仅使用固定时间间隔更新此数据,例如每4-6秒)。服务器通常使用Expires http标头提供此类信息。您可以自己依赖此信息并实现类似于以下内容的内容(从我的apple developers forum问题中借鉴):
if let httpResonse = response as? HTTPURLResponse, let expirationValue = httpResonse.value(forHTTPHeaderField: "Expires") {
    let dateFormatter = DateFormatter()
    dateFormatter.locale = Locale(identifier: "en_US_POSIX")
    dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
    if let expirationDate = dateFormatter.date(from: expirationValue) {
        let renewDate = max(expirationDate, Date(timeIntervalSinceNow: 8))
        contentInformationRequest.renewalDate = renewDate
    }
}

This line let renewDate = max(expirationDate, Date(timeIntervalSinceNow: 8)) adds 8 seconds grace period for the player to load videos. Otherwise it does not keep up with the pace of renewals, and video loads in poor quality.

如果您事先知道它是一个没有固定长度的实时资产,并且您的服务器没有提供所需的信息,那么只需定期更新即可:

contentInformationRequest.renewalDate = Date(timeIntervalSinceNow: 8)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接