AVPlayer 播放器对远端缺失 Content-Range 功能的本地支持和处理

在 iOS 中提供最原生的视频播放体验,使用 AVPlayerController 和他的 view 是很棒的选择。同时 AVPlayer 的素材 AVPlayerItem 支持远端的视频地址,和常见的编码。通常来说 H264/HEVC 编码可以通过 AVPlayer 直接播放。

但是有一些比较 的后台路由,在定位文件的时候并不支持 Content-Range 和系列相关报头,则会导致无法播放。

对于较小的视频流来说,比如朋友圈的视频,我们可以手动下载并缓存,然后通过本地的 GCDWebServer 进行传送。

首先,我们要创建一个基本的 WebServer

let server = GCDWebServer()
currentServer = server
server.addDefaultHandler(
    forMethod: "GET",
    request: GCDWebServerRequest.self
) { request, completion in
    self.handle(request: request, completion: completion)
}
server.start(withPort: 0, bonjourName: nil)

然后,创建一个可编译成 JSON 的类,以便我们将请求的信息嵌入到 URL 中进行处理。

struct ProxyResource: Codable {
    let upstream: URL
    let size: Int // content-length
    let type: String
}

接下来准备两个方法,分别用于嵌入和解析 URL 中的视频信息。

func prepareLocalProxy(from url: URL, contentSize: Int? = nil, contentType: String? = "video/mp4") -> URL

func decodeLocalProxy(from url: URL) -> ProxyResource?

如此一来,我们只需要将这个 URL 塞给 AVPlayer 即可在 WebServer 中接到请求。这里列举必须要填入的报头。

HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1/1046987
Content-Length: 2
Content-Type: video/mp4
Connection: keep-alive

AVPlayer 在播放时,会首先发出一个 HEAD 的请求。(该请求实际 METHOD 为 GET,但 Content-Range 为 0-1,需要返回 2 字节)。接下来,便会发送剩余的 buffer 请求。具体请参考代码。由于我们要进行后台下载,不能一次性读取全部的 Range 数据,下面的代码会对文件切块。

func handle(request: GCDWebServerRequest, completion: GCDWebServerCompletionBlock) {
    
    guard let proxyRes = decodeLocalProxy(from: request.url) else {
        completion(GCDWebServerErrorResponse(statusCode: 503))
        return
    }

    // 调用下载器进行缓存
    prepareDownload(for: proxyRes.upstream)

    // 如果没有提供 Range 则使用全部文件长度
    var decisionRange = NSRange(location: 0, length: proxyRes.size)
    if request.hasByteRange() {
        decisionRange = request.byteRange
    }

    // 处理读取进度
    var offset = decisionRange.lowerBound

    // 写入的回掉 GCDWebServer 会负责调用我们
    // 我们只需要在他调用我们的时候读取并写回数据即可
    // 不要重复多次调用 completion
    let asyncResponse: GCDWebServerAsyncStreamBlock = { [self] completion in
        // 貌似要塞一个空的 Data 回去
        guard offset < decisionRange.upperBound else {
            completion(Data(), nil)
            return
        }

        // 开始准备数据
        var readLength = offset + Self.streamBlockSize
        if offset + readLength > decisionRange.upperBound {
            readLength = decisionRange.upperBound - offset
        }
        let range = offset ..< offset + readLength + 1
        offset += readLength

        // 从缓存中读取数据
        prepareData(
            for: proxyRes.upstream,
            inRange: range
        ) { result in
            switch result {
            case let .success(data): completion(data, nil)
            case let .failure(error): completion(nil, error)
            }
        }
    }

    // 准备返回请求
    let response = GCDWebServerStreamedResponse(
        contentType: proxyRes.type,
        asyncStreamBlock: asyncResponse
    )

    // 处理必要的报头
    response.setValue(proxyRes.type, forAdditionalHeader: "Content-Type") // Content-Type: video/mp4
    response.setValue("keep-alive", forAdditionalHeader: "Connection") // Connection: keep-alive
    if request.hasByteRange() {
        response.statusCode = 206 // HTTP/1.1 206 Partial Content
        let byteRangeStr = "\(request.byteRange.lowerBound)-\(request.byteRange.upperBound - 1)"
        // Content-Range: bytes 0-1/1046987
        response.setValue("bytes \(byteRangeStr)/\(proxyRes.size)", forAdditionalHeader: "Content-Range")
        let length = UInt(request.byteRange.length)
        response.contentLength = length // Content-Length: 2
    }

    // 发送响应
    completion(response)
    }
}

实现效果

对于更加完整的代码,@82flex 明后天会打包目前的实现为 Package,届时更新这里的内容。

3 个赞

群友提供了一个轮子 GitHub - ChangbaDevs/KTVHTTPCache: A powerful media cache framework.

这个 cache 不带 header 不管用

:+1::+1::+1:

牛啊啊啊啊啊啊啊啊啊