502 lines
23 KiB
Swift
502 lines
23 KiB
Swift
//
|
||
// MP_AVURLAsset.swift
|
||
// MusicPlayer
|
||
//
|
||
// Created by Mr.Zhou on 2024/5/23.
|
||
//
|
||
|
||
import UIKit
|
||
import AVFoundation
|
||
extension URL {
|
||
func convertCustomSchemeURL(_ scheme: String) -> URL? {
|
||
var components = URLComponents(url: self, resolvingAgainstBaseURL: false)
|
||
components?.scheme = scheme
|
||
return components?.url
|
||
}
|
||
|
||
}
|
||
///媒体加载协议
|
||
@objc protocol MP_AVPlayerItemDelegate {
|
||
///当媒体项目初次缓存后
|
||
@objc func playerItemReadyToPlay(_ playerItem: MP_AVPlayerItem)
|
||
///当媒体项目收到新缓存后
|
||
@objc func playerItem(_ playerItem: MP_AVPlayerItem, progress:Float)
|
||
///当媒体项目完全加载后
|
||
@objc func playerItem(_ playerItem: MP_AVPlayerItem, didFinishLoadingData data:Data)
|
||
///当媒体项目加载数据中断(比如断网了),导致停止播放时
|
||
@objc func playerItemPlaybackStalled(_ playerItem: MP_AVPlayerItem)
|
||
///当媒体项目加载错误时调用。
|
||
@objc func playerItem(_ playerItem: MP_AVPlayerItem, loadingError error:Error)
|
||
}
|
||
///自定义媒体项目
|
||
class MP_AVPlayerItem: AVPlayerItem {
|
||
///拦截协议
|
||
var resourceLoaderDelegate:MP_ResourceLoaderDelegate!
|
||
///地址路径
|
||
fileprivate var url:URL
|
||
///原生Scheme
|
||
fileprivate let initialScheme:String?
|
||
///自定义文件扩展名
|
||
fileprivate var customFileExtension:String?
|
||
///拦截式scheme
|
||
fileprivate let customScheme = "myapp"
|
||
///媒体项目标题
|
||
fileprivate var title:String = "Random"
|
||
///媒体ID
|
||
fileprivate var videoId:String = "Random"
|
||
///媒体资产
|
||
fileprivate var resourceAsset:MP_AVURLAsset!
|
||
|
||
///媒体项目协议
|
||
weak var delegate:MP_AVPlayerItemDelegate?
|
||
|
||
///自定义初始化
|
||
init(url: URL, bitrate:Int64, customFileExtension: String? = "mp4", title:String?, videoId:String?) {
|
||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||
let scheme = components.scheme,
|
||
var urlWithCustomScheme = url.convertCustomSchemeURL(customScheme) else {
|
||
fatalError("不支持没有scheme的URL")
|
||
}
|
||
self.url = url
|
||
self.initialScheme = scheme
|
||
//将扩展文件名赋予自定义scheme
|
||
if let stringPath = customFileExtension {
|
||
//先删除扩展名(网络文件路径一般没有)
|
||
urlWithCustomScheme.deletePathExtension()
|
||
//扩展路径
|
||
urlWithCustomScheme.appendPathExtension(stringPath)
|
||
//更新自定义文件扩展名
|
||
self.customFileExtension = stringPath
|
||
}
|
||
if let title = title, let videoId = videoId {
|
||
//更新标题
|
||
self.title = title
|
||
self.videoId = videoId
|
||
}
|
||
resourceLoaderDelegate = .init(bitrate)
|
||
resourceAsset = MP_AVURLAsset(url: urlWithCustomScheme, delegate: resourceLoaderDelegate, title: title ?? "")
|
||
//生成一个媒体资产
|
||
super.init(asset: resourceAsset, automaticallyLoadedAssetKeys: nil)
|
||
//为拦截协议传入指定的playerItem
|
||
resourceLoaderDelegate.playItem = self
|
||
//kvo监听项目状态
|
||
addObserver(self, forKeyPath: "status", options: [.old,.new], context: nil)
|
||
//kvo监听项目加载值
|
||
addObserver(self, forKeyPath: "loadedTimeRanges", options: [.old,.new], context: nil)
|
||
//kvo监听项目准备情况
|
||
addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: [.old,.new], context: nil)
|
||
//kvo监听项目报错
|
||
addObserver(self, forKeyPath: "error", options: [.old,.new], context: nil)
|
||
NotificationCenter.default.addObserver(self, selector: #selector(playbackStalledHandler), name:NSNotification.Name.AVPlayerItemPlaybackStalled, object: self)
|
||
}
|
||
//MARK: - KVO实现
|
||
override open func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||
guard let keyPath = keyPath else {
|
||
return
|
||
}
|
||
//根据keyPath检索
|
||
switch keyPath {
|
||
case "status"://playerItem状态
|
||
if let statuValue = change?[.newKey] as? Int, statuValue == AVPlayerItem.Status.readyToPlay.rawValue {
|
||
//判断当前播放器是否在播放当前音乐中
|
||
print("当前音乐-\(title) 已经准备好播放")
|
||
}
|
||
case "loadedTimeRanges"://当前缓冲进度
|
||
guard self.videoId == MP_PlayerManager.shared.loadPlayer.currentVideo?.song.videoId else {return}
|
||
let timeRanges = self.loadedTimeRanges.map({$0.timeRangeValue})
|
||
//获取当前播放Item的缓冲值组
|
||
if let first = timeRanges.first {
|
||
//获取开始时间的秒数
|
||
let startSeconds = first.start.seconds
|
||
//获取缓冲区的持续时间
|
||
let durationSeconds = first.duration.seconds
|
||
//计算当前缓冲总时间
|
||
let bufferedSeconds = startSeconds + durationSeconds
|
||
//获取当前播放音乐资源的最大时间值
|
||
let maxDuration = CMTimeGetSeconds(self.duration)
|
||
let progress = bufferedSeconds/maxDuration
|
||
//传递缓存值
|
||
delegate?.playerItem(self, progress: Float(progress))
|
||
}
|
||
case "playbackLikelyToKeepUp"://是否存在足够的数据开始播放
|
||
if let playbackLikelyToKeepUp = change?[.newKey] as? Bool, playbackLikelyToKeepUp == true {
|
||
//有足够的数据来支持播放
|
||
delegate?.playerItemReadyToPlay(self)
|
||
}else {
|
||
//没有足够的数据支持播放
|
||
delegate?.playerItemPlaybackStalled(self)
|
||
}
|
||
case "error":
|
||
if let error = change?[.newKey] as? Error {
|
||
print("当前音乐-\(title) 未做好准备播放,失败原因是\(error)")
|
||
delegate?.playerItem(self, loadingError: error)
|
||
}
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
//playerItem突然停止了
|
||
@objc func playbackStalledHandler() {
|
||
delegate?.playerItemPlaybackStalled(self)
|
||
}
|
||
|
||
deinit {
|
||
print("已销毁\(title)")
|
||
NotificationCenter.default.removeObserver(self)
|
||
removeObserver(self, forKeyPath: "status")
|
||
removeObserver(self, forKeyPath: "loadedTimeRanges")
|
||
removeObserver(self, forKeyPath: "playbackLikelyToKeepUp")
|
||
removeObserver(self, forKeyPath: "error")
|
||
//终止网络请求
|
||
resourceLoaderDelegate.session?.invalidateAndCancel()
|
||
//清理资产空间
|
||
resourceAsset = nil
|
||
}
|
||
}
|
||
///自定义媒体资产
|
||
class MP_AVURLAsset: AVURLAsset {
|
||
init(url URL: URL, delegate:MP_ResourceLoaderDelegate, title:String) {
|
||
super.init(url: URL, options: nil)
|
||
self.resourceLoader.setDelegate(delegate, queue: .main)
|
||
//对该Asset实现预加载,以让Asset触发resourceLoaderdelegate
|
||
// 加载关键的播放属性
|
||
let keys = ["playable"]
|
||
self.loadValuesAsynchronously(forKeys: keys) {
|
||
[weak self] in
|
||
// 检查加载属性的结果
|
||
for key in keys {
|
||
var error: NSError?
|
||
let status = self?.statusOfValue(forKey: key, error: &error)
|
||
if status == .loaded {
|
||
print("开始对\(title)的预加载")
|
||
} else {
|
||
// 处理加载失败的情况
|
||
print("无法加载 \(key): \(String(describing: error))")
|
||
//一般是网络数据问题,需要重新请求一遍
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
///媒体资源加载代理
|
||
class MP_ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDelegate, URLSessionDataDelegate, URLSessionTaskDelegate {
|
||
//判断缓存空间的数据是否完整
|
||
private var iscompleted:Bool?
|
||
// 从内存播放时需要
|
||
var mimeType: String?
|
||
//比特率
|
||
var bitrate:Int64
|
||
//网络会话
|
||
var session: URLSession?
|
||
//媒体数据
|
||
var mediaData: Data?
|
||
//媒体数据块
|
||
var mediaDataBlocks: [MediaDataBlock]?
|
||
//响应体
|
||
var response: URLResponse?
|
||
//活跃的加载请求
|
||
var pendingRequests = Set<AVAssetResourceLoadingRequest>()
|
||
//缓存中的最长长度值
|
||
var maxCount:Int64?
|
||
//缓存情况
|
||
var progress:Float = 0
|
||
weak var playItem: MP_AVPlayerItem?
|
||
//最后加载范围
|
||
private var lastRequestedEndOffset:Int64?
|
||
|
||
init(_ bitrate:Int64) {
|
||
self.bitrate = bitrate
|
||
super.init()
|
||
//添加关于用手动seek的监听
|
||
NotificationCenter.notificationKey.add(observer: self, selector: #selector(seekAction(_ :)), notificationName: .positive_player_seek)
|
||
}
|
||
|
||
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
|
||
guard let initialUrl = playItem?.url else {
|
||
fatalError("internal inconsistency")
|
||
}
|
||
//判断session是否存在
|
||
if session == nil {
|
||
//判断加载数据方法
|
||
if let media = MP_CacheManager.shared.data(forKey: playItem?.videoId ?? "") {
|
||
print("缓存中存在\(playItem?.title ?? ""),取出中")
|
||
//从缓存中去获取数据,并覆盖响应数据
|
||
mediaData = media.data
|
||
mediaDataBlocks = media.dataBlocks
|
||
//判断是否完整
|
||
if media.isComplete == false {
|
||
print("\(playItem?.title ?? "")数据不完整,开始网络请求相关数据")
|
||
maxCount = media.maxCount
|
||
//缓存中的数据是不完整的,继续网络请求
|
||
continuationDataRequest(with: initialUrl, count: Int64(media.data.count))
|
||
iscompleted = false
|
||
}else {
|
||
iscompleted = true
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||
guard let self = self else {return}
|
||
if MP_PlayerManager.shared.loadPlayer.currentVideo?.song.videoId == playItem?.videoId {
|
||
playItem?.delegate?.playerItem(playItem!, didFinishLoadingData: media.data)
|
||
}
|
||
}
|
||
}
|
||
}else {
|
||
print("开始网络请求\(playItem?.title ?? "")相关数据")
|
||
//初次请求
|
||
startDataRequest(with: initialUrl)
|
||
}
|
||
}else {
|
||
}
|
||
pendingRequests.insert(loadingRequest)
|
||
// processPendingRequests()
|
||
return true
|
||
|
||
}
|
||
@objc private func seekAction(_ sender:Notification) {
|
||
if playItem?.videoId == MP_PlayerManager.shared.loadPlayer.currentVideo.song.videoId {
|
||
if let progress = sender.object as? Double {
|
||
let requestedOffset = progress * Double(maxCount ?? 0)
|
||
// 测试是否发送网络请求
|
||
if requestedOffset > Double(mediaData?.count ?? 0) {
|
||
print("用户滚动到了当前缓存范围外")
|
||
// 用户seek位置在媒体数据范围内,假设这里已经有了初始URL
|
||
let initialUrl = playItem?.url
|
||
continuationDataRequest(with: initialUrl!, count: Int64(requestedOffset))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/// 开始网络请求
|
||
/// - Parameters:
|
||
/// - url: 网络请求地址
|
||
func startDataRequest(with url: URL) {
|
||
//取消之前的请求方法
|
||
session?.invalidateAndCancel()
|
||
let configuration = URLSessionConfiguration.default
|
||
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||
session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
||
session?.dataTask(with: url).resume()
|
||
}
|
||
///继续请求网络资源(因各种原因中断时)
|
||
/// - Parameters:
|
||
/// - url: 网络请求地址
|
||
/// - data: 数据内容
|
||
func continuationDataRequest(with url: URL, count:Int64) {
|
||
if session == nil {
|
||
let configuration = URLSessionConfiguration.default
|
||
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||
session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
||
}else {
|
||
//取消之前的请求方法
|
||
session?.getAllTasks(completionHandler: { tasks in
|
||
//取消所有方法
|
||
tasks.forEach({$0.cancel()})
|
||
})
|
||
}
|
||
//设置一个网络请求
|
||
var request = URLRequest(url: url)
|
||
//设置请求头内容
|
||
let bytes = "bytes=\(count)-"
|
||
request.addValue(bytes, forHTTPHeaderField: "Range")
|
||
request.addValue(url.host ?? "", forHTTPHeaderField: "Host")
|
||
request.addValue("video/mp4", forHTTPHeaderField: "Accept")
|
||
session?.dataTask(with: request).resume()
|
||
}
|
||
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
|
||
pendingRequests.remove(loadingRequest)
|
||
}
|
||
|
||
// MARK: URLSession
|
||
///网络请求响应体内容
|
||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
||
guard response.mimeType?.contains("video") == true else {
|
||
print("\(playItem?.title ?? "")网络地址不可用,响应类型是\(response.mimeType ?? "")")
|
||
//重新请求数据
|
||
MP_NetWorkManager.shared.requestPlayer(playItem?.videoId ?? "", playlistId: "") {[weak self] resourceUrls, coverUrls in
|
||
//只需要重新配置第一条网络资源地址
|
||
guard let self = self else {return}
|
||
print("\(playItem?.title ?? "")重新加载一次")
|
||
let url = URL(string: resourceUrls.0.first ?? "")!
|
||
playItem?.url = url
|
||
if mediaData == nil {
|
||
startDataRequest(with: url)
|
||
}else {
|
||
continuationDataRequest(with: url, count: Int64(mediaData!.count))
|
||
}
|
||
}
|
||
return
|
||
}
|
||
completionHandler(Foundation.URLSession.ResponseDisposition.allow)
|
||
if mediaData == nil {
|
||
mediaData = Data()
|
||
mediaDataBlocks = []
|
||
}
|
||
self.response = response
|
||
processPendingRequests()
|
||
}
|
||
///网络请求数据更新
|
||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||
guard let response = dataTask.response as? HTTPURLResponse,
|
||
let contentRange = response.allHeaderFields["Accept-Ranges"] as? String else {
|
||
print("\(playItem?.title ?? "")数据更新失败")
|
||
return
|
||
}
|
||
//从 Content-Range 标头中提取范围信息
|
||
let rangeInfo = extractRangeInfo(contentRange)
|
||
//使用范围的起始偏移量和接收到的数据创建一个 DataSegment
|
||
let dataSegment = MediaDataBlock(offset: (rangeInfo?.start ?? 0), data: data)
|
||
mediaData?.append(data)
|
||
mediaDataBlocks?.append(dataSegment)
|
||
if mediaData != nil {
|
||
//存入缓存数据
|
||
MP_CacheManager.shared.cacheData(mediaData!, dataBlocks: mediaDataBlocks!, forKey: MP_PlayerManager.shared.loadPlayer.currentVideo?.song.videoId ?? "", isComplete: false, maxCount: self.response?.expectedContentLength ?? 0)
|
||
}
|
||
processPendingRequests()
|
||
//更新最后一次加载完成的范围量
|
||
self.lastRequestedEndOffset = Int64(data.count)
|
||
}
|
||
//解析 Content-Range 标头以提取字节范围
|
||
private func extractRangeInfo(_ contentRange: String) -> (start: Int64, end: Int64)? {
|
||
// 正则表达式用于匹配 "bytes start-end/total" 格式
|
||
let pattern = "bytes (\\d+)-(\\d+)/(\\d+|\\*)"
|
||
// 尝试创建正则表达式
|
||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
|
||
return nil
|
||
}
|
||
// 在 Content-Range 标头中查找匹配项
|
||
let nsRange = NSRange(contentRange.startIndex..<contentRange.endIndex, in: contentRange)
|
||
guard let match = regex.firstMatch(in: contentRange, options: [], range: nsRange) else {
|
||
return nil
|
||
}
|
||
// 提取并转换匹配到的数值
|
||
let startString = (contentRange as NSString).substring(with: match.range(at: 1))
|
||
let endString = (contentRange as NSString).substring(with: match.range(at: 2))
|
||
let totalString = (contentRange as NSString).substring(with: match.range(at: 3))
|
||
|
||
guard let start = Int64(startString), let end = Int64(endString), let total = totalString == "*" ? nil : Int64(totalString) else {
|
||
return nil
|
||
}
|
||
return (start: start, end: end)
|
||
}
|
||
///网络请求结束/错误
|
||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||
if let errorUnwrapped = error as NSError?, errorUnwrapped.domain == NSURLErrorDomain && errorUnwrapped.code == NSURLErrorCancelled {
|
||
//主动取消了
|
||
print("\(playItem?.title ?? "")网络请求被释放了")
|
||
if mediaData != nil {
|
||
//存入缓存数据
|
||
MP_CacheManager.shared.cacheData(mediaData!, dataBlocks: mediaDataBlocks!, forKey: MP_PlayerManager.shared.loadPlayer.currentVideo?.song.videoId ?? "", isComplete: true, maxCount: response?.expectedContentLength ?? 0)
|
||
}
|
||
processPendingRequests()
|
||
}else if let error = error {
|
||
print("\(playItem?.title ?? "")-网络请求失败,原因为:\(error)")
|
||
playItem?.delegate?.playerItem(playItem!, loadingError: error)
|
||
return
|
||
}else {
|
||
//完成媒体资源加载
|
||
if mediaData != nil {
|
||
//存入缓存数据
|
||
MP_CacheManager.shared.cacheData(mediaData!, dataBlocks: mediaDataBlocks!, forKey: MP_PlayerManager.shared.loadPlayer.currentVideo?.song.videoId ?? "", isComplete: true, maxCount: response?.expectedContentLength ?? 0)
|
||
}
|
||
print("\(playItem?.title ?? "")资源加载完成")
|
||
//更新最后一次加载完成的范围量
|
||
self.lastRequestedEndOffset = response?.expectedContentLength
|
||
processPendingRequests()
|
||
playItem?.delegate?.playerItem(playItem!, didFinishLoadingData: mediaData!)
|
||
}
|
||
}
|
||
|
||
func processPendingRequests() {
|
||
// 获取所有已满的请求
|
||
let requestsFulfilled = Set<AVAssetResourceLoadingRequest>(pendingRequests.compactMap {
|
||
self.fillInContentInformationRequest($0.contentInformationRequest)
|
||
if self.haveEnoughDataToFulfillRequest($0.dataRequest!) {
|
||
$0.finishLoading()
|
||
return $0
|
||
}
|
||
return nil
|
||
})
|
||
// 从待处理的请求中删除已完成的请求
|
||
_ = requestsFulfilled.map { self.pendingRequests.remove($0) }
|
||
|
||
}
|
||
///填写contentInformationRequest
|
||
func fillInContentInformationRequest(_ contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
|
||
//如果缓存数据加载完全了
|
||
if iscompleted == true {
|
||
contentInformationRequest?.contentType = "video/mp4"
|
||
self.maxCount = Int64(mediaData!.count)
|
||
contentInformationRequest?.contentLength = Int64(mediaData!.count)
|
||
contentInformationRequest?.isByteRangeAccessSupported = true
|
||
return
|
||
}
|
||
//通过获取响应体,填写contentInformationRequest
|
||
if let responseUnwrapped = response {
|
||
contentInformationRequest?.contentType = responseUnwrapped.mimeType
|
||
self.maxCount = responseUnwrapped.expectedContentLength
|
||
contentInformationRequest?.contentLength = responseUnwrapped.expectedContentLength
|
||
contentInformationRequest?.isByteRangeAccessSupported = true
|
||
}else if let maxCount = maxCount {
|
||
contentInformationRequest?.contentType = "video/mp4"
|
||
contentInformationRequest?.contentLength = maxCount
|
||
contentInformationRequest?.isByteRangeAccessSupported = true
|
||
}
|
||
}
|
||
///判读请求是否有足够的数据
|
||
func haveEnoughDataToFulfillRequest(_ dataRequest: AVAssetResourceLoadingDataRequest) -> Bool {
|
||
let requestedOffset = Int64(dataRequest.requestedOffset)
|
||
let requestedLength = dataRequest.requestedLength
|
||
let currentOffset = Int64(dataRequest.currentOffset)
|
||
// 需要构建一个包含请求范围数据的Data对象
|
||
var responseData = Data()
|
||
|
||
// 遍历存储的数据块,查找并拼接满足请求范围的数据块
|
||
// 假设数据块已经按照偏移量升序排序
|
||
for item in (mediaDataBlocks ?? []).sorted(by: { $0.offset < $1.offset }) {
|
||
// 判断当前数据块是否在请求范围内
|
||
if item.offset < currentOffset + Int64(requestedLength) && item.offset + Int64(item.data.count) > currentOffset {
|
||
// 计算数据块在请求范围内的部分,并追加到responseData
|
||
let start = max(currentOffset, item.offset) - item.offset
|
||
let end = min(currentOffset + Int64(requestedLength), item.offset + Int64(item.data.count)) - item.offset
|
||
if start < end { // 确保范围有效
|
||
responseData.append(item.data.subdata(in: Int(start)..<Int(end)))
|
||
}
|
||
}
|
||
}
|
||
// 检查是否成功构建了包含整个请求范围数据的responseData
|
||
if responseData.count >= requestedLength {
|
||
// 使用请求范围内的数据回应dataRequest
|
||
dataRequest.respond(with: responseData)
|
||
return true
|
||
}
|
||
|
||
// 得到的数据长度不满足请求长度,返回false
|
||
return false
|
||
// guard let songDataUnwrapped = mediaData,
|
||
// songDataUnwrapped.count > currentOffset else {
|
||
// return false
|
||
// }
|
||
// let bytesToRespond = min(songDataUnwrapped.count - currentOffset, requestedLength)
|
||
// let dataToRespond = songDataUnwrapped.subdata(in: Range(uncheckedBounds: (currentOffset, currentOffset + bytesToRespond)))
|
||
// dataRequest.respond(with: dataToRespond)
|
||
// return songDataUnwrapped.count >= requestedLength + requestedOffset
|
||
|
||
}
|
||
|
||
deinit {
|
||
NotificationCenter.default.removeObserver(self)
|
||
session?.invalidateAndCancel()
|
||
mediaData = nil
|
||
mediaDataBlocks = nil
|
||
}
|
||
|
||
}
|
||
///媒体数据模型
|
||
struct MediaDataBlock: Codable {
|
||
///数据偏移量
|
||
var offset:Int64
|
||
///数据块内容
|
||
var data:Data
|
||
}
|