From d551dee4261e6035046af98fe70221b8304b9f91 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 18 Jun 2021 00:43:29 +0200 Subject: [PATCH] Implement SponsorBlock API --- Apple TV/PlayerViewController.swift | 20 +++++++++++++ Model/PlayerState.swift | 30 +++++++++++++++++-- Model/Segment.swift | 38 ++++++++++++++++++++++++ Model/SponsorBlockSegment.swift | 24 +++++++++++++++ Model/SponsorBlockSegmentsProvider.swift | 35 ++++++++++++++++++++++ Model/TrendingCountriesProvider.swift | 2 +- Model/Video.swift | 25 ++++------------ Pearvidious.xcodeproj/project.pbxproj | 28 +++++++++++++++-- Shared/Country.swift | 17 ++++++++--- 9 files changed, 190 insertions(+), 29 deletions(-) create mode 100644 Model/Segment.swift create mode 100644 Model/SponsorBlockSegment.swift create mode 100644 Model/SponsorBlockSegmentsProvider.swift diff --git a/Apple TV/PlayerViewController.swift b/Apple TV/PlayerViewController.swift index 49c60260..d08f8014 100644 --- a/Apple TV/PlayerViewController.swift +++ b/Apple TV/PlayerViewController.swift @@ -117,6 +117,14 @@ struct PlayerViewController: UIViewControllerRepresentable { #if os(tvOS) controller.transportBarCustomMenuItems = items #endif + + if let skip = skipSegmentAction { + if controller.contextualActions.isEmpty { + controller.contextualActions = [skip] + } + } else { + controller.contextualActions = [] + } } fileprivate var streamingQualityMenu: UIMenu { @@ -149,4 +157,16 @@ struct PlayerViewController: UIViewControllerRepresentable { } } } + + private var skipSegmentAction: UIAction? { + if state.currentSegment == nil { + return nil + } + + return UIAction(title: "Skip \(state.currentSegment!.title())") { _ in + DispatchQueue.main.async { + state.player.seek(to: state.currentSegment!.skipTo) + } + } + } } diff --git a/Model/PlayerState.swift b/Model/PlayerState.swift index 0ec8c94a..a3e2cf2c 100644 --- a/Model/PlayerState.swift +++ b/Model/PlayerState.swift @@ -14,23 +14,34 @@ final class PlayerState: ObservableObject { @Published private(set) var streamToLoad: Stream! @Published private(set) var streamLoading = false + @Published private(set) var currentTime: CMTime? @Published private(set) var savedTime: CMTime? + @Published var currentSegment: Segment? var playerItem: AVPlayerItem { let playerItem = AVPlayerItem(asset: composition) - playerItem.externalMetadata = [makeMetadataItem(.commonIdentifierTitle, value: video.title)] + playerItem.externalMetadata = [ + makeMetadataItem(.commonIdentifierTitle, value: video.title), + makeMetadataItem(.quickTimeMetadataGenre, value: video.genre), + makeMetadataItem(.commonIdentifierDescription, value: video.description) + ] playerItem.preferredForwardBufferDuration = 10 return playerItem } + var segmentsProvider: SponsorBlockSegmentsProvider + var timeObserver: Any? + init(_ video: Video) { self.video = video + segmentsProvider = SponsorBlockSegmentsProvider(video.id) + + segmentsProvider.load() } deinit { - print("destr deinit") destroyPlayer() } @@ -51,12 +62,15 @@ final class PlayerState: ObservableObject { func streamDidLoad(_ stream: Stream?) { logger.info("didload stream: \(stream!.description)") + currentStream?.cancelLoadingAssets() currentStream = stream streamLoading = streamToLoad != stream if streamToLoad == stream { streamToLoad = nil } + + addTimeObserver() } func cancelLoadingStream(_ stream: Stream) { @@ -121,6 +135,18 @@ final class PlayerState: ObservableObject { player.cancelPendingPrerolls() player.replaceCurrentItem(with: nil) + + if timeObserver != nil { + player.removeTimeObserver(timeObserver!) + } + } + + func addTimeObserver() { + let interval = CMTime(value: 1, timescale: 1) + timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in + self.currentTime = time + self.currentSegment = self.segmentsProvider.segments.first { $0.timeInSegment(time) } + } } private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem { diff --git a/Model/Segment.swift b/Model/Segment.swift new file mode 100644 index 00000000..4e579a10 --- /dev/null +++ b/Model/Segment.swift @@ -0,0 +1,38 @@ +import CoreMedia +import Foundation +import SwiftyJSON + +// swiftlint:disable:next final_class +class Segment: ObservableObject, Hashable { + let category: String + let segment: [Double] + let uuid: String + let videoDuration: Int + + init(category: String, segment: [Double], uuid: String, videoDuration: Int) { + self.category = category + self.segment = segment + self.uuid = uuid + self.videoDuration = videoDuration + } + + func timeInSegment(_ time: CMTime) -> Bool { + (segment.first! ... segment.last!).contains(time.seconds) + } + + var skipTo: CMTime { + CMTime(seconds: segment.last!, preferredTimescale: 1) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(uuid) + } + + static func == (lhs: Segment, rhs: Segment) -> Bool { + lhs.uuid == rhs.uuid + } + + func title() -> String { + category + } +} diff --git a/Model/SponsorBlockSegment.swift b/Model/SponsorBlockSegment.swift new file mode 100644 index 00000000..f1203fb8 --- /dev/null +++ b/Model/SponsorBlockSegment.swift @@ -0,0 +1,24 @@ +import Foundation +import SwiftyJSON + +final class SponsorBlockSegment: Segment { + init(_ json: JSON) { + super.init( + category: json["category"].string!, + segment: json["segment"].array!.map { $0.double! }, + uuid: json["UUID"].string!, + videoDuration: json["videoDuration"].int! + ) + } + + override func title() -> String { + switch category { + case "selfpromo": + return "self-promotion" + case "music_offtopic": + return "to music" + default: + return category + } + } +} diff --git a/Model/SponsorBlockSegmentsProvider.swift b/Model/SponsorBlockSegmentsProvider.swift new file mode 100644 index 00000000..ea7fc9b7 --- /dev/null +++ b/Model/SponsorBlockSegmentsProvider.swift @@ -0,0 +1,35 @@ +import Alamofire +import Foundation +import SwiftyJSON + +final class SponsorBlockSegmentsProvider: ObservableObject { + let categories = ["sponsor", "selfpromo", "outro", "intro", "music_offtopic", "interaction"] + + @Published var video: Video? + + @Published var segments = [Segment]() + + var id: String + + init(_ id: String) { + self.id = id + } + + func load() { + AF.request("https://sponsor.ajay.app/api/skipSegments", parameters: parameters).responseJSON { response in + switch response.result { + case let .success(value): + self.segments = JSON(value).arrayValue.map { SponsorBlockSegment($0) } + case let .failure(error): + print(error) + } + } + } + + private var parameters: [String: String] { + [ + "videoID": id, + "categories": JSON(categories).rawString(String.Encoding.utf8)! + ] + } +} diff --git a/Model/TrendingCountriesProvider.swift b/Model/TrendingCountriesProvider.swift index e9c59bc8..2a92ebf3 100644 --- a/Model/TrendingCountriesProvider.swift +++ b/Model/TrendingCountriesProvider.swift @@ -13,6 +13,6 @@ final class TrendingCountriesProvider: DataProvider { } self.query = query - countries = Country.searchByName(query) + countries = Country.search(query) } } diff --git a/Model/Video.swift b/Model/Video.swift index 5681b4b1..f408dffd 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -12,29 +12,11 @@ final class Video: Identifiable, ObservableObject { var published: String var views: Int var channelID: String + var description: String + var genre: String var streams = [Stream]() - init( - id: String, - title: String, - thumbnailURL: URL?, - author: String, - length: TimeInterval, - published: String, - views: Int, - channelID: String - ) { - self.id = id - self.title = title - self.thumbnailURL = thumbnailURL - self.author = author - self.length = length - self.published = published - self.views = views - self.channelID = channelID - } - init(_ json: JSON) { id = json["videoId"].stringValue title = json["title"].stringValue @@ -43,6 +25,9 @@ final class Video: Identifiable, ObservableObject { published = json["publishedText"].stringValue views = json["viewCount"].intValue channelID = json["authorId"].stringValue + description = json["description"].stringValue + genre = json["genre"].stringValue + thumbnailURL = extractThumbnailURL(from: json) streams = extractFormatStreams(from: json["formatStreams"].arrayValue) diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index e9117dd4..6e806014 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -82,6 +82,9 @@ 37B767DC2677C3CA0098BAA8 /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */; }; 37B767DD2677C3CA0098BAA8 /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */; }; 37B767E02678C5BF0098BAA8 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 37B767DF2678C5BF0098BAA8 /* Logging */; }; + 37C7A1D5267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; }; + 37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; }; + 37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; }; 37C7A9042679059200E721B4 /* AVKeyValueStatus+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A9032679059200E721B4 /* AVKeyValueStatus+String.swift */; }; 37C7A905267905AE00E721B4 /* AVKeyValueStatus+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A9032679059200E721B4 /* AVKeyValueStatus+String.swift */; }; 37C7A906267905AF00E721B4 /* AVKeyValueStatus+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A9032679059200E721B4 /* AVKeyValueStatus+String.swift */; }; @@ -128,6 +131,12 @@ 37D4B1B42672A30700C925CA /* VideoDetailsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B1B32672A30700C925CA /* VideoDetailsProvider.swift */; }; 37D4B1B52672A30700C925CA /* VideoDetailsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B1B32672A30700C925CA /* VideoDetailsProvider.swift */; }; 37D4B1B62672A30700C925CA /* VideoDetailsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B1B32672A30700C925CA /* VideoDetailsProvider.swift */; }; + 37EAD86B267B9C5600D9E01B /* SponsorBlockSegmentsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86A267B9C5600D9E01B /* SponsorBlockSegmentsProvider.swift */; }; + 37EAD86C267B9C5600D9E01B /* SponsorBlockSegmentsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86A267B9C5600D9E01B /* SponsorBlockSegmentsProvider.swift */; }; + 37EAD86D267B9C5600D9E01B /* SponsorBlockSegmentsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86A267B9C5600D9E01B /* SponsorBlockSegmentsProvider.swift */; }; + 37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86E267B9ED100D9E01B /* Segment.swift */; }; + 37EAD870267B9ED100D9E01B /* Segment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86E267B9ED100D9E01B /* Segment.swift */; }; + 37EAD871267B9ED100D9E01B /* Segment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86E267B9ED100D9E01B /* Segment.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -177,6 +186,7 @@ 37AAF29B26741B5F007FC770 /* SubscriptionVideosProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionVideosProvider.swift; sourceTree = ""; }; 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsView.swift; sourceTree = ""; }; 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerState.swift; sourceTree = ""; }; + 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockSegment.swift; sourceTree = ""; }; 37C7A9032679059200E721B4 /* AVKeyValueStatus+String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVKeyValueStatus+String.swift"; sourceTree = ""; }; 37CEE4B42677B628005A1EFE /* StreamType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamType.swift; sourceTree = ""; }; 37CEE4B82677B63F005A1EFE /* StreamResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolution.swift; sourceTree = ""; }; @@ -202,6 +212,8 @@ 37D4B1AE26729DEB00C925CA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 37D4B1AF2672A01000C925CA /* DataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProvider.swift; sourceTree = ""; }; 37D4B1B32672A30700C925CA /* VideoDetailsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDetailsProvider.swift; sourceTree = ""; }; + 37EAD86A267B9C5600D9E01B /* SponsorBlockSegmentsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockSegmentsProvider.swift; sourceTree = ""; }; + 37EAD86E267B9ED100D9E01B /* Segment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Segment.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -376,16 +388,19 @@ 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */, 37D4B19226717CE100C925CA /* PopularVideosProvider.swift */, 37AAF2812673791F007FC770 /* SearchedVideosProvider.swift */, + 37EAD86E267B9ED100D9E01B /* Segment.swift */, + 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */, + 37EAD86A267B9C5600D9E01B /* SponsorBlockSegmentsProvider.swift */, 37CEE4C02677B697005A1EFE /* Stream.swift */, 37CEE4B82677B63F005A1EFE /* StreamResolution.swift */, 37CEE4B42677B628005A1EFE /* StreamType.swift */, 37AAF29B26741B5F007FC770 /* SubscriptionVideosProvider.swift */, + 3705B181267B4E4900704544 /* TrendingCategory.swift */, 3714167A267AA1CF006CA35D /* TrendingCountriesProvider.swift */, 37141676267A9AAD006CA35D /* TrendingState.swift */, + 3714167E267AB55D006CA35D /* TrendingVideosProvider.swift */, 37D4B19626717E1500C925CA /* Video.swift */, 37D4B1B32672A30700C925CA /* VideoDetailsProvider.swift */, - 3714167E267AB55D006CA35D /* TrendingVideosProvider.swift */, - 3705B181267B4E4900704544 /* TrendingCategory.swift */, ); path = Model; sourceTree = ""; @@ -639,6 +654,7 @@ 37D4B19326717CE100C925CA /* PopularVideosProvider.swift in Sources */, 37AAF29C26741B5F007FC770 /* SubscriptionVideosProvider.swift in Sources */, 37141668267A83F9006CA35D /* StreamAVPlayerViewController.swift in Sources */, + 37EAD86B267B9C5600D9E01B /* SponsorBlockSegmentsProvider.swift in Sources */, 377FC7E6267A085600A6BBAF /* PlayerView.swift in Sources */, 37CEE4C12677B697005A1EFE /* Stream.swift in Sources */, 37141677267A9AAD006CA35D /* TrendingState.swift in Sources */, @@ -646,6 +662,7 @@ 377FC7DC267A081800A6BBAF /* PopularVideosView.swift in Sources */, 3714167F267AB55D006CA35D /* TrendingVideosProvider.swift in Sources */, 3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */, + 37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */, 37CEE4B52677B628005A1EFE /* StreamType.swift in Sources */, 3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */, 377FC7E3267A084A00A6BBAF /* VideoThumbnailView.swift in Sources */, @@ -657,6 +674,7 @@ 377FC7E9267A085D00A6BBAF /* PlayerViewController.swift in Sources */, 377FC7E5267A084E00A6BBAF /* SearchView.swift in Sources */, 377FC7E1267A082600A6BBAF /* ChannelView.swift in Sources */, + 37C7A1D5267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 37C7A9042679059200E721B4 /* AVKeyValueStatus+String.swift in Sources */, 37B767DB2677C3CA0098BAA8 /* PlayerState.swift in Sources */, 37D4B1B42672A30700C925CA /* VideoDetailsProvider.swift in Sources */, @@ -678,6 +696,7 @@ 37D4B19426717CE100C925CA /* PopularVideosProvider.swift in Sources */, 37AAF29D26741B5F007FC770 /* SubscriptionVideosProvider.swift in Sources */, 37141669267A83F9006CA35D /* StreamAVPlayerViewController.swift in Sources */, + 37EAD86C267B9C5600D9E01B /* SponsorBlockSegmentsProvider.swift in Sources */, 377FC7E7267A085600A6BBAF /* PlayerView.swift in Sources */, 37CEE4C22677B697005A1EFE /* Stream.swift in Sources */, 37141678267A9AAD006CA35D /* TrendingState.swift in Sources */, @@ -685,6 +704,7 @@ 377FC7DD267A081A00A6BBAF /* PopularVideosView.swift in Sources */, 37141680267AB55D006CA35D /* TrendingVideosProvider.swift in Sources */, 3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */, + 37EAD870267B9ED100D9E01B /* Segment.swift in Sources */, 37CEE4B62677B628005A1EFE /* StreamType.swift in Sources */, 37141670267A8ACC006CA35D /* TrendingView.swift in Sources */, 377FC7E2267A084A00A6BBAF /* VideoThumbnailView.swift in Sources */, @@ -696,6 +716,7 @@ 377FC7E8267A085D00A6BBAF /* PlayerViewController.swift in Sources */, 377FC7E4267A084E00A6BBAF /* SearchView.swift in Sources */, 377FC7E0267A082600A6BBAF /* ChannelView.swift in Sources */, + 37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 37C7A906267905AF00E721B4 /* AVKeyValueStatus+String.swift in Sources */, 37B767DC2677C3CA0098BAA8 /* PlayerState.swift in Sources */, 37D4B1B52672A30700C925CA /* VideoDetailsProvider.swift in Sources */, @@ -730,6 +751,7 @@ buildActionMask = 2147483647; files = ( 37AAF28026737550007FC770 /* SearchView.swift in Sources */, + 37EAD871267B9ED100D9E01B /* Segment.swift in Sources */, 37CEE4BF2677B670005A1EFE /* AudioVideoStream.swift in Sources */, 37CEE4B72677B628005A1EFE /* StreamType.swift in Sources */, 3714166A267A83F9006CA35D /* StreamAVPlayerViewController.swift in Sources */, @@ -742,6 +764,7 @@ 37D4B1B22672A01000C925CA /* DataProvider.swift in Sources */, 37141671267A8ACC006CA35D /* TrendingView.swift in Sources */, 37AAF29226740715007FC770 /* AppState.swift in Sources */, + 37EAD86D267B9C5600D9E01B /* SponsorBlockSegmentsProvider.swift in Sources */, 3705B17C267B4D9A00704544 /* VisualEffectView.swift in Sources */, 3741B5302676213400125C5E /* PlayerViewController.swift in Sources */, 37B767DD2677C3CA0098BAA8 /* PlayerState.swift in Sources */, @@ -751,6 +774,7 @@ 37AAF27E26737323007FC770 /* PopularVideosView.swift in Sources */, 37AAF29A26740A01007FC770 /* VideosView.swift in Sources */, 37AAF2962674086B007FC770 /* TabSelection.swift in Sources */, + 37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 37CEE4C32677B697005A1EFE /* Stream.swift in Sources */, 37AAF28A2673AB89007FC770 /* ChannelView.swift in Sources */, 37AAF28E2673ABD3007FC770 /* ChannelVideosProvider.swift in Sources */, diff --git a/Shared/Country.swift b/Shared/Country.swift index 636fc6d9..3f30367d 100644 --- a/Shared/Country.swift +++ b/Shared/Country.swift @@ -521,10 +521,18 @@ extension Country { return result } - static func searchByName(_ name: String) -> [Country] { - let countries = filteredCountries { stringFolding($0) == stringFolding(name) } + static func search(_ query: String) -> [Country] { + if let country = searchByCode(query) { + return [country] + } - return countries.isEmpty ? searchByPartialName(name) : countries + let countries = filteredCountries { stringFolding($0) == stringFolding(query) } + + return countries.isEmpty ? searchByPartialName(query) : countries + } + + static func searchByCode(_ code: String) -> Country? { + Country(rawValue: code.uppercased()) } static func searchByPartialName(_ name: String) -> [Country] { @@ -540,7 +548,8 @@ extension Country { } private static func filteredCountries(_ predicate: (String) -> Bool) -> [Country] { - Country.allCases.map { $0.name } + Country.allCases + .map { $0.name } .filter(predicate) .compactMap { string in Country.allCases.first { $0.name == string } } }