diff --git a/BilibiliLive.xcodeproj/project.pbxproj b/BilibiliLive.xcodeproj/project.pbxproj index ce3d72e3..0d6ebb07 100644 --- a/BilibiliLive.xcodeproj/project.pbxproj +++ b/BilibiliLive.xcodeproj/project.pbxproj @@ -21,6 +21,12 @@ 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 */; }; + 492AD7092BFF1E6C007221C8 /* CommonPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492AD7082BFF1E6C007221C8 /* CommonPlayerViewController.swift */; }; + 492AD70B2BFF23B1007221C8 /* DanmuViewPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492AD70A2BFF23B1007221C8 /* DanmuViewPlugin.swift */; }; + 492AD70D2BFF33DF007221C8 /* VideoPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492AD70C2BFF33DF007221C8 /* VideoPlayerViewController.swift */; }; + 492AD70F2BFF6761007221C8 /* NewVideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492AD70E2BFF6761007221C8 /* NewVideoPlayerViewModel.swift */; }; + 492AD7112C001C7B007221C8 /* BVideoPlayPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492AD7102C001C7B007221C8 /* BVideoPlayPlugin.swift */; }; + 492AD7132C001CA7007221C8 /* String+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492AD7122C001CA7007221C8 /* String+Error.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 */; }; @@ -36,9 +42,18 @@ 49508E0F2943420100D26812 /* CocoaLumberjack in Frameworks */ = {isa = PBXBuildFile; productRef = 49508E0E2943420100D26812 /* CocoaLumberjack */; }; 49508E112943420100D26812 /* CocoaLumberjackSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 49508E102943420100D26812 /* CocoaLumberjackSwift */; }; 496400D32943431E0098ACA6 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496400D22943431E0098ACA6 /* Logger.swift */; }; + 496E5A4F2C0194720062951B /* MaskViewPugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496E5A4E2C0194720062951B /* MaskViewPugin.swift */; }; + 496E5A512C0194CD0062951B /* BUpnpPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496E5A502C0194CD0062951B /* BUpnpPlugin.swift */; }; + 496E5A552C01CDBB0062951B /* DebugPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496E5A542C01CDBB0062951B /* DebugPlugin.swift */; }; + 496E5A572C01CDCA0062951B /* SpeedChangerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496E5A562C01CDCA0062951B /* SpeedChangerPlugin.swift */; }; 4973502B29161B770045C26B /* WeeklyWatchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4973502A29161B770045C26B /* WeeklyWatchViewController.swift */; }; 4973502D29162A6D0045C26B /* StandardVideoCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4973502C29162A6D0045C26B /* StandardVideoCollectionViewController.swift */; }; 497361082BF1A16600ED213F /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 497361072BF1A16600ED213F /* Keys.swift */; }; + 497CF22F2C16EBA4006E1488 /* Published+..swift in Sources */ = {isa = PBXBuildFile; fileRef = 497CF22E2C16EBA4006E1488 /* Published+..swift */; }; + 497CF2312C16ED45006E1488 /* MaskProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 497CF2302C16ED45006E1488 /* MaskProvider.swift */; }; + 497CF2372C16EDE5006E1488 /* BVideoClipsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 497CF2342C16EDE5006E1488 /* BVideoClipsPlugin.swift */; }; + 497CF2382C16EDE5006E1488 /* BVideoInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 497CF2352C16EDE5006E1488 /* BVideoInfoPlugin.swift */; }; + 497CF2392C16EDE5006E1488 /* VideoPlayListPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 497CF2362C16EDE5006E1488 /* VideoPlayListPlugin.swift */; }; 498CF2902B63AABE0009793E /* dictionary_hash.c in Sources */ = {isa = PBXBuildFile; fileRef = 498CF2422B63AABE0009793E /* dictionary_hash.c */; }; 498CF2912B63AABE0009793E /* backward_references_hq.c in Sources */ = {isa = PBXBuildFile; fileRef = 498CF2432B63AABE0009793E /* backward_references_hq.c */; }; 498CF2922B63AABE0009793E /* histogram.c in Sources */ = {isa = PBXBuildFile; fileRef = 498CF2462B63AABE0009793E /* histogram.c */; }; @@ -73,7 +88,10 @@ 499C76132931A781003160FB /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = 499C76122931A781003160FB /* Swifter */; }; 499C76162931A7AE003160FB /* SwiftyXMLParser in Frameworks */ = {isa = PBXBuildFile; productRef = 499C76152931A7AE003160FB /* SwiftyXMLParser */; }; 49A441CD293F6DFD0007606C /* FollowUpsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49A441CC293F6DFD0007606C /* FollowUpsViewController.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 */; }; + 49D6A7122C0200ED0084A5A7 /* CommonPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49D6A7112C0200ED0084A5A7 /* CommonPlayerPlugin.swift */; }; 49DA01A0296C466C00EEAE15 /* AVInfoPanelCollectionViewThumbnailCell+Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49DA019F296C466C00EEAE15 /* AVInfoPanelCollectionViewThumbnailCell+Hook.swift */; }; 49E5F85028AF73C500FAA3CE /* BilibiliVideoResourceLoaderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E5F84F28AF73C500FAA3CE /* BilibiliVideoResourceLoaderDelegate.swift */; }; 49F9186E2931E3C9001D3EC3 /* DLNAInfo.xml in Resources */ = {isa = PBXBuildFile; fileRef = 49F9186D2931E3C9001D3EC3 /* DLNAInfo.xml */; }; @@ -107,8 +125,6 @@ F927ED902610A5E900EAB8E3 /* CookieManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F927ED8F2610A5E900EAB8E3 /* CookieManager.swift */; }; F927ED992610AD8D00EAB8E3 /* LiveViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F927ED982610AD8D00EAB8E3 /* LiveViewController.swift */; }; F927ED9F2610B5C300EAB8E3 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = F927ED9E2610B5C300EAB8E3 /* Kingfisher */; }; - F9562C92261A0D2200573B74 /* VideoPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9562C91261A0D2200573B74 /* VideoPlayerViewController.swift */; }; - F99D28E12619591300F8E66A /* CommonPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F99D28E02619591300F8E66A /* CommonPlayerViewController.swift */; }; F99D28EA26195EC200F8E66A /* UIView+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F99D28E926195EC200F8E66A /* UIView+Layout.swift */; }; F99D28F72619F5F000F8E66A /* FollowsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F99D28F62619F5F000F8E66A /* FollowsViewController.swift */; }; F9B57354260F5F7400771ED5 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B57353260F5F7400771ED5 /* AppDelegate.swift */; }; @@ -150,6 +166,12 @@ 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 = ""; }; + 492AD7082BFF1E6C007221C8 /* CommonPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonPlayerViewController.swift; sourceTree = ""; }; + 492AD70A2BFF23B1007221C8 /* DanmuViewPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DanmuViewPlugin.swift; sourceTree = ""; }; + 492AD70C2BFF33DF007221C8 /* VideoPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewController.swift; sourceTree = ""; }; + 492AD70E2BFF6761007221C8 /* NewVideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewVideoPlayerViewModel.swift; sourceTree = ""; }; + 492AD7102C001C7B007221C8 /* BVideoPlayPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BVideoPlayPlugin.swift; sourceTree = ""; }; + 492AD7122C001CA7007221C8 /* String+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Error.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 = ""; }; @@ -164,9 +186,18 @@ 49474212290509F6005D6885 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = ""; }; 4947423A2906B308005D6885 /* BLTextOnlyCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLTextOnlyCollectionViewCell.swift; sourceTree = ""; }; 496400D22943431E0098ACA6 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 496E5A4E2C0194720062951B /* MaskViewPugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaskViewPugin.swift; sourceTree = ""; }; + 496E5A502C0194CD0062951B /* BUpnpPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BUpnpPlugin.swift; sourceTree = ""; }; + 496E5A542C01CDBB0062951B /* DebugPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPlugin.swift; sourceTree = ""; }; + 496E5A562C01CDCA0062951B /* SpeedChangerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeedChangerPlugin.swift; sourceTree = ""; }; 4973502A29161B770045C26B /* WeeklyWatchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeeklyWatchViewController.swift; sourceTree = ""; }; 4973502C29162A6D0045C26B /* StandardVideoCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandardVideoCollectionViewController.swift; sourceTree = ""; }; 497361072BF1A16600ED213F /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = ""; }; + 497CF22E2C16EBA4006E1488 /* Published+..swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Published+..swift"; sourceTree = ""; }; + 497CF2302C16ED45006E1488 /* MaskProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaskProvider.swift; sourceTree = ""; }; + 497CF2342C16EDE5006E1488 /* BVideoClipsPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BVideoClipsPlugin.swift; sourceTree = ""; }; + 497CF2352C16EDE5006E1488 /* BVideoInfoPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BVideoInfoPlugin.swift; sourceTree = ""; }; + 497CF2362C16EDE5006E1488 /* VideoPlayListPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayListPlugin.swift; sourceTree = ""; }; 498CF2352B63AABE0009793E /* NSData+BrotliCompression.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSData+BrotliCompression.h"; sourceTree = ""; }; 498CF2362B63AABE0009793E /* LMBrotliCompression.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = LMBrotliCompression.h; sourceTree = ""; }; 498CF2372B63AABE0009793E /* BrotliKit.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BrotliKit.h; sourceTree = ""; }; @@ -256,7 +287,10 @@ 499C75ED29305A1E003160FB /* BiliBiliUpnpDMR.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiliBiliUpnpDMR.swift; sourceTree = ""; }; 499C760E2930E068003160FB /* NVASocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NVASocket.swift; sourceTree = ""; }; 49A441CC293F6DFD0007606C /* FollowUpsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowUpsViewController.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 = ""; }; + 49D6A7112C0200ED0084A5A7 /* CommonPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonPlayerPlugin.swift; sourceTree = ""; }; 49DA019F296C466C00EEAE15 /* AVInfoPanelCollectionViewThumbnailCell+Hook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVInfoPanelCollectionViewThumbnailCell+Hook.swift"; sourceTree = ""; }; 49E5F84F28AF73C500FAA3CE /* BilibiliVideoResourceLoaderDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BilibiliVideoResourceLoaderDelegate.swift; sourceTree = ""; }; 49F9186D2931E3C9001D3EC3 /* DLNAInfo.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = DLNAInfo.xml; sourceTree = ""; }; @@ -287,9 +321,7 @@ F927ED8C2610A5A800EAB8E3 /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; F927ED8F2610A5E900EAB8E3 /* CookieManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieManager.swift; sourceTree = ""; }; F927ED982610AD8D00EAB8E3 /* LiveViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveViewController.swift; sourceTree = ""; }; - F9562C91261A0D2200573B74 /* VideoPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewController.swift; sourceTree = ""; }; F99D28DA2618A55900F8E66A /* BilibiliLive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "BilibiliLive-Bridging-Header.h"; sourceTree = ""; }; - F99D28E02619591300F8E66A /* CommonPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonPlayerViewController.swift; sourceTree = ""; }; F99D28E926195EC200F8E66A /* UIView+Layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Layout.swift"; sourceTree = ""; }; F99D28F62619F5F000F8E66A /* FollowsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowsViewController.swift; sourceTree = ""; }; F9B57350260F5F7400771ED5 /* BilibiliLive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BilibiliLive.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -370,14 +402,15 @@ 49389D5D28AFE6DC00B9DAFD /* Video */ = { isa = PBXGroup; children = ( - F9562C91261A0D2200573B74 /* VideoPlayerViewController.swift */, F9EDADD2262AA421007CB99F /* VideoDetailViewController.swift */, 2806E51F2C15A59E00164C10 /* ReplyDetailViewController.swift */, 2806E5212C16011B00164C10 /* ReplyCell.swift */, 2806E5222C16011B00164C10 /* ReplyCell.xib */, 49389D6128AFEA2900B9DAFD /* VideoDanmuProvider.swift */, - 498DB1DE291BC24700F95607 /* BMaskProvider.swift */, - 49FB8EBF291F4C520045D5DE /* VMaskProvider.swift */, + 497CF2322C16EDAC006E1488 /* MaskProvider */, + 492AD70C2BFF33DF007221C8 /* VideoPlayerViewController.swift */, + 492AD70E2BFF6761007221C8 /* NewVideoPlayerViewModel.swift */, + 497CF2332C16EDC0006E1488 /* Plugins */, ); path = Video; sourceTree = ""; @@ -395,6 +428,8 @@ AEA5FDE1290F6E2600E7C0B2 /* String.swift */, AE2B41562914C02000BF2B0B /* Int.swift */, 49DA019F296C466C00EEAE15 /* AVInfoPanelCollectionViewThumbnailCell+Hook.swift */, + 492AD7122C001CA7007221C8 /* String+Error.swift */, + 497CF22E2C16EBA4006E1488 /* Published+..swift */, ); path = Extensions; sourceTree = ""; @@ -410,6 +445,41 @@ path = View; sourceTree = ""; }; + 497CF2322C16EDAC006E1488 /* MaskProvider */ = { + isa = PBXGroup; + children = ( + 498DB1DE291BC24700F95607 /* BMaskProvider.swift */, + 49FB8EBF291F4C520045D5DE /* VMaskProvider.swift */, + 497CF2302C16ED45006E1488 /* MaskProvider.swift */, + ); + path = MaskProvider; + sourceTree = ""; + }; + 497CF2332C16EDC0006E1488 /* Plugins */ = { + isa = PBXGroup; + children = ( + 497CF2342C16EDE5006E1488 /* BVideoClipsPlugin.swift */, + 497CF2352C16EDE5006E1488 /* BVideoInfoPlugin.swift */, + 497CF2362C16EDE5006E1488 /* VideoPlayListPlugin.swift */, + 492AD7102C001C7B007221C8 /* BVideoPlayPlugin.swift */, + 496E5A502C0194CD0062951B /* BUpnpPlugin.swift */, + ); + path = Plugins; + sourceTree = ""; + }; + 497CF23A2C16EE04006E1488 /* Plugins */ = { + isa = PBXGroup; + children = ( + 49D2509F2C118FA700173908 /* URLPlayPlugin.swift */, + 492AD70A2BFF23B1007221C8 /* DanmuViewPlugin.swift */, + 496E5A4E2C0194720062951B /* MaskViewPugin.swift */, + 496E5A542C01CDBB0062951B /* DebugPlugin.swift */, + 496E5A562C01CDCA0062951B /* SpeedChangerPlugin.swift */, + 49D6A7112C0200ED0084A5A7 /* CommonPlayerPlugin.swift */, + ); + path = Plugins; + sourceTree = ""; + }; 498CF2342B63AABE0009793E /* BrotliKit */ = { isa = PBXGroup; children = ( @@ -635,8 +705,10 @@ isa = PBXGroup; children = ( 49E5F84F28AF73C500FAA3CE /* BilibiliVideoResourceLoaderDelegate.swift */, - F99D28E02619591300F8E66A /* CommonPlayerViewController.swift */, 49FB8EE829208EBE0045D5DE /* SidxParseUtil.swift */, + 492AD7082BFF1E6C007221C8 /* CommonPlayerViewController.swift */, + 497CF23A2C16EE04006E1488 /* Plugins */, + 49D250A12C11A82B00173908 /* AVPlayerMetaUtils.swift */, ); path = Player; sourceTree = ""; @@ -811,6 +883,7 @@ F9B9EAED261B25E40045C2C6 /* ToViewViewController.swift in Sources */, 4973502B29161B770045C26B /* WeeklyWatchViewController.swift in Sources */, AE7A3B20290298BE006FEBB0 /* Colors.swift in Sources */, + 496E5A4F2C0194720062951B /* MaskViewPugin.swift in Sources */, 49FB8EC0291F4C520045D5DE /* VMaskProvider.swift in Sources */, 497361082BF1A16600ED213F /* Keys.swift in Sources */, 49E5F85028AF73C500FAA3CE /* BilibiliVideoResourceLoaderDelegate.swift in Sources */, @@ -820,6 +893,7 @@ 498CF29E2B63AABE0009793E /* bit_cost.c in Sources */, 498CF29B2B63AABE0009793E /* utf8_util.c in Sources */, 498CF2AA2B63AABE0009793E /* LMBrotliCompressor.m in Sources */, + 497CF22F2C16EBA4006E1488 /* Published+..swift in Sources */, AEA6AB1628FFE951007CE72E /* SettingsViewController.swift in Sources */, 49389D8928B0A1B700B9DAFD /* UIViewController+Ext.swift in Sources */, AE2B41572914C02000BF2B0B /* Int.swift in Sources */, @@ -827,9 +901,9 @@ 498CF2A72B63AABE0009793E /* decode.c in Sources */, F90AAE04265549B5008DE7C2 /* FeedViewController.swift in Sources */, 2DBE4C4D2628818F00D20413 /* HistoryViewController.swift in Sources */, + 492AD70D2BFF33DF007221C8 /* VideoPlayerViewController.swift in Sources */, F9171D6629026AC5002868C7 /* TitleSupplementaryView.swift in Sources */, F9B9EAE7261AC6F80045C2C6 /* BLTabBarViewController.swift in Sources */, - F9562C92261A0D2200573B74 /* VideoPlayerViewController.swift in Sources */, 498CF2A12B63AABE0009793E /* metablock.c in Sources */, F99D28EA26195EC200F8E66A /* UIView+Layout.swift in Sources */, 498CF29D2B63AABE0009793E /* brotli_bit_stream.c in Sources */, @@ -838,12 +912,15 @@ 49FB8EE929208EBE0045D5DE /* SidxParseUtil.swift in Sources */, F9171D6129010429002868C7 /* UICollectionView+..swift in Sources */, 498CF2982B63AABE0009793E /* encoder_dict.c in Sources */, + 497CF2312C16ED45006E1488 /* MaskProvider.swift in Sources */, AE2B41552912706700BF2B0B /* SearchResultViewController.swift in Sources */, 499C75EE29305A1E003160FB /* BiliBiliUpnpDMR.swift in Sources */, + 49D250A22C11A82B00173908 /* AVPlayerMetaUtils.swift in Sources */, 4947423B2906B308005D6885 /* BLTextOnlyCollectionViewCell.swift in Sources */, F927ED8D2610A5A800EAB8E3 /* LoginViewController.swift in Sources */, F9B9EAEA261B15020045C2C6 /* FeedCollectionViewController.swift in Sources */, 0A41EE1D2A63102B0066444C /* dmView.pb.swift in Sources */, + 49D6A7122C0200ED0084A5A7 /* CommonPlayerPlugin.swift in Sources */, 490425F729AB54B200CDBC60 /* CategoryViewController.swift in Sources */, 4973502D29162A6D0045C26B /* StandardVideoCollectionViewController.swift in Sources */, F927ED752610395300EAB8E3 /* DanmakuAsyncLayer.swift in Sources */, @@ -855,40 +932,50 @@ F927ED682610113A00EAB8E3 /* LiveDanMuProvider.swift in Sources */, 498CF2922B63AABE0009793E /* histogram.c in Sources */, 49474213290509F6005D6885 /* DateFormatter.swift in Sources */, + 49D250A02C118FA700173908 /* URLPlayPlugin.swift in Sources */, + 492AD7112C001C7B007221C8 /* BVideoPlayPlugin.swift in Sources */, F927ED742610395300EAB8E3 /* DanmakuView.swift in Sources */, F927ED782610395300EAB8E3 /* DanmakuTrack.swift in Sources */, + 492AD7092BFF1E6C007221C8 /* CommonPlayerViewController.swift in Sources */, 498CF29C2B63AABE0009793E /* compress_fragment.c in Sources */, 499C760F2930E068003160FB /* NVASocket.swift in Sources */, 498CF2912B63AABE0009793E /* backward_references_hq.c in Sources */, 494742112905053E005D6885 /* BLMotionCollectionViewCell.swift in Sources */, + 496E5A512C0194CD0062951B /* BUpnpPlugin.swift in Sources */, 494741E7290391A7005D6885 /* BLButton.swift in Sources */, + 496E5A572C01CDCA0062951B /* SpeedChangerPlugin.swift in Sources */, F927ED992610AD8D00EAB8E3 /* LiveViewController.swift in Sources */, + 492AD7132C001CA7007221C8 /* String+Error.swift in Sources */, 490EC3E7290CC8F8001E00B6 /* RankingViewController.swift in Sources */, 2806E5232C16011B00164C10 /* ReplyCell.swift in Sources */, F927ED732610395300EAB8E3 /* DanmakuCell.swift in Sources */, + 497CF2392C16EDE5006E1488 /* VideoPlayListPlugin.swift in Sources */, 498CF2A92B63AABE0009793E /* bit_reader.c in Sources */, 498CF2972B63AABE0009793E /* encode.c in Sources */, 49389D8C28B0A84500B9DAFD /* PersonalViewController.swift in Sources */, 498CF2992B63AABE0009793E /* cluster.c in Sources */, 498CF2952B63AABE0009793E /* compress_fragment_two_pass.c in Sources */, F927ED772610395300EAB8E3 /* DanmakuQueuePool.swift in Sources */, + 497CF2382C16EDE5006E1488 /* BVideoInfoPlugin.swift in Sources */, 49A441CD293F6DFD0007606C /* FollowUpsViewController.swift in Sources */, AEA6AB1928FFF3DD007CE72E /* Settings.swift in Sources */, 498CF2962B63AABE0009793E /* block_splitter.c in Sources */, 494741C6290177BB005D6885 /* UpSpaceViewController.swift in Sources */, + 496E5A552C01CDBB0062951B /* DebugPlugin.swift in Sources */, 49DA01A0296C466C00EEAE15 /* AVInfoPanelCollectionViewThumbnailCell+Hook.swift in Sources */, 492731EE29096677005F5B0A /* HotViewController.swift in Sources */, F927ED8926103CFB00EAB8E3 /* DanmakuTextCellModel.swift in Sources */, 498CF2AB2B63AABE0009793E /* LMBrotliCompression.c in Sources */, 498CF2AC2B63AABE0009793E /* NSData+BrotliCompression.m in Sources */, 498CF2A32B63AABE0009793E /* transform.c in Sources */, + 492AD70B2BFF23B1007221C8 /* DanmuViewPlugin.swift in Sources */, 49D39F28263AD40000F14497 /* WebRequest.swift in Sources */, 498DB1DF291BC24700F95607 /* BMaskProvider.swift in Sources */, 494741C029002797005D6885 /* UserDefault+..swift in Sources */, - F99D28E12619591300F8E66A /* CommonPlayerViewController.swift in Sources */, AE4889B228FE55DA00E8C5CD /* FavoriteViewController.swift in Sources */, F9B57356260F5F7400771ED5 /* LivePlayerViewController.swift in Sources */, 498CF29F2B63AABE0009793E /* static_dict.c in Sources */, + 492AD70F2BFF6761007221C8 /* NewVideoPlayerViewModel.swift in Sources */, 498CF2A02B63AABE0009793E /* literal_cost.c in Sources */, F99D28F72619F5F000F8E66A /* FollowsViewController.swift in Sources */, F9D382B426359EF90070508F /* ApiRequest.swift in Sources */, @@ -902,6 +989,7 @@ F9171D6429010DF1002868C7 /* FeedCollectionViewCell.swift in Sources */, F927ED902610A5E900EAB8E3 /* CookieManager.swift in Sources */, F927ED792610395400EAB8E3 /* DanmakuCellModel.swift in Sources */, + 497CF2372C16EDE5006E1488 /* BVideoClipsPlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BilibiliLive.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/BilibiliLive.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 323c2904..82687eca 100644 --- a/BilibiliLive.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/BilibiliLive.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f89b743e598074e66556cd063dd1258c33483d783163efc65f648349cece8790", + "originHash" : "45c5c9267c8c84275fa647f629e1033e605b18efefc17e781b907af506eb5385", "pins" : [ { "identity" : "alamofire", diff --git a/BilibiliLive/Component/Player/AVPlayerMetaUtils.swift b/BilibiliLive/Component/Player/AVPlayerMetaUtils.swift new file mode 100644 index 00000000..a8d85b63 --- /dev/null +++ b/BilibiliLive/Component/Player/AVPlayerMetaUtils.swift @@ -0,0 +1,42 @@ +// +// AVPlayerMetaUtils.swift +// BilibiliLive +// +// Created by yicheng on 2024/6/6. +// + +import AVKit +import Kingfisher + +enum AVPlayerMetaUtils { + static func setPlayerInfo(title: String?, subTitle: String?, desp: String?, pic: URL?, player: AVPlayer) async { + let desp = desp?.components(separatedBy: "\n").joined(separator: " ") + let mapping: [AVMetadataIdentifier: Any?] = [ + .commonIdentifierTitle: title, + .iTunesMetadataTrackSubTitle: subTitle, + .commonIdentifierDescription: desp, + ] + var metas = mapping.compactMap { createMetadataItem(for: $0, value: $1) } + + player.currentItem?.externalMetadata = metas + + if let pic = pic, + let resource = try? await KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: pic)), + let data = resource.image.pngData(), + let item = createMetadataItem(for: .commonIdentifierArtwork, value: data) + { + metas.append(item) + player.currentItem?.externalMetadata = metas + } + } + + static func createMetadataItem(for identifier: AVMetadataIdentifier, value: Any?) -> AVMetadataItem? { + if value == nil { return nil } + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as? AVMetadataItem + } +} diff --git a/BilibiliLive/Component/Player/CommonPlayerViewController.swift b/BilibiliLive/Component/Player/CommonPlayerViewController.swift index 786097ea..f5654d8a 100644 --- a/BilibiliLive/Component/Player/CommonPlayerViewController.swift +++ b/BilibiliLive/Component/Player/CommonPlayerViewController.swift @@ -1,266 +1,60 @@ // -// CommonPlayerViewController.swift +// NewCommonPlayerViewController.swift // BilibiliLive // -// Created by Etan Chen on 2021/4/4. +// Created by yicheng on 2024/5/23. // import AVKit -import Kingfisher import UIKit -import Vision -protocol MaskProvider: AnyObject { - func getMask(for time: CMTime, frame: CGRect, onGet: @escaping (CALayer) -> Void) - func needVideoOutput() -> Bool - func setVideoOutout(ouput: AVPlayerItemVideoOutput) - func preferFPS() -> Int -} - -class CommonPlayerViewController: AVPlayerViewController { - let danMuView = DanmakuView() - var allowChangeSpeed = true - var playerStartPos: Int? - private var retryCount = 0 - private let maxRetryCount = 3 - private var observer: NSKeyValueObservation? +class CommonPlayerViewController: UIViewController { + private let playerVC = AVPlayerViewController() + private var activePlugins = [CommonPlayerPlugin]() + private var observations = Set() private var rateObserver: NSKeyValueObservation? - private var debugView: UILabel? - var maskProvider: MaskProvider? - - var playerItem: AVPlayerItem? { - didSet { - if let playerItem = playerItem { - removeObservarPlayerItem() - observePlayerItem(playerItem) - if let playerInfo = playerInfo { - playerItem.externalMetadata = playerInfo - } - } - } - } - - override var player: AVPlayer? { - didSet { - if let player = player { - rateObserver = player.observe(\.rate, options: [.old, .new]) { - [weak self] player, _ in - guard let self = self else { return } - playerRateDidChange(player: player) - } - danMuView.play() - } else { - rateObserver = nil - } - } - } - - var videoOutput: AVPlayerItemVideoOutput? - - private var playerInfo: [AVMetadataItem]? - - deinit { - stopDebug() - } + private var statusObserver: NSKeyValueObservation? + private var isEnd = false override func viewDidLoad() { super.viewDidLoad() - appliesPreferredDisplayCriteriaAutomatically = Settings.contentMatch - allowsPictureInPicturePlayback = true - delegate = self - initDanmuView() - setupPlayerMenu() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - danMuView.recaculateTracks() - danMuView.paddingTop = 5 - danMuView.trackHeight = 50 - danMuView.displayArea = Settings.danmuArea.percent - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillAppear(animated) - danMuView.stop() - } - - func extraInfoForPlayerError() -> String { - return "" - } - - func playerStatusDidChange() { - Logger.debug("player status: \(player?.currentItem?.status.rawValue ?? -1)") - switch player?.currentItem?.status { - case .readyToPlay: - if maskProvider?.needVideoOutput() == true { - setUpOutput() - } - startPlay() - case .failed: - removeObservarPlayerItem() - Logger.debug(player?.currentItem?.error ?? "no error") - Logger.debug(player?.currentItem?.errorLog() ?? "no error log") - if retryCount < maxRetryCount, !retryPlay() { - let log = playerItem?.errorLog() - let errorLogData = log?.extendedLogData() ?? Data() - var str = String(data: errorLogData, encoding: .utf8) ?? "" - str = str.split(separator: "\n").dropFirst(4).joined() - showErrorAlertAndExit(title: "播放器失败", message: str + extraInfoForPlayerError()) + addChild(playerVC) + view.addSubview(playerVC.view) + playerVC.didMove(toParent: self) + playerVC.view.snp.makeConstraints { $0.edges.equalToSuperview() } + playerVC.allowsPictureInPicturePlayback = true + playerVC.delegate = self + + let playerObservation = playerVC.observe(\.player) { [weak self] vc, obs in + if let oldPlayer = obs.oldValue, let oldPlayer { + self?.activePlugins.forEach { $0.playerDidCleanUp(player: oldPlayer) } } - retryCount += 1 - default: - break + self?.playerDidChange(player: vc.player) } + observations.insert(playerObservation) + activePlugins.forEach { $0.playerDidLoad(playerVC: playerVC) } } - func playerRateDidChange(player: AVPlayer) {} - - @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, - .iTunesMetadataTrackSubTitle: subTitle, - .commonIdentifierDescription: desp, - ] - let meta = mapping.compactMap { createMetadataItem(for: $0, value: $1) } - playerInfo = meta - playerItem?.externalMetadata = meta - - if let pic = pic { - let resource = Kingfisher.ImageResource(downloadURL: pic) - KingfisherManager.shared.retrieveImage(with: resource) { - [weak self] result in - guard let self = self, - let data = try? result.get().image.pngData(), - let item = self.createMetadataItem(for: .commonIdentifierArtwork, value: data) - else { return } - - self.playerInfo?.removeAll { $0.identifier == .commonIdentifierArtwork } - self.playerInfo?.append(item) - self.playerItem?.externalMetadata = self.playerInfo ?? [] - } - } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + activePlugins.forEach { $0.playerDidDismiss(playerVC: playerVC) } } - func createMetadataItem(for identifier: AVMetadataIdentifier, - value: Any?) -> AVMetadataItem? - { - if value == nil { return nil } - let item = AVMutableMetadataItem() - item.identifier = identifier - item.value = value as? NSCopying & NSObjectProtocol - // Specify "und" to indicate an undefined language. - item.extendedLanguageTag = "und" - return item.copy() as? AVMetadataItem + override var preferredFocusEnvironments: [UIFocusEnvironment] { + return [playerVC.view] } - private func setupPlayerMenu() { - var menus = [UIMenuElement]() - let danmuImage = UIImage(systemName: "list.bullet.rectangle.fill") - let danmuImageDisable = UIImage(systemName: "list.bullet.rectangle") - let danmuAction = UIAction(title: "Show Danmu", image: danMuView.isHidden ? danmuImageDisable : danmuImage) { - [weak self] action in - guard let self = self else { return } - Settings.defaultDanmuStatus.toggle() - self.danMuView.isHidden.toggle() - action.image = self.danMuView.isHidden ? danmuImageDisable : danmuImage - } - menus.append(danmuAction) - - let danmuDurationMenu = UIMenu(title: "弹幕展示时长", options: [.displayInline, .singleSelection], children: [4, 6, 8].map { dur in - UIAction(title: "\(dur) 秒", state: dur == Settings.danmuDuration ? .on : .off) { _ in Settings.danmuDuration = dur } - }) - let danmuAILevelMenu = UIMenu(title: "弹幕屏蔽等级", options: [.displayInline, .singleSelection], children: [Int32](1...10).map { level in - UIAction(title: "\(level)", state: level == Settings.danmuAILevel ? .on : .off) { _ in Settings.danmuAILevel = level } - }) - let danmuSettingMenu = UIMenu(title: "弹幕设置", image: UIImage(systemName: "keyboard.badge.ellipsis"), children: [danmuDurationMenu, danmuAILevelMenu]) - menus.append(danmuSettingMenu) - - let debugEnableImage = UIImage(systemName: "terminal.fill") - let debugDisableImage = UIImage(systemName: "terminal") - let debugAction = UIAction(title: "Debug", image: debugEnable ? debugEnableImage : debugDisableImage) { - [weak self] action in - guard let self = self else { return } - if self.debugEnable { - self.stopDebug() - action.image = debugDisableImage - } else { - action.image = debugEnableImage - self.startDebug() - } - } - - if allowChangeSpeed { - // Create ∞ and ⚙ images. - let loopImage = UIImage(systemName: "infinity") - let gearImage = UIImage(systemName: "gearshape") - - // Create an action to enable looping playback. - let loopAction = UIAction(title: "循环播放", image: loopImage, state: Settings.loopPlay ? .on : .off) { - action in - action.state = (action.state == .off) ? .on : .off - Settings.loopPlay = action.state == .on - } - - let speedActions = PlaySpeed.blDefaults.map { playSpeed in - UIAction(title: playSpeed.name, state: player?.rate ?? 1 == playSpeed.value ? .on : .off) { [weak self] _ in - self?.player?.currentItem?.audioTimePitchAlgorithm = .timeDomain - self?.selectSpeed(AVPlaybackSpeed(rate: playSpeed.value, localizedName: playSpeed.name)) - self?.danMuView.playingSpeed = playSpeed.value - } - } - let playSpeedMenu = UIMenu(title: "播放速度", options: [.displayInline, .singleSelection], children: speedActions) - let menu = UIMenu(title: "播放设置", image: gearImage, children: [playSpeedMenu, loopAction, debugAction]) - menus.append(menu) - } else { - menus.append(debugAction) - } - - transportBarCustomMenuItems = menus - } - - private func removeObservarPlayerItem() { - NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) - } - - private func observePlayerItem(_ playerItem: AVPlayerItem) { - observer = playerItem.observe(\.status, options: [.new, .old]) { - [weak self] _, _ in - self?.playerStatusDidChange() - } - NotificationCenter.default.addObserver(self, - selector: #selector(playerDidFinishPlaying), - name: .AVPlayerItemDidPlayToEndTime, - object: playerItem) - } - - func setupMask() { - guard let maskProvider else { return } -// danMuView.backgroundColor = UIColor.red.withAlphaComponent(0.5) - Logger.info("mask provider is \(maskProvider)") - let interval = CMTime(seconds: 1.0 / CGFloat(maskProvider.preferFPS()), - preferredTimescale: CMTimeScale(NSEC_PER_SEC)) - player?.addPeriodicTimeObserver(forInterval: interval, queue: .main, using: { - [weak self, weak maskProvider] time in - guard let self else { return } - guard self.danMuView.isHidden == false else { return } - maskProvider?.getMask(for: time, frame: self.danMuView.frame) { - maskLayer in - self.danMuView.layer.mask = maskLayer - } - }) + func addPlugin(plugin: CommonPlayerPlugin) { + plugin.addViewToPlayerOverlay(container: playerVC.contentOverlayView!) + activePlugins.append(plugin) + plugin.playerDidLoad(playerVC: playerVC) } - func retryPlay() -> Bool { - return false - } - - @objc private func playerDidFinishPlaying() { - playDidEnd() + func removePlugin(plugin: CommonPlayerPlugin) { + activePlugins.removeAll { $0 == plugin } } - func playDidEnd() {} + func playerDidEnd(player: AVPlayer) {} func showErrorAlertAndExit(title: String = "播放失败", message: String = "未知错误") { let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) @@ -271,111 +65,74 @@ class CommonPlayerViewController: AVPlayerViewController { alertController.addAction(actionOk) present(alertController, animated: true, completion: nil) } +} - private func startPlay() { - guard player?.rate == 0 && player?.error == nil else { return } - if let playerStartPos = playerStartPos { - player?.seek(to: CMTime(seconds: Double(playerStartPos), preferredTimescale: 1), toleranceBefore: .zero, toleranceAfter: .zero) +extension CommonPlayerViewController { + private func playerDidChange(player: AVPlayer?) { + if let player { + activePlugins.forEach { $0.playerDidChange(player: player) } + rateObserver = player.observe(\.rate, options: [.old, .new]) { + [weak self] _player, obs in + DispatchQueue.main.async { [weak self] in + self?.playerRateDidChange(player: player) + } + } + if let playItem = player.currentItem { + observePlayerItem(playItem) + } + var menus = [UIMenuElement]() + activePlugins.forEach { + let newMenus = $0.addMenuItems(current: &menus) + menus.append(contentsOf: newMenus) + } + playerVC.transportBarCustomMenuItems = menus + } else { + rateObserver = nil } - player?.play() } - private func fetchDebugInfo() -> String { - let bitrateStr: (Double) -> String = { - bit in - String(format: "%.2fMbps", bit / 1024.0 / 1024.0) + private func playerRateDidChange(player: AVPlayer) { + if player.rate > 0 { + activePlugins.forEach { $0.playerDidStart(player: player) } + } else if player.rate == 0 { + if !isEnd { + activePlugins.forEach { $0.playerDidPause(player: player) } + } } - guard let player else { return "Player no init" } - - var logs = """ - \(additionDebugInfo()) - time control status: \(player.timeControlStatus.rawValue) \(player.reasonForWaitingToPlay?.rawValue ?? "") - player status:\(player.status.rawValue) - """ - - guard let log = player.currentItem?.accessLog() else { return logs } - guard let item = log.events.last else { return logs } - let uri = item.uri ?? "" - let addr = item.serverAddress ?? "" - let changes = item.numberOfServerAddressChanges - let dropped = item.numberOfDroppedVideoFrames - let stalls = item.numberOfStalls - let averageAudioBitrate = item.averageAudioBitrate - let averageVideoBitrate = item.averageVideoBitrate - let indicatedBitrate = item.indicatedBitrate - let observedBitrate = item.observedBitrate - logs += """ - uri:\(uri), ip:\(addr), change:\(changes) - drop:\(dropped) stalls:\(stalls) - bitrate audio:\(bitrateStr(averageAudioBitrate)), video: \(bitrateStr(averageVideoBitrate)) - observedBitrate:\(bitrateStr(observedBitrate)) - indicatedAverageBitrate:\(bitrateStr(indicatedBitrate)) - maskProvider: \(String(describing: maskProvider)) - """ - return logs } - func additionDebugInfo() -> String { return "" } - - var debugTimer: Timer? - var debugEnable: Bool { debugTimer?.isValid ?? false } - private func startDebug() { - if debugView == nil { - debugView = UILabel() - debugView?.backgroundColor = UIColor.black.withAlphaComponent(0.8) - debugView?.textColor = UIColor.white - view.addSubview(debugView!) - debugView?.numberOfLines = 0 - debugView?.font = UIFont.systemFont(ofSize: 26) - debugView?.snp.makeConstraints { make in - make.top.equalToSuperview().offset(12) - make.right.equalToSuperview().offset(-12) - make.width.equalTo(800) + private func observePlayerItem(_ playerItem: AVPlayerItem) { + statusObserver = playerItem.observe(\.status, options: [.new, .old]) { + [weak self] item, _ in + guard let self, let player = playerVC.player else { return } + switch item.status { + case .readyToPlay: + isEnd = false + activePlugins.forEach { $0.playerWillStart(player: player) } + player.play() + case .failed: + activePlugins.forEach { $0.playerDidFail(player: player) } + default: + break } } - debugView?.isHidden = false - debugTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in - let info = self?.fetchDebugInfo() - self?.debugView?.text = info + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) + NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main) { [weak self] note in + guard let self, let player = playerVC.player else { return } + isEnd = true + activePlugins.forEach { $0.playerDidEnd(player: player) } + playerDidEnd(player: player) } } - - private func stopDebug() { - debugTimer?.invalidate() - debugTimer = nil - debugView?.isHidden = true - } - - private func initDanmuView() { - view.addSubview(danMuView) - danMuView.accessibilityLabel = "danmuView" - danMuView.makeConstraintsToBindToSuperview() - danMuView.isHidden = !Settings.defaultDanmuStatus - } - - func setUpOutput() { - guard videoOutput == nil, let videoItem = player?.currentItem else { return } - let pixelBuffAttributes = [ - kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, - ] - let videoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBuffAttributes) - videoItem.add(videoOutput) - self.videoOutput = videoOutput - maskProvider?.setVideoOutout(ouput: videoOutput) - } - - func ensureDanmuViewFront() { - view.bringSubviewToFront(danMuView) - danMuView.play() - } } extension CommonPlayerViewController: AVPlayerViewControllerDelegate { @objc func playerViewControllerShouldDismiss(_ playerViewController: AVPlayerViewController) -> Bool { - if let presentedViewController = UIViewController.topMostViewController() as? AVPlayerViewController, - presentedViewController == playerViewController + if let presentedViewController = UIViewController.topMostViewController() as? CommonPlayerViewController, + presentedViewController.playerVC == playerViewController { - return true + dismiss(animated: true) + return false } return false } @@ -384,38 +141,37 @@ extension CommonPlayerViewController: AVPlayerViewControllerDelegate { return true } + func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { + PipRecorder.shared.playingPipViewController.append(self) + } + + func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { + PipRecorder.shared.playingPipViewController.removeAll { $0.playerVC == playerViewController } + } + @objc func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { let presentedViewController = UIViewController.topMostViewController() - if presentedViewController is AVPlayerViewController { + guard let containerPlayer = PipRecorder.shared.playingPipViewController.first(where: { $0.playerVC == playerViewController }) else { + completionHandler(false) + return + } + if presentedViewController is CommonPlayerViewController { let parent = presentedViewController.presentingViewController presentedViewController.dismiss(animated: false) { - parent?.present(playerViewController, animated: false) + parent?.present(containerPlayer, animated: false) completionHandler(true) - (playerViewController as? CommonPlayerViewController)?.ensureDanmuViewFront() } } else { - presentedViewController.present(playerViewController, animated: false) { + presentedViewController.present(containerPlayer, animated: false) { completionHandler(true) - (playerViewController as? CommonPlayerViewController)?.ensureDanmuViewFront() } } } -} -struct PlaySpeed { - var name: String - var value: Float -} - -extension PlaySpeed { - static let blDefaults = [ - PlaySpeed(name: "0.5X", value: 0.5), - PlaySpeed(name: "0.75X", value: 0.75), - PlaySpeed(name: "1X", value: 1), - PlaySpeed(name: "1.25X", value: 1.25), - PlaySpeed(name: "1.5X", value: 1.5), - PlaySpeed(name: "2X", value: 2), - ] + class PipRecorder { + static let shared = PipRecorder() + var playingPipViewController = [CommonPlayerViewController]() + } } diff --git a/BilibiliLive/Component/Player/Plugins/CommonPlayerPlugin.swift b/BilibiliLive/Component/Player/Plugins/CommonPlayerPlugin.swift new file mode 100644 index 00000000..4841d860 --- /dev/null +++ b/BilibiliLive/Component/Player/Plugins/CommonPlayerPlugin.swift @@ -0,0 +1,43 @@ +// +// CommonPlayerPlugin.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/25. +// + +import AVKit +import UIKit + +protocol CommonPlayerPlugin: NSObject { + func addViewToPlayerOverlay(container: UIView) + func addMenuItems(current: inout [UIMenuElement]) -> [UIMenuElement] + + func playerDidLoad(playerVC: AVPlayerViewController) + func playerDidDismiss(playerVC: AVPlayerViewController) + func playerDidChange(player: AVPlayer) + func playerItemDidChange(playerItem: AVPlayerItem) + + func playerWillStart(player: AVPlayer) + func playerDidStart(player: AVPlayer) + func playerDidPause(player: AVPlayer) + func playerDidEnd(player: AVPlayer) + func playerDidFail(player: AVPlayer) + func playerDidCleanUp(player: AVPlayer) +} + +extension CommonPlayerPlugin { + func addViewToPlayerOverlay(container: UIView) {} + func addMenuItems(current: inout [UIMenuElement]) -> [UIMenuElement] { return [] } + + func playerWillStart(player: AVPlayer) {} + func playerDidStart(player: AVPlayer) {} + func playerDidPause(player: AVPlayer) {} + func playerDidEnd(player: AVPlayer) {} + func playerDidFail(player: AVPlayer) {} + func playerDidCleanUp(player: AVPlayer) {} + + func playerDidLoad(playerVC: AVPlayerViewController) {} + func playerDidDismiss(playerVC: AVPlayerViewController) {} + func playerDidChange(player: AVPlayer) {} + func playerItemDidChange(playerItem: AVPlayerItem) {} +} diff --git a/BilibiliLive/Component/Player/Plugins/DanmuViewPlugin.swift b/BilibiliLive/Component/Player/Plugins/DanmuViewPlugin.swift new file mode 100644 index 00000000..8e2e1758 --- /dev/null +++ b/BilibiliLive/Component/Player/Plugins/DanmuViewPlugin.swift @@ -0,0 +1,104 @@ +// +// DanmuViewPlugin.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/23. +// + +import AVKit +import Combine +import UIKit + +protocol DanmuProviderProtocol { + var observerPlayerTime: Bool { get } + var onSendTextModel: PassthroughSubject { get } + func playerTimeChange(time: TimeInterval) +} + +class DanmuViewPlugin: NSObject { + let danMuView = DanmakuView() + + init(provider: DanmuProviderProtocol) { + danmuProvider = provider + super.init() + provider.onSendTextModel + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.shoot($0) + }.store(in: &cancellable) + + Defaults.shared.$showDanmu + .receive(on: DispatchQueue.main) + .sink { + [weak self] in + self?.danMuView.isHidden = !$0 + }.store(in: &cancellable) + } + + private let danmuProvider: DanmuProviderProtocol + private var timeObserver: Any? + private var cancellable = Set() + + private func shoot(_ model: DanmakuCellModel) { + danMuView.shoot(danmaku: model) + } +} + +extension DanmuViewPlugin: CommonPlayerPlugin { + func playerWillStart(player: AVPlayer) { + guard danmuProvider.observerPlayerTime else { + return + } + player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: 1), + queue: DispatchQueue.global()) { [weak self] time in + guard let self else { return } + if !Defaults.shared.showDanmu { return } + let seconds = time.seconds + danmuProvider.playerTimeChange(time: seconds) + } + } + + func playerDidCleanUp(player: AVPlayer) { + if let timeObserver { + player.removeTimeObserver(timeObserver) + } + } + + func addViewToPlayerOverlay(container: UIView) { + container.addSubview(danMuView) + danMuView.makeConstraintsToBindToSuperview() + danMuView.setNeedsLayout() + danMuView.layoutIfNeeded() + danMuView.paddingTop = 5 + danMuView.trackHeight = 50 + danMuView.displayArea = Settings.danmuArea.percent + danMuView.recaculateTracks() + } + + func playerDidStart(player: AVPlayer) { + danMuView.play() + } + + func playerDidPause(player: AVPlayer) { + danMuView.pause() + } + + func addMenuItems(current: inout [UIMenuElement]) -> [UIMenuElement] { + let danmuImage = UIImage(systemName: "list.bullet.rectangle.fill") + let danmuImageDisable = UIImage(systemName: "list.bullet.rectangle") + let danmuAction = UIAction(title: "Show Danmu", image: danMuView.isHidden ? danmuImageDisable : danmuImage) { + action in + Defaults.shared.showDanmu.toggle() + action.image = Defaults.shared.showDanmu ? danmuImage : danmuImageDisable + } + let danmuDurationMenu = UIMenu(title: "弹幕展示时长", options: [.displayInline, .singleSelection], children: [4, 6, 8].map { dur in + UIAction(title: "\(dur) 秒", state: dur == Settings.danmuDuration ? .on : .off) { _ in Settings.danmuDuration = dur } + }) + let danmuAILevelMenu = UIMenu(title: "弹幕屏蔽等级", options: [.displayInline, .singleSelection], children: [Int32](1...10).map { level in + UIAction(title: "\(level)", state: level == Settings.danmuAILevel ? .on : .off) { _ in Settings.danmuAILevel = level } + }) + let danmuSettingMenu = UIMenu(title: "弹幕设置", image: UIImage(systemName: "keyboard.badge.ellipsis"), children: [danmuDurationMenu, danmuAILevelMenu]) + + return [danmuAction, danmuSettingMenu] + } +} diff --git a/BilibiliLive/Component/Player/Plugins/DebugPlugin.swift b/BilibiliLive/Component/Player/Plugins/DebugPlugin.swift new file mode 100644 index 00000000..27880059 --- /dev/null +++ b/BilibiliLive/Component/Player/Plugins/DebugPlugin.swift @@ -0,0 +1,126 @@ +// +// DebugPlugin.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/25. +// + +import AVKit +import UIKit + +class DebugPlugin: NSObject, CommonPlayerPlugin { + private var debugView: UILabel? + private weak var containerView: UIView? + private var debugTimer: Timer? + private weak var player: AVPlayer? + private var debugEnable: Bool { debugTimer?.isValid ?? false } + + var customInfo: String = "" + var additionDebugInfo: (() -> String)? + + func addViewToPlayerOverlay(container: UIView) { + containerView = container + } + + func playerDidChange(player: AVPlayer) { + self.player = player + } + + func addMenuItems(current: inout [UIMenuElement]) -> [UIMenuElement] { + let debugEnableImage = UIImage(systemName: "terminal.fill") + let debugDisableImage = UIImage(systemName: "terminal") + let debugAction = UIAction(title: "Debug", image: debugEnable ? debugEnableImage : debugDisableImage) { + [weak self] action in + guard let self = self else { return } + if self.debugEnable { + self.stopDebug() + action.image = debugDisableImage + } else { + action.image = debugEnableImage + self.startDebug() + } + } + if let setting = current.compactMap({ $0 as? UIMenu }) + .first(where: { $0.identifier == UIMenu.Identifier(rawValue: "setting") }) + { + var child = setting.children + child.append(debugAction) + if let index = current.firstIndex(of: setting) { + current[index] = setting.replacingChildren(child) + } + return [] + } + return [debugAction] + } + + deinit { + debugTimer?.invalidate() + } + + private func startDebug() { + if debugView == nil { + debugView = UILabel() + debugView?.backgroundColor = UIColor.black.withAlphaComponent(0.8) + debugView?.textColor = UIColor.white + containerView?.addSubview(debugView!) + debugView?.numberOfLines = 0 + debugView?.font = UIFont.systemFont(ofSize: 26) + debugView?.snp.makeConstraints { make in + make.top.equalToSuperview().offset(12) + make.right.equalToSuperview().offset(-12) + make.width.equalTo(800) + } + } + debugView?.isHidden = false + debugTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + let info = self?.fetchDebugInfo() + self?.debugView?.text = info + } + } + + private func stopDebug() { + debugTimer?.invalidate() + debugTimer = nil + debugView?.isHidden = true + } + + private func fetchDebugInfo() -> String { + let bitrateStr: (Double) -> String = { + bit in + String(format: "%.2fMbps", bit / 1024.0 / 1024.0) + } + guard let player else { return "Player no init" } + + var logs = """ + time control status: \(player.timeControlStatus.rawValue) \(player.reasonForWaitingToPlay?.rawValue ?? "") + player status:\(player.status.rawValue) + """ + + guard let log = player.currentItem?.accessLog() else { return logs } + guard let item = log.events.last else { return logs } + let uri = item.uri ?? "" + let addr = item.serverAddress ?? "" + let changes = item.numberOfServerAddressChanges + let dropped = item.numberOfDroppedVideoFrames + let stalls = item.numberOfStalls + let averageAudioBitrate = item.averageAudioBitrate + let averageVideoBitrate = item.averageVideoBitrate + let indicatedBitrate = item.indicatedBitrate + let observedBitrate = item.observedBitrate + logs += """ + uri:\(uri), ip:\(addr), change:\(changes) + drop:\(dropped) stalls:\(stalls) + bitrate audio:\(bitrateStr(averageAudioBitrate)), video: \(bitrateStr(averageVideoBitrate)) + observedBitrate:\(bitrateStr(observedBitrate)) + indicatedAverageBitrate:\(bitrateStr(indicatedBitrate)) + """ + + if let additionDebugInfo = additionDebugInfo?() { + logs = additionDebugInfo + "\n" + logs + } + if customInfo.isEmpty == false { + logs = logs + "\n" + customInfo + } + return logs + } +} diff --git a/BilibiliLive/Component/Player/Plugins/MaskViewPugin.swift b/BilibiliLive/Component/Player/Plugins/MaskViewPugin.swift new file mode 100644 index 00000000..3c8181ce --- /dev/null +++ b/BilibiliLive/Component/Player/Plugins/MaskViewPugin.swift @@ -0,0 +1,62 @@ +// +// MaskViewPugin.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/25. +// + +import AVKit +import UIKit + +class MaskViewPugin: NSObject, CommonPlayerPlugin { + weak var maskView: UIView? + var maskProvider: MaskProvider + private var observer: Any? + private let queue = DispatchQueue(label: "plugin.mask") + private var videoOutput: AVPlayerItemVideoOutput? + + init(maskView: UIView, maskProvider: MaskProvider) { + self.maskView = maskView + self.maskProvider = maskProvider + } + + func playerDidChange(player: AVPlayer) { + if maskProvider.needVideoOutput() { + setUpOutput(player: player) + } + + let timePerFrame = CMTime(value: 1, timescale: CMTimeScale(maskProvider.preferFPS())) + + observer = player.addPeriodicTimeObserver(forInterval: timePerFrame, queue: queue) { + [weak self] time in + guard let self, let maskView, !maskView.isHidden else { return } + maskProvider.getMask(for: time, frame: maskView.frame) { + [weak maskView] maskLayer in + if Thread.isMainThread { + maskView?.layer.mask = maskLayer + } else { + DispatchQueue.main.async { + maskView?.layer.mask = maskLayer + } + } + } + } + } + + func playerDidCleanUp(player: AVPlayer) { + if let observer { + player.removeTimeObserver(observer) + } + } + + private func setUpOutput(player: AVPlayer) { + guard videoOutput == nil, let videoItem = player.currentItem else { return } + let pixelBuffAttributes = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + ] + let videoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBuffAttributes) + videoItem.add(videoOutput) + self.videoOutput = videoOutput + maskProvider.setVideoOutout(ouput: videoOutput) + } +} diff --git a/BilibiliLive/Component/Player/Plugins/SpeedChangerPlugin.swift b/BilibiliLive/Component/Player/Plugins/SpeedChangerPlugin.swift new file mode 100644 index 00000000..6ba63b6e --- /dev/null +++ b/BilibiliLive/Component/Player/Plugins/SpeedChangerPlugin.swift @@ -0,0 +1,62 @@ +// +// SpeedChangerPlugin.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/25. +// + +import AVKit + +class SpeedChangerPlugin: NSObject, CommonPlayerPlugin { + private weak var player: AVPlayer? + private weak var playerVC: AVPlayerViewController? + + @Published private(set) var currentPlaySpeed: PlaySpeed = .default + + func playerDidLoad(playerVC: AVPlayerViewController) { + self.playerVC = playerVC + } + + func playerDidChange(player: AVPlayer) { + self.player = player + } + + func playerWillStart(player: AVPlayer) { + playerVC?.selectSpeed(AVPlaybackSpeed(rate: currentPlaySpeed.value, localizedName: currentPlaySpeed.name)) + } + + func addMenuItems(current: inout [UIMenuElement]) -> [UIMenuElement] { + let gearImage = UIImage(systemName: "gearshape") + + let speedActions = PlaySpeed.blDefaults.map { playSpeed in + UIAction(title: playSpeed.name, state: currentPlaySpeed == playSpeed ? .on : .off) { + [weak self] _ in + guard let self else { return } + player?.currentItem?.audioTimePitchAlgorithm = .timeDomain + playerVC?.selectSpeed(AVPlaybackSpeed(rate: playSpeed.value, localizedName: playSpeed.name)) + currentPlaySpeed = playSpeed + } + } + let playSpeedMenu = UIMenu(title: "播放速度", options: [.displayInline, .singleSelection], children: speedActions) + let menu = UIMenu(title: "播放设置", image: gearImage, identifier: UIMenu.Identifier(rawValue: "setting"), children: [playSpeedMenu]) + return [menu] + } +} + +struct PlaySpeed { + var name: String + var value: Float +} + +extension PlaySpeed: Equatable { + static let `default` = PlaySpeed(name: "1X", value: 1) + + static let blDefaults = [ + PlaySpeed(name: "0.5X", value: 0.5), + PlaySpeed(name: "0.75X", value: 0.75), + PlaySpeed(name: "1X", value: 1), + PlaySpeed(name: "1.25X", value: 1.25), + PlaySpeed(name: "1.5X", value: 1.5), + PlaySpeed(name: "2X", value: 2), + ] +} diff --git a/BilibiliLive/Component/Player/Plugins/URLPlayPlugin.swift b/BilibiliLive/Component/Player/Plugins/URLPlayPlugin.swift new file mode 100644 index 00000000..6381a3c8 --- /dev/null +++ b/BilibiliLive/Component/Player/Plugins/URLPlayPlugin.swift @@ -0,0 +1,57 @@ +// +// LivePlayPlugin.swift +// BilibiliLive +// +// Created by yicheng on 2024/6/6. +// + +import AVKit +import Foundation + +class URLPlayPlugin: NSObject { + var onPlayFail: (() -> Void)? + + private weak var playerVC: AVPlayerViewController? + private let referer: String + private let isLive: Bool + private var currentUrl: String? + + init(referer: String = "", isLive: Bool = false) { + self.referer = referer + self.isLive = isLive + } + + func play(urlString: String) { + currentUrl = urlString + let headers: [String: String] = [ + "User-Agent": Keys.userAgent, + "Referer": referer, + ] + let asset = AVURLAsset(url: URL(string: urlString)!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) + let playerItem = AVPlayerItem(asset: asset) + let player = AVPlayer(playerItem: playerItem) + player.automaticallyWaitsToMinimizeStalling = !isLive + playerVC?.player = player + } +} + +extension URLPlayPlugin: CommonPlayerPlugin { + func playerDidLoad(playerVC: AVPlayerViewController) { + self.playerVC = playerVC + playerVC.requiresLinearPlayback = isLive + playerVC.player = nil + if let currentUrl { + play(urlString: currentUrl) + } + } + + func playerDidFail(player: AVPlayer) { + onPlayFail?() + } + + func playerDidPause(player: AVPlayer) { + if isLive { + onPlayFail?() + } + } +} diff --git a/BilibiliLive/Component/Settings.swift b/BilibiliLive/Component/Settings.swift index 073cca2d..97f4042f 100644 --- a/BilibiliLive/Component/Settings.swift +++ b/BilibiliLive/Component/Settings.swift @@ -5,7 +5,9 @@ // Created by whw on 2022/10/19. // +import Combine import Foundation +import SwiftUI enum FeedDisplayStyle: Codable, CaseIterable { case large @@ -17,6 +19,13 @@ enum FeedDisplayStyle: Codable, CaseIterable { } } +class Defaults { + static let shared = Defaults() + private init() {} + + @Published(key: "Settings.danmuStatus") var showDanmu = true +} + enum Settings { @UserDefaultCodable("Settings.displayStyle", defaultValue: .normal) static var displayStyle: FeedDisplayStyle @@ -45,9 +54,6 @@ enum Settings { @UserDefault("Settings.preferAvc", defaultValue: false) static var preferAvc: Bool - @UserDefault("Settings.defaultDanmuStatus", defaultValue: true) - static var defaultDanmuStatus: Bool - @UserDefault("Settings.danmuMask", defaultValue: true) static var danmuMask: Bool diff --git a/BilibiliLive/Component/Video/BMaskProvider.swift b/BilibiliLive/Component/Video/MaskProvider/BMaskProvider.swift similarity index 100% rename from BilibiliLive/Component/Video/BMaskProvider.swift rename to BilibiliLive/Component/Video/MaskProvider/BMaskProvider.swift diff --git a/BilibiliLive/Component/Video/MaskProvider/MaskProvider.swift b/BilibiliLive/Component/Video/MaskProvider/MaskProvider.swift new file mode 100644 index 00000000..8a249b90 --- /dev/null +++ b/BilibiliLive/Component/Video/MaskProvider/MaskProvider.swift @@ -0,0 +1,14 @@ +// +// MaskProvider.swift +// BilibiliLive +// +// Created by yicheng on 2024/6/10. +// +import AVKit + +protocol MaskProvider: AnyObject { + func getMask(for time: CMTime, frame: CGRect, onGet: @escaping (CALayer) -> Void) + func needVideoOutput() -> Bool + func setVideoOutout(ouput: AVPlayerItemVideoOutput) + func preferFPS() -> Int +} diff --git a/BilibiliLive/Component/Video/VMaskProvider.swift b/BilibiliLive/Component/Video/MaskProvider/VMaskProvider.swift similarity index 100% rename from BilibiliLive/Component/Video/VMaskProvider.swift rename to BilibiliLive/Component/Video/MaskProvider/VMaskProvider.swift diff --git a/BilibiliLive/Component/Video/NewVideoPlayerViewModel.swift b/BilibiliLive/Component/Video/NewVideoPlayerViewModel.swift new file mode 100644 index 00000000..69435430 --- /dev/null +++ b/BilibiliLive/Component/Video/NewVideoPlayerViewModel.swift @@ -0,0 +1,239 @@ +// +// NewVideoPlayerViewModel.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/23. +// + +import Combine +import UIKit + +struct PlayerDetailData { + let aid: Int + let cid: Int + let epid: Int? // 港澳台解锁需要 + let isBangumi: Bool + + var playerStartPos: Int? + var detail: VideoDetail? + var clips: [VideoPlayURLInfo.ClipInfo]? + var playerInfo: PlayerInfo? + var videoPlayURLInfo: VideoPlayURLInfo +} + +class VideoPlayerViewModel { + var onPluginReady = PassthroughSubject<[CommonPlayerPlugin], String>() + var onPluginRemove = PassthroughSubject() + var onExit: (() -> Void)? + var nextProvider: VideoNextProvider? + + private var playInfo: PlayInfo + private let danmuProvider = VideoDanmuProvider() + private var videoDetail: VideoDetail? + private var cancellable = Set() + private var playPlugin: CommonPlayerPlugin? + + init(playInfo: PlayInfo) { + self.playInfo = playInfo + } + + func load() async { + do { + let data = try await loadVideoInfo() + let plugin = await generatePlayerPlugin(data) + onPluginReady.send(plugin) + } catch let err { + onPluginReady.send(completion: .failure(err.localizedDescription)) + } + } + + private func loadVideoInfo() async throws -> PlayerDetailData { + try await initPlayInfo() + let data = try await fetchVideoData() + await danmuProvider.initVideo(cid: data.cid, startPos: data.playerStartPos ?? 0) + return data + } + + private func initPlayInfo() async throws { + if !playInfo.isCidVaild { + playInfo.cid = try await WebRequest.requestCid(aid: playInfo.aid) + } + BiliBiliUpnpDMR.shared.sendVideoSwitch(aid: playInfo.aid, cid: playInfo.cid ?? 0) + } + + private func updateVideoDetailIfNeeded() async { + if videoDetail == nil { + videoDetail = try? await WebRequest.requestDetailVideo(aid: playInfo.aid) + } + } + + private func fetchVideoData() async throws -> PlayerDetailData { + assert(playInfo.isCidVaild) + let aid = playInfo.aid + let cid = playInfo.cid! + async let infoReq = try? WebRequest.requestPlayerInfo(aid: aid, cid: cid) + async let detailUpdate: () = updateVideoDetailIfNeeded() + do { + let playData: VideoPlayURLInfo + var clipInfos: [VideoPlayURLInfo.ClipInfo]? + + if playInfo.isBangumi { + do { + playData = try await WebRequest.requestPcgPlayUrl(aid: aid, cid: cid) + } catch let err as RequestError { + if case let .statusFail(code, _) = err, + code == -404 || code == -10403, + let data = try await fetchAreaLimitPcgVideoData() + { + playData = data + } else { + throw err + } + } + + clipInfos = playData.clip_info_list + } else { + playData = try await WebRequest.requestPlayUrl(aid: aid, cid: cid) + } + + let info = await infoReq + _ = await detailUpdate + + var detail = PlayerDetailData(aid: playInfo.aid, cid: playInfo.cid!, epid: playInfo.epid, isBangumi: playInfo.isBangumi, detail: videoDetail, clips: clipInfos, playerInfo: info, videoPlayURLInfo: playData) + + if let info, info.last_play_cid == cid, playData.dash.duration - info.playTimeInSecond > 5, Settings.continuePlay { + detail.playerStartPos = info.playTimeInSecond + } + + return detail + + } catch let err { + if case let .statusFail(code, message) = err as? RequestError { + throw "\(code) \(message),可能需要大会员" + } else if await infoReq?.is_upower_exclusive == true { + throw "该视频为充电专属视频 \(err)" + } else { + throw err + } + } + } + + private func playNext(newPlayInfo: PlayInfo) { + playInfo = newPlayInfo + if let playPlugin { + onPluginRemove.send(playPlugin) + } + Task { + do { + let data = try await loadVideoInfo() + let player = BVideoPlayPlugin(detailData: data) + onPluginReady.send([player]) + } catch let err { + onPluginReady.send(completion: .failure(err.localizedDescription)) + } + } + } + + @MainActor private func generatePlayerPlugin(_ data: PlayerDetailData) async -> [CommonPlayerPlugin] { + let player = BVideoPlayPlugin(detailData: data) + let danmu = DanmuViewPlugin(provider: danmuProvider) + let upnp = BUpnpPlugin(duration: data.detail?.View.duration) + let debug = DebugPlugin() + let playSpeed = SpeedChangerPlugin() + playSpeed.$currentPlaySpeed.sink { [weak danmu] speed in + danmu?.danMuView.playingSpeed = speed.value + }.store(in: &cancellable) + + let playlist = VideoPlayListPlugin(nextProvider: nextProvider) + playlist.onPlayEnd = { [weak self] in + self?.onExit?() + } + playlist.onPlayNextWithInfo = { + [weak self] info in + guard let self else { return } + playNext(newPlayInfo: info) + } + + playPlugin = player + + var plugins: [CommonPlayerPlugin] = [player, danmu, playSpeed, upnp, debug, playlist] + + if let clips = data.clips { + let clip = BVideoClipsPlugin(clipInfos: clips) + plugins.append(clip) + } + + if Settings.danmuMask { + if let mask = data.playerInfo?.dm_mask, + let video = data.videoPlayURLInfo.dash.video.first, + mask.fps > 0 + { + let maskProvider = BMaskProvider(info: mask, videoSize: CGSize(width: video.width ?? 0, height: video.height ?? 0)) + plugins.append(MaskViewPugin(maskView: danmu.danMuView, maskProvider: maskProvider)) + } else if Settings.vnMask { + let maskProvider = VMaskProvider() + plugins.append(MaskViewPugin(maskView: danmu.danMuView, maskProvider: maskProvider)) + } + } + + if let detail = data.detail { + let info = BVideoInfoPlugin(title: detail.title, subTitle: detail.ownerName, desp: detail.View.desc, pic: detail.pic, viewPoints: data.playerInfo?.view_points) + plugins.append(info) + } + + return plugins + } +} + +// 港澳台解锁 +extension VideoPlayerViewModel { + private func fetchAreaLimitPcgVideoData() async throws -> VideoPlayURLInfo? { + guard Settings.areaLimitUnlock else { return nil } + guard let epid = playInfo.epid, epid > 0 else { return nil } + + let season = try await WebRequest.requestBangumiSeasonView(epid: epid) + let checkTitle = season.title.contains("僅") ? season.title : season.series_title + let checkAreaList = parseAreaByTitle(title: checkTitle) + guard !checkAreaList.isEmpty else { return nil } + + let playData = try await requestAreaLimitPcgPlayUrl(epid: epid, cid: playInfo.cid!, areaList: checkAreaList) + return playData + } + + private func requestAreaLimitPcgPlayUrl(epid: Int, cid: Int, areaList: [String]) async throws -> VideoPlayURLInfo? { + for area in areaList { + do { + return try await WebRequest.requestAreaLimitPcgPlayUrl(epid: epid, cid: cid, area: area) + } catch let err { + if area == areaList.last { + throw err + } else { + print(err) + } + } + } + return nil + } + + private func parseAreaByTitle(title: String) -> [String] { + if title.isMatch(pattern: "[仅|僅].*[东南亚|其他]") { + // TODO: 未支持 + return [] + } + + var areas: [String] = [] + if title.isMatch(pattern: "僅.*台") { + areas.append("tw") + } + if title.isMatch(pattern: "僅.*港") { + areas.append("hk") + } + + if areas.isEmpty { + // 标题没有地区限制信息,返回尝试检测的区域 + return ["tw", "hk"] + } else { + return areas + } + } +} diff --git a/BilibiliLive/Component/Video/Plugins/BUpnpPlugin.swift b/BilibiliLive/Component/Video/Plugins/BUpnpPlugin.swift new file mode 100644 index 00000000..e0bf758f --- /dev/null +++ b/BilibiliLive/Component/Video/Plugins/BUpnpPlugin.swift @@ -0,0 +1,70 @@ +// +// BUpnpPlugin.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/25. +// + +import AVFoundation +import Foundation +class BUpnpPlugin: NSObject, CommonPlayerPlugin { + let duration: Int? + weak var player: AVPlayer? + + init(duration: Int?) { + self.duration = duration + } + + func pause() { + player?.pause() + } + + func resume() { + player?.play() + } + + func seek(to time: TimeInterval) { + player?.seek(to: CMTime(seconds: time, preferredTimescale: 1), toleranceBefore: .zero, toleranceAfter: .zero) + } + + func playerWillStart(player: AVPlayer) { + BiliBiliUpnpDMR.shared.currentPlugin = self + self.player = player + guard let duration else { return } + player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 5, preferredTimescale: 1), queue: .global()) { time in + DispatchQueue.main.async { + BiliBiliUpnpDMR.shared.sendProgress(duration: duration, current: Int(time.seconds)) + } + } + } + + func playerDidStart(player: AVPlayer) { + DispatchQueue.main.async { + BiliBiliUpnpDMR.shared.sendStatus(status: .playing) + } + } + + func playerDidPause(player: AVPlayer) { + DispatchQueue.main.async { + BiliBiliUpnpDMR.shared.sendStatus(status: .paused) + } + } + + func playerDidEnd(player: AVPlayer) { + DispatchQueue.main.async { + BiliBiliUpnpDMR.shared.sendStatus(status: .end) + } + } + + func playerDidFail(player: AVPlayer) { + DispatchQueue.main.async { + BiliBiliUpnpDMR.shared.sendStatus(status: .stop) + } + } + + func playerDidCleanUp(player: AVPlayer) { + DispatchQueue.main.async { + BiliBiliUpnpDMR.shared.sendStatus(status: .stop) + } + } +} diff --git a/BilibiliLive/Component/Video/Plugins/BVideoClipsPlugin.swift b/BilibiliLive/Component/Video/Plugins/BVideoClipsPlugin.swift new file mode 100644 index 00000000..112315ac --- /dev/null +++ b/BilibiliLive/Component/Video/Plugins/BVideoClipsPlugin.swift @@ -0,0 +1,56 @@ +// +// BVideoClipsPlugin.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/25. +// + +import AVKit + +class BVideoClipsPlugin: NSObject, CommonPlayerPlugin { + let clipInfos: [VideoPlayURLInfo.ClipInfo] + + private var observers = [Any]() + private weak var playerVC: AVPlayerViewController? + + init(clipInfos: [VideoPlayURLInfo.ClipInfo]) { + self.clipInfos = clipInfos + } + + func playerDidLoad(playerVC: AVPlayerViewController) { + self.playerVC = playerVC + } + + func playerWillStart(player: AVPlayer) { + for clip in clipInfos { + let start = CMTime(seconds: clip.start, preferredTimescale: 1) + let end = CMTime(seconds: clip.end, preferredTimescale: 1) + let startObserver = player.addBoundaryTimeObserver(forTimes: [NSValue(time: start)], queue: .main) { + [weak player, weak self] in + let action = { + clip.skipped = true + player?.seek(to: CMTime(seconds: Double(clip.end), preferredTimescale: 1), toleranceBefore: .zero, toleranceAfter: .zero) + } + if clip.skipped == true, Settings.autoSkip { + action() + } else { + let action = UIAction(title: clip.customText) { _ in action() } + self?.playerVC?.contextualActions = [action] + } + } + observers.append(startObserver) + + let endObserver = player.addBoundaryTimeObserver(forTimes: [NSValue(time: end)], queue: .main) { + [weak self] in + self?.playerVC?.contextualActions = [] + } + observers.append(endObserver) + } + } + + func playerDidCleanUp(player: AVPlayer) { + for observer in observers { + player.removeTimeObserver(observer) + } + } +} diff --git a/BilibiliLive/Component/Video/Plugins/BVideoInfoPlugin.swift b/BilibiliLive/Component/Video/Plugins/BVideoInfoPlugin.swift new file mode 100644 index 00000000..0003bbbf --- /dev/null +++ b/BilibiliLive/Component/Video/Plugins/BVideoInfoPlugin.swift @@ -0,0 +1,91 @@ +// +// BVideoInfoPlugin.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/25. +// + +import AVKit +import Kingfisher + +class BVideoInfoPlugin: NSObject, CommonPlayerPlugin { + let title: String? + let subTitle: String? + let desp: String? + let pic: URL? + let viewPoints: [PlayerInfo.ViewPoint]? + + init(title: String?, subTitle: String?, desp: String?, pic: URL?, viewPoints: [PlayerInfo.ViewPoint]?) { + self.title = title + self.subTitle = subTitle + self.desp = desp + self.pic = pic + self.viewPoints = viewPoints + } + + func playerWillStart(player: AVPlayer) { + Task { + async let info: () = AVPlayerMetaUtils.setPlayerInfo(title: title, subTitle: subTitle, desp: desp, pic: pic, player: player) + if let viewPoints { + async let vp: () = updatePlayerCharpter(viewPoints: viewPoints, player: player) + await vp + } + await info + } + } + + private func updatePlayerCharpter(viewPoints: [PlayerInfo.ViewPoint], player: AVPlayer) async { + _ = await withTaskGroup(of: Void.self) { group in + for viewPoint in viewPoints { + group.addTask { + if let pic = viewPoint.imgUrl?.addSchemeIfNeed(), + let result = try? await KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: pic)), + let data = result.image.pngData() + { + viewPoint.imageData = data + } + } + } + return group + } + + let metas = viewPoints.compactMap { convertTimedMetadataGroup(viewPoint: $0) } + + player.currentItem?.navigationMarkerGroups = [AVNavigationMarkersGroup(title: nil, timedNavigationMarkers: metas)] + } + + private func convertTimedMetadataGroup(viewPoint: PlayerInfo.ViewPoint) -> AVTimedMetadataGroup { + let mapping: [AVMetadataIdentifier: Any?] = [ + .commonIdentifierTitle: viewPoint.content, + ] + var metadatas = mapping.compactMap { AVPlayerMetaUtils.createMetadataItem(for: $0, value: $1) } + let timescale: Int32 = 600 + let cmStartTime = CMTimeMakeWithSeconds(viewPoint.from, preferredTimescale: timescale) + let cmEndTime = CMTimeMakeWithSeconds(viewPoint.to, preferredTimescale: timescale) + let timeRange = CMTimeRangeFromTimeToTime(start: cmStartTime, end: cmEndTime) + if let imageData = viewPoint.imageData, + let item = AVPlayerMetaUtils.createMetadataItem(for: .commonIdentifierArtwork, value: imageData) + { + metadatas.append(item) + } + + return AVTimedMetadataGroup(items: metadatas, timeRange: timeRange) + } +} + +extension KingfisherManager { + func retrieveImage(with resource: Resource, + options: KingfisherOptionsInfo? = nil) async throws -> RetrieveImageResult + { + try await withCheckedThrowingContinuation { conf in + retrieveImage(with: resource, options: options) { result in + switch result { + case let .success(result): + conf.resume(returning: result) + case let .failure(err): + conf.resume(throwing: err) + } + } + } + } +} diff --git a/BilibiliLive/Component/Video/Plugins/BVideoPlayPlugin.swift b/BilibiliLive/Component/Video/Plugins/BVideoPlayPlugin.swift new file mode 100644 index 00000000..92f33948 --- /dev/null +++ b/BilibiliLive/Component/Video/Plugins/BVideoPlayPlugin.swift @@ -0,0 +1,68 @@ +// +// BVideoPlayPlugin.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/24. +// + +import AVKit + +class BVideoPlayPlugin: NSObject, CommonPlayerPlugin { + private weak var playerVC: AVPlayerViewController? + private var playerDelegate: BilibiliVideoResourceLoaderDelegate? + private let playData: PlayerDetailData + + init(detailData: PlayerDetailData) { + playData = detailData + } + + func playerDidLoad(playerVC: AVPlayerViewController) { + self.playerVC = playerVC + playerVC.player = nil + playerVC.appliesPreferredDisplayCriteriaAutomatically = Settings.contentMatch + Task { + try? await playmedia(urlInfo: playData.videoPlayURLInfo, playerInfo: playData.playerInfo) + } + } + + func playerWillStart(player: AVPlayer) { + if let playerStartPos = playData.playerStartPos { + player.seek(to: CMTime(seconds: Double(playerStartPos), preferredTimescale: 1), toleranceBefore: .zero, toleranceAfter: .zero) + } + } + + func playerDidDismiss(playerVC: AVPlayerViewController) { + guard let currentTime = playerVC.player?.currentTime().seconds, currentTime > 0 else { return } + WebRequest.reportWatchHistory(aid: playData.aid, cid: playData.cid, currentTime: Int(currentTime)) + } + + @MainActor + private func playmedia(urlInfo: VideoPlayURLInfo, playerInfo: PlayerInfo?) async throws { + let playURL = URL(string: BilibiliVideoResourceLoaderDelegate.URLs.play)! + let headers: [String: String] = [ + "User-Agent": Keys.userAgent, + "Referer": Keys.referer(for: playData.aid), + ] + let asset = AVURLAsset(url: playURL, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) + playerDelegate = BilibiliVideoResourceLoaderDelegate() + playerDelegate?.setBilibili(info: urlInfo, subtitles: playerInfo?.subtitle?.subtitles ?? [], aid: playData.aid) + if Settings.contentMatchOnlyInHDR { + if playerDelegate?.isHDR != true { + playerVC?.appliesPreferredDisplayCriteriaAutomatically = false + } + } + asset.resourceLoader.setDelegate(playerDelegate, queue: DispatchQueue(label: "loader")) + let playable = try await asset.load(.isPlayable) + if !playable { + throw "加载资源失败" + } + await prepare(toPlay: asset) + } + + @MainActor + func prepare(toPlay asset: AVURLAsset) async { + let playerItem = AVPlayerItem(asset: asset) + let player = AVPlayer(playerItem: playerItem) + playerVC?.player = player + } +} diff --git a/BilibiliLive/Component/Video/Plugins/VideoPlayListPlugin.swift b/BilibiliLive/Component/Video/Plugins/VideoPlayListPlugin.swift new file mode 100644 index 00000000..8fbb5128 --- /dev/null +++ b/BilibiliLive/Component/Video/Plugins/VideoPlayListPlugin.swift @@ -0,0 +1,61 @@ +// +// VideoPlayListPlugin.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/26. +// + +import AVKit + +class VideoPlayListPlugin: NSObject, CommonPlayerPlugin { + var onPlayEnd: (() -> Void)? + var onPlayNextWithInfo: ((PlayInfo) -> Void)? + + let nextProvider: VideoNextProvider? + + init(nextProvider: VideoNextProvider?) { + self.nextProvider = nextProvider + } + + func addMenuItems(current: inout [UIMenuElement]) -> [UIMenuElement] { + let loopImage = UIImage(systemName: "infinity") + let loopAction = UIAction(title: "循环播放", image: loopImage, state: Settings.loopPlay ? .on : .off) { + action in + action.state = (action.state == .off) ? .on : .off + Settings.loopPlay = action.state == .on + } + if let setting = current.compactMap({ $0 as? UIMenu }) + .first(where: { $0.identifier == UIMenu.Identifier(rawValue: "setting") }) + { + var child = setting.children + child.append(loopAction) + if let index = current.firstIndex(of: setting) { + current[index] = setting.replacingChildren(child) + } + return [] + } + return [loopAction] + } + + func playerDidEnd(player: AVPlayer) { + if !playNext() { + if Settings.loopPlay { + nextProvider?.reset() + if !playNext() { + player.currentItem?.seek(to: .zero, completionHandler: nil) + player.play() + } + return + } + onPlayEnd?() + } + } + + private func playNext() -> Bool { + if let next = nextProvider?.getNext() { + onPlayNextWithInfo?(next) + return true + } + return false + } +} diff --git a/BilibiliLive/Component/Video/VideoDanmuProvider.swift b/BilibiliLive/Component/Video/VideoDanmuProvider.swift index 1ef4b9e3..990efc45 100644 --- a/BilibiliLive/Component/Video/VideoDanmuProvider.swift +++ b/BilibiliLive/Component/Video/VideoDanmuProvider.swift @@ -6,6 +6,7 @@ // import Alamofire +import Combine import Foundation import SwiftyXMLParser import UIKit @@ -35,11 +36,14 @@ struct Danmu: Codable { } } -class VideoDanmuProvider { +class VideoDanmuProvider: DanmuProviderProtocol { var cid: Int! private var allDanmus = [Danmu]() private var playingDanmus = [Danmu]() + let observerPlayerTime: Bool = true + let onSendTextModel = PassthroughSubject() + var onShowDanmu: ((DanmakuTextCellModel) -> Void)? private var upDanmus = [Danmu]() @@ -55,7 +59,7 @@ class VideoDanmuProvider { private let segmentDuration = 60 * 6 private func getSegmentIdx(time: TimeInterval) -> Int { Int(time) / segmentDuration + 1 } - func initVideo(cid id: Int?, startPos: Int) async { + func initVideo(cid id: Int, startPos: Int) async { cid = id upDanmus.removeAll() segmentDanmus.removeAll(keepingCapacity: true) @@ -160,7 +164,9 @@ class VideoDanmuProvider { let dm = upDanmus[upDanmuIdx] guard dm.time < time else { break } upDanmuIdx += 1 - onShowDanmu?(DanmakuTextCellModel(dm: dm)) + let model = DanmakuTextCellModel(dm: dm) + onShowDanmu?(model) + onSendTextModel.send(model) } while danmuIdx < dms.count { @@ -168,7 +174,9 @@ class VideoDanmuProvider { guard dm.time < time else { break } danmuIdx += 1 if dm.aiLevel < Settings.danmuAILevel { continue } - onShowDanmu?(DanmakuTextCellModel(dm: dm)) + let model = DanmakuTextCellModel(dm: dm) + onShowDanmu?(model) + onSendTextModel.send(model) } } } diff --git a/BilibiliLive/Component/Video/VideoPlayerViewController.swift b/BilibiliLive/Component/Video/VideoPlayerViewController.swift index 18f1510a..5d1968cb 100644 --- a/BilibiliLive/Component/Video/VideoPlayerViewController.swift +++ b/BilibiliLive/Component/Video/VideoPlayerViewController.swift @@ -1,16 +1,12 @@ // -// VideoPlayerViewController.swift +// NewVideoPlayerViewController.swift // BilibiliLive // -// Created by Etan Chen on 2021/4/4. +// Created by yicheng on 2024/5/23. // -import Alamofire -import AVFoundation import AVKit -import Kingfisher -import SwiftyJSON -import SwiftyXMLParser +import Combine import UIKit struct PlayInfo { @@ -46,9 +42,11 @@ class VideoNextProvider { } class VideoPlayerViewController: CommonPlayerViewController { - var playInfo: PlayInfo + var data: VideoDetail? + var nextProvider: VideoNextProvider? + init(playInfo: PlayInfo) { - self.playInfo = playInfo + viewModel = VideoPlayerViewModel(playInfo: playInfo) super.init(nibName: nil, bundle: nil) } @@ -57,388 +55,30 @@ class VideoPlayerViewController: CommonPlayerViewController { fatalError("init(coder:) has not been implemented") } - var data: VideoDetail? - var nextProvider: VideoNextProvider? - private var allDanmus = [Danmu]() - private var playingDanmus = [Danmu]() - private var playerDelegate: BilibiliVideoResourceLoaderDelegate? - private let danmuProvider = VideoDanmuProvider() - private var clipInfos: [VideoPlayURLInfo.ClipInfo]? - private var skipAction: UIAction? - 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)) - } - BiliBiliUpnpDMR.shared.sendStatus(status: .stop) - } + private let viewModel: VideoPlayerViewModel + private var cancelable = Set() override func viewDidLoad() { super.viewDidLoad() - Task { - await initPlayer() - } - danmuProvider.onShowDanmu = { - [weak self] in - self?.danMuView.shoot(danmaku: $0) - } - } - - 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)") - } - } - await fetchVideoData() - await danmuProvider.initVideo(cid: playInfo.cid, startPos: playerStartPos ?? 0) - } - - private func playmedia(urlInfo: VideoPlayURLInfo, playerInfo: PlayerInfo?) async { - let playURL = URL(string: BilibiliVideoResourceLoaderDelegate.URLs.play)! - let headers: [String: String] = [ - "User-Agent": "Bilibili/APPLE TV", - "Referer": "https://www.bilibili.com/video/av\(playInfo.aid)", - ] - let asset = AVURLAsset(url: playURL, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) - playerDelegate = BilibiliVideoResourceLoaderDelegate() - playerDelegate?.setBilibili(info: urlInfo, subtitles: playerInfo?.subtitle?.subtitles ?? [], aid: playInfo.aid) - if Settings.contentMatchOnlyInHDR { - if playerDelegate?.isHDR != true { - appliesPreferredDisplayCriteriaAutomatically = false - } - } - asset.resourceLoader.setDelegate(playerDelegate, queue: DispatchQueue(label: "loader")) - let requestedKeys = ["playable"] - await asset.loadValues(forKeys: requestedKeys) - prepare(toPlay: asset, withKeys: requestedKeys) - updatePlayerCharpter(playerInfo: playerInfo) - BiliBiliUpnpDMR.shared.sendVideoSwitch(aid: playInfo.aid, cid: playInfo.cid ?? 0) - } - - private func updatePlayerCharpter(playerInfo: PlayerInfo?) { - let group = DispatchGroup() - var metas = [AVTimedMetadataGroup]() - for viewPoint in playerInfo?.view_points ?? [] { - group.enter() - convertTimedMetadataGroup(viewPoint: viewPoint) { - metas.append($0) - group.leave() - } - } - group.notify(queue: .main) { - if metas.count > 0 { - self.playerItem?.navigationMarkerGroups = [AVNavigationMarkersGroup(title: nil, timedNavigationMarkers: metas)] - } - } - } - - override func extraInfoForPlayerError() -> String { - return playerDelegate?.infoDebugText ?? "-" - } - - override func additionDebugInfo() -> String { - if let port = playerDelegate?.httpPort.string() { - return " :" + port - } - return "" - } - - override func playerStatusDidChange() { - super.playerStatusDidChange() - switch player?.status { - case .readyToPlay: - BiliBiliUpnpDMR.shared.sendStatus(status: .playing) - case .failed: - BiliBiliUpnpDMR.shared.sendStatus(status: .stop) - default: - break - } - } - - override func playerRateDidChange(player: AVPlayer) { - if player.rate > 0, danMuView.status == .pause { - danMuView.play() - } else if player.rate == 0, danMuView.status == .play { - danMuView.pause() + viewModel.nextProvider = nextProvider + viewModel.onPluginReady.receive(on: DispatchQueue.main).sink { [weak self] completion in + switch completion { + case let .failure(err): + self?.showErrorAlertAndExit(message: err) + default: + break + } + } receiveValue: { [weak self] plugins in + plugins.forEach { self?.addPlugin(plugin: $0) } + }.store(in: &cancelable) + viewModel.onPluginRemove.sink { [weak self] in + self?.removePlugin(plugin: $0) + }.store(in: &cancelable) + viewModel.onExit = { [weak self] in + self?.dismiss(animated: true) } - } - - func playNext() -> Bool { - if let next = nextProvider?.getNext() { - playInfo = next - playerStartPos = 0 - Task { - await initPlayer() - } - return 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() - } - return - } - dismiss(animated: true) - } - } - - private func convertTimedMetadataGroup(viewPoint: PlayerInfo.ViewPoint, onResult: ((AVTimedMetadataGroup) -> Void)? = nil) { - let mapping: [AVMetadataIdentifier: Any?] = [ - .commonIdentifierTitle: viewPoint.content, - ] - - var metadatas = mapping.compactMap { createMetadataItem(for: $0, value: $1) } - let timescale: Int32 = 600 - let cmStartTime = CMTimeMakeWithSeconds(viewPoint.from, preferredTimescale: timescale) - let cmEndTime = CMTimeMakeWithSeconds(viewPoint.to, preferredTimescale: timescale) - let timeRange = CMTimeRangeFromTimeToTime(start: cmStartTime, end: cmEndTime) - if let pic = viewPoint.imgUrl?.addSchemeIfNeed() { - let resource = Kingfisher.ImageResource(downloadURL: pic) - KingfisherManager.shared.retrieveImage(with: resource) { - [weak self] result in - guard let self = self, - let data = try? result.get().image.pngData(), - let item = self.createMetadataItem(for: .commonIdentifierArtwork, value: data) - else { - onResult?(AVTimedMetadataGroup(items: metadatas, timeRange: timeRange)) - return - } - metadatas.append(item) - onResult?(AVTimedMetadataGroup(items: metadatas, timeRange: timeRange)) - } - } else { - onResult?(AVTimedMetadataGroup(items: metadatas, timeRange: timeRange)) - } - } -} - -// MARK: - Requests - -extension VideoPlayerViewController { - func fetchVideoData() async { - assert(playInfo.isCidVaild) - let aid = playInfo.aid - let cid = playInfo.cid! - let info = try? await WebRequest.requestPlayerInfo(aid: aid, cid: cid) - do { - let playData: VideoPlayURLInfo - if playInfo.isBangumi { - playData = try await WebRequest.requestPcgPlayUrl(aid: aid, cid: cid) - clipInfos = playData.clip_info_list - } else { - playData = try await WebRequest.requestPlayUrl(aid: aid, cid: cid) - } - if info?.last_play_cid == cid, let startTime = info?.playTimeInSecond, playData.dash.duration - startTime > 5, Settings.continuePlay { - playerStartPos = startTime - } - - await playmedia(urlInfo: playData, playerInfo: info) - - if Settings.danmuMask { - if let mask = info?.dm_mask, - let video = playData.dash.video.first, - let fps = info?.dm_mask?.fps, fps > 0 - { - maskProvider = BMaskProvider(info: mask, videoSize: CGSize(width: video.width ?? 0, height: video.height ?? 0)) - } else if Settings.vnMask { - maskProvider = VMaskProvider() - } - setupMask() - } - - if data == nil { - data = try? await WebRequest.requestDetailVideo(aid: aid) - } - setPlayerInfo(title: data?.title, subTitle: data?.ownerName, desp: data?.View.desc, pic: data?.pic) - } catch let err { - if case let .statusFail(code, message) = err as? RequestError { - if code == -404 || code == -10403 { - // 解锁港澳台番剧处理 - do { - if let ok = try await fetchAreaLimitVideoData(), ok { - return - } - } catch let err { - showErrorAlertAndExit(message: "请求失败,\(err)") - } - } - showErrorAlertAndExit(message: "请求失败\(code) \(message),可能需要大会员") - } else if info?.is_upower_exclusive == true { - showErrorAlertAndExit(message: "请求失败,该视频为充电专属视频 \(err)") - } else { - showErrorAlertAndExit(message: "请求失败,\(err)") - } - } - } - - func fetchAreaLimitVideoData() async throws -> Bool? { - guard Settings.areaLimitUnlock else { return false } - guard let epid = playInfo.epid, epid > 0 else { return false } - - let aid = playInfo.aid - let cid = playInfo.cid! - - let season = try await WebRequest.requestBangumiSeasonView(epid: epid) - let checkTitle = season.title.contains("僅") ? season.title : season.series_title - let checkAreaList = parseAreaByTitle(title: checkTitle) - guard !checkAreaList.isEmpty else { return false } - - let playData = try await requestAreaLimitPcgPlayUrl(epid: epid, cid: cid, areaList: checkAreaList) - guard let playData = playData else { return false } - - let info = try? await WebRequest.requestPlayerInfo(aid: aid, cid: cid) - if info?.last_play_cid == cid, let startTime = info?.playTimeInSecond, playData.dash.duration - startTime > 5, Settings.continuePlay { - playerStartPos = startTime - } else { - playerStartPos = 0 - } - - await playmedia(urlInfo: playData, playerInfo: info) - - if Settings.danmuMask { - if let mask = info?.dm_mask, - let video = playData.dash.video.first, - let fps = info?.dm_mask?.fps, fps > 0 - { - maskProvider = BMaskProvider(info: mask, videoSize: CGSize(width: video.width ?? 0, height: video.height ?? 0)) - } else if Settings.vnMask { - maskProvider = VMaskProvider() - } - setupMask() - } - - if data == nil { - if let epi = season.episodes.first(where: { $0.ep_id == epid }) { - setPlayerInfo(title: epi.index + " " + (epi.index_title ?? ""), subTitle: season.up_info.uname, desp: season.evaluate, pic: epi.cover) - } - } else { - setPlayerInfo(title: data?.title, subTitle: data?.ownerName, desp: data?.View.desc, pic: data?.pic) - } - - return true - } - - private func requestAreaLimitPcgPlayUrl(epid: Int, cid: Int, areaList: [String]) async throws -> VideoPlayURLInfo? { - for area in areaList { - do { - return try await WebRequest.requestAreaLimitPcgPlayUrl(epid: epid, cid: cid, area: area) - } catch let err { - if area == areaList.last { - throw err - } else { - print(err) - } - } - } - - return nil - } - - private func parseAreaByTitle(title: String) -> [String] { - if title.isMatch(pattern: "[仅|僅].*[东南亚|其他]") { - // TODO: 未支持 - return [] - } - - var areas: [String] = [] - if title.isMatch(pattern: "僅.*台") { - areas.append("tw") - } - if title.isMatch(pattern: "僅.*港") { - areas.append("hk") - } - - if areas.isEmpty { - // 标题没有地区限制信息,返回尝试检测的区域 - return ["tw", "hk"] - } else { - return areas - } - } -} - -// MARK: - Player - -extension VideoPlayerViewController { - @MainActor - func prepare(toPlay asset: AVURLAsset, withKeys requestedKeys: [AnyHashable]) { - for thisKey in requestedKeys { - guard let thisKey = thisKey as? String else { - continue - } - var error: NSError? - let keyStatus = asset.statusOfValue(forKey: thisKey, error: &error) - if keyStatus == .failed { - showErrorAlertAndExit(title: error?.localizedDescription ?? "", message: error?.localizedFailureReason ?? "") - return - } - } - - if !asset.isPlayable { - showErrorAlertAndExit(message: "URL解析错误") - return - } - - 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)) - } - - 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 = [] - } - } - } - 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)) - } else { - self.player = player + Task { + await viewModel.load() } } } diff --git a/BilibiliLive/Component/Video/VideoPlayerViewModel.swift b/BilibiliLive/Component/Video/VideoPlayerViewModel.swift new file mode 100644 index 00000000..9a7f55c1 --- /dev/null +++ b/BilibiliLive/Component/Video/VideoPlayerViewModel.swift @@ -0,0 +1,183 @@ +// +// NewVideoPlayerViewModel.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/23. +// + +import Combine +import UIKit + +struct PlayerDetailData { + let aid: Int + let cid: Int + let epid: Int? // 港澳台解锁需要 + let isBangumi: Bool + + var playerStartPos: Int? + var detail: VideoDetail? + var clips: [VideoPlayURLInfo.ClipInfo]? + var playerInfo: PlayerInfo? + var videoPlayURLInfo: VideoPlayURLInfo +} + +class VideoPlayerViewModel { + var onPluginReady = PassthroughSubject<[CommonPlayerPlugin], String>() + var onPluginRemove = PassthroughSubject() + var onExit: (() -> Void)? + var nextProvider: VideoNextProvider? + + private var playInfo: PlayInfo + private let danmuProvider = VideoDanmuProvider() + private var videoDetail: VideoDetail? + private var cancellable = Set() + private var playPlugin: CommonPlayerPlugin? + + init(playInfo: PlayInfo) { + self.playInfo = playInfo + } + + func load() async { + do { + let data = try await loadVideoInfo() + let plugin = await generatePlayerPlugin(data) + onPluginReady.send(plugin) + } catch let err { + onPluginReady.send(completion: .failure(err.localizedDescription)) + } + } + + private func loadVideoInfo() async throws -> PlayerDetailData { + try await initPlayInfo() + let data = try await fetchVideoData() + await danmuProvider.initVideo(cid: data.cid, startPos: data.playerStartPos ?? 0) + return data + } + + private func initPlayInfo() async throws { + if !playInfo.isCidVaild { + playInfo.cid = try await WebRequest.requestCid(aid: playInfo.aid) + } + BiliBiliUpnpDMR.shared.sendVideoSwitch(aid: playInfo.aid, cid: playInfo.cid ?? 0) + } + + private func updateVideoDetailIfNeeded() async { + if videoDetail == nil { + videoDetail = try? await WebRequest.requestDetailVideo(aid: playInfo.aid) + } + } + + private func fetchVideoData() async throws -> PlayerDetailData { + assert(playInfo.isCidVaild) + let aid = playInfo.aid + let cid = playInfo.cid! + async let infoReq = try? WebRequest.requestPlayerInfo(aid: aid, cid: cid) + async let detailUpdate: () = updateVideoDetailIfNeeded() + do { + let playData: VideoPlayURLInfo + var clipInfos: [VideoPlayURLInfo.ClipInfo]? + + if playInfo.isBangumi { + playData = try await WebRequest.requestPcgPlayUrl(aid: aid, cid: cid) + clipInfos = playData.clip_info_list + } else { + playData = try await WebRequest.requestPlayUrl(aid: aid, cid: cid) + } + + let info = await infoReq + _ = await detailUpdate + + var detail = PlayerDetailData(aid: playInfo.aid, cid: playInfo.cid!, epid: playInfo.epid, isBangumi: playInfo.isBangumi, detail: videoDetail, clips: clipInfos, playerInfo: info, videoPlayURLInfo: playData) + + if let info, info.last_play_cid == cid, playData.dash.duration - info.playTimeInSecond > 5, Settings.continuePlay { + detail.playerStartPos = info.playTimeInSecond + } + + return detail + + } catch let err { + if case let .statusFail(code, message) = err as? RequestError { + if code == -404 || code == -10403 { +// 解锁港澳台番剧处理 +// do { +// if let ok = try await fetchAreaLimitVideoData(), ok { +// return +// } +// } catch let err { +// } + } + throw "\(code) \(message),可能需要大会员" + } else if await infoReq?.is_upower_exclusive == true { + throw "该视频为充电专属视频 \(err)" + } else { + throw err + } + } + } + + private func playNext(newPlayInfo: PlayInfo) { + playInfo = newPlayInfo + if let playPlugin { + onPluginRemove.send(playPlugin) + } + Task { + do { + let data = try await loadVideoInfo() + let player = BVideoPlayPlugin(detailData: data) + onPluginReady.send([player]) + } catch let err { + onPluginReady.send(completion: .failure(err.localizedDescription)) + } + } + } + + @MainActor private func generatePlayerPlugin(_ data: PlayerDetailData) async -> [CommonPlayerPlugin] { + let player = BVideoPlayPlugin(detailData: data) + let danmu = DanmuViewPlugin(provider: danmuProvider) + let upnp = BUpnpPlugin(duration: data.detail?.View.duration) + let debug = DebugPlugin() + let playSpeed = SpeedChangerPlugin() + playSpeed.$currentPlaySpeed.sink { [weak danmu] speed in + danmu?.danMuView.playingSpeed = speed.value + }.store(in: &cancellable) + + let playlist = VideoPlayListPlugin(nextProvider: nextProvider) + playlist.onPlayEnd = { [weak self] in + self?.onExit?() + } + playlist.onPlayNextWithInfo = { + [weak self] info in + guard let self else { return } + playNext(newPlayInfo: info) + } + + playPlugin = player + + var plugins: [CommonPlayerPlugin] = [player, danmu, playSpeed, upnp, debug, playlist] + + if let clips = data.clips { + let clip = BVideoClipsPlugin(clipInfos: clips) + plugins.append(clip) + } + + if Settings.danmuMask { + if let mask = data.playerInfo?.dm_mask, + let video = data.videoPlayURLInfo.dash.video.first, + mask.fps > 0 + { + let maskProvider = BMaskProvider(info: mask, videoSize: CGSize(width: video.width ?? 0, height: video.height ?? 0)) + plugins.append(MaskViewPugin(maskView: danmu.danMuView, maskProvider: maskProvider)) + } else if Settings.vnMask { + let maskProvider = VMaskProvider() + plugins.append(MaskViewPugin(maskView: danmu.danMuView, maskProvider: maskProvider)) + } + } + + if let detail = data.detail { + let info = BVideoInfoPlugin(title: detail.title, subTitle: detail.ownerName, desp: detail.View.desc, pic: detail.pic, viewPoints: data.playerInfo?.view_points) + plugins.append(info) + } + + return plugins + } +} diff --git a/BilibiliLive/Extensions/Published+..swift b/BilibiliLive/Extensions/Published+..swift new file mode 100644 index 00000000..424a1779 --- /dev/null +++ b/BilibiliLive/Extensions/Published+..swift @@ -0,0 +1,20 @@ +// +// Published+..swift +// BilibiliLive +// +// Created by yicheng on 2024/6/10. +// + +import Combine + +fileprivate var cancellables = [String: AnyCancellable]() + +public extension Published { + init(wrappedValue defaultValue: Value, key: String) { + let value = UserDefaults.standard.object(forKey: key) as? Value ?? defaultValue + self.init(initialValue: value) + cancellables[key] = projectedValue.sink { val in + UserDefaults.standard.set(val, forKey: key) + } + } +} diff --git a/BilibiliLive/Extensions/String+Error.swift b/BilibiliLive/Extensions/String+Error.swift new file mode 100644 index 00000000..7754b844 --- /dev/null +++ b/BilibiliLive/Extensions/String+Error.swift @@ -0,0 +1,12 @@ +// +// String+Error.swift +// BilibiliLive +// +// Created by yicheng on 2024/5/24. +// + +import Foundation + +extension String: LocalizedError { + public var errorDescription: String? { return self } +} diff --git a/BilibiliLive/Keys.swift b/BilibiliLive/Keys.swift index 197799ac..ff7194b3 100644 --- a/BilibiliLive/Keys.swift +++ b/BilibiliLive/Keys.swift @@ -10,4 +10,7 @@ enum Keys { static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15" static let liveReferer = "https://live.bilibili.com" static let referer = "https://www.bilibili.com" + static func referer(for aid: Int) -> String { + return "https://www.bilibili.com/video/av\(aid)" + } } diff --git a/BilibiliLive/LoginViewController.swift b/BilibiliLive/LoginViewController.swift index 36a39c7f..1da55fde 100644 --- a/BilibiliLive/LoginViewController.swift +++ b/BilibiliLive/LoginViewController.swift @@ -79,12 +79,8 @@ class LoginViewController: UIViewController { func didValidationSuccess() { qrcodeImageView.image = nil - let alert = UIAlertController() - alert.addAction(UIAlertAction(title: "Success", style: .default, handler: { [weak self] _ in - self?.dismiss(animated: true, completion: nil) - AppDelegate.shared.showTabBar() - })) - present(alert, animated: true, completion: nil) + AppDelegate.shared.showTabBar() + stopValidationTimer() } func loopValidation() { diff --git a/BilibiliLive/Module/DLNA/BiliBiliUpnpDMR.swift b/BilibiliLive/Module/DLNA/BiliBiliUpnpDMR.swift index 05ebf1a3..031377ca 100644 --- a/BilibiliLive/Module/DLNA/BiliBiliUpnpDMR.swift +++ b/BilibiliLive/Module/DLNA/BiliBiliUpnpDMR.swift @@ -14,6 +14,9 @@ import UIKit class BiliBiliUpnpDMR: NSObject { static let shared = BiliBiliUpnpDMR() + + weak var currentPlugin: BUpnpPlugin? + private var udp: GCDAsyncUdpSocket! private var httpServer = HttpServer() private var connectedSockets = [GCDAsyncSocket]() @@ -221,18 +224,18 @@ class BiliBiliUpnpDMR: NSObject { handlePlay(json: JSON(parseJSON: frame.body)) session.sendEmpty() case "Pause": - (topMost as? CommonPlayerViewController)?.player?.pause() + currentPlugin?.pause() session.sendEmpty() case "Resume": - (topMost as? CommonPlayerViewController)?.player?.play() + currentPlugin?.resume() session.sendEmpty() case "SwitchDanmaku": let json = JSON(parseJSON: frame.body) - (topMost as? CommonPlayerViewController)?.danMuView.isHidden = !json["open"].boolValue + Defaults.shared.showDanmu = json["open"].boolValue session.sendEmpty() case "Seek": let json = JSON(parseJSON: frame.body) - (topMost as? VideoPlayerViewController)?.player?.seek(to: CMTime(seconds: json["seekTs"].doubleValue, preferredTimescale: 1), toleranceBefore: .zero, toleranceAfter: .zero) + currentPlugin?.seek(to: json["seekTs"].doubleValue) session.sendEmpty() case "Stop": (topMost as? CommonPlayerViewController)?.dismiss(animated: true) diff --git a/BilibiliLive/Module/Live/LiveDanMuProvider.swift b/BilibiliLive/Module/Live/LiveDanMuProvider.swift index 08b5ce2a..e787d0a9 100644 --- a/BilibiliLive/Module/Live/LiveDanMuProvider.swift +++ b/BilibiliLive/Module/Live/LiveDanMuProvider.swift @@ -5,18 +5,20 @@ // Created by Etan on 2021/3/28. // +import Combine import Foundation @_spi(WebSocket) import Alamofire import Gzip import SwiftyJSON -class LiveDanMuProvider { +class LiveDanMuProvider: DanmuProviderProtocol { + let observerPlayerTime = false + var onSendTextModel = PassthroughSubject() + private var websocket: WebSocketRequest? private var heartBeatTimer: Timer? private let roomID: Int private var token = "" - var onDanmu: ((String) -> Void)? - var onSC: ((String) -> Void)? init(roomID: Int) { self.roomID = roomID @@ -26,6 +28,8 @@ class LiveDanMuProvider { stop() } + func playerTimeChange(time: TimeInterval) {} + func start() async throws { let info = try await WebRequest.requestDanmuServerInfo(roomID: roomID) guard let server = info.host_list.first else { @@ -117,19 +121,24 @@ extension LiveDanMuProvider { switch cmd { case "DANMU_MSG": if let str = json["info"][1].string { - onDanmu?(str) + let model = DanmakuTextCellModel(str: str) + onSendTextModel.send(model) } case "DM_INTERACTION": guard let data = json["data"]["data"].string else { return } let comboArr = JSON(parseJSON: data)["combo"] for combo in comboArr.arrayValue { if let str = combo["content"].string { - // let cnt = combo["cnt"].int - onDanmu?("\(str)") + let model = DanmakuTextCellModel(str: str) + onSendTextModel.send(model) } } case "SUPER_CHAT_MESSAGE": - if let str = json["data"]["message"].string { onSC?(str) } + if let str = json["data"]["message"].string { + let model = DanmakuTextCellModel(str: str) + model.type = .top + model.displayTime = 60 + } default: break } diff --git a/BilibiliLive/Module/Live/LivePlayerViewController.swift b/BilibiliLive/Module/Live/LivePlayerViewController.swift index 1c35c576..750ef493 100644 --- a/BilibiliLive/Module/Live/LivePlayerViewController.swift +++ b/BilibiliLive/Module/Live/LivePlayerViewController.swift @@ -12,75 +12,27 @@ import SwiftyJSON import UIKit class LivePlayerViewController: CommonPlayerViewController { - var room: LiveRoom? { - didSet { - roomID = room?.room_id ?? 0 - } - } + var room: LiveRoom? - private var roomID: Int = 0 - private var failCount = 0 private var viewModel: LivePlayerViewModel? deinit { Logger.debug("deinit live player") } override func viewDidLoad() { - allowChangeSpeed = false - requiresLinearPlayback = true super.viewDidLoad() - viewModel = LivePlayerViewModel(roomID: roomID) - viewModel?.onShootDanmu = { [weak self] in - self?.danMuView.shoot(danmaku: $0) - } - 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: $0)!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) - playerItem = AVPlayerItem(asset: asset) - player = AVPlayer(playerItem: playerItem) - player?.automaticallyWaitsToMinimizeStalling = false + viewModel = LivePlayerViewModel(room: room!) + viewModel?.onPluginReady = { [weak self] plugins in + DispatchQueue.main.async { + plugins.forEach { self?.addPlugin(plugin: $0) } + } } + viewModel?.onError = { [weak self] in self?.showErrorAlertAndExit(message: $0) } viewModel?.start() - - if Settings.danmuMask, Settings.vnMask { - maskProvider = VMaskProvider() - setupMask() - } - - 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) - } - } - } - - override func retryPlay() -> Bool { - Logger.warn("play fail, retry") - viewModel?.playerDidFailToPlay() - return true - } - - override func playerRateDidChange(player: AVPlayer) { - Logger.info("play speed change to", player.rate) - if player.rate == 0 { - viewModel?.playerDidFailToPlay() - } - } - - override func additionDebugInfo() -> String { - return viewModel?.debugInfo() ?? "" } } diff --git a/BilibiliLive/Module/Live/LivePlayerViewModel.swift b/BilibiliLive/Module/Live/LivePlayerViewModel.swift index 05619d5e..a8399759 100644 --- a/BilibiliLive/Module/Live/LivePlayerViewModel.swift +++ b/BilibiliLive/Module/Live/LivePlayerViewModel.swift @@ -19,24 +19,48 @@ enum LiveError: String, LocalizedError { } class LivePlayerViewModel { - init(roomID: Int) { - self.roomID = roomID + init(room: LiveRoom) { + self.room = room + roomID = room.room_id } deinit { danMuProvider?.stop() } - var onShootDanmu: ((DanmakuTextCellModel) -> Void)? - var onPlayUrlStr: ((String) -> Void)? + var onPluginReady: (([CommonPlayerPlugin]) -> Void)? var onError: ((String) -> Void)? + private let playPlugin = URLPlayPlugin(referer: Keys.liveReferer, isLive: true) + private let debugPlugin = DebugPlugin() + func start() { + playPlugin.onPlayFail = { [weak self] in + self?.playerDidFailToPlay() + } + + debugPlugin.additionDebugInfo = { [weak self] in + self?.debugInfo() ?? "" + } + + onPluginReady?([playPlugin, debugPlugin]) Task { do { try await refreshRoomsID() try await initPlayer() - await initDanmu() + + let danmu = await initDanmu() + self.onPluginReady?(danmu) + + if let info = await fetchDespInfo() { + let subtitle = "\(room.ownerName)·\(info.parent_area_name) \(info.area_name)" + let desp = "\(info.description)\nTags:\(info.tags ?? "")" + let infoPlugin = BVideoInfoPlugin(title: info.title, subTitle: subtitle, desp: desp, pic: room.pic, viewPoints: nil) + self.onPluginReady?([infoPlugin]) + } else { + let infoPlugin = BVideoInfoPlugin(title: room.title, subTitle: nil, desp: nil, pic: room.pic, viewPoints: nil) + self.onPluginReady?([infoPlugin]) + } } catch let err { await MainActor.run { onError?(String(describing: err)) @@ -76,6 +100,7 @@ class LivePlayerViewModel { private var allPlayInfos = [LivePlayUrlInfo]() private var playInfos = [LivePlayUrlInfo]() private var roomID: Int + private let room: LiveRoom private var danMuProvider: LiveDanMuProvider? private var retryCount = 0 @@ -127,32 +152,25 @@ class LivePlayerViewModel { if let info = playInfos.first { Logger.debug("play =>", playInfos) await MainActor.run { - onPlayUrlStr?(info.url) + playPlugin.play(urlString: info.url) } } else { throw LiveError.noPlaybackUrl } } - private func initDanmu() async { + @MainActor private func initDanmu() async -> [CommonPlayerPlugin] { 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) - } - } + let danmuPlugin = DanmuViewPlugin(provider: danMuProvider!) + try? await danMuProvider?.start() + var plugins: [CommonPlayerPlugin] = [danmuPlugin] + if Settings.danmuMask, Settings.vnMask { + let plugin = MaskViewPugin(maskView: danmuPlugin.danMuView, maskProvider: VMaskProvider()) + plugins.append(plugin) + } + + return plugins } } diff --git a/BilibiliLive/Request/WebRequest.swift b/BilibiliLive/Request/WebRequest.swift index 86f1fec9..8ed2b087 100644 --- a/BilibiliLive/Request/WebRequest.swift +++ b/BilibiliLive/Request/WebRequest.swift @@ -843,12 +843,18 @@ struct PlayerInfo: Codable { last_play_time / 1000 } - struct ViewPoint: Codable { + class ViewPoint: Codable { let type: Int let from: TimeInterval let to: TimeInterval let content: String let imgUrl: URL? + + var imageData: Data? + + enum CodingKeys: String, CodingKey { + case type, from, to, content, imgUrl + } } struct MaskInfo: Codable { diff --git a/BilibiliLive/Vendor/DanmakuKit/DanmakuView.swift b/BilibiliLive/Vendor/DanmakuKit/DanmakuView.swift index f8645b77..0e0f516a 100644 --- a/BilibiliLive/Vendor/DanmakuKit/DanmakuView.swift +++ b/BilibiliLive/Vendor/DanmakuKit/DanmakuView.swift @@ -433,6 +433,7 @@ public extension DanmakuView { private extension DanmakuView { func recaculateFloatingTracks() { + if viewHeight == 0 { return } let trackCount = Int(floorf(Float((viewHeight - paddingTop - paddingBottom) / trackHeight))) let offsetY = max(0, (viewHeight - CGFloat(trackCount) * trackHeight) / 2.0) let diffFloatingTrackCount = trackCount - floatingTracks.count @@ -458,6 +459,7 @@ private extension DanmakuView { } func recaculateTopTracks() { + if viewHeight == 0 { return } let trackCount = Int(floorf(Float((viewHeight - paddingTop - paddingBottom) / trackHeight))) let offsetY = max(0, (viewHeight - CGFloat(trackCount) * trackHeight) / 2.0) let diffFloatingTrackCount = trackCount - topTracks.count @@ -483,6 +485,7 @@ private extension DanmakuView { } func recaculateBottomTracks() { + if viewHeight == 0 { return } let trackCount = Int(floorf(Float((viewHeight - paddingTop - paddingBottom) / trackHeight))) let offsetY = max(0, (viewHeight - CGFloat(trackCount) * trackHeight) / 2.0) let diffFloatingTrackCount = trackCount - bottomTracks.count