diff --git a/Apple TV/PlayerViewController.swift b/Apple TV/PlayerViewController.swift index a463372c..cb453c9a 100644 --- a/Apple TV/PlayerViewController.swift +++ b/Apple TV/PlayerViewController.swift @@ -8,13 +8,15 @@ struct PlayerViewController: UIViewControllerRepresentable { @ObservedObject private var state: PlayerState + @ObservedObject private var profile = Profile() + var video: Video init(video: Video) { self.video = video state = PlayerState(video) - loadStream(video.defaultStream, loadBest: true) + loadStream(video.defaultStreamForProfile(profile), loadBest: profile.defaultStreamResolution == .hd720pFirstThenBest) } fileprivate func loadStream(_ stream: Stream?, loadBest: Bool = false) { @@ -93,6 +95,7 @@ struct PlayerViewController: UIViewControllerRepresentable { items.append(actionsMenu) } + items.append(playbackRateMenu) items.append(streamingQualityMenu) #if os(tvOS) @@ -150,4 +153,28 @@ struct PlayerViewController: UIViewControllerRepresentable { } } } + + private var playbackRateMenu: UIMenu { + UIMenu(title: "Playback rate", image: UIImage(systemName: playbackRateMenuImageSystemName), children: playbackRateMenuActions) + } + + private var playbackRateMenuImageSystemName: String { + if [0.0, 1.0].contains(state.player.rate) { + return "speedometer" + } + + return state.player.rate < 1.0 ? "tortoise.fill" : "hare.fill" + } + + private var playbackRateMenuActions: [UIAction] { + PlayerState.availablePlaybackRates.map { rate in + let image = state.currentRate == Float(rate) ? UIImage(systemName: "checkmark") : nil + + return UIAction(title: "\(rate)x", image: image) { _ in + DispatchQueue.main.async { + state.setPlayerRate(Float(rate)) + } + } + } + } } diff --git a/Model/PlayerState.swift b/Model/PlayerState.swift index 4477b593..42d04bb1 100644 --- a/Model/PlayerState.swift +++ b/Model/PlayerState.swift @@ -12,8 +12,6 @@ final class PlayerState: ObservableObject { @Published private(set) var composition = AVMutableComposition() @Published private(set) var nextComposition = AVMutableComposition() - private var comp: AVMutableComposition? - @Published private(set) var currentStream: Stream! @Published private(set) var nextStream: Stream! @@ -24,6 +22,11 @@ final class PlayerState: ObservableObject { @Published var currentSegment: Segment? + private var profile = Profile() + + @Published private(set) var currentRate: Float = 0.0 + static let availablePlaybackRates: [Double] = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] + var playerItem: AVPlayerItem { let playerItem = AVPlayerItem(asset: composition) @@ -115,6 +118,8 @@ final class PlayerState: ObservableObject { player.replaceCurrentItem(with: playerItem) streamDidLoad(stream) + player.play() + seekToSavedTime() } @@ -179,10 +184,9 @@ final class PlayerState: ObservableObject { } if let time = savedTime { + logger.info("seeking to \(time.seconds)") player.seek(to: time) } - - player.play() } func destroyPlayer() { @@ -205,7 +209,22 @@ final class PlayerState: ObservableObject { 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) } + + let currentSegment = self.segmentsProvider.segments.first { $0.timeInSegment(time) } + + if let segment = currentSegment { + if self.profile.skippedSegmentsCategories.contains(segment.category) { + if segment.shouldSkip(self.currentTime!) { + self.player.seek(to: segment.skipTo) + } + } + } + + if self.player.rate != self.currentRate, self.player.rate != 0, self.currentRate != 0 { + self.player.rate = self.currentRate + } + + self.currentSegment = currentSegment } } @@ -218,4 +237,9 @@ final class PlayerState: ObservableObject { return item.copy() as! AVMetadataItem } + + func setPlayerRate(_ rate: Float) { + currentRate = rate + player.rate = rate + } } diff --git a/Model/Profile.swift b/Model/Profile.swift new file mode 100644 index 00000000..cec4c542 --- /dev/null +++ b/Model/Profile.swift @@ -0,0 +1,23 @@ +import Foundation + +final class Profile: ObservableObject { + let defaultStreamResolution: DefaultStreamResolution = .hd720pFirstThenBest + + let skippedSegmentsCategories = [String]() // SponsorBlockSegmentsProvider.categories + + // let sid = "B3_WzklziGu8JKefihLrCsTNavdj73KMiPUBfN5HW2M=" + let sid = "RpoS7YPPK2-QS81jJF9z4KSQAjmzsOnMpn84c73-GQ8=" +} + +enum DefaultStreamResolution: String { + case hd720pFirstThenBest, hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p + + var value: StreamResolution { + switch self { + case .hd720pFirstThenBest: + return .hd720p + default: + return StreamResolution(rawValue: rawValue)! + } + } +} diff --git a/Model/Segment.swift b/Model/Segment.swift index 188ba7ed..66032644 100644 --- a/Model/Segment.swift +++ b/Model/Segment.swift @@ -47,4 +47,8 @@ class Segment: ObservableObject, Hashable { func title() -> String { category } + + func shouldSkip(_ atTime: CMTime) -> Bool { + atTime.seconds - start < 2 && end - atTime.seconds > 2 + } } diff --git a/Model/SponsorBlockSegmentsProvider.swift b/Model/SponsorBlockSegmentsProvider.swift index 86831e6f..312d7abf 100644 --- a/Model/SponsorBlockSegmentsProvider.swift +++ b/Model/SponsorBlockSegmentsProvider.swift @@ -3,7 +3,7 @@ import Foundation import SwiftyJSON final class SponsorBlockSegmentsProvider: ObservableObject { - let categories = ["sponsor", "selfpromo", "outro", "intro", "music_offtopic", "interaction"] + static let categories = ["sponsor", "selfpromo", "outro", "intro", "music_offtopic", "interaction"] @Published var video: Video? @@ -29,7 +29,7 @@ final class SponsorBlockSegmentsProvider: ObservableObject { private var parameters: [String: String] { [ "videoID": id, - "categories": JSON(categories).rawString(String.Encoding.utf8)!, + "categories": JSON(SponsorBlockSegmentsProvider.categories).rawString(String.Encoding.utf8)!, ] } } diff --git a/Model/StreamResolution.swift b/Model/StreamResolution.swift index 6145e436..9656e57c 100644 --- a/Model/StreamResolution.swift +++ b/Model/StreamResolution.swift @@ -1,7 +1,7 @@ import Foundation enum StreamResolution: String, CaseIterable, Comparable { - case hd_1080p, hd_720p, sd_480p, sd_360p, sd_240p, sd_144p + case hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p var height: Int { Int(rawValue.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())! diff --git a/Model/SubscriptionVideosProvider.swift b/Model/SubscriptionVideosProvider.swift index 9e602fce..896be124 100644 --- a/Model/SubscriptionVideosProvider.swift +++ b/Model/SubscriptionVideosProvider.swift @@ -5,10 +5,10 @@ import SwiftyJSON final class SubscriptionVideosProvider: DataProvider { @Published var videos = [Video]() - var sid: String = "RpoS7YPPK2-QS81jJF9z4KSQAjmzsOnMpn84c73-GQ8=" + let profile = Profile() func load() { - let headers = HTTPHeaders([HTTPHeader(name: "Cookie", value: "SID=\(sid)")]) + let headers = HTTPHeaders([HTTPHeader(name: "Cookie", value: "SID=\(profile.sid)")]) DataProvider.request("auth/feed", headers: headers).responseJSON { response in switch response.result { case let .success(value): diff --git a/Model/Video.swift b/Model/Video.swift index f408dffd..6b1bb36e 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -88,6 +88,14 @@ final class Video: Identifiable, ObservableObject { selectableStreams.min { $0.resolution > $1.resolution } } + func streamWithResolution(_ resolution: StreamResolution) -> Stream? { + selectableStreams.first { $0.resolution == resolution } + } + + func defaultStreamForProfile(_ profile: Profile) -> Stream? { + streamWithResolution(profile.defaultStreamResolution.value) + } + private func extractThumbnailURL(from details: JSON) -> URL? { if details["videoThumbnails"].arrayValue.isEmpty { return nil diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index 37cd6a74..f5da7b3d 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -88,6 +88,9 @@ 37C7A1D8267CACE10010EAD6 /* TrendingCategorySelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B17D267B4DDE00704544 /* TrendingCategorySelectionView.swift */; }; 37C7A1D9267CACE60010EAD6 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B17B267B4D9A00704544 /* VisualEffectView.swift */; }; 37C7A1DA267CACF50010EAD6 /* TrendingCountrySelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B17F267B4DFB00704544 /* TrendingCountrySelectionView.swift */; }; + 37C7A1DC267CE9D90010EAD6 /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1DB267CE9D90010EAD6 /* Profile.swift */; }; + 37C7A1DD267CE9D90010EAD6 /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1DB267CE9D90010EAD6 /* Profile.swift */; }; + 37C7A1DE267CE9D90010EAD6 /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1DB267CE9D90010EAD6 /* Profile.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 */; }; @@ -190,6 +193,7 @@ 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 = ""; }; + 37C7A1DB267CE9D90010EAD6 /* Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profile.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 = ""; }; @@ -390,6 +394,7 @@ 37D4B1AF2672A01000C925CA /* DataProvider.swift */, 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */, 37D4B19226717CE100C925CA /* PopularVideosProvider.swift */, + 37C7A1DB267CE9D90010EAD6 /* Profile.swift */, 37AAF2812673791F007FC770 /* SearchedVideosProvider.swift */, 37EAD86E267B9ED100D9E01B /* Segment.swift */, 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */, @@ -669,6 +674,7 @@ 3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */, 37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */, 37CEE4B52677B628005A1EFE /* StreamType.swift in Sources */, + 37C7A1DC267CE9D90010EAD6 /* Profile.swift in Sources */, 3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */, 377FC7E3267A084A00A6BBAF /* VideoThumbnailView.swift in Sources */, 37AAF2822673791F007FC770 /* SearchedVideosProvider.swift in Sources */, @@ -723,6 +729,7 @@ 377FC7E4267A084E00A6BBAF /* SearchView.swift in Sources */, 377FC7E0267A082600A6BBAF /* ChannelView.swift in Sources */, 37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, + 37C7A1DD267CE9D90010EAD6 /* Profile.swift in Sources */, 37C7A906267905AF00E721B4 /* AVKeyValueStatus+String.swift in Sources */, 37B767DC2677C3CA0098BAA8 /* PlayerState.swift in Sources */, 37D4B1B52672A30700C925CA /* VideoDetailsProvider.swift in Sources */, @@ -772,6 +779,7 @@ 37AAF29226740715007FC770 /* AppState.swift in Sources */, 37EAD86D267B9C5600D9E01B /* SponsorBlockSegmentsProvider.swift in Sources */, 3705B17C267B4D9A00704544 /* VisualEffectView.swift in Sources */, + 37C7A1DE267CE9D90010EAD6 /* Profile.swift in Sources */, 3741B5302676213400125C5E /* PlayerViewController.swift in Sources */, 37B767DD2677C3CA0098BAA8 /* PlayerState.swift in Sources */, 37C7A905267905AE00E721B4 /* AVKeyValueStatus+String.swift in Sources */,