diff --git a/BilibiliLive.xcodeproj/project.pbxproj b/BilibiliLive.xcodeproj/project.pbxproj index 81faea09..f3672fcc 100644 --- a/BilibiliLive.xcodeproj/project.pbxproj +++ b/BilibiliLive.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 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 */; }; + 49425E812B0B410400D8AEBF /* BLContentProposalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49425E802B0B410400D8AEBF /* BLContentProposalViewController.swift */; }; 494741C029002797005D6885 /* UserDefault+..swift in Sources */ = {isa = PBXBuildFile; fileRef = 494741BF29002797005D6885 /* UserDefault+..swift */; }; 494741C329016CCE005D6885 /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 494741C229016CCE005D6885 /* MarqueeLabel */; }; 494741C6290177BB005D6885 /* UpSpaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 494741C5290177BB005D6885 /* UpSpaceViewController.swift */; }; @@ -116,6 +117,7 @@ 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 = ""; }; + 49425E802B0B410400D8AEBF /* BLContentProposalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLContentProposalViewController.swift; sourceTree = ""; }; 494741BF29002797005D6885 /* UserDefault+..swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefault+..swift"; sourceTree = ""; }; 494741C5290177BB005D6885 /* UpSpaceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpSpaceViewController.swift; sourceTree = ""; }; 494741C72902C45D005D6885 /* Array+..swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+..swift"; sourceTree = ""; }; @@ -382,6 +384,7 @@ children = ( 49E5F84F28AF73C500FAA3CE /* BilibiliVideoResourceLoaderDelegate.swift */, F99D28E02619591300F8E66A /* CommonPlayerViewController.swift */, + 49425E802B0B410400D8AEBF /* BLContentProposalViewController.swift */, 49FB8EE829208EBE0045D5DE /* SidxParseUtil.swift */, ); path = Player; @@ -607,6 +610,7 @@ F9D382B426359EF90070508F /* ApiRequest.swift in Sources */, 490EC3E9290CE23E001E00B6 /* BLSettingLineCollectionViewCell.swift in Sources */, F9B57354260F5F7400771ED5 /* AppDelegate.swift in Sources */, + 49425E812B0B410400D8AEBF /* BLContentProposalViewController.swift in Sources */, F9EDADD3262AA421007CB99F /* VideoDetailViewController.swift in Sources */, 496400D32943431E0098ACA6 /* Logger.swift in Sources */, F927ED8826103CFB00EAB8E3 /* DanmakuTextCell.swift in Sources */, diff --git a/BilibiliLive/Component/Player/BLContentProposalViewController.swift b/BilibiliLive/Component/Player/BLContentProposalViewController.swift new file mode 100644 index 00000000..06ce4e6a --- /dev/null +++ b/BilibiliLive/Component/Player/BLContentProposalViewController.swift @@ -0,0 +1,33 @@ +// +// BLContentProposalViewController.swift +// BilibiliLive +// +// Created by yicheng on 2023/11/20. +// + +import AVKit +import UIKit + +class BLContentProposalViewController: AVContentProposalViewController { + let nextButton = BLCustomTextButton() + + override func viewDidLoad() { + super.viewDidLoad() + view.addSubview(nextButton) + view.backgroundColor = UIColor.clear + nextButton.addTarget(self, action: #selector(actionAccept), for: .primaryActionTriggered) + nextButton.title = "下一个:" + (contentProposal?.title ?? "") + nextButton.titleFont = UIFont.systemFont(ofSize: 30) + nextButton.snp.makeConstraints { make in + make.right.equalToSuperview().offset(-50) + make.height.equalTo(60) + make.width.greaterThanOrEqualTo(200) + make.width.lessThanOrEqualTo(500) + make.bottom.equalToSuperview().multipliedBy(0.75) + } + } + + @objc func actionAccept() { + dismissContentProposal(for: .accept, animated: true) + } +} diff --git a/BilibiliLive/Component/Player/CommonPlayerViewController.swift b/BilibiliLive/Component/Player/CommonPlayerViewController.swift index 2d3e7758..12c127ef 100644 --- a/BilibiliLive/Component/Player/CommonPlayerViewController.swift +++ b/BilibiliLive/Component/Player/CommonPlayerViewController.swift @@ -92,6 +92,8 @@ class CommonPlayerViewController: AVPlayerViewController { return "" } + func loopDidChange() {} + func playerStatusDidChange() { Logger.debug("player status: \(player?.currentItem?.status.rawValue ?? -1)") switch player?.currentItem?.status { @@ -156,6 +158,7 @@ class CommonPlayerViewController: AVPlayerViewController { return item.copy() as? AVMetadataItem } + // TODO: this should move out of common private func setupPlayerMenu() { var menus = [UIMenuElement]() let danmuImage = UIImage(systemName: "list.bullet.rectangle.fill") @@ -199,9 +202,10 @@ class CommonPlayerViewController: AVPlayerViewController { // Create an action to enable looping playback. let loopAction = UIAction(title: "循环播放", image: loopImage, state: Settings.loopPlay ? .on : .off) { - action in + [weak self] action in action.state = (action.state == .off) ? .on : .off Settings.loopPlay = action.state == .on + self?.loopDidChange() } let speedActions = PlaySpeed.blDefaults.map { playSpeed in diff --git a/BilibiliLive/Component/Video/VideoDetailViewController.swift b/BilibiliLive/Component/Video/VideoDetailViewController.swift index 41aafcd1..75be896a 100644 --- a/BilibiliLive/Component/Video/VideoDetailViewController.swift +++ b/BilibiliLive/Component/Video/VideoDetailViewController.swift @@ -348,13 +348,8 @@ class VideoDetailViewController: UIViewController { @IBAction func actionPlay(_ sender: Any) { let player = VideoPlayerViewController(playInfo: PlayInfo(aid: aid, cid: cid, epid: epid, isBangumi: isBangumi)) player.data = data - if pages.count > 0, let index = pages.firstIndex(where: { $0.cid == cid }) { - let seq = pages.dropFirst(index).map({ PlayInfo(aid: aid, cid: $0.cid, epid: $0.epid, isBangumi: isBangumi) }) - if seq.count > 0 { - let nextProvider = VideoNextProvider(seq: seq) - player.nextProvider = nextProvider - } - } + player.nextProvider = getContinouslyPlayProvider() + present(player, animated: true, completion: nil) } @@ -432,6 +427,32 @@ class VideoDetailViewController: UIViewController { } } +extension VideoDetailViewController { + func getContinouslyPlayProvider() -> VideoNextProvider? { + // 有分P,分P联播 + if pages.count > 1, let index = pages.firstIndex(where: { $0.cid == cid }) { + let seq = pages.map({ PlayInfo(aid: aid, cid: $0.cid, epid: $0.epid, isBangumi: isBangumi, title: $0.part) }) + let nextProvider = VideoNextProvider(seq: seq, startIndex: index) + return nextProvider + } + + // 合集 + if allUgcEpisodes.count > 0 { + let index = allUgcEpisodes.firstIndex { $0.cid == cid } ?? 0 + let seq = allUgcEpisodes.map({ PlayInfo(aid: $0.aid, cid: $0.cid, epid: 0, isBangumi: isBangumi, title: $0.title) }) + return VideoNextProvider(seq: seq, startIndex: index) + } + + // 推荐 + if let recommand = data?.Related, recommand.count > 0 { + let seq = recommand.map({ PlayInfo(aid: $0.aid, cid: $0.cid, epid: 0, isBangumi: false, title: $0.title) }) + return VideoNextProvider(seq: seq, startIndex: -1) + } + + return nil + } +} + extension VideoDetailViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { switch collectionView { @@ -440,9 +461,10 @@ extension VideoDetailViewController: UICollectionViewDelegate { let player = VideoPlayerViewController(playInfo: PlayInfo(aid: isBangumi ? page.page : aid, cid: page.cid, epid: page.epid, isBangumi: isBangumi)) player.data = isBangumi ? nil : data - let seq = pages.dropFirst(indexPath.item).map({ PlayInfo(aid: aid, cid: $0.cid, isBangumi: isBangumi) }) + let seq = pages.map({ PlayInfo(aid: aid, cid: $0.cid, isBangumi: isBangumi, title: $0.part) }) + // 分p选择,分p联播 if seq.count > 0 { - let nextProvider = VideoNextProvider(seq: seq) + let nextProvider = VideoNextProvider(seq: seq, startIndex: indexPath.item) player.nextProvider = nextProvider } present(player, animated: true, completion: nil) diff --git a/BilibiliLive/Component/Video/VideoPlayerViewController.swift b/BilibiliLive/Component/Video/VideoPlayerViewController.swift index a4a0a4b4..744c3626 100644 --- a/BilibiliLive/Component/Video/VideoPlayerViewController.swift +++ b/BilibiliLive/Component/Video/VideoPlayerViewController.swift @@ -18,15 +18,16 @@ struct PlayInfo { var cid: Int? = 0 var epid: Int? = 0 // 港澳台解锁需要 var isBangumi: Bool = false - + var title: String? var isCidVaild: Bool { return cid ?? 0 > 0 } } class VideoNextProvider { - init(seq: [PlayInfo]) { + init(seq: [PlayInfo], startIndex: Int = 0) { playSeq = seq + index = startIndex } private var index = 0 @@ -36,12 +37,30 @@ class VideoNextProvider { } func getNext() -> PlayInfo? { + if index + 1 < playSeq.count { + return playSeq[index + 1] + } + return nil + } + + func popNext() -> PlayInfo? { index += 1 if index < playSeq.count { return playSeq[index] } return nil } + + func isPlayToEnd() -> Bool { + if playSeq.count > 0, index == playSeq.count { + return true + } + return false + } + + func vaild() -> Bool { + return playSeq.count > 1 + } } class VideoPlayerViewController: CommonPlayerViewController { @@ -64,13 +83,11 @@ class VideoPlayerViewController: CommonPlayerViewController { private let danmuProvider = VideoDanmuProvider() private var clipInfos: [VideoPlayURLInfo.ClipInfo]? private var skipAction: UIAction? + private var looper: AVPlayerLooper? + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - guard let currentTime = player?.currentTime().seconds, currentTime > 0 else { return } - - if let cid = playInfo.cid, cid > 0 { - WebRequest.reportWatchHistory(aid: playInfo.aid, cid: cid, currentTime: Int(currentTime)) - } + reportHistory() BiliBiliUpnpDMR.shared.sendStatus(status: .stop) } @@ -78,6 +95,9 @@ class VideoPlayerViewController: CommonPlayerViewController { super.viewDidLoad() Task { await initPlayer() + + await fetchVideoData() + await danmuProvider.initVideo(cid: playInfo.cid, startPos: playerStartPos ?? 0) } danmuProvider.onShowDanmu = { [weak self] in @@ -86,18 +106,51 @@ class VideoPlayerViewController: CommonPlayerViewController { } private func initPlayer() async { - if !playInfo.isCidVaild { - do { - playInfo.cid = try await WebRequest.requestCid(aid: playInfo.aid) - } catch let err { - self.showErrorAlertAndExit(message: "请求cid失败,\(err.localizedDescription)") + Logger.info("init player") + let player = AVQueuePlayer() + player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: 1), queue: .main) { [weak self] time in + guard let self else { return } + if self.danMuView.isHidden { return } + let seconds = time.seconds + self.danmuProvider.playerTimeChange(time: seconds) + + if let duration = self.data?.View.duration { + BiliBiliUpnpDMR.shared.sendProgress(duration: duration, current: Int(seconds)) + } + + if let clipInfos = self.clipInfos { + var matched = false + for clip in clipInfos { + if seconds > clip.start, seconds < clip.end { + let action = { + clip.skipped = true + self.player?.seek(to: CMTime(seconds: Double(clip.end), preferredTimescale: 1), toleranceBefore: .zero, toleranceAfter: .zero) + } + if !(clip.skipped ?? false), Settings.autoSkip { + action() + self.skipAction = nil + } else if self.skipAction?.accessibilityLabel != clip.a11Tag { + self.skipAction = UIAction(title: clip.customText) { _ in + action() + } + self.skipAction?.accessibilityLabel = clip.a11Tag + } + + self.contextualActions = [self.skipAction].compactMap { $0 } + matched = true + break + } + } + if !matched { + self.contextualActions = [] + } } } - await fetchVideoData() - await danmuProvider.initVideo(cid: playInfo.cid, startPos: playerStartPos ?? 0) + self.player = player } private func playmedia(urlInfo: VideoPlayURLInfo, playerInfo: PlayerInfo?) async { + Logger.info("play media") let playURL = URL(string: BilibiliVideoResourceLoaderDelegate.URLs.play)! let headers: [String: String] = [ "User-Agent": "Bilibili/APPLE TV", @@ -118,6 +171,12 @@ class VideoPlayerViewController: CommonPlayerViewController { danMuView.play() updatePlayerCharpter(playerInfo: playerInfo) BiliBiliUpnpDMR.shared.sendVideoSwitch(aid: playInfo.aid, cid: playInfo.cid ?? 0) + + if Settings.loopPlay { + loopDidChange() + } else if Settings.continouslyPlay { + playerItem?.nextContentProposal = await makeProposal() + } } private func updatePlayerCharpter(playerInfo: PlayerInfo?) { @@ -160,29 +219,24 @@ class VideoPlayerViewController: CommonPlayerViewController { } } - func playNext() -> Bool { - if let next = nextProvider?.getNext() { - playInfo = next - Task { - await initPlayer() - } - return true + override func playDidEnd() { + if playerItem?.nextContentProposal == nil, !Settings.loopPlay { + BiliBiliUpnpDMR.shared.sendStatus(status: .end) + dismiss(animated: true) } - return false } - override func playDidEnd() { - BiliBiliUpnpDMR.shared.sendStatus(status: .end) - if !playNext() { - if Settings.loopPlay { - nextProvider?.reset() - if !playNext() { - playerItem?.seek(to: .zero, completionHandler: nil) - player?.play() + override func loopDidChange() { + if Settings.loopPlay { + if looper == nil { + if let player = player as? AVQueuePlayer, let playerItem { + looper = AVPlayerLooper(player: player, templateItem: playerItem) } - return } - dismiss(animated: true) + } else { + playerItem?.nextContentProposal = nil + looper?.disableLooping() + looper = nil } } @@ -214,12 +268,29 @@ class VideoPlayerViewController: CommonPlayerViewController { onResult?(AVTimedMetadataGroup(items: metadatas, timeRange: timeRange)) } } + + private func reportHistory() { + if let currentTime = player?.currentTime().seconds, + currentTime > 0, + let cid = playInfo.cid, cid > 0 + { + WebRequest.reportWatchHistory(aid: playInfo.aid, cid: cid, currentTime: Int(currentTime)) + } + } } // MARK: - Requests extension VideoPlayerViewController { func fetchVideoData() async { + if !playInfo.isCidVaild { + do { + playInfo.cid = try await WebRequest.requestCid(aid: playInfo.aid) + } catch let err { + self.showErrorAlertAndExit(message: "请求cid失败,\(err.localizedDescription)") + } + } + assert(playInfo.isCidVaild) let aid = playInfo.aid let cid = playInfo.cid! @@ -362,7 +433,6 @@ extension VideoPlayerViewController { // MARK: - Player extension VideoPlayerViewController { - @MainActor func prepare(toPlay asset: AVURLAsset, withKeys requestedKeys: [AnyHashable]) { for thisKey in requestedKeys { guard let thisKey = thisKey as? String else { @@ -382,52 +452,42 @@ extension VideoPlayerViewController { } playerItem = AVPlayerItem(asset: asset) - let player = AVPlayer(playerItem: playerItem) - player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: 1), queue: .main) { [weak self] time in - guard let self else { return } - if self.danMuView.isHidden { return } - let seconds = time.seconds - self.danmuProvider.playerTimeChange(time: seconds) - - if let duration = self.data?.View.duration { - BiliBiliUpnpDMR.shared.sendProgress(duration: duration, current: Int(seconds)) - } + player?.replaceCurrentItem(with: playerItem) + } +} - if let clipInfos = self.clipInfos { - var matched = false - for clip in clipInfos { - if seconds > clip.start, seconds < clip.end { - let action = { - clip.skipped = true - self.player?.seek(to: CMTime(seconds: Double(clip.end), preferredTimescale: 1), toleranceBefore: .zero, toleranceAfter: .zero) - } - if !(clip.skipped ?? false), Settings.autoSkip { - action() - self.skipAction = nil - } else if self.skipAction?.accessibilityLabel != clip.a11Tag { - self.skipAction = UIAction(title: clip.customText) { _ in - action() - } - self.skipAction?.accessibilityLabel = clip.a11Tag - } +extension VideoPlayerViewController { + func playerViewController(_ playerViewController: AVPlayerViewController, shouldPresent proposal: AVContentProposal) -> Bool { + playerViewController.contentProposalViewController = BLContentProposalViewController() + return true + } - self.contextualActions = [self.skipAction].compactMap { $0 } - matched = true - break - } - } - if !matched { - self.contextualActions = [] - } + func playerViewController(_ playerViewController: AVPlayerViewController, didAccept proposal: AVContentProposal) { + if let next = nextProvider?.popNext() { + reportHistory() + playInfo = next + Task { + await fetchVideoData() + await danmuProvider.initVideo(cid: playInfo.cid, startPos: playerStartPos ?? 0) } } - if let defaultRate = self.player?.defaultRate, - let speed = PlaySpeed.blDefaults.first(where: { $0.value == defaultRate }) - { - self.player = player - selectSpeed(AVPlaybackSpeed(rate: speed.value, localizedName: speed.name)) + } + + private func makeProposal() async -> AVContentProposal? { + guard let next = nextProvider?.getNext(), let title = next.title else { return nil } + // Present 10 seconds prior to the end of current presentation + guard let duration = try? await playerItem?.asset.load(.duration) else { + return nil + } + let time: CMTime + if duration.seconds < 5 { + time = CMTime(value: Int64(duration.seconds) / 2, timescale: 1) } else { - self.player = player + time = duration - CMTime(value: 5, timescale: 1) } + + let proposal = AVContentProposal(contentTimeForTransition: time, title: title, previewImage: nil) + proposal.automaticAcceptanceInterval = -1 + return proposal } }