diff --git a/BilibiliLive.xcodeproj/project.pbxproj b/BilibiliLive.xcodeproj/project.pbxproj index e67d4b6..10bafd7 100644 --- a/BilibiliLive.xcodeproj/project.pbxproj +++ b/BilibiliLive.xcodeproj/project.pbxproj @@ -94,6 +94,7 @@ 499C76162931A7AE003160FB /* SwiftyXMLParser in Frameworks */ = {isa = PBXBuildFile; productRef = 499C76152931A7AE003160FB /* SwiftyXMLParser */; }; 49A441CD293F6DFD0007606C /* FollowUpsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49A441CC293F6DFD0007606C /* FollowUpsViewController.swift */; }; 49ADA8392D0BCD79004801EF /* MainActor+Safe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49ADA8382D0BCD71004801EF /* MainActor+Safe.swift */; }; + 49ADA83D2D0C0FC2004801EF /* BvidConvertor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49ADA83C2D0C0FC2004801EF /* BvidConvertor.swift */; }; 49D250A02C118FA700173908 /* URLPlayPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49D2509F2C118FA700173908 /* URLPlayPlugin.swift */; }; 49D250A22C11A82B00173908 /* AVPlayerMetaUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49D250A12C11A82B00173908 /* AVPlayerMetaUtils.swift */; }; 49D39F28263AD40000F14497 /* WebRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49D39F27263AD40000F14497 /* WebRequest.swift */; }; @@ -299,6 +300,7 @@ 499C760E2930E068003160FB /* NVASocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NVASocket.swift; sourceTree = ""; }; 49A441CC293F6DFD0007606C /* FollowUpsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowUpsViewController.swift; sourceTree = ""; }; 49ADA8382D0BCD71004801EF /* MainActor+Safe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainActor+Safe.swift"; sourceTree = ""; }; + 49ADA83C2D0C0FC2004801EF /* BvidConvertor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BvidConvertor.swift; sourceTree = ""; }; 49D2509F2C118FA700173908 /* URLPlayPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLPlayPlugin.swift; sourceTree = ""; }; 49D250A12C11A82B00173908 /* AVPlayerMetaUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayerMetaUtils.swift; sourceTree = ""; }; 49D39F27263AD40000F14497 /* WebRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRequest.swift; sourceTree = ""; }; @@ -634,6 +636,7 @@ 49D39F26263AD3F100F14497 /* Request */ = { isa = PBXGroup; children = ( + 49ADA83C2D0C0FC2004801EF /* BvidConvertor.swift */, 0A41EE1A2A63102B0066444C /* dm.pb.swift */, 0A41EE1B2A63102B0066444C /* dmView.pb.swift */, 49D39F27263AD40000F14497 /* WebRequest.swift */, @@ -1011,6 +1014,7 @@ F927ED8826103CFB00EAB8E3 /* DanmakuTextCell.swift in Sources */, 0A41EE1C2A63102B0066444C /* dm.pb.swift in Sources */, F9171D6429010DF1002868C7 /* FeedCollectionViewCell.swift in Sources */, + 49ADA83D2D0C0FC2004801EF /* BvidConvertor.swift in Sources */, F927ED902610A5E900EAB8E3 /* CookieManager.swift in Sources */, F927ED792610395400EAB8E3 /* DanmakuCellModel.swift in Sources */, 497CF2372C16EDE5006E1488 /* BVideoClipsPlugin.swift in Sources */, diff --git a/BilibiliLive/Component/Video/ReplyDetailViewController.swift b/BilibiliLive/Component/Video/ReplyDetailViewController.swift index f453540..8919988 100644 --- a/BilibiliLive/Component/Video/ReplyDetailViewController.swift +++ b/BilibiliLive/Component/Video/ReplyDetailViewController.swift @@ -12,6 +12,7 @@ class ReplyDetailViewController: UIViewController { private var replyLabel: UIButton! private var replyCollectionView: UICollectionView! private var imageStackView: UIStackView! + private var buttonStackView: UIStackView! private let reply: Replys.Reply @@ -44,10 +45,26 @@ class ReplyDetailViewController: UIViewController { } imageStackView.addArrangedSubview(imageView) } + + reply.content.jump_url?.forEach { url, jump in + guard let bvId = ReplyUrlBVParser.parser(url: url) else { return } + let button = BLCustomTextButton() + button.title = jump.title + button.onPrimaryAction = { [weak self] _ in + self?.jumpLink(bvid: bvId) + } + buttonStackView.addArrangedSubview(button) + } } // MARK: - Private + private func jumpLink(bvid: String) { + let aid = BvidConvertor.bv2av(bvid: bvid) + let detailVC = VideoDetailViewController.create(aid: Int(aid), cid: nil) + detailVC.present(from: self) + } + private func setUpViews() { scrollView = { let scroll = UIScrollView() @@ -89,11 +106,15 @@ class ReplyDetailViewController: UIViewController { label.titleLabel?.textAlignment = .left label.titleLabel?.font = .preferredFont(forTextStyle: .headline) label.contentHorizontalAlignment = .left + label.setContentCompressionResistancePriority(.required, for: .vertical) label.snp.makeConstraints { make in make.top.equalTo(self.titleLabel.snp.bottom).offset(60) make.leading.equalTo(contentView.snp.leadingMargin) make.trailing.equalTo(contentView.snp.trailingMargin) } + label.titleLabel?.snp.makeConstraints { make in + make.top.bottom.equalToSuperview() + } return label }() @@ -103,7 +124,7 @@ class ReplyDetailViewController: UIViewController { stackView.axis = .horizontal stackView.distribution = .fillEqually stackView.spacing = 10 - contentView.addSubview(stackView) // 改为添加到 contentView + contentView.addSubview(stackView) stackView.snp.makeConstraints { make in make.top.equalTo(self.replyLabel.snp.bottom).offset(60) @@ -113,6 +134,21 @@ class ReplyDetailViewController: UIViewController { return stackView }() + buttonStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .leading + stackView.spacing = 10 + stackView.distribution = .equalSpacing + contentView.addSubview(stackView) + stackView.snp.makeConstraints { make in + make.top.equalTo(self.imageStackView.snp.bottom).offset(60) + make.leading.equalTo(contentView.snp.leadingMargin) + make.trailing.lessThanOrEqualTo(contentView.snp.trailingMargin) + } + return stackView + }() + replyCollectionView = { let flowLayout = UICollectionViewFlowLayout() flowLayout.itemSize = CGSize(width: 582, height: 360) @@ -128,7 +164,7 @@ class ReplyDetailViewController: UIViewController { collectionView.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() - make.top.equalTo(self.imageStackView.snp.bottom).offset(60) + make.top.equalTo(self.buttonStackView.snp.bottom).offset(60) make.height.width.equalTo(360) make.bottom.equalToSuperview() } @@ -163,3 +199,19 @@ extension ReplyDetailViewController: UICollectionViewDataSource, UICollectionVie present(detail, animated: true) } } + +enum ReplyUrlBVParser { + static func parser(url: String) -> String? { + if url.hasPrefix("BV"), url.count == 12 { + return url + } + if url.hasPrefix("https://www.bilibili.com/video/") { + // get bvid in url + guard let url = URL(string: url), + let bvid = url.path.split(separator: "/").filter({ !$0.isEmpty }).last + else { return nil } + return parser(url: String(bvid)) + } + return nil + } +} diff --git a/BilibiliLive/Component/View/BLButton.swift b/BilibiliLive/Component/View/BLButton.swift index 877cf19..18357b7 100644 --- a/BilibiliLive/Component/View/BLButton.swift +++ b/BilibiliLive/Component/View/BLButton.swift @@ -103,6 +103,7 @@ class BLCustomButton: BLButton { @MainActor class BLCustomTextButton: BLButton { private let titleLabel = UILabel() + var object: Any? @IBInspectable var title: String? { didSet { titleLabel.text = title } @@ -145,6 +146,8 @@ class BLButton: UIControl { fileprivate let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) private let selectedWhiteView = UIView() + var onPrimaryAction: ((BLButton) -> Void)? + override init(frame: CGRect) { super.init(frame: frame) setup() @@ -182,6 +185,7 @@ class BLButton: UIControl { super.pressesEnded(presses, with: event) if presses.first?.type == .select { sendActions(for: .primaryActionTriggered) + onPrimaryAction?(self) } } diff --git a/BilibiliLive/Request/BvidConvertor.swift b/BilibiliLive/Request/BvidConvertor.swift new file mode 100644 index 0000000..c93cbc7 --- /dev/null +++ b/BilibiliLive/Request/BvidConvertor.swift @@ -0,0 +1,61 @@ +// +// BvidConvertor.swift +// BilibiliLive +// +// Created by yicheng on 2024/12/13. +// + +enum BvidConvertor { + // https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/bvid_desc.md + private static let XOR_CODE: UInt64 = 23442827791579 + private static let MASK_CODE: UInt64 = 2251799813685247 + private static let MAX_AID: UInt64 = 1 << 51 + + private static let data: [UInt8] = [70, 99, 119, 65, 80, 78, 75, 84, 77, 117, 103, 51, 71, 86, 53, 76, 106, 55, 69, 74, 110, 72, 112, 87, 115, 120, 52, 116, 98, 56, 104, 97, 89, 101, 118, 105, 113, 66, 122, 54, 114, 107, 67, 121, 49, 50, 109, 85, 83, 68, 81, 88, 57, 82, 100, 111, 90, 102] + + private static let BASE: UInt64 = 58 + private static let BV_LEN: Int = 12 + private static let PREFIX: String = "BV1" + + static func av2bv(avid: UInt64) -> String { + var bytes: [UInt8] = [66, 86, 49, 48, 48, 48, 48, 48, 48, 48, 48, 48] + var bvIdx = BV_LEN - 1 + var tmp = (MAX_AID | avid) ^ XOR_CODE + + while tmp != 0 { + bytes[bvIdx] = data[Int(tmp % BASE)] + tmp /= BASE + bvIdx -= 1 + } + + bytes.swapAt(3, 9) + bytes.swapAt(4, 7) + + return String(decoding: bytes, as: UTF8.self) + } + + static func bv2av(bvid: String) -> UInt64 { + let fixedBvid: String + if bvid.hasPrefix("BV") { + fixedBvid = bvid + } else { + fixedBvid = "BV" + bvid + } + var bvidArray = Array(fixedBvid.utf8) + + bvidArray.swapAt(3, 9) + bvidArray.swapAt(4, 7) + + let trimmedBvid = String(decoding: bvidArray[3...], as: UTF8.self) + + var tmp: UInt64 = 0 + + for char in trimmedBvid { + if let idx = data.firstIndex(of: char.utf8.first!) { + tmp = tmp * BASE + UInt64(idx) + } + } + + return (tmp & MASK_CODE) ^ XOR_CODE + } +} diff --git a/BilibiliLive/Request/WebRequest.swift b/BilibiliLive/Request/WebRequest.swift index 44040f4..184e4ae 100644 --- a/BilibiliLive/Request/WebRequest.swift +++ b/BilibiliLive/Request/WebRequest.swift @@ -728,10 +728,15 @@ struct Replys: Codable, Hashable { let url: String } + struct JumpUrl: Codable, Hashable { + let title: String + } + struct Content: Codable, Hashable { let message: String let pictures: [Picture]? let emote: [String: Emote]? + let jump_url: [String: JumpUrl]? struct Picture: Codable, Hashable { let img_src: String