From edb4b834aa925fe74ddc62eaffcbe5af4679bd90 Mon Sep 17 00:00:00 2001 From: yicheng <11733500+yichengchen@users.noreply.github.com> Date: Mon, 13 May 2024 17:23:06 +0800 Subject: [PATCH] misc: add LivePlayerViewModel --- BilibiliLive.xcodeproj/project.pbxproj | 4 + .../Player/CommonPlayerViewController.swift | 4 +- .../Module/Live/LiveDanMuProvider.swift | 6 +- .../Live/LivePlayerViewController.swift | 194 +++---------- .../Module/Live/LivePlayerViewModel.swift | 255 ++++++++++++++++++ 5 files changed, 294 insertions(+), 169 deletions(-) create mode 100644 BilibiliLive/Module/Live/LivePlayerViewModel.swift diff --git a/BilibiliLive.xcodeproj/project.pbxproj b/BilibiliLive.xcodeproj/project.pbxproj index 068129b..3235a2e 100644 --- a/BilibiliLive.xcodeproj/project.pbxproj +++ b/BilibiliLive.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 490EC3E7290CC8F8001E00B6 /* RankingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490EC3E6290CC8F8001E00B6 /* RankingViewController.swift */; }; 490EC3E9290CE23E001E00B6 /* BLSettingLineCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490EC3E8290CE23E001E00B6 /* BLSettingLineCollectionViewCell.swift */; }; 492731EE29096677005F5B0A /* HotViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492731ED29096677005F5B0A /* HotViewController.swift */; }; + 493307FD2BF230DB003622ED /* LivePlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 493307FC2BF230DB003622ED /* LivePlayerViewModel.swift */; }; 49389D6228AFEA2900B9DAFD /* VideoDanmuProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49389D6128AFEA2900B9DAFD /* VideoDanmuProvider.swift */; }; 49389D8928B0A1B700B9DAFD /* UIViewController+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49389D8828B0A1B700B9DAFD /* UIViewController+Ext.swift */; }; 49389D8C28B0A84500B9DAFD /* PersonalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49389D8B28B0A84500B9DAFD /* PersonalViewController.swift */; }; @@ -142,6 +143,7 @@ 490EC3E6290CC8F8001E00B6 /* RankingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RankingViewController.swift; sourceTree = ""; }; 490EC3E8290CE23E001E00B6 /* BLSettingLineCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLSettingLineCollectionViewCell.swift; sourceTree = ""; }; 492731ED29096677005F5B0A /* HotViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotViewController.swift; sourceTree = ""; }; + 493307FC2BF230DB003622ED /* LivePlayerViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LivePlayerViewModel.swift; sourceTree = ""; }; 49389D6128AFEA2900B9DAFD /* VideoDanmuProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDanmuProvider.swift; sourceTree = ""; }; 49389D8828B0A1B700B9DAFD /* UIViewController+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Ext.swift"; sourceTree = ""; }; 49389D8B28B0A84500B9DAFD /* PersonalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalViewController.swift; sourceTree = ""; }; @@ -349,6 +351,7 @@ 2D3CD4002635BE7C00191419 /* Live */ = { isa = PBXGroup; children = ( + 493307FC2BF230DB003622ED /* LivePlayerViewModel.swift */, F927ED982610AD8D00EAB8E3 /* LiveViewController.swift */, F9B57355260F5F7400771ED5 /* LivePlayerViewController.swift */, F927ED672610113A00EAB8E3 /* LiveDanMuProvider.swift */, @@ -876,6 +879,7 @@ 490EC3E9290CE23E001E00B6 /* BLSettingLineCollectionViewCell.swift in Sources */, F9B57354260F5F7400771ED5 /* AppDelegate.swift in Sources */, F9EDADD3262AA421007CB99F /* VideoDetailViewController.swift in Sources */, + 493307FD2BF230DB003622ED /* LivePlayerViewModel.swift in Sources */, 496400D32943431E0098ACA6 /* Logger.swift in Sources */, F927ED8826103CFB00EAB8E3 /* DanmakuTextCell.swift in Sources */, 0A41EE1C2A63102B0066444C /* dm.pb.swift in Sources */, diff --git a/BilibiliLive/Component/Player/CommonPlayerViewController.swift b/BilibiliLive/Component/Player/CommonPlayerViewController.swift index 7e15129..786097e 100644 --- a/BilibiliLive/Component/Player/CommonPlayerViewController.swift +++ b/BilibiliLive/Component/Player/CommonPlayerViewController.swift @@ -116,7 +116,7 @@ class CommonPlayerViewController: AVPlayerViewController { func playerRateDidChange(player: AVPlayer) {} - func setPlayerInfo(title: String?, subTitle: String?, desp: String?, pic: URL?) { + @MainActor func setPlayerInfo(title: String?, subTitle: String?, desp: String?, pic: URL?) { let desp = desp?.components(separatedBy: "\n").joined(separator: " ") let mapping: [AVMetadataIdentifier: Any?] = [ .commonIdentifierTitle: title, @@ -310,7 +310,7 @@ class CommonPlayerViewController: AVPlayerViewController { bitrate audio:\(bitrateStr(averageAudioBitrate)), video: \(bitrateStr(averageVideoBitrate)) observedBitrate:\(bitrateStr(observedBitrate)) indicatedAverageBitrate:\(bitrateStr(indicatedBitrate)) - maskProvider: \(String(describing: maskProvider))) + maskProvider: \(String(describing: maskProvider)) """ return logs } diff --git a/BilibiliLive/Module/Live/LiveDanMuProvider.swift b/BilibiliLive/Module/Live/LiveDanMuProvider.swift index 197c5ff..08b5ce2 100644 --- a/BilibiliLive/Module/Live/LiveDanMuProvider.swift +++ b/BilibiliLive/Module/Live/LiveDanMuProvider.swift @@ -49,7 +49,9 @@ class LiveDanMuProvider { } private func setupHeartBeat() { - heartBeatTimer = Timer.scheduledTimer(timeInterval: 30, target: self, selector: #selector(sendHeartBeat), userInfo: nil, repeats: true) + heartBeatTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true, block: { [weak self] _ in + self?.sendHeartBeat() + }) sendHeartBeat() } @@ -112,11 +114,9 @@ extension LiveDanMuProvider { .map { JSON(parseJSON: $0) } .forEach { json in let cmd = json["cmd"].stringValue - Logger.debug("get cmd:\(cmd)") switch cmd { case "DANMU_MSG": if let str = json["info"][1].string { - Logger.debug("danmu:\(str)") onDanmu?(str) } case "DM_INTERACTION": diff --git a/BilibiliLive/Module/Live/LivePlayerViewController.swift b/BilibiliLive/Module/Live/LivePlayerViewController.swift index f9e880a..1c35c57 100644 --- a/BilibiliLive/Module/Live/LivePlayerViewController.swift +++ b/BilibiliLive/Module/Live/LivePlayerViewController.swift @@ -12,12 +12,6 @@ import SwiftyJSON import UIKit class LivePlayerViewController: CommonPlayerViewController { - enum LiveError: Error { - case noLiving - case noPlaybackUrl - case fetchApiFail - } - var room: LiveRoom? { didSet { roomID = room?.room_id ?? 0 @@ -25,10 +19,8 @@ class LivePlayerViewController: CommonPlayerViewController { } private var roomID: Int = 0 - private var danMuProvider: LiveDanMuProvider? private var failCount = 0 - private var playInfo = [PlayInfo]() - + private var viewModel: LivePlayerViewModel? deinit { Logger.debug("deinit live player") } @@ -38,183 +30,57 @@ class LivePlayerViewController: CommonPlayerViewController { requiresLinearPlayback = true super.viewDidLoad() - Task { - do { - try await refreshRoomsID() - await initDataSource() - try await initPlayer() - } catch let err { - endWithError(err: err) - } - if let info = try? await WebRequest.requestLiveBaseInfo(roomID: roomID) { - let subtitle = "\(room?.ownerName ?? "")·\(info.parent_area_name) \(info.area_name)" - let desp = "\(info.description)\nTags:\(info.tags ?? "")\n Hot words:\(info.hot_words?.joined(separator: ",") ?? "")" - setPlayerInfo(title: info.title, subTitle: subtitle, desp: desp, pic: room?.pic) - } else { - setPlayerInfo(title: room?.title, subTitle: "nil", desp: room?.ownerName, pic: room?.pic) - } - } - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillAppear(animated) - danMuProvider?.stop() - } - - override func retryPlay() -> Bool { - Logger.warn("play fail, retry") - failCount += 1 - if playInfo.count > 0 { - playInfo = Array(playInfo.dropFirst()) + viewModel = LivePlayerViewModel(roomID: roomID) + viewModel?.onShootDanmu = { [weak self] in + self?.danMuView.shoot(danmaku: $0) } - play() - return true - } - - override func playerRateDidChange(player: AVPlayer) { - Logger.info("play speed change to", player.rate) - if player.rate == 0 { - Task { - do { - try await initPlayer() - } catch let err { - endWithError(err: err) - } - } - } - } - - func play() { - if let url = playInfo.first?.url { - danMuView.play() - + viewModel?.onPlayUrlStr = { [weak self] in + guard let self else { return } let headers: [String: String] = [ "User-Agent": Keys.userAgent, "Referer": Keys.liveReferer, ] - let asset = AVURLAsset(url: URL(string: url)!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) + let asset = AVURLAsset(url: URL(string: $0)!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) playerItem = AVPlayerItem(asset: asset) player = AVPlayer(playerItem: playerItem) player?.automaticallyWaitsToMinimizeStalling = false - } else { - showErrorAlertAndExit(title: "url is nil", message: "url: \(playInfo.first?.url.count ?? 0)") } + viewModel?.onError = { [weak self] in + self?.showErrorAlertAndExit(message: $0) + } + + viewModel?.start() + if Settings.danmuMask, Settings.vnMask { maskProvider = VMaskProvider() setupMask() } - } - func endWithError(err: Error) { - let alert = UIAlertController(title: "播放失败", message: "\(err)", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { - [weak self] _ in - self?.dismiss(animated: true, completion: nil) - })) - present(alert, animated: true, completion: nil) - } - - func refreshRoomsID() async throws { - let url = "https://api.live.bilibili.com/room/v1/Room/room_init?id=\(roomID)" - let resp = await AF.request(url).serializingData().result - switch resp { - case let .success(object): - let json = JSON(object) - let isLive = json["data"]["live_status"].intValue == 1 - if !isLive { - throw LiveError.noLiving - } - if let newID = json["data"]["room_id"].int { - roomID = newID + Task { + if let info = await viewModel?.fetchDespInfo() { + let subtitle = "\(room?.ownerName ?? "")·\(info.parent_area_name) \(info.area_name)" + let desp = "\(info.description)\nTags:\(info.tags ?? "")" + setPlayerInfo(title: info.title, subTitle: subtitle, desp: desp, pic: room?.pic) + } else { + setPlayerInfo(title: room?.title, subTitle: "", desp: room?.ownerName, pic: room?.pic) } - case let .failure(error): - throw error - } - } - - func initDataSource() async { - danMuProvider = LiveDanMuProvider(roomID: roomID) - danMuProvider?.onDanmu = { - [weak self] string in - let model = DanmakuTextCellModel(str: string) - self?.danMuView.shoot(danmaku: model) } - danMuProvider?.onSC = { - [weak self] string in - let model = DanmakuTextCellModel(str: string) - model.type = .top - model.displayTime = 60 - self?.danMuView.shoot(danmaku: model) - } - try? await danMuProvider?.start() - } - - override func additionDebugInfo() -> String { - return "\(playInfo.first?.formate ?? "") \(playInfo.first?.current_qn ?? 0) failed: \(failCount)" } - struct PlayInfo { - let formate: String? - let url: String - let current_qn: Int? + override func retryPlay() -> Bool { + Logger.warn("play fail, retry") + viewModel?.playerDidFailToPlay() + return true } - func initPlayer() async throws { - let requestUrl = "https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?room_id=\(roomID)&protocol=1&format=0,1,2&codec=0,1&qn=10000&platform=web&ptype=8&dolby=5&panorama=1" - guard let data = try? await AF.request(requestUrl).serializingData().result.get() else { - throw LiveError.fetchApiFail - } - var playInfos = [PlayInfo]() - let json = JSON(data) - - for stream in json["data"]["playurl_info"]["playurl"]["stream"].arrayValue { - for content in stream["format"].arrayValue { - let formate = content["format_name"].stringValue - let codecs = content["codec"].arrayValue - - for codec in codecs { - let qn = codec["current_qn"].intValue - let baseUrl = codec["base_url"].stringValue - for url_info in codec["url_info"].arrayValue { - let host = url_info["host"] - let extra = url_info["extra"] - let url = "\(host)\(baseUrl)\(extra)" - let playInfo = PlayInfo(formate: formate, url: url, current_qn: qn) - playInfos.append(playInfo) - } - } - } - } - Logger.debug("info arry:\(playInfos)") - let info = playInfos.filter({ $0.formate == "fmp4" }) - if info.count > 0 { - Logger.debug("play =>", info) - playInfo = info - play() - } else { - if playInfos.count > 0 { - Logger.debug("no fmp4 found, play directly") - playInfo = playInfos - play() - return - } - - throw LiveError.noPlaybackUrl + override func playerRateDidChange(player: AVPlayer) { + Logger.info("play speed change to", player.rate) + if player.rate == 0 { + viewModel?.playerDidFailToPlay() } } -} -extension WebRequest { - struct LiveRoomInfo: Codable { - let description: String - let parent_area_name: String - let title: String - let tags: String? - let area_name: String - let hot_words: [String]? - } - - static func requestLiveBaseInfo(roomID: Int) async throws -> LiveRoomInfo { - return try await request(url: "https://api.live.bilibili.com/room/v1/Room/get_info", parameters: ["room_id": roomID]) + override func additionDebugInfo() -> String { + return viewModel?.debugInfo() ?? "" } } diff --git a/BilibiliLive/Module/Live/LivePlayerViewModel.swift b/BilibiliLive/Module/Live/LivePlayerViewModel.swift new file mode 100644 index 0000000..05619d5 --- /dev/null +++ b/BilibiliLive/Module/Live/LivePlayerViewModel.swift @@ -0,0 +1,255 @@ +// +// LiveInfoViewModel.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/13. +// + +import Alamofire +import SwiftyJSON + +enum LiveError: String, LocalizedError { + case noLiving + case noPlaybackUrl + case fetchApiFail + + var errorDescription: String? { + rawValue + } +} + +class LivePlayerViewModel { + init(roomID: Int) { + self.roomID = roomID + } + + deinit { + danMuProvider?.stop() + } + + var onShootDanmu: ((DanmakuTextCellModel) -> Void)? + var onPlayUrlStr: ((String) -> Void)? + var onError: ((String) -> Void)? + + func start() { + Task { + do { + try await refreshRoomsID() + try await initPlayer() + await initDanmu() + } catch let err { + await MainActor.run { + onError?(String(describing: err)) + } + } + } + } + + func playerDidFailToPlay() { + retryCount += 1 + Task { + do { + playInfos = Array(playInfos.dropFirst()) + if playInfos.isEmpty { + try await initPlayer() + return + } else { + try await playFirstInfo() + } + } catch let err { + await MainActor.run { + onError?(err.localizedDescription) + } + } + } + } + + func debugInfo() -> String { + return "\(allPlayInfos.first?.formate ?? "") \(allPlayInfos.first?.current_qn ?? 0) retry:\(retryCount)" + } + + func fetchDespInfo() async -> WebRequest.LiveRoomInfo? { + return try? await WebRequest.requestLiveBaseInfo(roomID: roomID) + } + + // Private + private var allPlayInfos = [LivePlayUrlInfo]() + private var playInfos = [LivePlayUrlInfo]() + private var roomID: Int + private var danMuProvider: LiveDanMuProvider? + private var retryCount = 0 + + private func refreshRoomsID() async throws { + let info = try await WebRequest.requestLiveRoomInit(roomID: roomID) + if info.live_status != 1 { + throw LiveError.noLiving + } + roomID = info.room_id + } + + private func initPlayer() async throws { + allPlayInfos = [] + + let streams = try await WebRequest.requestLiveStreams(roomID: roomID) + + for stream in streams { + for content in stream.format { + let formate = content.formatName + let codecs = content.codec + for codec in codecs { + let qn = codec.currentQn + let baseUrl = codec.baseurl + for url_info in codec.urlInfo { + let host = url_info.host + let extra = url_info.extra + let url = "\(host)\(baseUrl)\(extra)" + let playInfo = LivePlayUrlInfo(formate: formate, url: url, current_qn: qn) + allPlayInfos.append(playInfo) + } + } + } + } + + allPlayInfos.sort { a, b in + return a.current_qn ?? 0 > b.current_qn ?? 0 + } + + allPlayInfos.sort { a, b in + return a.formate == "fmp4" + } + + Logger.debug("all info arry:\(playInfos)") + playInfos = allPlayInfos + try await playFirstInfo() + } + + func playFirstInfo() async throws { + if let info = playInfos.first { + Logger.debug("play =>", playInfos) + await MainActor.run { + onPlayUrlStr?(info.url) + } + } else { + throw LiveError.noPlaybackUrl + } + } + + private func initDanmu() async { + danMuProvider = LiveDanMuProvider(roomID: roomID) + danMuProvider?.onDanmu = { + [weak self] string in + let model = DanmakuTextCellModel(str: string) + DispatchQueue.main.async { [weak self] in + self?.onShootDanmu?(model) + } + } + danMuProvider?.onSC = { + [weak self] string in + let model = DanmakuTextCellModel(str: string) + model.type = .top + model.displayTime = 60 + DispatchQueue.main.async { [weak self] in + self?.onShootDanmu?(model) + } + } + try? await danMuProvider?.start() + } +} + +struct LivePlayUrlInfo { + let formate: String? + let url: String + let current_qn: Int? +} + +extension WebRequest.EndPoint { + static let liveRoomInfo = "https://api.live.bilibili.com/room/v1/Room/get_info" + static let liveRoomStream = "https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo" + static let liveRoomInit = "https://api.live.bilibili.com/room/v1/Room/room_init" +} + +extension WebRequest { + struct LiveRoomInfo: Codable { + let description: String + let parent_area_name: String + let title: String + let tags: String? + let area_name: String + let hot_words: [String]? + } + + static func requestLiveBaseInfo(roomID: Int) async throws -> LiveRoomInfo { + return try await request(url: EndPoint.liveRoomInfo, parameters: ["room_id": roomID]) + } + + struct LiveRoomInit: Codable { + var live_status: Int + var room_id: Int + } + + static func requestLiveRoomInit(roomID: Int) async throws -> LiveRoomInit { + return try await request(url: EndPoint.liveRoomInfo, parameters: ["id": roomID]) + } + + static func requestLiveStreams(roomID: Int) async throws -> [LiveStream] { + struct LiveRoomStreamInfo: Codable { + let playurl_info: PlayUrlInfo + + struct PlayUrlInfo: Codable { + let playurl: PlayUrl + } + + struct PlayUrl: Codable { + let cid: Int + let stream: [LiveStream] + } + } + let info: LiveRoomStreamInfo = try await request(url: EndPoint.liveRoomStream, + parameters: ["room_id": roomID, + "protocol": "1", + "format": "0,1,2", + "codec": "0,1", + "qn": "10000", + "platform": "web", + "ptype": "8", + "dolby": "5", + "panorama": 1]) + return info.playurl_info.playurl.stream + } +} + +struct LiveStream: Codable { + let format: [Format] + let protocol_name: String + + struct Format: Codable { + let formatName: String + let codec: [Codec] + let masterurl: String + + enum CodingKeys: String, CodingKey { + case formatName = "format_name" + case codec + case masterurl = "master_url" + } + + struct Codec: Codable { + let urlInfo: [URLInfo] + let codecName: String + let currentQn: Int + let baseurl: String + + enum CodingKeys: String, CodingKey { + case urlInfo = "url_info" + case codecName = "codec_name" + case currentQn = "current_qn" + case baseurl = "base_url" + } + + struct URLInfo: Codable { + let host: String + let extra: String + } + } + } +}