From 33e102207f0eb82a54fe2077a0ef6b3af2375170 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 22 Jul 2021 14:43:13 +0200 Subject: [PATCH] UI improvements, player state refactor --- Apple TV/PlaylistFormView.swift | 4 +- Apple TV/SearchOptionsView.swift | 10 +- Apple TV/StreamAVPlayerViewController.swift | 11 - Apple TV/VideoCellView.swift | 69 ++-- Apple TV/VideoListRowView.swift | 2 +- Fixtures/Thumbnail+Fixtures.swift | 36 ++ Fixtures/Video+Fixtures.swift | 40 ++ Model/AudioVideoStream.swift | 12 - Model/PlayerState.swift | 357 +++++++----------- Model/Playlist.swift | 14 +- Model/PlaylistVisibility.swift | 13 - Model/Profile.swift | 6 +- Model/SearchDate.swift | 13 - Model/SearchDuration.swift | 13 - Model/SearchQuery.swift | 63 +++- Model/SearchSortOrder.swift | 32 -- Model/SingleAssetStream.swift | 12 + Model/Stream.swift | 53 ++- Model/StreamResolution.swift | 17 - Model/StreamType.swift | 18 - Model/Thumbnail.swift | 13 +- Model/ThumbnailQuality.swift | 5 - Model/Video.swift | 72 +++- Pearvidious.xcodeproj/project.pbxproj | 112 ++---- .../xcdebugger/Breakpoints_v2.xcbkptlist | 34 ++ .../Contents.json | 38 ++ .../Contents.json | 38 ++ Shared/Defaults.swift | 23 +- Shared/DetailBadge.swift | 96 +++++ Shared/ListingLayout.swift | 18 - 30 files changed, 743 insertions(+), 501 deletions(-) delete mode 100644 Apple TV/StreamAVPlayerViewController.swift create mode 100644 Fixtures/Thumbnail+Fixtures.swift create mode 100644 Fixtures/Video+Fixtures.swift delete mode 100644 Model/AudioVideoStream.swift delete mode 100644 Model/PlaylistVisibility.swift delete mode 100644 Model/SearchDate.swift delete mode 100644 Model/SearchDuration.swift delete mode 100644 Model/SearchSortOrder.swift create mode 100644 Model/SingleAssetStream.swift delete mode 100644 Model/StreamResolution.swift delete mode 100644 Model/StreamType.swift delete mode 100644 Model/ThumbnailQuality.swift create mode 100644 Shared/Assets.xcassets/DetailBadgeInformationalStyleBackgroundColor.colorset/Contents.json create mode 100644 Shared/Assets.xcassets/DetailBadgeOutstandingStyleBackgroundColor.colorset/Contents.json create mode 100644 Shared/DetailBadge.swift delete mode 100644 Shared/ListingLayout.swift diff --git a/Apple TV/PlaylistFormView.swift b/Apple TV/PlaylistFormView.swift index 61e6e909..da5fbcf9 100644 --- a/Apple TV/PlaylistFormView.swift +++ b/Apple TV/PlaylistFormView.swift @@ -3,7 +3,7 @@ import SwiftUI struct PlaylistFormView: View { @State private var name = "" - @State private var visibility = PlaylistVisibility.public + @State private var visibility = Playlist.Visibility.public @State private var valid = false @State private var showingDeleteConfirmation = false @@ -89,7 +89,7 @@ struct PlaylistFormView: View { self.visibility = self.visibility.next() } .contextMenu { - ForEach(PlaylistVisibility.allCases) { visibility in + ForEach(Playlist.Visibility.allCases) { visibility in Button(visibility.name) { self.visibility = visibility } diff --git a/Apple TV/SearchOptionsView.swift b/Apple TV/SearchOptionsView.swift index 79b512d9..cc56deb5 100644 --- a/Apple TV/SearchOptionsView.swift +++ b/Apple TV/SearchOptionsView.swift @@ -19,7 +19,7 @@ struct SearchOptionsView: View { self.searchSortOrder = self.searchSortOrder.next() } .contextMenu { - ForEach(SearchSortOrder.allCases) { sortOrder in + ForEach(SearchQuery.SortOrder.allCases) { sortOrder in Button(sortOrder.name) { self.searchSortOrder = sortOrder } @@ -29,11 +29,11 @@ struct SearchOptionsView: View { var searchDateButton: some View { Button(self.searchDate?.name ?? "All") { - self.searchDate = self.searchDate == nil ? SearchDate.allCases.first : self.searchDate!.next(nilAtEnd: true) + self.searchDate = self.searchDate == nil ? SearchQuery.Date.allCases.first : self.searchDate!.next(nilAtEnd: true) } .contextMenu { - ForEach(SearchDate.allCases) { searchDate in + ForEach(SearchQuery.Date.allCases) { searchDate in Button(searchDate.name) { self.searchDate = searchDate } @@ -47,10 +47,10 @@ struct SearchOptionsView: View { var searchDurationButton: some View { Button(self.searchDuration?.name ?? "All") { - self.searchDuration = self.searchDuration == nil ? SearchDuration.allCases.first : self.searchDuration!.next(nilAtEnd: true) + self.searchDuration = self.searchDuration == nil ? SearchQuery.Duration.allCases.first : self.searchDuration!.next(nilAtEnd: true) } .contextMenu { - ForEach(SearchDuration.allCases) { searchDuration in + ForEach(SearchQuery.Duration.allCases) { searchDuration in Button(searchDuration.name) { self.searchDuration = searchDuration } diff --git a/Apple TV/StreamAVPlayerViewController.swift b/Apple TV/StreamAVPlayerViewController.swift deleted file mode 100644 index e7e53c83..00000000 --- a/Apple TV/StreamAVPlayerViewController.swift +++ /dev/null @@ -1,11 +0,0 @@ -import AVKit - -final class StreamAVPlayerViewController: AVPlayerViewController { - var state: PlayerState? - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - state?.destroyPlayer() - } -} diff --git a/Apple TV/VideoCellView.swift b/Apple TV/VideoCellView.swift index a3b437db..0527ac40 100644 --- a/Apple TV/VideoCellView.swift +++ b/Apple TV/VideoCellView.swift @@ -11,7 +11,7 @@ struct VideoCellView: View { var body: some View { Button(action: { navigationState.playVideo(video) }) { VStack(alignment: .leading) { - ZStack(alignment: .trailing) { + ZStack { if let thumbnail = video.thumbnailURL(quality: .high) { // to replace with AsyncImage when it is fixed with lazy views URLImage(thumbnail) { image in @@ -26,24 +26,30 @@ struct VideoCellView: View { .frame(width: 550, height: 310) } - VStack(alignment: .trailing) { - Text(video.author) - .padding(8) - .background(.thickMaterial) - .mask(RoundedRectangle(cornerRadius: 12)) - .offset(x: -5, y: 5) - .truncationMode(.middle) + VStack { + HStack(alignment: .top) { + if video.live { + DetailBadge(text: "Live", style: .outstanding) + } else if video.upcoming { + DetailBadge(text: "Upcoming", style: .informational) + } + + Spacer() + + DetailBadge(text: video.author, style: .prominent) + } + .padding(10) Spacer() - if let time = video.playTime { - Text(time) - .fontWeight(.bold) - .padding(8) - .background(.thickMaterial) - .mask(RoundedRectangle(cornerRadius: 12)) - .offset(x: -5, y: -5) + HStack(alignment: .top) { + Spacer() + + if let time = video.playTime { + DetailBadge(text: time, style: .prominent) + } } + .padding(10) } } .frame(width: 550, height: 310) @@ -58,21 +64,32 @@ struct VideoCellView: View { .frame(minHeight: 80, alignment: .top) .truncationMode(.middle) - if !video.published.isEmpty || video.views != 0 { - HStack(spacing: 8) { - if !video.published.isEmpty { + HStack(spacing: 8) { + if video.publishedDate != nil || video.views != 0 { + if let date = video.publishedDate { Image(systemName: "calendar") - Text(video.published) + Text(date) } if video.views != 0 { Image(systemName: "eye") Text(video.viewsCount) } + } else { + Section { + if video.live { + Image(systemName: "camera.fill") + Text("Premiering now") + } else { + Image(systemName: "questionmark.app.fill") + Text("date and views unavailable") + } + } + .opacity(0.6) } - .padding([.horizontal, .bottom]) - .foregroundColor(.secondary) } + .padding([.horizontal, .bottom]) + .foregroundColor(.secondary) } } .frame(width: 550, alignment: .leading) @@ -81,3 +98,13 @@ struct VideoCellView: View { .padding(.vertical) } } + +struct VideoCellView_Preview: PreviewProvider { + static var previews: some View { + HStack { + VideoCellView(video: Video.fixture) + VideoCellView(video: Video.fixtureUpcomingWithoutPublishedOrViews) + VideoCellView(video: Video.fixtureLiveWithoutPublishedOrViews) + } + } +} diff --git a/Apple TV/VideoListRowView.swift b/Apple TV/VideoListRowView.swift index ff7e70b2..130d8470 100644 --- a/Apple TV/VideoListRowView.swift +++ b/Apple TV/VideoListRowView.swift @@ -158,7 +158,7 @@ struct VideoListRowView: View { } func thumbnail( - _ quality: ThumbnailQuality, + _ quality: Thumbnail.Quality, minWidth: Double = 320, maxWidth: Double = .infinity, minHeight: Double = 180, diff --git a/Fixtures/Thumbnail+Fixtures.swift b/Fixtures/Thumbnail+Fixtures.swift new file mode 100644 index 00000000..24335e3a --- /dev/null +++ b/Fixtures/Thumbnail+Fixtures.swift @@ -0,0 +1,36 @@ +import Foundation + +extension Thumbnail { + static func fixture(videoId: String, quality: Thumbnail.Quality = .maxres) -> Thumbnail { + Thumbnail(url: fixtureUrl(videoId: videoId, quality: quality), quality: quality) + } + + static func fixturesForAllQualities(videoId: String) -> [Thumbnail] { + Thumbnail.Quality.allCases.map { fixture(videoId: videoId, quality: $0) } + } + + private static var fixturesHost: String { + "https://invidious.home.arekf.net" + } + + private static func fixtureUrl(videoId: String, quality: Thumbnail.Quality) -> URL { + URL(string: "\(fixturesHost)/vi/\(videoId)/\(filenameForQuality(quality)).jpg")! + } + + private static func filenameForQuality(_ quality: Thumbnail.Quality) -> String { + switch quality { + case .high: + return "hqdefault" + case .medium: + return "mqdefault" + case .start: + return "1" + case .middle: + return "2" + case .end: + return "3" + default: + return quality.rawValue + } + } +} diff --git a/Fixtures/Video+Fixtures.swift b/Fixtures/Video+Fixtures.swift new file mode 100644 index 00000000..2512d12f --- /dev/null +++ b/Fixtures/Video+Fixtures.swift @@ -0,0 +1,40 @@ +extension Video { + static var fixture: Video { + let id = "D2sxamzaHkM" + + return Video( + id: id, + title: "Relaxing Piano Music", + author: "Fancy Videotuber", + length: 582, + published: "7 years ago", + views: 1024, + channelID: "AbCdEFgHI", + description: "Some relaxing live piano music", + genre: "Music", + thumbnails: Thumbnail.fixturesForAllQualities(videoId: id), + live: false, + upcoming: false + ) + } + + static var fixtureLiveWithoutPublishedOrViews: Video { + var video = fixture + + video.title = "\(video.title) \(video.title) \(video.title) \(video.title) \(video.title)" + video.published = "0 seconds ago" + video.views = 0 + video.live = true + + return video + } + + static var fixtureUpcomingWithoutPublishedOrViews: Video { + var video = fixtureLiveWithoutPublishedOrViews + + video.live = false + video.upcoming = true + + return video + } +} diff --git a/Model/AudioVideoStream.swift b/Model/AudioVideoStream.swift deleted file mode 100644 index f89c3121..00000000 --- a/Model/AudioVideoStream.swift +++ /dev/null @@ -1,12 +0,0 @@ -import AVFoundation -import Foundation - -final class AudioVideoStream: Stream { - var avAsset: AVURLAsset - - init(avAsset: AVURLAsset, resolution: StreamResolution, type: StreamType, encoding: String) { - self.avAsset = avAsset - - super.init(audioAsset: avAsset, videoAsset: avAsset, resolution: resolution, type: type, encoding: encoding) - } -} diff --git a/Model/PlayerState.swift b/Model/PlayerState.swift index 80592623..417f3c5d 100644 --- a/Model/PlayerState.swift +++ b/Model/PlayerState.swift @@ -9,28 +9,139 @@ final class PlayerState: ObservableObject { let logger = Logger(label: "net.arekf.Pearvidious.ps") var video: Video! - private(set) var composition = AVMutableComposition() - private(set) var nextComposition = AVMutableComposition() - private(set) var currentStream: Stream! + var player: AVPlayer! - private(set) var nextStream: Stream! - private(set) var streamLoading = false + private var compositions = [Stream: AVMutableComposition]() private(set) var currentTime: CMTime? private(set) var savedTime: CMTime? - var currentSegment: Segment? - - private(set) var profile = Profile() - 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] + static let availableRates: [Double] = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] - var player: AVPlayer! + let maxResolution: Stream.Resolution? + var timeObserver: Any? - var playerItem: AVPlayerItem { - let playerItem = AVPlayerItem(asset: composition) + init(_ video: Video? = nil, maxResolution: Stream.Resolution? = nil) { + self.video = video + self.maxResolution = maxResolution + } + + deinit { + destroyPlayer() + } + + func loadVideo(_ video: Video?) { + guard video != nil else { + return + } + + InvidiousAPI.shared.video(video!.id).load().onSuccess { response in + if let video: Video = response.typedContent() { + self.video = video + + self.playVideo(video) + } + } + } + + fileprivate func playVideo(_ video: Video) { + if video.hlsUrl != nil { + playHlsUrl() + return + } + + let stream = maxResolution != nil ? video.streamWithResolution(maxResolution!) : video.defaultStream + + guard stream != nil else { + return + } + + Task { + await self.loadStream(stream!) + + if stream != video.bestStream { + await self.loadBestStream() + } + } + } + + fileprivate func playHlsUrl() { + player.replaceCurrentItem(with: playerItemWithMetadata()) + player.playImmediately(atRate: 1.0) + } + + fileprivate func loadStream(_ stream: Stream) async { + if stream.oneMeaningfullAsset { + DispatchQueue.main.async { + self.playStream(stream) + } + + return + } else { + await playComposition(for: stream) + } + } + + fileprivate func playStream(_ stream: Stream) { + logger.warning("loading \(stream.description) to player") + + DispatchQueue.main.async { + self.saveTime() + self.player?.replaceCurrentItem(with: self.playerItemWithMetadata(for: stream)) + self.player?.playImmediately(atRate: 1.0) + self.seekToSavedTime() + } + } + + fileprivate func playComposition(for stream: Stream) async { + async let assetAudioTrack = stream.audioAsset.loadTracks(withMediaType: .audio) + async let assetVideoTrack = stream.videoAsset.loadTracks(withMediaType: .video) + + if let audioTrack = composition(for: stream).addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid), + let assetTrack = try? await assetAudioTrack.first + { + try! audioTrack.insertTimeRange( + CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)), + of: assetTrack, + at: .zero + ) + logger.critical("audio loaded") + } else { + fatalError("no track") + } + + if let videoTrack = composition(for: stream).addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid), + let assetTrack = try? await assetVideoTrack.first + { + try! videoTrack.insertTimeRange( + CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)), + of: assetTrack, + at: .zero + ) + logger.critical("video loaded") + + playStream(stream) + } else { + fatalError("no track") + } + } + + fileprivate func playerItem(for stream: Stream? = nil) -> AVPlayerItem { + if stream != nil { + if stream!.oneMeaningfullAsset { + return AVPlayerItem(asset: stream!.videoAsset, automaticallyLoadedAssetKeys: [.isPlayable]) + } else { + return AVPlayerItem(asset: composition(for: stream!)) + } + } + + return AVPlayerItem(url: video.hlsUrl!) + } + + fileprivate func playerItemWithMetadata(for stream: Stream? = nil) -> AVPlayerItem { + let playerItemWithMetadata = playerItem(for: stream) var externalMetadata = [ makeMetadataItem(.commonIdentifierTitle, value: video.title), @@ -47,196 +158,34 @@ final class PlayerState: ObservableObject { externalMetadata.append(artworkItem) } - playerItem.externalMetadata = externalMetadata + playerItemWithMetadata.externalMetadata = externalMetadata #endif - playerItem.preferredForwardBufferDuration = 10 + playerItemWithMetadata.preferredForwardBufferDuration = 10 - return playerItem + return playerItemWithMetadata } - var segmentsProvider: SponsorBlockAPI? - var timeObserver: Any? - - init(_ video: Video? = nil) { - self.video = video - - if self.video != nil { - segmentsProvider = SponsorBlockAPI(self.video.id) - segmentsProvider!.load() - } + func setPlayerRate(_ rate: Float) { + currentRate = rate + player.rate = rate } - deinit { - destroyPlayer() - } - - func loadVideo(_ video: Video?) { - guard video != nil else { - return + fileprivate func composition(for stream: Stream) -> AVMutableComposition { + if compositions[stream] == nil { + compositions[stream] = AVMutableComposition() } - InvidiousAPI.shared.video(video!.id).load().onSuccess { response in - if let video: Video = response.typedContent() { - self.video = video - Task { - let loadBest = self.profile.defaultStreamResolution == .hd720pFirstThenBest - await self.loadStream(video.defaultStreamForProfile(self.profile)!, loadBest: loadBest) - } - } - } - } - - func loadStream(_ stream: Stream, loadBest: Bool = false) async { - nextStream?.cancelLoadingAssets() -// removeTracksFromNextComposition() - - nextComposition = AVMutableComposition() - - DispatchQueue.main.async { - self.streamLoading = true - self.nextStream = stream - } - logger.info("replace streamToLoad: \(nextStream?.description ?? "nil"), streamLoading \(streamLoading)") - - await addTracksAndLoadAssets(stream, loadBest: loadBest) - } - - fileprivate func addTracksAndLoadAssets(_ stream: Stream, loadBest: Bool = false) async { - logger.info("adding tracks and loading assets for: \(stream.type), \(stream.description)") - - stream.assets.forEach { asset in - Task.init { - if try await asset.load(.isPlayable) { - handleAssetLoad(stream, asset: asset, type: asset == stream.videoAsset ? .video : .audio, loadBest: loadBest) - - if stream.assetsLoaded { - logger.info("ALL assets loaded: \(stream.type), \(stream.description)") - - playStream(stream) - - if loadBest { - await self.loadBestStream() - } - } - } - } - } - } - - fileprivate func handleAssetLoad(_ stream: Stream, asset: AVURLAsset, type: AVMediaType, loadBest _: Bool = false) { - logger.info("handling asset load: \(stream.type), \(type) \(stream.description)") - - guard stream != currentStream else { - logger.warning("IGNORING assets loaded: \(stream.type), \(stream.description)") - return - } - - addTrack(asset, stream: stream, type: type) - } - - fileprivate func addTrack(_ asset: AVURLAsset, stream: Stream, type: AVMediaType? = nil) { - let types: [AVMediaType] = stream.type == .adaptive ? [type!] : [.video, .audio] - - types.forEach { addTrackToNextComposition(asset, type: $0) } + return compositions[stream]! } fileprivate func loadBestStream() async { - guard currentStream != video.bestStream else { - return - } - if let bestStream = video.bestStream { await loadStream(bestStream) } } - func streamDidLoad(_ stream: Stream?) { - logger.info("didload stream: \(stream!.description)") - - currentStream?.cancelLoadingAssets() - currentStream = stream - streamLoading = nextStream != stream - - if nextStream == stream { - nextStream = nil - } - -// addTimeObserver() - } - - func cancelLoadingStream(_ stream: Stream) { - guard nextStream == stream else { - return - } - - nextStream = nil - streamLoading = false - - logger.info("cancel streamToLoad: \(nextStream?.description ?? "nil"), streamLoading \(streamLoading)") - } - - func playStream(_ stream: Stream) { -// guard player != nil else { -// fatalError("player does not exists for playing") -// } - - logger.warning("loading \(stream.description) to player") - - saveTime() - replaceCompositionTracks() - - player!.replaceCurrentItem(with: playerItem) - streamDidLoad(stream) - - DispatchQueue.main.async { - self.player?.play() - self.seekToSavedTime() - } - } - - func addTrackToNextComposition(_ asset: AVURLAsset, type: AVMediaType) { - guard let assetTrack = asset.tracks(withMediaType: type).first else { - return - } - - if let track = nextComposition.tracks(withMediaType: type).first { - logger.info("removing \(type) track") - nextComposition.removeTrack(track) - } - - let track = nextComposition.addMutableTrack(withMediaType: type, preferredTrackID: kCMPersistentTrackID_Invalid)! - - try! track.insertTimeRange( - CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)), - of: assetTrack, - at: .zero - ) - - logger.info("inserted \(type) track") - } - - func replaceCompositionTracks() { - logger.warning("replacing compositions") - - composition = AVMutableComposition() - - nextComposition.tracks.forEach { track in - let newTrack = composition.addMutableTrack(withMediaType: track.mediaType, preferredTrackID: kCMPersistentTrackID_Invalid)! - - try? newTrack.insertTimeRange( - CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)), - of: track, - at: .zero - ) - } - } - - func removeTracksFromNextComposition() { - nextComposition.tracks.forEach { nextComposition.removeTrack($0) } - } - - func saveTime() { + fileprivate func saveTime() { guard player != nil else { return } @@ -250,7 +199,7 @@ final class PlayerState: ObservableObject { savedTime = currentTime } - func seekToSavedTime() { + fileprivate func seekToSavedTime() { guard player != nil else { return } @@ -261,51 +210,32 @@ final class PlayerState: ObservableObject { } } - func destroyPlayer() { + fileprivate func destroyPlayer() { logger.critical("destroying player") player?.currentItem?.tracks.forEach { $0.assetTrack?.asset?.cancelLoading() } - currentStream?.cancelLoadingAssets() - nextStream?.cancelLoadingAssets() - - player?.cancelPendingPrerolls() player?.replaceCurrentItem(with: nil) if timeObserver != nil { player?.removeTimeObserver(timeObserver!) timeObserver = nil } + player = nil - currentStream = nil - nextStream = nil } - func addTimeObserver() { + fileprivate func addTimeObserver() { let interval = CMTime(value: 1, timescale: 1) - timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in - guard self.player != nil else { - return - } - self.currentTime = time - - self.currentSegment = self.segmentsProvider?.segments.first { $0.timeInSegment(time) } - - if let segment = self.currentSegment { - if self.profile.skippedSegmentsCategories.contains(segment.category) { - if segment.shouldSkip(self.currentTime!) { - self.player.seek(to: segment.skipTo) - } - } - } + timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { _ in if self.player.rate != self.currentRate, self.player.rate != 0, self.currentRate != 0 { self.player.rate = self.currentRate } } } - private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem { + fileprivate func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem { let item = AVMutableMetadataItem() item.identifier = identifier @@ -314,9 +244,4 @@ final class PlayerState: ObservableObject { return item.copy() as! AVMetadataItem } - - func setPlayerRate(_ rate: Float) { - currentRate = rate - player.rate = rate - } } diff --git a/Model/Playlist.swift b/Model/Playlist.swift index c7bfbb3c..ddcb147d 100644 --- a/Model/Playlist.swift +++ b/Model/Playlist.swift @@ -2,9 +2,21 @@ import Foundation import SwiftyJSON struct Playlist: Identifiable, Equatable, Hashable { + enum Visibility: String, CaseIterable, Identifiable { + case `public`, unlisted, `private` + + var id: String { + rawValue + } + + var name: String { + rawValue.capitalized + } + } + let id: String var title: String - var visibility: PlaylistVisibility + var visibility: Visibility var updated: TimeInterval diff --git a/Model/PlaylistVisibility.swift b/Model/PlaylistVisibility.swift deleted file mode 100644 index ed152a1f..00000000 --- a/Model/PlaylistVisibility.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -enum PlaylistVisibility: String, CaseIterable, Identifiable { - case `public`, unlisted, `private` - - var id: String { - rawValue - } - - var name: String { - rawValue.capitalized - } -} diff --git a/Model/Profile.swift b/Model/Profile.swift index 1d51f45d..91ba8599 100644 --- a/Model/Profile.swift +++ b/Model/Profile.swift @@ -2,7 +2,7 @@ import Defaults import Foundation struct Profile { - var defaultStreamResolution: DefaultStreamResolution = .hd720pFirstThenBest + var defaultStreamResolution: DefaultStreamResolution = .hd1080p var skippedSegmentsCategories = [String]() // SponsorBlockSegmentsProvider.categories @@ -15,12 +15,12 @@ struct Profile { enum DefaultStreamResolution: String { case hd720pFirstThenBest, hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p - var value: StreamResolution { + var value: Stream.Resolution { switch self { case .hd720pFirstThenBest: return .hd720p default: - return StreamResolution(rawValue: rawValue)! + return Stream.Resolution(rawValue: rawValue)! } } } diff --git a/Model/SearchDate.swift b/Model/SearchDate.swift deleted file mode 100644 index 2bed49d5..00000000 --- a/Model/SearchDate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Defaults - -enum SearchDate: String, CaseIterable, Identifiable, DefaultsSerializable { - case hour, today, week, month, year - - var id: SearchDate.RawValue { - rawValue - } - - var name: String { - rawValue.capitalized - } -} diff --git a/Model/SearchDuration.swift b/Model/SearchDuration.swift deleted file mode 100644 index 97f8ee35..00000000 --- a/Model/SearchDuration.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Defaults - -enum SearchDuration: String, CaseIterable, Identifiable, DefaultsSerializable { - case short, long - - var id: SearchDuration.RawValue { - rawValue - } - - var name: String { - rawValue.capitalized - } -} diff --git a/Model/SearchQuery.swift b/Model/SearchQuery.swift index 3a4960cc..3b34b57b 100644 --- a/Model/SearchQuery.swift +++ b/Model/SearchQuery.swift @@ -1,14 +1,69 @@ +import Defaults import Foundation final class SearchQuery: ObservableObject { + enum Date: String, CaseIterable, Identifiable, DefaultsSerializable { + case hour, today, week, month, year + + var id: SearchQuery.Date.RawValue { + rawValue + } + + var name: String { + rawValue.capitalized + } + } + + enum Duration: String, CaseIterable, Identifiable, DefaultsSerializable { + case short, long + + var id: SearchQuery.Duration.RawValue { + rawValue + } + + var name: String { + rawValue.capitalized + } + } + + enum SortOrder: String, CaseIterable, Identifiable, DefaultsSerializable { + case relevance, rating, uploadDate, viewCount + + var id: SearchQuery.SortOrder.RawValue { + rawValue + } + + var name: String { + switch self { + case .uploadDate: + return "Upload Date" + case .viewCount: + return "View Count" + default: + return rawValue.capitalized + } + } + + var parameter: String { + switch self { + case .uploadDate: + return "upload_date" + case .viewCount: + return "view_count" + default: + return rawValue + } + } + } + @Published var query: String - @Published var sortBy: SearchSortOrder = .relevance - @Published var date: SearchDate? = .month - @Published var duration: SearchDuration? + @Published var sortBy: SearchQuery.SortOrder = .relevance + @Published var date: SearchQuery.Date? = .month + @Published var duration: SearchQuery.Duration? @Published var page = 1 - init(query: String = "", page: Int = 1, sortBy: SearchSortOrder = .relevance, date: SearchDate? = nil, duration: SearchDuration? = nil) { + init(query: String = "", page: Int = 1, sortBy: SearchQuery.SortOrder = .relevance, date: SearchQuery.Date? = nil, duration: SearchQuery.Duration? = nil) { self.query = query self.page = page self.sortBy = sortBy diff --git a/Model/SearchSortOrder.swift b/Model/SearchSortOrder.swift deleted file mode 100644 index 651eb78c..00000000 --- a/Model/SearchSortOrder.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Defaults -import Foundation - -enum SearchSortOrder: String, CaseIterable, Identifiable, DefaultsSerializable { - case relevance, rating, uploadDate, viewCount - - var id: SearchSortOrder.RawValue { - rawValue - } - - var name: String { - switch self { - case .uploadDate: - return "Upload Date" - case .viewCount: - return "View Count" - default: - return rawValue.capitalized - } - } - - var parameter: String { - switch self { - case .uploadDate: - return "upload_date" - case .viewCount: - return "view_count" - default: - return rawValue - } - } -} diff --git a/Model/SingleAssetStream.swift b/Model/SingleAssetStream.swift new file mode 100644 index 00000000..cb7b938c --- /dev/null +++ b/Model/SingleAssetStream.swift @@ -0,0 +1,12 @@ +import AVFoundation +import Foundation + +final class SingleAssetStream: Stream { + var avAsset: AVURLAsset + + init(avAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String) { + self.avAsset = avAsset + + super.init(audioAsset: avAsset, videoAsset: avAsset, resolution: resolution, kind: kind, encoding: encoding) + } +} diff --git a/Model/Stream.swift b/Model/Stream.swift index 73af9c8b..da152622 100644 --- a/Model/Stream.swift +++ b/Model/Stream.swift @@ -2,20 +2,53 @@ import AVFoundation import Foundation // swiftlint:disable:next final_class -class Stream: Equatable { +class Stream: Equatable, Hashable { + enum Resolution: String, CaseIterable, Comparable { + case hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p + + var height: Int { + Int(rawValue.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())! + } + + static func from(resolution: String) -> Resolution? { + allCases.first { "\($0)".contains(resolution) } + } + + static func < (lhs: Resolution, rhs: Resolution) -> Bool { + lhs.height < rhs.height + } + } + + enum Kind: String, Comparable { + case stream, adaptive + + private var sortOrder: Int { + switch self { + case .stream: + return 0 + case .adaptive: + return 1 + } + } + + static func < (lhs: Kind, rhs: Kind) -> Bool { + lhs.sortOrder < rhs.sortOrder + } + } + var audioAsset: AVURLAsset var videoAsset: AVURLAsset - var resolution: StreamResolution - var type: StreamType + var resolution: Resolution + var kind: Kind var encoding: String - init(audioAsset: AVURLAsset, videoAsset: AVURLAsset, resolution: StreamResolution, type: StreamType, encoding: String) { + init(audioAsset: AVURLAsset, videoAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String) { self.audioAsset = audioAsset self.videoAsset = videoAsset self.resolution = resolution - self.type = type + self.kind = kind self.encoding = encoding } @@ -27,6 +60,10 @@ class Stream: Equatable { [audioAsset, videoAsset] } + var oneMeaningfullAsset: Bool { + assets.dropFirst().allSatisfy { $0 == assets.first } + } + var assetsLoaded: Bool { assets.allSatisfy { $0.statusOfValue(forKey: "playable", error: nil) == .loaded } } @@ -40,6 +77,10 @@ class Stream: Equatable { } static func == (lhs: Stream, rhs: Stream) -> Bool { - lhs.resolution == rhs.resolution && lhs.type == rhs.type + lhs.resolution == rhs.resolution && lhs.kind == rhs.kind + } + + func hash(into hasher: inout Hasher) { + hasher.combine(videoAsset.url) } } diff --git a/Model/StreamResolution.swift b/Model/StreamResolution.swift deleted file mode 100644 index 9656e57c..00000000 --- a/Model/StreamResolution.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -enum StreamResolution: String, CaseIterable, Comparable { - case hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p - - var height: Int { - Int(rawValue.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())! - } - - static func from(resolution: String) -> StreamResolution? { - allCases.first { "\($0)".contains(resolution) } - } - - static func < (lhs: StreamResolution, rhs: StreamResolution) -> Bool { - lhs.height < rhs.height - } -} diff --git a/Model/StreamType.swift b/Model/StreamType.swift deleted file mode 100644 index f6edfb77..00000000 --- a/Model/StreamType.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -enum StreamType: String, Comparable { - case stream, adaptive - - private var sortOrder: Int { - switch self { - case .stream: - return 0 - case .adaptive: - return 1 - } - } - - static func < (lhs: StreamType, rhs: StreamType) -> Bool { - lhs.sortOrder < rhs.sortOrder - } -} diff --git a/Model/Thumbnail.swift b/Model/Thumbnail.swift index 7340ad2b..46a06358 100644 --- a/Model/Thumbnail.swift +++ b/Model/Thumbnail.swift @@ -2,11 +2,20 @@ import Foundation import SwiftyJSON struct Thumbnail { + enum Quality: String, CaseIterable { + case maxres, maxresdefault, sddefault, high, medium, `default`, start, middle, end + } + var url: URL - var quality: ThumbnailQuality + var quality: Quality init(_ json: JSON) { url = json["url"].url! - quality = ThumbnailQuality(rawValue: json["quality"].string!)! + quality = Quality(rawValue: json["quality"].string!)! + } + + init(url: URL, quality: Quality) { + self.url = url + self.quality = quality } } diff --git a/Model/ThumbnailQuality.swift b/Model/ThumbnailQuality.swift deleted file mode 100644 index 5f63cfd9..00000000 --- a/Model/ThumbnailQuality.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -enum ThumbnailQuality: String { - case maxres, maxresdefault, sddefault, high, medium, `default`, start, middle, end -} diff --git a/Model/Video.swift b/Model/Video.swift index fd108022..c5b8d5c1 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -15,9 +15,44 @@ struct Video: Identifiable { var description: String var genre: String + // index used when in the Playlist let indexID: String? + var live: Bool + var upcoming: Bool + var streams = [Stream]() + var hlsUrl: URL? + + init( + id: String, + title: String, + author: String, + length: TimeInterval, + published: String, + views: Int, + channelID: String, + description: String, + genre: String, + thumbnails: [Thumbnail] = [], + indexID: String? = nil, + live: Bool = false, + upcoming: Bool = false + ) { + self.id = id + self.title = title + self.author = author + self.length = length + self.published = published + self.views = views + self.channelID = channelID + self.description = description + self.genre = genre + self.thumbnails = thumbnails + self.indexID = indexID + self.live = live + self.upcoming = upcoming + } init(_ json: JSON) { let videoID = json["videoId"].stringValue @@ -41,8 +76,13 @@ struct Video: Identifiable { thumbnails = Video.extractThumbnails(from: json) + live = json["liveNow"].boolValue + upcoming = json["isUpcoming"].boolValue + streams = Video.extractFormatStreams(from: json["formatStreams"].arrayValue) streams.append(contentsOf: Video.extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)) + + hlsUrl = json["hlsUrl"].url } var playTime: String? { @@ -59,6 +99,10 @@ struct Video: Identifiable { return formatter.string(from: length) } + var publishedDate: String? { + (published.isEmpty || published == "0 seconds ago") ? nil : published + } + var viewsCount: String { let formatter = NumberFormatter() formatter.numberStyle = .decimal @@ -82,8 +126,8 @@ struct Video: Identifiable { let streams = streams.sorted { $0.resolution > $1.resolution } var selectable = [Stream]() - StreamResolution.allCases.forEach { resolution in - if let stream = streams.filter({ $0.resolution == resolution }).min(by: { $0.type < $1.type }) { + Stream.Resolution.allCases.forEach { resolution in + if let stream = streams.filter({ $0.resolution == resolution }).min(by: { $0.kind < $1.kind }) { selectable.append(stream) } } @@ -92,14 +136,14 @@ struct Video: Identifiable { } var defaultStream: Stream? { - selectableStreams.first { $0.type == .stream } + selectableStreams.first { $0.kind == .stream } } var bestStream: Stream? { selectableStreams.min { $0.resolution > $1.resolution } } - func streamWithResolution(_ resolution: StreamResolution) -> Stream? { + func streamWithResolution(_ resolution: Stream.Resolution) -> Stream? { selectableStreams.first { $0.resolution == resolution } } @@ -107,7 +151,7 @@ struct Video: Identifiable { streamWithResolution(profile.defaultStreamResolution.value) ?? streams.first } - func thumbnailURL(quality: ThumbnailQuality) -> URL? { + func thumbnailURL(quality: Thumbnail.Quality) -> URL? { thumbnails.first { $0.quality == quality }?.url } @@ -117,12 +161,14 @@ struct Video: Identifiable { } } + static let options = [AVURLAssetPreferPreciseDurationAndTimingKey: false] + private static func extractFormatStreams(from streams: [JSON]) -> [Stream] { streams.map { - AudioVideoStream( - avAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset($0["url"].stringValue)!), - resolution: StreamResolution.from(resolution: $0["resolution"].stringValue)!, - type: .stream, + SingleAssetStream( + avAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset($0["url"].stringValue)!, options: options), + resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue)!, + kind: .stream, encoding: $0["encoding"].stringValue ) } @@ -138,10 +184,10 @@ struct Video: Identifiable { return videoAssetsURLs.map { Stream( - audioAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset(audioAssetURL!["url"].stringValue)!), - videoAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset($0["url"].stringValue)!), - resolution: StreamResolution.from(resolution: $0["resolution"].stringValue)!, - type: .adaptive, + audioAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset(audioAssetURL!["url"].stringValue)!, options: options), + videoAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset($0["url"].stringValue)!, options: options), + resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue)!, + kind: .adaptive, encoding: $0["encoding"].stringValue ) } diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index 115d9776..2bb0ca70 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -27,7 +27,6 @@ 372915E62687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; }; 372915E72687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; }; 372915E82687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; }; - 372915EC2687EBA500F5A35B /* ListingLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E92687EBA500F5A35B /* ListingLayout.swift */; }; 372F954A26A4D27000502766 /* VideoLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372F954926A4D0C900502766 /* VideoLoading.swift */; }; 373CFABE26966148003CB2C6 /* CoverSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFABD26966115003CB2C6 /* CoverSectionView.swift */; }; 373CFABF26966149003CB2C6 /* CoverSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFABD26966115003CB2C6 /* CoverSectionView.swift */; }; @@ -41,28 +40,24 @@ 373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFACA26966264003CB2C6 /* SearchQuery.swift */; }; 373CFACC26966264003CB2C6 /* SearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFACA26966264003CB2C6 /* SearchQuery.swift */; }; 373CFACD26966264003CB2C6 /* SearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFACA26966264003CB2C6 /* SearchQuery.swift */; }; - 373CFACF26966290003CB2C6 /* SearchSortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFACE26966290003CB2C6 /* SearchSortOrder.swift */; }; - 373CFAD026966290003CB2C6 /* SearchSortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFACE26966290003CB2C6 /* SearchSortOrder.swift */; }; - 373CFAD126966290003CB2C6 /* SearchSortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFACE26966290003CB2C6 /* SearchSortOrder.swift */; }; - 373CFAD3269662AB003CB2C6 /* SearchDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAD2269662AB003CB2C6 /* SearchDate.swift */; }; - 373CFAD4269662AB003CB2C6 /* SearchDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAD2269662AB003CB2C6 /* SearchDate.swift */; }; - 373CFAD5269662AB003CB2C6 /* SearchDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAD2269662AB003CB2C6 /* SearchDate.swift */; }; - 373CFAD7269662CD003CB2C6 /* SearchDuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAD6269662CD003CB2C6 /* SearchDuration.swift */; }; - 373CFAD8269662CD003CB2C6 /* SearchDuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAD6269662CD003CB2C6 /* SearchDuration.swift */; }; - 373CFAD9269662CD003CB2C6 /* SearchDuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAD6269662CD003CB2C6 /* SearchDuration.swift */; }; 373CFADB269663F1003CB2C6 /* Thumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFADA269663F1003CB2C6 /* Thumbnail.swift */; }; 373CFADC269663F1003CB2C6 /* Thumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFADA269663F1003CB2C6 /* Thumbnail.swift */; }; 373CFADD269663F1003CB2C6 /* Thumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFADA269663F1003CB2C6 /* Thumbnail.swift */; }; - 373CFAE326974812003CB2C6 /* PlaylistVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAE226974812003CB2C6 /* PlaylistVisibility.swift */; }; - 373CFAE426974812003CB2C6 /* PlaylistVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAE226974812003CB2C6 /* PlaylistVisibility.swift */; }; - 373CFAE526974812003CB2C6 /* PlaylistVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAE226974812003CB2C6 /* PlaylistVisibility.swift */; }; 373CFAEB26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */; }; 373CFAEC26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */; }; 373CFAED26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */; }; 373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; }; 373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; }; 373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; }; - 3755A0C2269B772000F67988 /* StreamAVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3755A0C1269B772000F67988 /* StreamAVPlayerViewController.swift */; }; + 3748186626A7627F0084E870 /* Video+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186526A7627F0084E870 /* Video+Fixtures.swift */; }; + 3748186726A7627F0084E870 /* Video+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186526A7627F0084E870 /* Video+Fixtures.swift */; }; + 3748186826A7627F0084E870 /* Video+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186526A7627F0084E870 /* Video+Fixtures.swift */; }; + 3748186A26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186926A764FB0084E870 /* Thumbnail+Fixtures.swift */; }; + 3748186B26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186926A764FB0084E870 /* Thumbnail+Fixtures.swift */; }; + 3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186926A764FB0084E870 /* Thumbnail+Fixtures.swift */; }; + 3748186E26A769D60084E870 /* DetailBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186D26A769D60084E870 /* DetailBadge.swift */; }; + 3748186F26A769D60084E870 /* DetailBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186D26A769D60084E870 /* DetailBadge.swift */; }; + 3748187026A769D60084E870 /* DetailBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186D26A769D60084E870 /* DetailBadge.swift */; }; 376578852685429C00D4EA09 /* CaseIterable+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578842685429C00D4EA09 /* CaseIterable+Next.swift */; }; 376578862685429C00D4EA09 /* CaseIterable+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578842685429C00D4EA09 /* CaseIterable+Next.swift */; }; 376578872685429C00D4EA09 /* CaseIterable+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578842685429C00D4EA09 /* CaseIterable+Next.swift */; }; @@ -158,15 +153,9 @@ 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 */; }; - 37CEE4B52677B628005A1EFE /* StreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4B42677B628005A1EFE /* StreamType.swift */; }; - 37CEE4B62677B628005A1EFE /* StreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4B42677B628005A1EFE /* StreamType.swift */; }; - 37CEE4B72677B628005A1EFE /* StreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4B42677B628005A1EFE /* StreamType.swift */; }; - 37CEE4B92677B63F005A1EFE /* StreamResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4B82677B63F005A1EFE /* StreamResolution.swift */; }; - 37CEE4BA2677B63F005A1EFE /* StreamResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4B82677B63F005A1EFE /* StreamResolution.swift */; }; - 37CEE4BB2677B63F005A1EFE /* StreamResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4B82677B63F005A1EFE /* StreamResolution.swift */; }; - 37CEE4BD2677B670005A1EFE /* AudioVideoStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* AudioVideoStream.swift */; }; - 37CEE4BE2677B670005A1EFE /* AudioVideoStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* AudioVideoStream.swift */; }; - 37CEE4BF2677B670005A1EFE /* AudioVideoStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* AudioVideoStream.swift */; }; + 37CEE4BD2677B670005A1EFE /* SingleAssetStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */; }; + 37CEE4BE2677B670005A1EFE /* SingleAssetStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */; }; + 37CEE4BF2677B670005A1EFE /* SingleAssetStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */; }; 37CEE4C12677B697005A1EFE /* Stream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4C02677B697005A1EFE /* Stream.swift */; }; 37CEE4C22677B697005A1EFE /* Stream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4C02677B697005A1EFE /* Stream.swift */; }; 37CEE4C32677B697005A1EFE /* Stream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4C02677B697005A1EFE /* Stream.swift */; }; @@ -188,9 +177,6 @@ 37D4B19D2671817900C925CA /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 37D4B19C2671817900C925CA /* SwiftyJSON */; }; 37D4B1AB2672580400C925CA /* URLImage in Frameworks */ = {isa = PBXBuildFile; productRef = 37D4B1AA2672580400C925CA /* URLImage */; }; 37D4B1AD2672580400C925CA /* URLImageStore in Frameworks */ = {isa = PBXBuildFile; productRef = 37D4B1AC2672580400C925CA /* URLImageStore */; }; - 37D80701269C74F8002ECBBA /* ThumbnailQuality.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D80700269C74F8002ECBBA /* ThumbnailQuality.swift */; }; - 37D80702269C74F8002ECBBA /* ThumbnailQuality.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D80700269C74F8002ECBBA /* ThumbnailQuality.swift */; }; - 37D80703269C74F8002ECBBA /* ThumbnailQuality.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D80700269C74F8002ECBBA /* ThumbnailQuality.swift */; }; 37EAD86B267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */; }; 37EAD86C267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */; }; 37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */; }; @@ -237,20 +223,17 @@ 37141672267A8E10006CA35D /* Country.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Country.swift; sourceTree = ""; }; 371F2F19269B43D300E4A7AB /* NavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationState.swift; sourceTree = ""; }; 372915E52687E3B900F5A35B /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = ""; }; - 372915E92687EBA500F5A35B /* ListingLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListingLayout.swift; sourceTree = ""; }; 372F954926A4D0C900502766 /* VideoLoading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoLoading.swift; sourceTree = ""; }; 373CFABD26966115003CB2C6 /* CoverSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverSectionView.swift; sourceTree = ""; }; 373CFAC126966159003CB2C6 /* CoverSectionRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverSectionRowView.swift; sourceTree = ""; }; 373CFAC52696617C003CB2C6 /* SearchOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOptionsView.swift; sourceTree = ""; }; 373CFACA26966264003CB2C6 /* SearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQuery.swift; sourceTree = ""; }; - 373CFACE26966290003CB2C6 /* SearchSortOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSortOrder.swift; sourceTree = ""; }; - 373CFAD2269662AB003CB2C6 /* SearchDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDate.swift; sourceTree = ""; }; - 373CFAD6269662CD003CB2C6 /* SearchDuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDuration.swift; sourceTree = ""; }; 373CFADA269663F1003CB2C6 /* Thumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnail.swift; sourceTree = ""; }; - 373CFAE226974812003CB2C6 /* PlaylistVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistVisibility.swift; sourceTree = ""; }; 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistFormView.swift; sourceTree = ""; }; 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToPlaylistView.swift; sourceTree = ""; }; - 3755A0C1269B772000F67988 /* StreamAVPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StreamAVPlayerViewController.swift; sourceTree = ""; }; + 3748186526A7627F0084E870 /* Video+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Video+Fixtures.swift"; sourceTree = ""; }; + 3748186926A764FB0084E870 /* Thumbnail+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Thumbnail+Fixtures.swift"; sourceTree = ""; }; + 3748186D26A769D60084E870 /* DetailBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailBadge.swift; sourceTree = ""; }; 376578842685429C00D4EA09 /* CaseIterable+Next.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaseIterable+Next.swift"; sourceTree = ""; }; 376578882685471400D4EA09 /* Playlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playlist.swift; sourceTree = ""; }; 376578902685490700D4EA09 /* PlaylistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsView.swift; sourceTree = ""; }; @@ -280,9 +263,7 @@ 37BE0BDB26A2367F0092E2DB /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.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 = ""; }; - 37CEE4B42677B628005A1EFE /* StreamType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamType.swift; sourceTree = ""; }; - 37CEE4B82677B63F005A1EFE /* StreamResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolution.swift; sourceTree = ""; }; - 37CEE4BC2677B670005A1EFE /* AudioVideoStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioVideoStream.swift; sourceTree = ""; }; + 37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleAssetStream.swift; sourceTree = ""; }; 37CEE4C02677B697005A1EFE /* Stream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stream.swift; sourceTree = ""; }; 37D4B0C22671614700C925CA /* PearvidiousApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PearvidiousApp.swift; sourceTree = ""; }; 37D4B0C32671614700C925CA /* AppTabNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTabNavigation.swift; sourceTree = ""; }; @@ -300,7 +281,6 @@ 37D4B18B26717B3800C925CA /* VideoListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoListRowView.swift; sourceTree = ""; }; 37D4B19626717E1500C925CA /* Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Video.swift; sourceTree = ""; }; 37D4B1AE26729DEB00C925CA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 37D80700269C74F8002ECBBA /* ThumbnailQuality.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailQuality.swift; sourceTree = ""; }; 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockAPI.swift; sourceTree = ""; }; 37EAD86E267B9ED100D9E01B /* Segment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Segment.swift; sourceTree = ""; }; 37F4AE7126828F0900BD60EA /* VideosCellsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosCellsView.swift; sourceTree = ""; }; @@ -375,6 +355,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 3748186426A762300084E870 /* Fixtures */ = { + isa = PBXGroup; + children = ( + 3748186926A764FB0084E870 /* Thumbnail+Fixtures.swift */, + 3748186526A7627F0084E870 /* Video+Fixtures.swift */, + ); + path = Fixtures; + sourceTree = ""; + }; 377FC7D1267A080300A6BBAF /* Frameworks */ = { isa = PBXGroup; children = ( @@ -409,6 +398,7 @@ 37D4B1B72672CFE300C925CA /* Model */, 37D4B159267164AE00C925CA /* Apple TV */, 37C7A9022679058300E721B4 /* Extensions */, + 3748186426A762300084E870 /* Fixtures */, 377FC7D1267A080300A6BBAF /* Frameworks */, 37D4B0CA2671614900C925CA /* Products */, 37D4B174267164B000C925CA /* Tests Apple TV */, @@ -425,7 +415,7 @@ 37BD07B42698AA4D003EBB87 /* ContentView.swift */, 37141672267A8E10006CA35D /* Country.swift */, 372915E52687E3B900F5A35B /* Defaults.swift */, - 372915E92687EBA500F5A35B /* ListingLayout.swift */, + 3748186D26A769D60084E870 /* DetailBadge.swift */, 37D4B0C22671614700C925CA /* PearvidiousApp.swift */, 37BE0BD226A1D4780092E2DB /* Player.swift */, 37BE0BD526A1D4A90092E2DB /* PlayerViewController.swift */, @@ -479,7 +469,6 @@ 37AAF27D26737323007FC770 /* PopularVideosView.swift */, 373CFAC52696617C003CB2C6 /* SearchOptionsView.swift */, 37AAF27F26737550007FC770 /* SearchView.swift */, - 3755A0C1269B772000F67988 /* StreamAVPlayerViewController.swift */, 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */, 3705B17F267B4DFB00704544 /* TrendingCountrySelectionView.swift */, 3714166E267A8ACC006CA35D /* TrendingView.swift */, @@ -509,27 +498,20 @@ 37D4B1B72672CFE300C925CA /* Model */ = { isa = PBXGroup; children = ( - 37CEE4BC2677B670005A1EFE /* AudioVideoStream.swift */, 37AAF28F26740715007FC770 /* Channel.swift */, 37977582268922F600DD52A8 /* InvidiousAPI.swift */, 371F2F19269B43D300E4A7AB /* NavigationState.swift */, 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */, 376578882685471400D4EA09 /* Playlist.swift */, - 373CFAE226974812003CB2C6 /* PlaylistVisibility.swift */, 37C7A1DB267CE9D90010EAD6 /* Profile.swift */, - 373CFAD2269662AB003CB2C6 /* SearchDate.swift */, - 373CFAD6269662CD003CB2C6 /* SearchDuration.swift */, 373CFACA26966264003CB2C6 /* SearchQuery.swift */, - 373CFACE26966290003CB2C6 /* SearchSortOrder.swift */, 37EAD86E267B9ED100D9E01B /* Segment.swift */, + 37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */, 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */, 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */, 3797758A2689345500DD52A8 /* Store.swift */, 37CEE4C02677B697005A1EFE /* Stream.swift */, - 37CEE4B82677B63F005A1EFE /* StreamResolution.swift */, - 37CEE4B42677B628005A1EFE /* StreamType.swift */, 373CFADA269663F1003CB2C6 /* Thumbnail.swift */, - 37D80700269C74F8002ECBBA /* ThumbnailQuality.swift */, 3705B181267B4E4900704544 /* TrendingCategory.swift */, 37D4B19626717E1500C925CA /* Video.swift */, ); @@ -791,7 +773,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 37CEE4BD2677B670005A1EFE /* AudioVideoStream.swift in Sources */, + 37CEE4BD2677B670005A1EFE /* SingleAssetStream.swift in Sources */, 37BD07C82698B71C003EBB87 /* AppTabNavigation.swift in Sources */, 37EAD86B267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, 37BD07B52698AA4D003EBB87 /* ContentView.swift in Sources */, @@ -802,8 +784,8 @@ 37BE0BD326A1D4780092E2DB /* Player.swift in Sources */, 37CEE4C12677B697005A1EFE /* Stream.swift in Sources */, 37F4AE7226828F0900BD60EA /* VideosCellsView.swift in Sources */, - 373CFAE326974812003CB2C6 /* PlaylistVisibility.swift in Sources */, 376578852685429C00D4EA09 /* CaseIterable+Next.swift in Sources */, + 3748186626A7627F0084E870 /* Video+Fixtures.swift in Sources */, 377FC7DC267A081800A6BBAF /* PopularVideosView.swift in Sources */, 373CFAC62696617C003CB2C6 /* SearchOptionsView.swift in Sources */, 371231842683E62F0000B307 /* VideosView.swift in Sources */, @@ -811,30 +793,27 @@ 37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */, 376578892685471400D4EA09 /* Playlist.swift in Sources */, 373CFADB269663F1003CB2C6 /* Thumbnail.swift in Sources */, - 37CEE4B52677B628005A1EFE /* StreamType.swift in Sources */, 37C7A1DC267CE9D90010EAD6 /* Profile.swift in Sources */, 373CFAC026966149003CB2C6 /* CoverSectionView.swift in Sources */, 3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */, 373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, 377FC7E3267A084A00A6BBAF /* VideoListRowView.swift in Sources */, 37AAF29026740715007FC770 /* Channel.swift in Sources */, + 3748186A26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, 37AAF2942674086B007FC770 /* TabSelection.swift in Sources */, 377FC7E5267A084E00A6BBAF /* SearchView.swift in Sources */, 376578912685490700D4EA09 /* PlaylistsView.swift in Sources */, 377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 37B17DA2268A1F8A006AEE9B /* VideoContextMenuView.swift in Sources */, 379775932689365600DD52A8 /* Array+Next.swift in Sources */, - 373CFAD3269662AB003CB2C6 /* SearchDate.swift in Sources */, 377FC7E1267A082600A6BBAF /* ChannelView.swift in Sources */, 37C7A1D5267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 37B767DB2677C3CA0098BAA8 /* PlayerState.swift in Sources */, 373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */, 373CFAC226966159003CB2C6 /* CoverSectionRowView.swift in Sources */, 37141673267A8E10006CA35D /* Country.swift in Sources */, + 3748186E26A769D60084E870 /* DetailBadge.swift in Sources */, 37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */, - 37D80701269C74F8002ECBBA /* ThumbnailQuality.swift in Sources */, - 373CFAD7269662CD003CB2C6 /* SearchDuration.swift in Sources */, - 373CFACF26966290003CB2C6 /* SearchSortOrder.swift in Sources */, 373CFAEB26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */, 377FC7DF267A082200A6BBAF /* VideosListView.swift in Sources */, 372915E62687E3B900F5A35B /* Defaults.swift in Sources */, @@ -843,7 +822,6 @@ 37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, 37BD07BB2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */, 37D4B0E42671614900C925CA /* PearvidiousApp.swift in Sources */, - 37CEE4B92677B63F005A1EFE /* StreamResolution.swift in Sources */, 3797758B2689345500DD52A8 /* Store.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -853,8 +831,7 @@ buildActionMask = 2147483647; files = ( 37BE0BDC26A2367F0092E2DB /* Player.swift in Sources */, - 37CEE4BE2677B670005A1EFE /* AudioVideoStream.swift in Sources */, - 37D80702269C74F8002ECBBA /* ThumbnailQuality.swift in Sources */, + 37CEE4BE2677B670005A1EFE /* SingleAssetStream.swift in Sources */, 37F4AE772682908700BD60EA /* VideoCellView.swift in Sources */, 373CFABF26966149003CB2C6 /* CoverSectionView.swift in Sources */, 37EAD86C267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, @@ -864,7 +841,6 @@ 3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */, 37BD07C32698AD4F003EBB87 /* ContentView.swift in Sources */, 37EAD870267B9ED100D9E01B /* Segment.swift in Sources */, - 37CEE4B62677B628005A1EFE /* StreamType.swift in Sources */, 37141670267A8ACC006CA35D /* TrendingView.swift in Sources */, 377FC7E2267A084A00A6BBAF /* VideoListRowView.swift in Sources */, 3765788A2685471400D4EA09 /* Playlist.swift in Sources */, @@ -874,19 +850,19 @@ 373CFAC726966187003CB2C6 /* SearchOptionsView.swift in Sources */, 37BD07BC2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */, 37AAF2952674086B007FC770 /* TabSelection.swift in Sources */, + 3748186F26A769D60084E870 /* DetailBadge.swift in Sources */, 372915E72687E3B900F5A35B /* Defaults.swift in Sources */, 376578922685490700D4EA09 /* PlaylistsView.swift in Sources */, 377FC7E4267A084E00A6BBAF /* SearchView.swift in Sources */, 377A20AA2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, - 373CFAD8269662CD003CB2C6 /* SearchDuration.swift in Sources */, 376578862685429C00D4EA09 /* CaseIterable+Next.swift in Sources */, 37F4AE7326828F0900BD60EA /* VideosCellsView.swift in Sources */, 377FC7E0267A082600A6BBAF /* ChannelView.swift in Sources */, 379775942689365600DD52A8 /* Array+Next.swift in Sources */, + 3748186726A7627F0084E870 /* Video+Fixtures.swift in Sources */, 37BE0BDA26A214630092E2DB /* PlayerViewController.swift in Sources */, 371231862683E7820000B307 /* VideosView.swift in Sources */, 37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, - 373CFAD026966290003CB2C6 /* SearchSortOrder.swift in Sources */, 37C7A1DD267CE9D90010EAD6 /* Profile.swift in Sources */, 37B767DC2677C3CA0098BAA8 /* PlayerState.swift in Sources */, 3797758C2689345500DD52A8 /* Store.swift in Sources */, @@ -897,12 +873,10 @@ 37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */, 37BD07C12698AD3B003EBB87 /* TrendingCountrySelectionView.swift in Sources */, 37BE0BD026A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, - 373CFAE426974812003CB2C6 /* PlaylistVisibility.swift in Sources */, - 37CEE4BA2677B63F005A1EFE /* StreamResolution.swift in Sources */, 373CFAEC26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */, 37977584268922F600DD52A8 /* InvidiousAPI.swift in Sources */, 37B17DA1268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */, - 373CFAD4269662AB003CB2C6 /* SearchDate.swift in Sources */, + 3748186B26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, 373CFADC269663F1003CB2C6 /* Thumbnail.swift in Sources */, 373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, ); @@ -930,16 +904,15 @@ files = ( 37AAF28026737550007FC770 /* SearchView.swift in Sources */, 37EAD871267B9ED100D9E01B /* Segment.swift in Sources */, - 37CEE4BF2677B670005A1EFE /* AudioVideoStream.swift in Sources */, - 37CEE4B72677B628005A1EFE /* StreamType.swift in Sources */, + 37CEE4BF2677B670005A1EFE /* SingleAssetStream.swift in Sources */, 37F4AE782682908700BD60EA /* VideoCellView.swift in Sources */, 37BE0BD426A1D47D0092E2DB /* Player.swift in Sources */, 37977585268922F600DD52A8 /* InvidiousAPI.swift in Sources */, 37F4AE7426828F0900BD60EA /* VideosCellsView.swift in Sources */, 376578872685429C00D4EA09 /* CaseIterable+Next.swift in Sources */, - 373CFAE526974812003CB2C6 /* PlaylistVisibility.swift in Sources */, 37D4B1802671650A00C925CA /* PearvidiousApp.swift in Sources */, 371231852683E7820000B307 /* VideosView.swift in Sources */, + 3748187026A769D60084E870 /* DetailBadge.swift in Sources */, 37BD07CA2698FBE5003EBB87 /* AppSidebarNavigation.swift in Sources */, 373CFAC926966188003CB2C6 /* SearchOptionsView.swift in Sources */, 37BD07C92698FBDB003EBB87 /* ContentView.swift in Sources */, @@ -949,7 +922,6 @@ 37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, 3765788B2685471400D4EA09 /* Playlist.swift in Sources */, 373CFADD269663F1003CB2C6 /* Thumbnail.swift in Sources */, - 3755A0C2269B772000F67988 /* StreamAVPlayerViewController.swift in Sources */, 37C7A1DE267CE9D90010EAD6 /* Profile.swift in Sources */, 373CFABE26966148003CB2C6 /* CoverSectionView.swift in Sources */, 37B767DD2677C3CA0098BAA8 /* PlayerState.swift in Sources */, @@ -958,35 +930,31 @@ 37BE0BD126A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, 37AAF27E26737323007FC770 /* PopularVideosView.swift in Sources */, 37AAF29A26740A01007FC770 /* VideosListView.swift in Sources */, - 37D80703269C74F8002ECBBA /* ThumbnailQuality.swift in Sources */, 37AAF2962674086B007FC770 /* TabSelection.swift in Sources */, 37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 376578932685490700D4EA09 /* PlaylistsView.swift in Sources */, + 3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, 377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, + 3748186826A7627F0084E870 /* Video+Fixtures.swift in Sources */, 371F2F1C269B43D300E4A7AB /* NavigationState.swift in Sources */, 37B17DA0268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */, 37BE0BD726A1D4A90092E2DB /* PlayerViewController.swift in Sources */, 372F954A26A4D27000502766 /* VideoLoading.swift in Sources */, 37CEE4C32677B697005A1EFE /* Stream.swift in Sources */, 37BE0BE526A336910092E2DB /* OptionsView.swift in Sources */, - 373CFAD5269662AB003CB2C6 /* SearchDate.swift in Sources */, 379775952689365600DD52A8 /* Array+Next.swift in Sources */, 37AAF28A2673AB89007FC770 /* ChannelView.swift in Sources */, 3705B180267B4DFB00704544 /* TrendingCountrySelectionView.swift in Sources */, 373CFACD26966264003CB2C6 /* SearchQuery.swift in Sources */, 37141675267A8E10006CA35D /* Country.swift in Sources */, 373CFAC42696616C003CB2C6 /* CoverSectionRowView.swift in Sources */, - 372915EC2687EBA500F5A35B /* ListingLayout.swift in Sources */, 37D4B19926717E1500C925CA /* Video.swift in Sources */, - 373CFAD9269662CD003CB2C6 /* SearchDuration.swift in Sources */, - 373CFAD126966290003CB2C6 /* SearchSortOrder.swift in Sources */, 373CFAED26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */, 3705B184267B4E4900704544 /* TrendingCategory.swift in Sources */, 37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */, 372915E82687E3B900F5A35B /* Defaults.swift in Sources */, 37BAB54C269B39FD00E75ED1 /* TVNavigationView.swift in Sources */, 37D4B1812671653A00C925CA /* AppTabNavigation.swift in Sources */, - 37CEE4BB2677B63F005A1EFE /* StreamResolution.swift in Sources */, 3797758D2689345500DD52A8 /* Store.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Pearvidious.xcodeproj/xcuserdata/arek.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Pearvidious.xcodeproj/xcuserdata/arek.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 15e94d43..1cf6f03c 100644 --- a/Pearvidious.xcodeproj/xcuserdata/arek.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Pearvidious.xcodeproj/xcuserdata/arek.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,4 +3,38 @@ uuid = "E30DA302-B258-4C14-8808-5E4CE238A4FF" type = "1" version = "2.0"> + + + + + + + + + + diff --git a/Shared/Assets.xcassets/DetailBadgeInformationalStyleBackgroundColor.colorset/Contents.json b/Shared/Assets.xcassets/DetailBadgeInformationalStyleBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..07c5d6b1 --- /dev/null +++ b/Shared/Assets.xcassets/DetailBadgeInformationalStyleBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.343", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.343", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/Assets.xcassets/DetailBadgeOutstandingStyleBackgroundColor.colorset/Contents.json b/Shared/Assets.xcassets/DetailBadgeOutstandingStyleBackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..4572da66 --- /dev/null +++ b/Shared/Assets.xcassets/DetailBadgeOutstandingStyleBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.197", + "green" : "0.168", + "red" : "0.691" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.197", + "green" : "0.168", + "red" : "0.543" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index fd7557c0..b6a5649b 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -1,14 +1,31 @@ import Defaults +enum ListingLayout: String, CaseIterable, Identifiable, Defaults.Serializable { + case list, cells + + var id: String { + rawValue + } + + var name: String { + switch self { + case .list: + return "List" + case .cells: + return "Cells" + } + } +} + extension Defaults.Keys { #if os(tvOS) static let layout = Key("listingLayout", default: .cells) #endif static let searchQuery = Key("searchQuery", default: "") - static let searchSortOrder = Key("searchSortOrder", default: .relevance) - static let searchDate = Key("searchDate") - static let searchDuration = Key("searchDuration") + static let searchSortOrder = Key("searchSortOrder", default: .relevance) + static let searchDate = Key("searchDate") + static let searchDuration = Key("searchDuration") static let selectedPlaylistID = Key("selectedPlaylistID") static let showingAddToPlaylist = Key("showingAddToPlaylist", default: false) diff --git a/Shared/DetailBadge.swift b/Shared/DetailBadge.swift new file mode 100644 index 00000000..30d5dd01 --- /dev/null +++ b/Shared/DetailBadge.swift @@ -0,0 +1,96 @@ +import SwiftUI + +struct DetailBadge: View { + enum Style { + case `default`, prominent, outstanding, informational + } + + struct StyleModifier: ViewModifier { + let style: Style + + func body(content: Content) -> some View { + Group { + switch style { + case .prominent: + content.modifier(ProminentStyleModifier()) + case .outstanding: + content.modifier(OutstandingStyleModifier()) + case .informational: + content.modifier(InformationalStyleModifier()) + default: + content.modifier(DefaultStyleModifier()) + } + } + } + } + + struct DefaultStyleModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background(.thinMaterial) + } + } + + struct ProminentStyleModifier: ViewModifier { + var font: Font { + Font.system(.body).weight(.semibold) + } + + func body(content: Content) -> some View { + content + .font(font) + .modifier(DefaultStyleModifier()) + } + } + + struct OutstandingStyleModifier: ViewModifier { + var backgroundColor: Color { + Color("DetailBadgeOutstandingStyleBackgroundColor") + } + + func body(content: Content) -> some View { + content + .textCase(.uppercase) + .background(backgroundColor) + .foregroundColor(.white) + } + } + + struct InformationalStyleModifier: ViewModifier { + var backgroundColor: Color { + Color("DetailBadgeInformationalStyleBackgroundColor") + } + + func body(content: Content) -> some View { + content + .background(backgroundColor) + .foregroundColor(.white) + } + } + + var text: String + var style: Style = .default + + var body: some View { + Text(text) + .lineLimit(1) + .truncationMode(.middle) + .padding(10) + .modifier(StyleModifier(style: style)) + .mask(RoundedRectangle(cornerRadius: 12)) + } +} + +struct DetailBadge_Previews: PreviewProvider { + static var previews: some View { + VStack { + DetailBadge(text: "Live", style: .outstanding) + DetailBadge(text: "Premieres", style: .informational) + DetailBadge(text: "Booyah", style: .prominent) + DetailBadge( + text: "Donec in neque mi. Phasellus quis sapien metus. Ut felis ante, posuere." + ) + } + .frame(maxWidth: 500) + } +} diff --git a/Shared/ListingLayout.swift b/Shared/ListingLayout.swift deleted file mode 100644 index 6ce5af71..00000000 --- a/Shared/ListingLayout.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Defaults - -enum ListingLayout: String, CaseIterable, Identifiable, Defaults.Serializable { - case list, cells - - var id: String { - rawValue - } - - var name: String { - switch self { - case .list: - return "List" - case .cells: - return "Cells" - } - } -}