From 821c048a4ad88740380aee5a56c69f5fdab90086 Mon Sep 17 00:00:00 2001 From: yicheng <11733500+yichengchen@users.noreply.github.com> Date: Sat, 2 Nov 2024 13:09:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:(#123):=20=E6=B7=BB=E5=8A=A0bilibili?= =?UTF-8?q?=E7=A9=BA=E9=99=8D=E5=8A=A9=E6=89=8B=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BilibiliLive.xcodeproj/project.pbxproj | 8 ++ .../Player/Plugins/SponsorSkipPlugin.swift | 112 ++++++++++++++++++ BilibiliLive/Component/Settings.swift | 20 ++++ .../Video/NewVideoPlayerViewModel.swift | 5 + .../Personal/SettingsViewController.swift | 5 + .../Request/SponsorBlockRequest.swift | 63 ++++++++++ 6 files changed, 213 insertions(+) create mode 100644 BilibiliLive/Component/Player/Plugins/SponsorSkipPlugin.swift create mode 100644 BilibiliLive/Request/SponsorBlockRequest.swift diff --git a/BilibiliLive.xcodeproj/project.pbxproj b/BilibiliLive.xcodeproj/project.pbxproj index 0d6ebb07..90462ec5 100644 --- a/BilibiliLive.xcodeproj/project.pbxproj +++ b/BilibiliLive.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ 49078E47291BEA2400F556BD /* PocketSVG in Frameworks */ = {isa = PBXBuildFile; productRef = 49078E46291BEA2400F556BD /* PocketSVG */; }; 490EC3E7290CC8F8001E00B6 /* RankingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490EC3E6290CC8F8001E00B6 /* RankingViewController.swift */; }; 490EC3E9290CE23E001E00B6 /* BLSettingLineCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490EC3E8290CE23E001E00B6 /* BLSettingLineCollectionViewCell.swift */; }; + 492138A02CD5CA6000891D56 /* SponsorBlockRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4921389F2CD5CA6000891D56 /* SponsorBlockRequest.swift */; }; + 492138A22CD5CDBA00891D56 /* SponsorSkipPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492138A12CD5CDBA00891D56 /* SponsorSkipPlugin.swift */; }; 492731EE29096677005F5B0A /* HotViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492731ED29096677005F5B0A /* HotViewController.swift */; }; 492AD7092BFF1E6C007221C8 /* CommonPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492AD7082BFF1E6C007221C8 /* CommonPlayerViewController.swift */; }; 492AD70B2BFF23B1007221C8 /* DanmuViewPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492AD70A2BFF23B1007221C8 /* DanmuViewPlugin.swift */; }; @@ -165,6 +167,8 @@ 490425F629AB54B200CDBC60 /* CategoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryViewController.swift; sourceTree = ""; }; 490EC3E6290CC8F8001E00B6 /* RankingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RankingViewController.swift; sourceTree = ""; }; 490EC3E8290CE23E001E00B6 /* BLSettingLineCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLSettingLineCollectionViewCell.swift; sourceTree = ""; }; + 4921389F2CD5CA6000891D56 /* SponsorBlockRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockRequest.swift; sourceTree = ""; }; + 492138A12CD5CDBA00891D56 /* SponsorSkipPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorSkipPlugin.swift; sourceTree = ""; }; 492731ED29096677005F5B0A /* HotViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotViewController.swift; sourceTree = ""; }; 492AD7082BFF1E6C007221C8 /* CommonPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonPlayerViewController.swift; sourceTree = ""; }; 492AD70A2BFF23B1007221C8 /* DanmuViewPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DanmuViewPlugin.swift; sourceTree = ""; }; @@ -476,6 +480,7 @@ 496E5A542C01CDBB0062951B /* DebugPlugin.swift */, 496E5A562C01CDCA0062951B /* SpeedChangerPlugin.swift */, 49D6A7112C0200ED0084A5A7 /* CommonPlayerPlugin.swift */, + 492138A12CD5CDBA00891D56 /* SponsorSkipPlugin.swift */, ); path = Plugins; sourceTree = ""; @@ -624,6 +629,7 @@ 49D39F27263AD40000F14497 /* WebRequest.swift */, F9D382B326359EF90070508F /* ApiRequest.swift */, F927ED8F2610A5E900EAB8E3 /* CookieManager.swift */, + 4921389F2CD5CA6000891D56 /* SponsorBlockRequest.swift */, ); path = Request; sourceTree = ""; @@ -903,6 +909,7 @@ 2DBE4C4D2628818F00D20413 /* HistoryViewController.swift in Sources */, 492AD70D2BFF33DF007221C8 /* VideoPlayerViewController.swift in Sources */, F9171D6629026AC5002868C7 /* TitleSupplementaryView.swift in Sources */, + 492138A02CD5CA6000891D56 /* SponsorBlockRequest.swift in Sources */, F9B9EAE7261AC6F80045C2C6 /* BLTabBarViewController.swift in Sources */, 498CF2A12B63AABE0009793E /* metablock.c in Sources */, F99D28EA26195EC200F8E66A /* UIView+Layout.swift in Sources */, @@ -923,6 +930,7 @@ 49D6A7122C0200ED0084A5A7 /* CommonPlayerPlugin.swift in Sources */, 490425F729AB54B200CDBC60 /* CategoryViewController.swift in Sources */, 4973502D29162A6D0045C26B /* StandardVideoCollectionViewController.swift in Sources */, + 492138A22CD5CDBA00891D56 /* SponsorSkipPlugin.swift in Sources */, F927ED752610395300EAB8E3 /* DanmakuAsyncLayer.swift in Sources */, 498CF2942B63AABE0009793E /* entropy_encode.c in Sources */, 498CF2A82B63AABE0009793E /* huffman.c in Sources */, diff --git a/BilibiliLive/Component/Player/Plugins/SponsorSkipPlugin.swift b/BilibiliLive/Component/Player/Plugins/SponsorSkipPlugin.swift new file mode 100644 index 00000000..f1544249 --- /dev/null +++ b/BilibiliLive/Component/Player/Plugins/SponsorSkipPlugin.swift @@ -0,0 +1,112 @@ +// +// SponsorSkipPlugin.swift +// BilibiliLive +// +// Created by yicheng on 2/11/2024. +// + +import AVKit + +class SponsorSkipPlugin: NSObject, CommonPlayerPlugin { + private var clipInfos: [SponsorBlockRequest.SkipSegment] = [] + private let bvid: String + private let duration: Double + private var observers = [Any]() + private weak var playerVC: AVPlayerViewController? + + private var set = false + + init(bvid: String, duration: Int) { + self.bvid = bvid + self.duration = Double(duration) + } + + func loadClips() async { + do { + clipInfos = try await SponsorBlockRequest.getSkipSegments(bvid: bvid) + clipInfos = clipInfos.filter { + abs(duration - $0.videoDuration) < 4 + } + + Logger.debug("[SponsorBlockRequest] get segs:" + clipInfos.map { "\($0.start)-\($0.end)" }.joined(separator: ",")) + if !set, let player = await playerVC?.player { + set = true + sendClipToPlayer(player: player) + } + } catch { + print(error) + } + } + + func sendClipToPlayer(player: AVPlayer) { + for clip in clipInfos { + let start: CMTime + let end: CMTime + + let buttonText: String + let autoSkip = Settings.enableSponsorBlock == .jump + if autoSkip { + start = CMTime(seconds: clip.start - 5, preferredTimescale: 1) + end = CMTime(seconds: clip.start, preferredTimescale: 1) + buttonText = "取消跳过广告" + } else { + start = CMTime(seconds: clip.start, preferredTimescale: 1) + end = CMTime(seconds: clip.end - 1, preferredTimescale: 1) + buttonText = "跳过广告" + } + + let skipAction = { [weak player, weak self] in + player?.seek(to: CMTime(seconds: Double(clip.end), preferredTimescale: 1), toleranceBefore: .zero, toleranceAfter: .zero) + self?.playerVC?.contextualActions = [] + } + + let startObserver = player.addBoundaryTimeObserver(forTimes: [NSValue(time: start)], queue: .main) { + [weak self] in + guard let self = self else { return } + let action: UIAction + let identifier = UIAction.Identifier(clip.UUID) + if autoSkip { + action = UIAction(title: buttonText, identifier: identifier) { [weak self] _ in + self?.playerVC?.contextualActions = [] + } + } else { + action = UIAction(title: buttonText, identifier: identifier) { _ in skipAction() } + } + playerVC?.contextualActions = [action] + } + observers.append(startObserver) + + let endObserver = player.addBoundaryTimeObserver(forTimes: [NSValue(time: end)], queue: .main) { + [weak self] in + guard let self = self else { return } + if let action = playerVC?.contextualActions.first, + action.identifier.rawValue == clip.UUID, autoSkip + { + skipAction() + } + playerVC?.contextualActions = [] + } + observers.append(endObserver) + } + } + + func playerDidLoad(playerVC: AVPlayerViewController) { + self.playerVC = playerVC + Task { + await loadClips() + } + } + + func playerWillStart(player: AVPlayer) { + if !clipInfos.isEmpty { + set = true + sendClipToPlayer(player: player) + } + } + + func playerDidCleanUp(player: AVPlayer) { + for observer in observers { + player.removeTimeObserver(observer) + } + } +} diff --git a/BilibiliLive/Component/Settings.swift b/BilibiliLive/Component/Settings.swift index 97f4042f..4e34ea05 100644 --- a/BilibiliLive/Component/Settings.swift +++ b/BilibiliLive/Component/Settings.swift @@ -101,6 +101,9 @@ enum Settings { @UserDefault("Settings.ui.sideMenuAutoSelectChange", defaultValue: false) static var sideMenuAutoSelectChange: Bool + + @UserDefaultCodable("Settings.SponsorBlockType", defaultValue: SponsorBlockType.none) + static var enableSponsorBlock: SponsorBlockType } struct MediaQuality { @@ -108,6 +111,23 @@ struct MediaQuality { var fnval: Int } +enum SponsorBlockType: String, Codable, CaseIterable { + case none + case jump + case tip + + var title: String { + switch self { + case .none: + return "关" + case .jump: + return "自动跳过" + case .tip: + return "手动跳过" + } + } +} + enum DanmuArea: Codable, CaseIterable { case style_75 case style_50 diff --git a/BilibiliLive/Component/Video/NewVideoPlayerViewModel.swift b/BilibiliLive/Component/Video/NewVideoPlayerViewModel.swift index 69435430..6a43f4bb 100644 --- a/BilibiliLive/Component/Video/NewVideoPlayerViewModel.swift +++ b/BilibiliLive/Component/Video/NewVideoPlayerViewModel.swift @@ -163,6 +163,11 @@ class VideoPlayerViewModel { plugins.append(clip) } + if Settings.enableSponsorBlock != .none, let bvid = data.detail?.View.bvid, let duration = data.detail?.View.duration { + let sponsor = SponsorSkipPlugin(bvid: bvid, duration: duration) + plugins.append(sponsor) + } + if Settings.danmuMask { if let mask = data.playerInfo?.dm_mask, let video = data.videoPlayURLInfo.dash.video.first, diff --git a/BilibiliLive/Module/Personal/SettingsViewController.swift b/BilibiliLive/Module/Personal/SettingsViewController.swift index a38e393f..9a447cc0 100644 --- a/BilibiliLive/Module/Personal/SettingsViewController.swift +++ b/BilibiliLive/Module/Personal/SettingsViewController.swift @@ -101,6 +101,11 @@ class SettingsViewController: UIViewController { } cellModels.append(hotWithoutCookie) + let sponsorBlock = cellModelWithActions(title: "空降助手广告屏蔽", message: "", current: Settings.enableSponsorBlock.title, options: SponsorBlockType.allCases, optionString: SponsorBlockType.allCases.map({ $0.title })) { + Settings.enableSponsorBlock = $0 + } + cellModels.append(sponsorBlock) + let continuePlay = CellModel(title: "从上次退出的位置继续播放", desp: Settings.continuePlay ? "开" : "关") { [weak self] in Settings.continuePlay.toggle() diff --git a/BilibiliLive/Request/SponsorBlockRequest.swift b/BilibiliLive/Request/SponsorBlockRequest.swift new file mode 100644 index 00000000..6aea2c00 --- /dev/null +++ b/BilibiliLive/Request/SponsorBlockRequest.swift @@ -0,0 +1,63 @@ +// +// SponsorBlockRequest.swift +// BilibiliLive +// +// Created by yicheng on 2/11/2024. +// + +import Alamofire +import CryptoKit +import Foundation + +enum SponsorBlockRequest { + class SkipSegment: Codable { + let segment: [Double] + let category: String + let UUID: String + let actionType: String + let videoDuration: Double + + var vaild: Bool { + segment.count == 2 + } + + var start: Double { + segment[0] + } + + var end: Double { + segment[1] + } + } + + enum Category: String, Codable { + case sponsor + } + + static let sponsorBlockAPI = "https://bsbsb.top/api/skipSegments/" + + static func getSkipSegments(bvid: String) async throws -> [SkipSegment] { + class Infos: Codable { + let segments: [SkipSegment] + let videoID: String + } + + let sha256 = SHA256.hash(data: bvid.data(using: .utf8)!) + .map({ String(format: "%02x", $0) }).prefix(2).joined() + let parameters = ["category": Category.sponsor.rawValue] + + let request = AF.request(sponsorBlockAPI + sha256, parameters: parameters) + .serializingDecodable([Infos].self) + do { + let response = try await request.value + + let segs = response.filter({ $0.videoID == bvid }) + .map({ $0.segments }) + .flatMap({ $0 }) + .filter({ $0.vaild }) + return segs + } catch { + throw error + } + } +}