From 70c089e6961e1d9403bbb7b7c55c4375e9967b28 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Tue, 5 Oct 2021 22:20:09 +0200 Subject: [PATCH] Initial functionality of player items queue Fix environment objects Hide video player placeholder on tvOS Queue improvements --- Extensions/View+Borders.swift | 16 + Fixtures/Video+Fixtures.swift | 2 +- Fixtures/View+Fixtures.swift | 22 +- Model/NavigationModel.swift | 19 +- Model/PlaybackModel.swift | 31 -- Model/PlayerModel.swift | 284 +++++++------- Model/PlayerQueue.swift | 145 +++++++ Model/PlayerQueueItem.swift | 14 + Model/Stream.swift | 2 +- Model/Video.swift | 15 +- Pearvidious.xcodeproj/project.pbxproj | 149 +++++++- .../xcshareddata/swiftpm/Package.resolved | 9 + .../Contents.json | 38 ++ Shared/Navigation/AppSidebarPlaylists.swift | 6 + Shared/Navigation/ContentView.swift | 63 ++- Shared/PearvidiousApp.swift | 27 +- Shared/Player/PlaybackBar.swift | 67 ++-- Shared/Player/Player.swift | 16 +- Shared/Player/PlayerQueueRow.swift | 37 ++ Shared/Player/PlayerQueueView.swift | 100 +++++ Shared/Player/PlayerViewController.swift | 75 ++-- Shared/Player/VideoDetails.swift | 361 ++++++++++++------ .../Player/VideoDetailsPaddingModifier.swift | 17 +- Shared/Player/VideoPlayerSizeModifier.swift | 17 +- Shared/Player/VideoPlayerView.swift | 203 ++++++---- Shared/Playlists/AddToPlaylistView.swift | 10 +- Shared/Playlists/PlaylistsView.swift | 45 ++- Shared/Trending/TrendingView.swift | 22 +- Shared/Videos/VideoBanner.swift | 71 ++++ Shared/Videos/VideoView.swift | 37 +- Shared/Videos/VideosCellsHorizontal.swift | 4 +- Shared/Views/ChannelVideosView.swift | 16 + Shared/Views/PlayerControlsView.swift | 109 ++++++ Shared/Views/PlaylistVideosView.swift | 10 +- Shared/Views/PopularView.swift | 18 +- Shared/Views/SearchView.swift | 44 ++- Shared/Views/SignInRequiredView.swift | 8 +- Shared/Views/SubscriptionsView.swift | 24 +- Shared/Views/VideoContextMenuView.swift | 70 +++- Shared/Watch Now/WatchNowView.swift | 40 +- macOS/Player.swift | 19 +- macOS/PlayerViewController.swift | 24 +- tvOS/NowPlayingView.swift | 81 ++++ tvOS/TVNavigationView.swift | 13 +- 44 files changed, 1711 insertions(+), 689 deletions(-) create mode 100644 Extensions/View+Borders.swift delete mode 100644 Model/PlaybackModel.swift create mode 100644 Model/PlayerQueue.swift create mode 100644 Model/PlayerQueueItem.swift create mode 100644 Shared/Assets.xcassets/PlayerControlsBorderColor.colorset/Contents.json create mode 100644 Shared/Player/PlayerQueueRow.swift create mode 100644 Shared/Player/PlayerQueueView.swift create mode 100644 Shared/Videos/VideoBanner.swift create mode 100644 Shared/Views/PlayerControlsView.swift create mode 100644 tvOS/NowPlayingView.swift diff --git a/Extensions/View+Borders.swift b/Extensions/View+Borders.swift new file mode 100644 index 00000000..828e1150 --- /dev/null +++ b/Extensions/View+Borders.swift @@ -0,0 +1,16 @@ +import Foundation +import SwiftUI + +extension View { + func borderTop(height: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View { + verticalEdgeBorder(.top, height: height, color: color) + } + + func borderBottom(height: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View { + verticalEdgeBorder(.bottom, height: height, color: color) + } + + private func verticalEdgeBorder(_ edge: Alignment, height: Double, color: Color) -> some View { + overlay(Rectangle().frame(width: nil, height: height, alignment: .top).foregroundColor(color), alignment: edge) + } +} diff --git a/Fixtures/Video+Fixtures.swift b/Fixtures/Video+Fixtures.swift index 3df29b11..5f2dc6b1 100644 --- a/Fixtures/Video+Fixtures.swift +++ b/Fixtures/Video+Fixtures.swift @@ -5,7 +5,7 @@ extension Video { let id = "D2sxamzaHkM" return Video( - id: UUID().uuidString, + videoID: UUID().uuidString, title: "Relaxing Piano Music that will make you feel amazingly good", author: "Fancy Videotuber", length: 582, diff --git a/Fixtures/View+Fixtures.swift b/Fixtures/View+Fixtures.swift index c8aa3402..5a6fb996 100644 --- a/Fixtures/View+Fixtures.swift +++ b/Fixtures/View+Fixtures.swift @@ -7,11 +7,11 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier { .environmentObject(InstancesModel()) .environmentObject(api) .environmentObject(NavigationModel()) - .environmentObject(PlaybackModel()) + .environmentObject(player) .environmentObject(PlaylistsModel()) .environmentObject(RecentsModel()) .environmentObject(SearchModel()) - .environmentObject(SubscriptionsModel(api: api)) + .environmentObject(subscriptions) } private var api: InvidiousAPI { @@ -22,6 +22,24 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier { return api } + + private var player: PlayerModel { + let player = PlayerModel() + + player.currentItem = PlayerQueueItem(Video.fixture) + player.queue = Video.allFixtures.map { PlayerQueueItem($0) } + player.history = player.queue + + return player + } + + private var subscriptions: SubscriptionsModel { + let subscriptions = SubscriptionsModel() + + subscriptions.channels = Video.allFixtures.map { $0.channel } + + return subscriptions + } } extension View { diff --git a/Model/NavigationModel.swift b/Model/NavigationModel.swift index 107d8886..2b094f6f 100644 --- a/Model/NavigationModel.swift +++ b/Model/NavigationModel.swift @@ -3,14 +3,20 @@ import SwiftUI final class NavigationModel: ObservableObject { enum TabSelection: Hashable { - case watchNow, subscriptions, popular, trending, playlists, channel(String), playlist(String), recentlyOpened(String), search + case watchNow + case subscriptions + case popular + case trending + case playlists + case channel(String) + case playlist(String) + case recentlyOpened(String) + case nowPlaying + case search } @Published var tabSelection: TabSelection! = .watchNow - @Published var showingVideo = false - @Published var video: Video? - @Published var presentingAddToPlaylist = false @Published var videoToAddToPlaylist: Video! @@ -25,11 +31,6 @@ final class NavigationModel: ObservableObject { @Published var presentingSettings = false - func playVideo(_ video: Video) { - self.video = video - showingVideo = true - } - var tabSelectionBinding: Binding { Binding( get: { diff --git a/Model/PlaybackModel.swift b/Model/PlaybackModel.swift deleted file mode 100644 index bade756f..00000000 --- a/Model/PlaybackModel.swift +++ /dev/null @@ -1,31 +0,0 @@ -import CoreMedia -import Foundation - -final class PlaybackModel: ObservableObject { - @Published var live = false - @Published var stream: Stream? - @Published var time: CMTime? - - var aspectRatio: Double? { - let tracks = stream?.videoAsset.tracks(withMediaType: .video) - - guard tracks != nil else { - return nil - } - - let size: CGSize! = tracks!.first.flatMap { - tracks!.isEmpty ? nil : $0.naturalSize.applying($0.preferredTransform) - } - - guard size != nil else { - return nil - } - - return size.width / size.height - } - - func reset() { - stream = nil - time = nil - } -} diff --git a/Model/PlayerModel.swift b/Model/PlayerModel.swift index 5bb42fd3..663214df 100644 --- a/Model/PlayerModel.swift +++ b/Model/PlayerModel.swift @@ -1,4 +1,5 @@ -import AVFoundation +import AVKit +import Defaults import Foundation import Logging #if !os(macOS) @@ -8,127 +9,114 @@ import Logging final class PlayerModel: ObservableObject { let logger = Logger(label: "net.arekf.Pearvidious.ps") - var video: Video! + private(set) var player = AVPlayer() + var controller: PlayerViewController? + #if os(tvOS) + var avPlayerViewController: AVPlayerViewController? + #endif - var player: AVPlayer! + @Published var presentingPlayer = false - private var compositions = [Stream: AVMutableComposition]() + @Published var stream: Stream? + @Published var currentRate: Float? - private(set) var savedTime: CMTime? + @Published var queue = [PlayerQueueItem]() + @Published var currentItem: PlayerQueueItem! + @Published var live = false + @Published var time: CMTime? - private(set) var currentRate: Float = 0.0 - static let availableRates: [Double] = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] + @Published var history = [PlayerQueueItem]() var api: InvidiousAPI - var playback: PlaybackModel var timeObserver: Any? - let resolution: Stream.ResolutionSetting? + private var statusObservation: NSKeyValueObservation? - var playingOutsideViewController = false - - init(_ video: Video? = nil, playback: PlaybackModel, api: InvidiousAPI, resolution: Stream.ResolutionSetting? = nil) { - self.video = video - self.playback = playback - self.api = api - self.resolution = resolution + var isPlaying: Bool { + stream != nil && currentRate != 0.0 } - deinit { - destroyPlayer() + init(api: InvidiousAPI? = nil) { + self.api = api ?? InvidiousAPI() + addItemDidPlayToEndTimeObserver() } - func loadVideo(_ video: Video?) { - guard video != nil else { + func presentPlayer() { + presentingPlayer = true + } + + func togglePlay() { + isPlaying ? pause() : play() + } + + func play() { + guard !isPlaying else { return } - playback.reset() - - loadExtendedVideoDetails(video) { video in - self.video = video - self.playVideo(video) - } + player.play() } - func loadExtendedVideoDetails(_ video: Video?, onSuccess: @escaping (Video) -> Void) { - guard video != nil else { + func pause() { + guard isPlaying else { return } - api.video(video!.id).load().onSuccess { response in - if let video: Video = response.typedContent() { - onSuccess(video) - } - } + player.pause() } - var requestedResolution: Bool { - resolution != nil && resolution != .hd720pFirstThenBest - } - - fileprivate func playVideo(_ video: Video) { - playback.live = video.live - + func playVideo(_ video: Video) { if video.live { - playHlsUrl() + playHlsUrl(video) return } - let stream = requestedResolution ? video.streamWithResolution(resolution!.value) : video.defaultStream - - guard stream != nil else { + guard let stream = video.streamWithResolution(Defaults[.quality].value) ?? video.defaultStream else { return } - Task { - await self.loadStream(stream!) - - if resolution == .hd720pFirstThenBest { - await self.loadBestStream() + if stream.oneMeaningfullAsset { + playStream(stream, for: video) + } else { + Task { + await playComposition(video, for: stream) } } } - fileprivate func playHlsUrl() { - player.replaceCurrentItem(with: playerItemWithMetadata()) + private func playHlsUrl(_ video: Video) { + player.replaceCurrentItem(with: playerItemWithMetadata(video)) player.playImmediately(atRate: 1.0) } - fileprivate func loadStream(_ stream: Stream) async { - if stream.oneMeaningfullAsset { - playStream(stream) - - return - } else { - await playComposition(for: stream) - } - } - - fileprivate func playStream(_ stream: Stream) { - guard player != nil else { - return - } - + private func playStream(_ stream: Stream, for video: Video) { logger.warning("loading \(stream.description) to player") + let playerItem: AVPlayerItem! = playerItemWithMetadata(video, for: stream) + guard playerItem != nil else { + return + } + + if let index = queue.firstIndex(where: { $0.video.id == video.id }) { + queue[index].playerItems.append(playerItem) + } + DispatchQueue.main.async { - self.saveTime() - self.player?.replaceCurrentItem(with: self.playerItemWithMetadata(for: stream)) - self.playback.stream = stream - if self.timeObserver.isNil { - self.addTimeObserver() - } - self.player?.play() - self.seekToSavedTime() + self.stream = stream + self.player.replaceCurrentItem(with: playerItem) + } + + if timeObserver.isNil { + addTimeObserver() } } - fileprivate func playComposition(for stream: Stream) async { + private func playComposition(_ video: Video, 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), + logger.info("loading audio track") + if let audioTrack = composition(video, for: stream)?.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid), let assetTrack = try? await assetAudioTrack.first { try! audioTrack.insertTimeRange( @@ -138,10 +126,11 @@ final class PlayerModel: ObservableObject { ) logger.critical("audio loaded") } else { - fatalError("no track") + logger.critical("NO audio track") } - if let videoTrack = composition(for: stream).addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid), + logger.info("loading video track") + if let videoTrack = composition(video, for: stream)?.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid), let assetTrack = try? await assetVideoTrack.first { try! videoTrack.insertTimeRange( @@ -150,27 +139,35 @@ final class PlayerModel: ObservableObject { at: .zero ) logger.critical("video loaded") - - playStream(stream) + playStream(stream, for: video) } else { - fatalError("no track") + logger.critical("NO video track") } } - fileprivate func playerItem(for stream: Stream? = nil) -> AVPlayerItem { + private func playerItem(_ video: Video, for stream: Stream? = nil) -> AVPlayerItem? { if stream != nil { if stream!.oneMeaningfullAsset { - return AVPlayerItem(asset: stream!.videoAsset, automaticallyLoadedAssetKeys: [.isPlayable]) + logger.info("stream has one meaningfull asset") + return AVPlayerItem(asset: AVURLAsset(url: stream!.videoAsset.url)) + } + if let composition = composition(video, for: stream!) { + logger.info("stream has MANY assets, using composition") + return AVPlayerItem(asset: composition) } else { - return AVPlayerItem(asset: composition(for: stream!)) + return nil } } return AVPlayerItem(url: video.hlsUrl!) } - fileprivate func playerItemWithMetadata(for stream: Stream? = nil) -> AVPlayerItem { - let playerItemWithMetadata = playerItem(for: stream) + private func playerItemWithMetadata(_ video: Video, for stream: Stream? = nil) -> AVPlayerItem? { + logger.info("building player item metadata") + let playerItemWithMetadata: AVPlayerItem! = playerItem(video, for: stream) + guard playerItemWithMetadata != nil else { + return nil + } var externalMetadata = [ makeMetadataItem(.commonIdentifierTitle, value: video.title), @@ -179,7 +176,7 @@ final class PlayerModel: ObservableObject { ] #if !os(macOS) - if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .high)!), + if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!), let image = UIImage(data: thumbnailData), let pngData = image.pngData() { @@ -190,92 +187,69 @@ final class PlayerModel: ObservableObject { playerItemWithMetadata.externalMetadata = externalMetadata #endif - playerItemWithMetadata.preferredForwardBufferDuration = 10 + playerItemWithMetadata.preferredForwardBufferDuration = 15 + statusObservation?.invalidate() + statusObservation = playerItemWithMetadata.observe(\.status, options: [.old, .new]) { playerItem, _ in + switch playerItem.status { + case .readyToPlay: + if self.isAutoplaying(playerItem) { + self.player.play() + } + default: + return + } + } + + logger.info("item metadata retrieved") return playerItemWithMetadata } - func setPlayerRate(_ rate: Float) { - currentRate = rate - player.rate = rate + func addItemDidPlayToEndTimeObserver() { + NotificationCenter.default.addObserver( + self, + selector: #selector(itemDidPlayToEndTime), + name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, + object: nil + ) } - fileprivate func composition(for stream: Stream) -> AVMutableComposition { - if compositions[stream].isNil { - compositions[stream] = AVMutableComposition() - } - - return compositions[stream]! - } - - fileprivate func loadBestStream() async { - if let bestStream = video.bestStream { - await loadStream(bestStream) + @objc func itemDidPlayToEndTime() { + if queue.isEmpty { + resetQueue() + #if os(tvOS) + avPlayerViewController!.dismiss(animated: true) { + self.controller!.dismiss(animated: true) + } + #endif + presentingPlayer = false + } else { + advanceToNextItem() } } - fileprivate func saveTime() { - guard player != nil else { - return + private func composition(_ video: Video, for stream: Stream) -> AVMutableComposition? { + if let index = queue.firstIndex(where: { $0.video == video }) { + if queue[index].compositions[stream].isNil { + queue[index].compositions[stream] = AVMutableComposition() + } + return queue[index].compositions[stream]! } - let currentTime = player.currentTime() - - guard currentTime.seconds > 0 else { - return - } - - savedTime = currentTime + return nil } - fileprivate func seekToSavedTime() { - guard player != nil else { - return - } - - if let time = savedTime { - logger.info("seeking to \(time.seconds)") - player.seek(to: time, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) - } - } - - fileprivate func destroyPlayer() { - logger.critical("destroying player") - - guard !playingOutsideViewController else { - logger.critical("cannot destroy, playing outside view controller") - return - } - - player?.currentItem?.tracks.forEach { $0.assetTrack?.asset?.cancelLoading() } - - player?.replaceCurrentItem(with: nil) - - if timeObserver != nil { - player?.removeTimeObserver(timeObserver!) - timeObserver = nil - } - - player = nil - } - - fileprivate func addTimeObserver() { - let interval = CMTime(value: 1, timescale: 1) + private func addTimeObserver() { + let interval = CMTime(seconds: 0.5, preferredTimescale: 1000) timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { _ in - guard self.player != nil else { - return - } - - if self.player.rate != self.currentRate, self.player.rate != 0, self.currentRate != 0 { - self.player.rate = self.currentRate - } - - self.playback.time = self.player.currentTime() + self.currentRate = self.player.rate + self.live = self.currentVideo?.live ?? false + self.time = self.player.currentTime() } } - fileprivate func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem { + private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem { let item = AVMutableMetadataItem() item.identifier = identifier diff --git a/Model/PlayerQueue.swift b/Model/PlayerQueue.swift new file mode 100644 index 00000000..d6e7f666 --- /dev/null +++ b/Model/PlayerQueue.swift @@ -0,0 +1,145 @@ +import AVFoundation +import Foundation + +extension PlayerModel { + var currentVideo: Video? { + currentItem?.video + } + + func playAll(_ videos: [Video]) { + let first = videos.first + + videos.forEach { video in + enqueueVideo(video) { _, item in + if item.video == first { + self.advanceToItem(item) + } + } + } + } + + func playNext(_ video: Video) { + enqueueVideo(video, prepending: true) { _, item in + if self.currentItem == nil { + self.advanceToItem(item) + } + } + } + + func playNow(_ video: Video) { + addCurrentItemToHistory() + + enqueueVideo(video, prepending: true) { _, item in + self.advanceToItem(item) + } + } + + func playItem(_ item: PlayerQueueItem, video: Video? = nil) { + currentItem = item + + if video != nil { + currentItem.video = video! + } + + playVideo(currentItem.video) + } + + func advanceToNextItem() { + addCurrentItemToHistory() + + if let nextItem = queue.first { + advanceToItem(nextItem) + } + } + + func advanceToItem(_ newItem: PlayerQueueItem) { + let item = remove(newItem)! + loadDetails(newItem.video) { video in + self.playItem(item, video: video) + } + } + + @discardableResult func remove(_ item: PlayerQueueItem) -> PlayerQueueItem? { + if let index = queue.firstIndex(where: { $0 == item }) { + return queue.remove(at: index) + } + + return nil + } + + func resetQueue() { + DispatchQueue.main.async { + self.currentItem = nil + self.stream = nil + self.removeQueueItems() + self.timeObserver = nil + } + + player.replaceCurrentItem(with: nil) + } + + func isAutoplaying(_ item: AVPlayerItem) -> Bool { + player.currentItem == item + } + + @discardableResult func enqueueVideo( + _ video: Video, + play: Bool = false, + prepending: Bool = false, + videoDetailsLoadHandler: @escaping (Video, PlayerQueueItem) -> Void = { _, _ in } + ) -> PlayerQueueItem? { + let item = PlayerQueueItem(video) + + queue.insert(item, at: prepending ? 0 : queue.endIndex) + + loadDetails(video) { video in + videoDetailsLoadHandler(video, item) + + if play { + self.playItem(item, video: video) + } + } + + return item + } + + private func loadDetails(_ video: Video?, onSuccess: @escaping (Video) -> Void) { + guard video != nil else { + return + } + + if !video!.streams.isEmpty { + logger.critical("not loading video details again") + onSuccess(video!) + return + } + + api.video(video!.videoID).load().onSuccess { response in + if let video: Video = response.typedContent() { + onSuccess(video) + } + } + } + + func addCurrentItemToHistory() { + if let item = currentItem, !history.contains(where: { $0.video.videoID == item.video.videoID }) { + history.insert(item, at: 0) + } + } + + @discardableResult func removeHistory(_ item: PlayerQueueItem) -> PlayerQueueItem? { + if let index = history.firstIndex(where: { $0 == item }) { + return history.remove(at: index) + } + + return nil + } + + func removeQueueItems() { + queue.removeAll() + } + + func removeHistoryItems() { + history.removeAll() + } +} diff --git a/Model/PlayerQueueItem.swift b/Model/PlayerQueueItem.swift new file mode 100644 index 00000000..1d71cd9d --- /dev/null +++ b/Model/PlayerQueueItem.swift @@ -0,0 +1,14 @@ +import AVFoundation +import Foundation + +struct PlayerQueueItem: Hashable, Identifiable { + var id = UUID() + var video: Video + + init(_ video: Video) { + self.video = video + } + + var playerItems = [AVPlayerItem]() + var compositions = [Stream: AVMutableComposition]() +} diff --git a/Model/Stream.swift b/Model/Stream.swift index d8b138d2..7254f6e5 100644 --- a/Model/Stream.swift +++ b/Model/Stream.swift @@ -84,7 +84,7 @@ class Stream: Equatable, Hashable { } var oneMeaningfullAsset: Bool { - assets.dropFirst().allSatisfy { $0 == assets.first } + assets.dropFirst().allSatisfy { $0.url == assets.first!.url } } static func == (lhs: Stream, rhs: Stream) -> Bool { diff --git a/Model/Video.swift b/Model/Video.swift index 122886b9..4af5f4b5 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -3,8 +3,9 @@ import AVKit import Foundation import SwiftyJSON -struct Video: Identifiable, Equatable { +struct Video: Identifiable, Equatable, Hashable { let id: String + let videoID: String var title: String var thumbnails: [Thumbnail] var author: String @@ -31,7 +32,8 @@ struct Video: Identifiable, Equatable { var channel: Channel init( - id: String, + id: String? = nil, + videoID: String, title: String, author: String, length: TimeInterval, @@ -49,7 +51,8 @@ struct Video: Identifiable, Equatable { dislikes: Int? = nil, keywords: [String] = [] ) { - self.id = id + self.id = id ?? UUID().uuidString + self.videoID = videoID self.title = title self.author = author self.length = length @@ -69,7 +72,7 @@ struct Video: Identifiable, Equatable { } init(_ json: JSON) { - let videoID = json["videoId"].stringValue + videoID = json["videoId"].stringValue if let id = json["indexId"].string { indexID = id @@ -206,4 +209,8 @@ struct Video: Identifiable, Equatable { static func == (lhs: Video, rhs: Video) -> Bool { lhs.id == rhs.id } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } } diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index e590dae2..70076e33 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -55,6 +55,10 @@ 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 */; }; + 3730D8A02712E2B70020ED53 /* NowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3730D89F2712E2B70020ED53 /* NowPlayingView.swift */; }; + 37319F0527103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; }; + 37319F0627103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; }; + 37319F0727103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; }; 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 */; }; @@ -66,6 +70,14 @@ 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 */; }; + 3743CA4A270EF79400E4D32B /* SwiftUIKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3743CA49270EF79400E4D32B /* SwiftUIKit */; }; + 3743CA4C270EF7A500E4D32B /* SwiftUIKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3743CA4B270EF7A500E4D32B /* SwiftUIKit */; }; + 3743CA4E270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */; }; + 3743CA4F270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */; }; + 3743CA50270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */; }; + 3743CA52270F284F00E4D32B /* View+Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA51270F284F00E4D32B /* View+Borders.swift */; }; + 3743CA53270F284F00E4D32B /* View+Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA51270F284F00E4D32B /* View+Borders.swift */; }; + 3743CA54270F284F00E4D32B /* View+Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA51270F284F00E4D32B /* View+Borders.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 */; }; @@ -193,9 +205,6 @@ 37B81B0026D2CA3700675966 /* VideoDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81AFE26D2CA3700675966 /* VideoDetails.swift */; }; 37B81B0226D2CAE700675966 /* PlaybackBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0126D2CAE700675966 /* PlaybackBar.swift */; }; 37B81B0326D2CAE700675966 /* PlaybackBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0126D2CAE700675966 /* PlaybackBar.swift */; }; - 37B81B0526D2CEDA00675966 /* PlaybackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackModel.swift */; }; - 37B81B0626D2CEDA00675966 /* PlaybackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackModel.swift */; }; - 37B81B0726D2D6CF00675966 /* PlaybackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackModel.swift */; }; 37BA793B26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */; }; 37BA793C26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */; }; 37BA793D26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */; }; @@ -245,6 +254,15 @@ 37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; }; 37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; }; 37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B17F267B4DFB00704544 /* TrendingCountry.swift */; }; + 37CC3F45270CE30600608308 /* PlayerQueueItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */; }; + 37CC3F46270CE30600608308 /* PlayerQueueItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */; }; + 37CC3F47270CE30600608308 /* PlayerQueueItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */; }; + 37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */; }; + 37CC3F4D270CFE1700608308 /* PlayerQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */; }; + 37CC3F4E270CFE1700608308 /* PlayerQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */; }; + 37CC3F50270D010D00608308 /* VideoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F4F270D010D00608308 /* VideoBanner.swift */; }; + 37CC3F51270D010D00608308 /* VideoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F4F270D010D00608308 /* VideoBanner.swift */; }; + 37CC3F52270D010D00608308 /* VideoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F4F270D010D00608308 /* VideoBanner.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 */; }; @@ -266,6 +284,9 @@ 37D4B19826717E1500C925CA /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B19626717E1500C925CA /* Video.swift */; }; 37D4B19926717E1500C925CA /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B19626717E1500C925CA /* Video.swift */; }; 37D4B19D2671817900C925CA /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 37D4B19C2671817900C925CA /* SwiftyJSON */; }; + 37E2EEAB270656EC00170416 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */; }; + 37E2EEAC270656EC00170416 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */; }; + 37E2EEAD270656EC00170416 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */; }; 37E64DD126D597EB00C71877 /* SubscriptionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */; }; 37E64DD226D597EB00C71877 /* SubscriptionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */; }; 37E64DD326D597EB00C71877 /* SubscriptionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */; }; @@ -335,10 +356,14 @@ 37152EE926EFEB95004FB96D /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; 371F2F19269B43D300E4A7AB /* NavigationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationModel.swift; sourceTree = ""; }; 372915E52687E3B900F5A35B /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = ""; }; + 3730D89F2712E2B70020ED53 /* NowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingView.swift; sourceTree = ""; }; + 37319F0427103F94004ECCD0 /* PlayerQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueue.swift; sourceTree = ""; }; 373CFACA26966264003CB2C6 /* SearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQuery.swift; sourceTree = ""; }; 373CFADA269663F1003CB2C6 /* Thumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnail.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 = ""; }; + 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueRow.swift; sourceTree = ""; }; + 3743CA51270F284F00E4D32B /* View+Borders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Borders.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 = ""; }; @@ -384,7 +409,6 @@ 37B81AFB26D2C9C900675966 /* VideoDetailsPaddingModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDetailsPaddingModifier.swift; sourceTree = ""; }; 37B81AFE26D2CA3700675966 /* VideoDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDetails.swift; sourceTree = ""; }; 37B81B0126D2CAE700675966 /* PlaybackBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBar.swift; sourceTree = ""; }; - 37B81B0426D2CEDA00675966 /* PlaybackModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackModel.swift; sourceTree = ""; }; 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistVideosView.swift; sourceTree = ""; }; 37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelVideosView.swift; sourceTree = ""; }; 37BA794226DBA973002A0235 /* PlaylistsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsModel.swift; sourceTree = ""; }; @@ -404,6 +428,9 @@ 37BE0BDB26A2367F0092E2DB /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = ""; }; 37C194C626F6A9C8005D3B96 /* RecentsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentsModel.swift; sourceTree = ""; }; 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockSegment.swift; sourceTree = ""; }; + 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueItem.swift; sourceTree = ""; }; + 37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueView.swift; sourceTree = ""; }; + 37CC3F4F270D010D00608308 /* VideoBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoBanner.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 = ""; }; @@ -422,6 +449,7 @@ 37D4B18B26717B3800C925CA /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.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 = ""; }; + 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsView.swift; sourceTree = ""; }; 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsModel.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 = ""; }; @@ -445,6 +473,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3743CA4A270EF79400E4D32B /* SwiftUIKit in Frameworks */, 37BD07B72698AB2E003EBB87 /* Defaults in Frameworks */, 37BADCA52699FB72009BE4FB /* Alamofire in Frameworks */, 377FC7D5267A080300A6BBAF /* SwiftyJSON in Frameworks */, @@ -459,6 +488,7 @@ buildActionMask = 2147483647; files = ( 37BD07BE2698AC96003EBB87 /* Defaults in Frameworks */, + 3743CA4C270EF7A500E4D32B /* SwiftUIKit in Frameworks */, 37BADCA7269A552E009BE4FB /* Alamofire in Frameworks */, 377FC7ED267A0A0800A6BBAF /* SwiftyJSON in Frameworks */, 37BD07C02698AC97003EBB87 /* Siesta in Frameworks */, @@ -522,6 +552,8 @@ children = ( 37B81B0126D2CAE700675966 /* PlaybackBar.swift */, 37BE0BD226A1D4780092E2DB /* Player.swift */, + 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */, + 37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */, 37BE0BD526A1D4A90092E2DB /* PlayerViewController.swift */, 37B81AFE26D2CA3700675966 /* VideoDetails.swift */, 37B81AFB26D2C9C900675966 /* VideoDetailsPaddingModifier.swift */, @@ -554,6 +586,7 @@ isa = PBXGroup; children = ( 3748186D26A769D60084E870 /* DetailBadge.swift */, + 37CC3F4F270D010D00608308 /* VideoBanner.swift */, 37A9965926D6F8CA006E3224 /* VideosCellsHorizontal.swift */, 37F4AE7126828F0900BD60EA /* VideosCellsVertical.swift */, 37D4B18B26717B3800C925CA /* VideoView.swift */, @@ -566,6 +599,7 @@ children = ( 37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */, 37152EE926EFEB95004FB96D /* LazyView.swift */, + 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */, 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */, 37AAF27D26737323007FC770 /* PopularView.swift */, 37AAF27F26737550007FC770 /* SearchView.swift */, @@ -670,6 +704,7 @@ 376578842685429C00D4EA09 /* CaseIterable+Next.swift */, 37BA794E26DC3E0E002A0235 /* Int+Format.swift */, 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */, + 3743CA51270F284F00E4D32B /* View+Borders.swift */, ); path = Extensions; sourceTree = ""; @@ -749,6 +784,7 @@ isa = PBXGroup; children = ( 37666BA927023AF000F869E5 /* AccountSelectionView.swift */, + 3730D89F2712E2B70020ED53 /* NowPlayingView.swift */, 37BAB54B269B39FD00E75ED1 /* TVNavigationView.swift */, 37D4B15E267164AF00C925CA /* Assets.xcassets */, 37D4B1AE26729DEB00C925CA /* Info.plist */, @@ -767,15 +803,16 @@ 37D4B1B72672CFE300C925CA /* Model */ = { isa = PBXGroup; children = ( + 37484C3026FCB8F900287258 /* AccountValidator.swift */, 37AAF28F26740715007FC770 /* Channel.swift */, 37141672267A8E10006CA35D /* Country.swift */, 378E50FA26FE8B9F00F49626 /* Instance.swift */, - 37484C3026FCB8F900287258 /* AccountValidator.swift */, 375DFB5726F9DA010013F468 /* InstancesModel.swift */, 37977582268922F600DD52A8 /* InvidiousAPI.swift */, 371F2F19269B43D300E4A7AB /* NavigationModel.swift */, - 37B81B0426D2CEDA00675966 /* PlaybackModel.swift */, 37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */, + 37319F0427103F94004ECCD0 /* PlayerQueue.swift */, + 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */, 376578882685471400D4EA09 /* Playlist.swift */, 37BA794226DBA973002A0235 /* PlaylistsModel.swift */, 37C194C626F6A9C8005D3B96 /* RecentsModel.swift */, @@ -832,6 +869,7 @@ 37D4B0C52671614900C925CA /* Sources */, 37D4B0C62671614900C925CA /* Frameworks */, 37D4B0C72671614900C925CA /* Resources */, + 37CC3F48270CE89B00608308 /* ShellScript */, ); buildRules = ( ); @@ -845,6 +883,7 @@ 37BD07B82698AB2E003EBB87 /* Siesta */, 37BD07C62698B27B003EBB87 /* Introspect */, 37BADCA42699FB72009BE4FB /* Alamofire */, + 3743CA49270EF79400E4D32B /* SwiftUIKit */, ); productName = "Pearvidious (iOS)"; productReference = 37D4B0C92671614900C925CA /* Pearvidious.app */; @@ -857,6 +896,7 @@ 37D4B0CB2671614900C925CA /* Sources */, 37D4B0CC2671614900C925CA /* Frameworks */, 37D4B0CD2671614900C925CA /* Resources */, + 37CC3F4A270CE8D000608308 /* ShellScript */, ); buildRules = ( ); @@ -869,6 +909,7 @@ 37BD07BD2698AC96003EBB87 /* Defaults */, 37BD07BF2698AC97003EBB87 /* Siesta */, 37BADCA6269A552E009BE4FB /* Alamofire */, + 3743CA4B270EF7A500E4D32B /* SwiftUIKit */, ); productName = "Pearvidious (macOS)"; productReference = 37D4B0CF2671614900C925CA /* Pearvidious.app */; @@ -917,6 +958,7 @@ 37D4B154267164AE00C925CA /* Sources */, 37D4B155267164AE00C925CA /* Frameworks */, 37D4B156267164AE00C925CA /* Resources */, + 37CC3F49270CE8CA00608308 /* ShellScript */, ); buildRules = ( ); @@ -1011,6 +1053,7 @@ 3797757B268922D100DD52A8 /* XCRemoteSwiftPackageReference "siesta" */, 37BD07C52698B27B003EBB87 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 37BADCA32699FB72009BE4FB /* XCRemoteSwiftPackageReference "Alamofire" */, + 3743CA48270EF79400E4D32B /* XCRemoteSwiftPackageReference "SwiftUIKit" */, ); productRefGroup = 37D4B0CA2671614900C925CA /* Products */; projectDirPath = ""; @@ -1086,6 +1129,57 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 37CC3F48270CE89B00608308 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + }; + 37CC3F49270CE8CA00608308 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + }; + 37CC3F4A270CE8D000608308 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + }; 37FD43EA2704A2350073EE42 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1137,8 +1231,10 @@ buildActionMask = 2147483647; files = ( 37CEE4BD2677B670005A1EFE /* SingleAssetStream.swift in Sources */, + 37CC3F45270CE30600608308 /* PlayerQueueItem.swift in Sources */, 37BD07C82698B71C003EBB87 /* AppTabNavigation.swift in Sources */, 37EAD86B267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, + 3743CA52270F284F00E4D32B /* View+Borders.swift in Sources */, 3763495126DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */, 376CD21626FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */, 37BA793B26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */, @@ -1165,18 +1261,19 @@ 37BA794726DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */, 377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */, 37FD43DE2704717F0073EE42 /* DefaultAccountHint.swift in Sources */, + 37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */, 3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */, 37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */, 375168D62700FAFF008F96A6 /* Debounce.swift in Sources */, 37E64DD126D597EB00C71877 /* SubscriptionsModel.swift in Sources */, 376578892685471400D4EA09 /* Playlist.swift in Sources */, - 37B81B0526D2CEDA00675966 /* PlaybackModel.swift in Sources */, 373CFADB269663F1003CB2C6 /* Thumbnail.swift in Sources */, 3788AC2B26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */, 3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */, 373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, 37B044B726F7AB9000E1419D /* SettingsView.swift in Sources */, 377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */, + 37E2EEAB270656EC00170416 /* PlayerControlsView.swift in Sources */, 37BA794326DBA973002A0235 /* PlaylistsModel.swift in Sources */, 37AAF29026740715007FC770 /* Channel.swift in Sources */, 3748186A26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, @@ -1187,7 +1284,9 @@ 37484C2D26FC844700287258 /* AccountsSettingsView.swift in Sources */, 377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */, + 37319F0527103F94004ECCD0 /* PlayerQueue.swift in Sources */, 376B2E0726F920D600B1D64D /* SignInRequiredView.swift in Sources */, + 3743CA4E270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */, 37BD672426F13D65004BE0C1 /* AppSidebarPlaylists.swift in Sources */, 37B17DA2268A1F8A006AEE9B /* VideoContextMenuView.swift in Sources */, 379775932689365600DD52A8 /* Array+Next.swift in Sources */, @@ -1212,6 +1311,7 @@ 37484C2926FC83FF00287258 /* AccountFormView.swift in Sources */, 371F2F1A269B43D300E4A7AB /* NavigationModel.swift in Sources */, 37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, + 37CC3F50270D010D00608308 /* VideoBanner.swift in Sources */, 378E50FB26FE8B9F00F49626 /* Instance.swift in Sources */, 37484C1D26FC83A400287258 /* InstancesSettingsView.swift in Sources */, 37BD07BB2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */, @@ -1227,6 +1327,7 @@ files = ( 37C194C826F6A9C8005D3B96 /* RecentsModel.swift in Sources */, 37BE0BDC26A2367F0092E2DB /* Player.swift in Sources */, + 3743CA53270F284F00E4D32B /* View+Borders.swift in Sources */, 3788AC2C26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */, 37CEE4BE2677B670005A1EFE /* SingleAssetStream.swift in Sources */, 37BA794826DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */, @@ -1236,11 +1337,13 @@ 37FD43DF2704717F0073EE42 /* DefaultAccountHint.swift in Sources */, 3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */, 37BA795026DC3E0E002A0235 /* Int+Format.swift in Sources */, + 3743CA4F270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */, 377FC7DD267A081A00A6BBAF /* PopularView.swift in Sources */, 375DFB5926F9DA010013F468 /* InstancesModel.swift in Sources */, 3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */, 37FD43DC270470B70073EE42 /* InstancesSettingsView.swift in Sources */, 376B2E0826F920D600B1D64D /* SignInRequiredView.swift in Sources */, + 37CC3F4D270CFE1700608308 /* PlayerQueueView.swift in Sources */, 37B81B0026D2CA3700675966 /* VideoDetails.swift in Sources */, 37484C1A26FC837400287258 /* PlaybackSettingsView.swift in Sources */, 37BD07C32698AD4F003EBB87 /* ContentView.swift in Sources */, @@ -1254,10 +1357,11 @@ 37141670267A8ACC006CA35D /* TrendingView.swift in Sources */, 37152EEB26EFEB95004FB96D /* LazyView.swift in Sources */, 377FC7E2267A084A00A6BBAF /* VideoView.swift in Sources */, + 37CC3F51270D010D00608308 /* VideoBanner.swift in Sources */, 378E50FC26FE8B9F00F49626 /* Instance.swift in Sources */, - 37B81B0626D2CEDA00675966 /* PlaybackModel.swift in Sources */, 37B044B826F7AB9000E1419D /* SettingsView.swift in Sources */, 3765788A2685471400D4EA09 /* Playlist.swift in Sources */, + 37E2EEAC270656EC00170416 /* PlayerControlsView.swift in Sources */, 373CFACC26966264003CB2C6 /* SearchQuery.swift in Sources */, 37AAF29126740715007FC770 /* Channel.swift in Sources */, 37BD07BC2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */, @@ -1281,6 +1385,7 @@ 37BE0BDA26A214630092E2DB /* PlayerViewController.swift in Sources */, 37E64DD226D597EB00C71877 /* SubscriptionsModel.swift in Sources */, 37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, + 37319F0627103F94004ECCD0 /* PlayerQueue.swift in Sources */, 37B767DC2677C3CA0098BAA8 /* PlayerModel.swift in Sources */, 3797758C2689345500DD52A8 /* Store.swift in Sources */, 37141674267A8E10006CA35D /* Country.swift in Sources */, @@ -1288,6 +1393,7 @@ 37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */, 37732FF12703A26300F04329 /* ValidationStatusView.swift in Sources */, 37BA794C26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */, + 37CC3F46270CE30600608308 /* PlayerQueueItem.swift in Sources */, 37D4B19826717E1500C925CA /* Video.swift in Sources */, 375168D72700FDB8008F96A6 /* Debounce.swift in Sources */, 37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */, @@ -1333,6 +1439,7 @@ 37AAF28026737550007FC770 /* SearchView.swift in Sources */, 3788AC2D26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */, 37EAD871267B9ED100D9E01B /* Segment.swift in Sources */, + 37CC3F52270D010D00608308 /* VideoBanner.swift in Sources */, 37F49BA526CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */, 376CD21826FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */, 37CEE4BF2677B670005A1EFE /* SingleAssetStream.swift in Sources */, @@ -1348,11 +1455,11 @@ 376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */, 37141671267A8ACC006CA35D /* TrendingView.swift in Sources */, 3788AC2926F6840700F6BAA9 /* WatchNowSection.swift in Sources */, + 37319F0727103F94004ECCD0 /* PlayerQueue.swift in Sources */, 375168D82700FDB9008F96A6 /* Debounce.swift in Sources */, 37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */, 37AAF29226740715007FC770 /* Channel.swift in Sources */, 37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, - 37B81B0726D2D6CF00675966 /* PlaybackModel.swift in Sources */, 37732FF22703A26300F04329 /* ValidationStatusView.swift in Sources */, 37666BAA27023AF000F869E5 /* AccountSelectionView.swift in Sources */, 3765788B2685471400D4EA09 /* Playlist.swift in Sources */, @@ -1362,18 +1469,23 @@ 37B767DD2677C3CA0098BAA8 /* PlayerModel.swift in Sources */, 373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, 3761AC1126F0F9A600AA496F /* UnsubscribeAlertModifier.swift in Sources */, + 3730D8A02712E2B70020ED53 /* NowPlayingView.swift in Sources */, 37D4B18E26717B3800C925CA /* VideoView.swift in Sources */, 37BE0BD126A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, 37AAF27E26737323007FC770 /* PopularView.swift in Sources */, + 3743CA50270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */, 37A9966026D6F9B9006E3224 /* WatchNowView.swift in Sources */, 37484C1F26FC83A400287258 /* InstancesSettingsView.swift in Sources */, 37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 376578932685490700D4EA09 /* PlaylistsView.swift in Sources */, + 37CC3F47270CE30600608308 /* PlayerQueueItem.swift in Sources */, 37BA795126DC3E0E002A0235 /* Int+Format.swift in Sources */, 3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, 377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 3748186826A7627F0084E870 /* Video+Fixtures.swift in Sources */, + 3743CA54270F284F00E4D32B /* View+Borders.swift in Sources */, 371F2F1C269B43D300E4A7AB /* NavigationModel.swift in Sources */, + 37E2EEAD270656EC00170416 /* PlayerControlsView.swift in Sources */, 37BA794526DBA973002A0235 /* PlaylistsModel.swift in Sources */, 37B17DA0268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */, 37BE0BD726A1D4A90092E2DB /* PlayerViewController.swift in Sources */, @@ -1399,6 +1511,7 @@ 37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */, 37484C1B26FC837400287258 /* PlaybackSettingsView.swift in Sources */, 372915E82687E3B900F5A35B /* Defaults.swift in Sources */, + 37CC3F4E270CFE1700608308 /* PlayerQueueView.swift in Sources */, 37BAB54C269B39FD00E75ED1 /* TVNavigationView.swift in Sources */, 3797758D2689345500DD52A8 /* Store.swift in Sources */, 37484C2F26FC844700287258 /* AccountsSettingsView.swift in Sources */, @@ -2086,6 +2199,14 @@ minimumVersion = 5.0.0; }; }; + 3743CA48270EF79400E4D32B /* XCRemoteSwiftPackageReference "SwiftUIKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/danielsaidi/SwiftUIKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; 3797757B268922D100DD52A8 /* XCRemoteSwiftPackageReference "siesta" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/bustoutsolutions/siesta"; @@ -2134,6 +2255,16 @@ package = 372915E22687E33E00F5A35B /* XCRemoteSwiftPackageReference "Defaults" */; productName = Defaults; }; + 3743CA49270EF79400E4D32B /* SwiftUIKit */ = { + isa = XCSwiftPackageProductDependency; + package = 3743CA48270EF79400E4D32B /* XCRemoteSwiftPackageReference "SwiftUIKit" */; + productName = SwiftUIKit; + }; + 3743CA4B270EF7A500E4D32B /* SwiftUIKit */ = { + isa = XCSwiftPackageProductDependency; + package = 3743CA48270EF79400E4D32B /* XCRemoteSwiftPackageReference "SwiftUIKit" */; + productName = SwiftUIKit; + }; 377FC7D4267A080300A6BBAF /* SwiftyJSON */ = { isa = XCSwiftPackageProductDependency; package = 37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */; diff --git a/Pearvidious.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Pearvidious.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e4e6775c..80793e68 100644 --- a/Pearvidious.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Pearvidious.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -46,6 +46,15 @@ "version": "0.1.3" } }, + { + "package": "SwiftUIKit", + "repositoryURL": "https://github.com/danielsaidi/SwiftUIKit.git", + "state": { + "branch": null, + "revision": "ad509355ba9bc87f8375a297c3df93acd42e6c01", + "version": "2.0.0" + } + }, { "package": "SwiftyJSON", "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git", diff --git a/Shared/Assets.xcassets/PlayerControlsBorderColor.colorset/Contents.json b/Shared/Assets.xcassets/PlayerControlsBorderColor.colorset/Contents.json new file mode 100644 index 00000000..0dfef113 --- /dev/null +++ b/Shared/Assets.xcassets/PlayerControlsBorderColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.757", + "green" : "0.761", + "red" : "0.757" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.259", + "green" : "0.259", + "red" : "0.259" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/Navigation/AppSidebarPlaylists.swift b/Shared/Navigation/AppSidebarPlaylists.swift index e68c35a1..890b9ddf 100644 --- a/Shared/Navigation/AppSidebarPlaylists.swift +++ b/Shared/Navigation/AppSidebarPlaylists.swift @@ -2,6 +2,7 @@ import SwiftUI struct AppSidebarPlaylists: View { @EnvironmentObject private var navigation + @EnvironmentObject private var player @EnvironmentObject private var playlists var body: some View { @@ -15,6 +16,11 @@ struct AppSidebarPlaylists: View { } .id(playlist.id) .contextMenu { + Button("Add to queue...") { + playlists.find(id: playlist.id)?.videos.forEach { video in + player.enqueueVideo(video) + } + } Button("Edit") { navigation.presentEditPlaylistForm(playlists.find(id: playlist.id)) } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 5ae3fc43..af1d4f1c 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -2,9 +2,14 @@ import Defaults import SwiftUI struct ContentView: View { + @StateObject private var api = InvidiousAPI() + @StateObject private var instances = InstancesModel() @StateObject private var navigation = NavigationModel() - @StateObject private var playback = PlaybackModel() + @StateObject private var player = PlayerModel() + @StateObject private var playlists = PlaylistsModel() @StateObject private var recents = RecentsModel() + @StateObject private var search = SearchModel() + @StateObject private var subscriptions = SubscriptionsModel() #if os(iOS) @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -24,34 +29,62 @@ struct ContentView: View { TVNavigationView() #endif } + .onAppear(perform: configureAPI) + .environmentObject(api) + .environmentObject(instances) .environmentObject(navigation) - .environmentObject(playback) + .environmentObject(player) + .environmentObject(playlists) .environmentObject(recents) - #if !os(tvOS) - .sheet(isPresented: $navigation.showingVideo) { - if let video = navigation.video { - VideoPlayerView(video) - .environmentObject(playback) - - #if !os(iOS) - .frame(minWidth: 550, minHeight: 720) - .onExitCommand { - navigation.showingVideo = false - } - #endif - } + .environmentObject(search) + .environmentObject(subscriptions) + #if os(iOS) + .fullScreenCover(isPresented: $player.presentingPlayer) { + VideoPlayerView() + .environmentObject(api) + .environmentObject(navigation) + .environmentObject(player) + .environmentObject(subscriptions) } + #elseif os(macOS) + .sheet(isPresented: $player.presentingPlayer) { + VideoPlayerView() + .frame(minWidth: 900, minHeight: 800) + .environmentObject(api) + .environmentObject(navigation) + .environmentObject(player) + .environmentObject(subscriptions) + } + #endif + #if !os(tvOS) .sheet(isPresented: $navigation.presentingAddToPlaylist) { AddToPlaylistView(video: navigation.videoToAddToPlaylist) + .environmentObject(api) + .environmentObject(playlists) } .sheet(isPresented: $navigation.presentingPlaylistForm) { PlaylistFormView(playlist: $navigation.editedPlaylist) + .environmentObject(api) + .environmentObject(playlists) } .sheet(isPresented: $navigation.presentingSettings) { SettingsView() + .environmentObject(api) + .environmentObject(instances) } #endif } + + func configureAPI() { + if let account = instances.defaultAccount, api.account.isEmpty { + api.setAccount(account) + } + + player.api = api + playlists.api = api + search.api = api + subscriptions.api = api + } } struct ContentView_Previews: PreviewProvider { diff --git a/Shared/PearvidiousApp.swift b/Shared/PearvidiousApp.swift index 0ac1e240..dd92267f 100644 --- a/Shared/PearvidiousApp.swift +++ b/Shared/PearvidiousApp.swift @@ -3,21 +3,9 @@ import SwiftUI @main struct PearvidiousApp: App { - @StateObject private var api = InvidiousAPI() - @StateObject private var instances = InstancesModel() - @StateObject private var playlists = PlaylistsModel() - @StateObject private var search = SearchModel() - @StateObject private var subscriptions = SubscriptionsModel() - var body: some Scene { WindowGroup { ContentView() - .onAppear(perform: configureAPI) - .environmentObject(api) - .environmentObject(instances) - .environmentObject(playlists) - .environmentObject(search) - .environmentObject(subscriptions) } #if !os(tvOS) .commands { @@ -28,20 +16,9 @@ struct PearvidiousApp: App { #if os(macOS) Settings { SettingsView() - .onAppear(perform: configureAPI) - .environmentObject(api) - .environmentObject(instances) + .environmentObject(InvidiousAPI()) + .environmentObject(InstancesModel()) } #endif } - - fileprivate func configureAPI() { - playlists.api = api - search.api = api - subscriptions.api = api - - if let account = instances.defaultAccount, api.account.isEmpty { - api.setAccount(account) - } - } } diff --git a/Shared/Player/PlaybackBar.swift b/Shared/Player/PlaybackBar.swift index e94509b7..f224404d 100644 --- a/Shared/Player/PlaybackBar.swift +++ b/Shared/Player/PlaybackBar.swift @@ -2,55 +2,59 @@ import Foundation import SwiftUI struct PlaybackBar: View { - let video: Video - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var playback: PlaybackModel + @Environment(\.inNavigationView) private var inNavigationView + + @EnvironmentObject private var player var body: some View { HStack { closeButton - .frame(width: 60, alignment: .leading) + .frame(width: 80, alignment: .leading) - Text(playbackStatus) - .foregroundColor(.gray) - .font(.caption2) - .frame(minWidth: 60, maxWidth: .infinity) + if player.currentItem != nil { + Text(playbackStatus) + .foregroundColor(.gray) + .font(.caption2) + .frame(minWidth: 130, maxWidth: .infinity) - VStack { - if playback.stream != nil { - Text(currentStreamString) - } else { - if video.live { - Image(systemName: "dot.radiowaves.left.and.right") + VStack { + if player.stream != nil { + Text(currentStreamString) } else { - Image(systemName: "bolt.horizontal.fill") + if player.currentVideo!.live { + Image(systemName: "dot.radiowaves.left.and.right") + } else { + Image(systemName: "bolt.horizontal.fill") + } } } + .foregroundColor(.gray) + .font(.caption2) + .frame(width: 80, alignment: .trailing) + .fixedSize(horizontal: true, vertical: true) + } else { + Spacer() } - .foregroundColor(.gray) - .font(.caption2) - .frame(width: 60, alignment: .trailing) - .fixedSize(horizontal: true, vertical: true) } .padding(4) .background(.black) } var currentStreamString: String { - playback.stream != nil ? "\(playback.stream!.resolution.height)p" : "" + "\(player.stream!.resolution.height)p" } var playbackStatus: String { - guard playback.time != nil else { - if playback.live { - return "LIVE" - } else { - return "loading..." - } + if player.live { + return "LIVE" } - let remainingSeconds = video.length - playback.time!.seconds + guard player.time != nil, player.time!.isValid else { + return "loading..." + } + + let remainingSeconds = player.currentVideo!.length - player.time!.seconds if remainingSeconds < 60 { return "less than a minute" @@ -59,12 +63,15 @@ struct PlaybackBar: View { let timeFinishAt = Date.now.addingTimeInterval(remainingSeconds) let timeFinishAtString = timeFinishAt.formatted(date: .omitted, time: .shortened) - return "finishes at \(timeFinishAtString)" + return "ends at \(timeFinishAtString)" } var closeButton: some View { - Button(action: { dismiss() }) { - Image(systemName: "xmark.circle.fill") + Button { + dismiss() + } label: { + Label("Close", systemImage: inNavigationView ? "chevron.backward.circle.fill" : "chevron.down.circle.fill") + .labelStyle(.iconOnly) } .accessibilityLabel(Text("Close")) .buttonStyle(.borderless) diff --git a/Shared/Player/Player.swift b/Shared/Player/Player.swift index b915bafa..d83ba60b 100644 --- a/Shared/Player/Player.swift +++ b/Shared/Player/Player.swift @@ -3,15 +3,23 @@ import SwiftUI struct Player: UIViewControllerRepresentable { @EnvironmentObject private var api - @EnvironmentObject private var playback + @EnvironmentObject private var player - var video: Video? + var controller: PlayerViewController? + + init(controller: PlayerViewController? = nil) { + self.controller = controller + } func makeUIViewController(context _: Context) -> PlayerViewController { + if self.controller != nil { + return self.controller! + } + let controller = PlayerViewController() - controller.video = video - controller.playback = playback + player.controller = controller + controller.playerModel = player controller.api = api controller.resolution = Defaults[.quality] diff --git a/Shared/Player/PlayerQueueRow.swift b/Shared/Player/PlayerQueueRow.swift new file mode 100644 index 00000000..283d8515 --- /dev/null +++ b/Shared/Player/PlayerQueueRow.swift @@ -0,0 +1,37 @@ +import Foundation +import SwiftUI + +struct PlayerQueueRow: View { + let item: PlayerQueueItem + var history = false + @Binding var fullScreen: Bool + + @EnvironmentObject private var player + + var body: some View { + Group { + Button { + player.addCurrentItemToHistory() + + if history { + let newItem = player.enqueueVideo(item.video, prepending: true) + player.advanceToItem(newItem!) + if let historyItemIndex = player.history.firstIndex(of: item) { + player.history.remove(at: historyItemIndex) + } + } else { + player.advanceToItem(item) + } + + if fullScreen { + withAnimation { + fullScreen = false + } + } + } label: { + VideoBanner(video: item.video) + } + .buttonStyle(.plain) + } + } +} diff --git a/Shared/Player/PlayerQueueView.swift b/Shared/Player/PlayerQueueView.swift new file mode 100644 index 00000000..e287fde7 --- /dev/null +++ b/Shared/Player/PlayerQueueView.swift @@ -0,0 +1,100 @@ +import Foundation +import SwiftUI + +struct PlayerQueueView: View { + @Binding var fullScreen: Bool + + @EnvironmentObject private var player + + var body: some View { + List { + playingNext + playedPreviously + } + + #if os(macOS) + .listStyle(.groupedWithInsets) + #elseif os(iOS) + .listStyle(.insetGrouped) + #else + .listStyle(.plain) + #endif + } + + var playingNext: some View { + Section(header: Text("Playing Next")) { + if player.queue.isEmpty { + Text("Playback queue is empty") + .foregroundColor(.secondary) + } + + ForEach(player.queue) { item in + PlayerQueueRow(item: item, fullScreen: $fullScreen) + .contextMenu { + removeButton(item, history: false) + removeAllButton(history: false) + } + #if os(iOS) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + removeButton(item, history: false) + } + #endif + } + } + } + + var playedPreviously: some View { + Section(header: Text("Played Previously")) { + if player.history.isEmpty { + Text("History is empty") + .foregroundColor(.secondary) + } + + ForEach(player.history) { item in + PlayerQueueRow(item: item, history: true, fullScreen: $fullScreen) + .contextMenu { + removeButton(item, history: true) + removeAllButton(history: true) + } + #if os(iOS) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + removeButton(item, history: true) + } + #endif + } + } + } + + func removeButton(_ item: PlayerQueueItem, history: Bool) -> some View { + Button(role: .destructive) { + if history { + player.removeHistory(item) + } else { + player.remove(item) + } + } label: { + Label("Remove", systemImage: "trash") + } + } + + func removeAllButton(history: Bool) -> some View { + Button(role: .destructive) { + if history { + player.removeHistoryItems() + } else { + player.removeQueueItems() + } + } label: { + Label("Remove All", systemImage: "trash.fill") + } + } +} + +struct PlayerQueueView_Previews: PreviewProvider { + static var previews: some View { + VStack { + PlayerQueueView(fullScreen: .constant(true)) + } + .injectFixtureEnvironmentObjects() + } +} diff --git a/Shared/Player/PlayerViewController.swift b/Shared/Player/PlayerViewController.swift index 7fb1700f..f9e012d7 100644 --- a/Shared/Player/PlayerViewController.swift +++ b/Shared/Player/PlayerViewController.swift @@ -3,15 +3,12 @@ import Logging import SwiftUI final class PlayerViewController: UIViewController { - var video: Video! - var api: InvidiousAPI! var playerLoaded = false - var player = AVPlayer() var playerModel: PlayerModel! - var playback: PlaybackModel! var playerViewController = AVPlayerViewController() var resolution: Stream.ResolutionSetting! + var shouldResume = false override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -22,61 +19,42 @@ final class PlayerViewController: UIViewController { try? AVAudioSession.sharedInstance().setActive(true) } - override func viewDidDisappear(_ animated: Bool) { - #if os(iOS) - if !playerModel.playingOutsideViewController { - playerViewController.player?.replaceCurrentItem(with: nil) - playerViewController.player = nil - - try? AVAudioSession.sharedInstance().setActive(false) - } - #endif - - super.viewDidDisappear(animated) - } - func loadPlayer() { - playerModel = PlayerModel(playback: playback, api: api, resolution: resolution) - guard !playerLoaded else { return } - playerModel.player = player + playerModel.controller = self playerViewController.player = playerModel.player - playerModel.loadVideo(video) + playerViewController.allowsPictureInPicturePlayback = true + playerViewController.delegate = self #if os(tvOS) + playerModel.avPlayerViewController = playerViewController + playerViewController.customInfoViewControllers = [playerQueueInfoViewController] present(playerViewController, animated: false) - - addItemDidPlayToEndTimeObserver() #else embedViewController() #endif - playerViewController.allowsPictureInPicturePlayback = true - playerViewController.delegate = self playerLoaded = true } #if os(tvOS) - func addItemDidPlayToEndTimeObserver() { - NotificationCenter.default.addObserver( - self, - selector: #selector(itemDidPlayToEndTime), - name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, - object: nil + var playerQueueInfoViewController: UIHostingController { + let controller = UIHostingController(rootView: + AnyView( + NowPlayingView(infoViewController: true) + .environmentObject(playerModel) + ) ) - } - @objc func itemDidPlayToEndTime() { - playerViewController.dismiss(animated: true) { - self.dismiss(animated: false) - } + controller.title = "Playing Next" + + return controller } #else func embedViewController() { - playerViewController.exitsFullScreenWhenPlaybackEnds = true playerViewController.view.frame = view.bounds addChild(playerViewController) @@ -96,17 +74,22 @@ extension PlayerViewController: AVPlayerViewControllerDelegate { false } + func playerViewControllerWillBeginDismissalTransition(_: AVPlayerViewController) { + shouldResume = playerModel.isPlaying + } + func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) { - playerModel.playingOutsideViewController = false + if shouldResume { + playerModel.player.play() + } + dismiss(animated: false) } func playerViewController( _: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator - ) { - playerModel.playingOutsideViewController = true - } + ) {} func playerViewController( _: AVPlayerViewController, @@ -114,8 +97,6 @@ extension PlayerViewController: AVPlayerViewControllerDelegate { ) { coordinator.animate(alongsideTransition: nil) { context in if !context.isCancelled { - self.playerModel.playingOutsideViewController = false - #if os(iOS) if self.traitCollection.verticalSizeClass == .compact { self.dismiss(animated: true) @@ -125,11 +106,7 @@ extension PlayerViewController: AVPlayerViewControllerDelegate { } } - func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) { - playerModel.playingOutsideViewController = true - } + func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) {} - func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) { - playerModel.playingOutsideViewController = false - } + func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {} } diff --git a/Shared/Player/VideoDetails.swift b/Shared/Player/VideoDetails.swift index 9d10a29f..f36dab64 100644 --- a/Shared/Player/VideoDetails.swift +++ b/Shared/Player/VideoDetails.swift @@ -2,160 +2,305 @@ import Foundation import SwiftUI struct VideoDetails: View { - @EnvironmentObject private var subscriptions + enum Page { + case details, queue + } + + @Binding var sidebarQueue: Bool + @Binding var fullScreen: Bool @State private var subscribed = false @State private var confirmationShown = false - var video: Video + @State private var currentPage = Page.details + + @Environment(\.dismiss) private var dismiss + + @EnvironmentObject private var player + @EnvironmentObject private var subscriptions + + init( + sidebarQueue: Binding? = nil, + fullScreen: Binding? = nil + ) { + _sidebarQueue = sidebarQueue ?? .constant(true) + _fullScreen = fullScreen ?? .constant(false) + } + + var video: Video? { + player.currentItem?.video + } var body: some View { VStack(alignment: .leading) { - Text(video.title) - .font(.title2.bold()) - .padding(.bottom, 0) + Group { + Group { + HStack(spacing: 0) { + title - Divider() - - HStack(alignment: .center) { - HStack(spacing: 4) { - if subscribed { - Image(systemName: "star.circle.fill") + toggleFullScreenDetailsButton } - VStack(alignment: .leading) { - Text(video.channel.name) - .font(.system(size: 13)) - .bold() - if let subscribers = video.channel.subscriptionsString { - Text("\(subscribers) subscribers") - .font(.caption2) + #if os(macOS) + .padding(.top, 10) + #endif + + if !video.isNil { + Divider() + } + + subscriptionsSection + } + .padding(.horizontal) + + if !video.isNil, !sidebarQueue { + pagePicker + .padding(.horizontal) + } + } + .contentShape(Rectangle()) + .onSwipeGesture( + up: { + withAnimation { + fullScreen = true + } + }, + down: { + withAnimation { + if fullScreen { + fullScreen = false + } else { + self.dismiss() } } } - .foregroundColor(.secondary) + ) - Spacer() + switch currentPage { + case .details: + ScrollView(.vertical) { + detailsPage + } + case .queue: + PlayerQueueView(fullScreen: $fullScreen) + .edgesIgnoringSafeArea(.horizontal) + } + } + .onAppear { + guard video != nil else { + return + } - Section { - if subscribed { - Button("Unsubscribe") { - confirmationShown = true - } - #if os(iOS) - .tint(.gray) + subscribed = subscriptions.isSubscribing(video!.channel.id) + } + .edgesIgnoringSafeArea(.horizontal) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + + var title: some View { + Group { + if video != nil { + Text(video!.title) + .onAppear { + #if !os(macOS) + currentPage = .details #endif - .confirmationDialog("Are you you want to unsubscribe from \(video.channel.name)?", isPresented: $confirmationShown) { + } + + .font(.title2.bold()) + } else { + Text("Not playing") + .foregroundColor(.secondary) + .onAppear { + #if !os(macOS) + currentPage = .queue + #endif + } + } + + Spacer() + } + } + + var toggleFullScreenDetailsButton: some View { + Button { + withAnimation { + fullScreen.toggle() + } + } label: { + Label("Resize", systemImage: fullScreen ? "chevron.down" : "chevron.up") + .labelStyle(.iconOnly) + } + .help("Toggle fullscreen details") + .buttonStyle(.plain) + .keyboardShortcut("t") + } + + var subscriptionsSection: some View { + Group { + if video != nil { + HStack(alignment: .center) { + HStack(spacing: 4) { + if subscribed { + Image(systemName: "star.circle.fill") + } + VStack(alignment: .leading) { + Text(video!.channel.name) + .font(.system(size: 13)) + .bold() + if let subscribers = video!.channel.subscriptionsString { + Text("\(subscribers) subscribers") + .font(.caption2) + } + } + } + .foregroundColor(.secondary) + + Spacer() + + Section { + if subscribed { Button("Unsubscribe") { - subscriptions.unsubscribe(video.channel.id) + confirmationShown = true + } + #if os(iOS) + .tint(.gray) + #endif + .confirmationDialog("Are you you want to unsubscribe from \(video!.channel.name)?", isPresented: $confirmationShown) { + Button("Unsubscribe") { + subscriptions.unsubscribe(video!.channel.id) + + withAnimation { + subscribed.toggle() + } + } + } + } else { + Button("Subscribe") { + subscriptions.subscribe(video!.channel.id) withAnimation { subscribed.toggle() } } + .tint(.blue) } - } else { - Button("Subscribe") { - subscriptions.subscribe(video.channel.id) + } + .font(.system(size: 13)) + .buttonStyle(.borderless) + .buttonBorderShape(.roundedRectangle) + } + Divider() + } + } + } - withAnimation { - subscribed.toggle() - } + var pagePicker: some View { + Picker("Page", selection: $currentPage) { + Text("Details").tag(Page.details) + Text("Queue").tag(Page.queue) + } + + .pickerStyle(.segmented) + .onDisappear { + currentPage = .details + } + } + + var publishedDateSection: some View { + Group { + if let video = player.currentItem.video { + HStack(spacing: 4) { + if let published = video.publishedDate { + Text(published) + } + + if let publishedAt = video.publishedAt { + if video.publishedDate != nil { + Text("•") + .foregroundColor(.secondary) + .opacity(0.3) } - .tint(.blue) + Text(publishedAt.formatted(date: .abbreviated, time: .omitted)) } } - .font(.system(size: 13)) - .buttonStyle(.borderless) - .buttonBorderShape(.roundedRectangle) + .font(.system(size: 12)) + .padding(.bottom, -1) + .foregroundColor(.secondary) } - .padding(.bottom, -1) + } + } - Divider() + var countsSection: some View { + Group { + if let video = player.currentItem.video { + HStack { + Spacer() - HStack(spacing: 4) { - if let published = video.publishedDate { - Text(published) - } - - if let publishedAt = video.publishedAt { - if video.publishedDate != nil { - Text("•") - .foregroundColor(.secondary) - .opacity(0.3) + if let views = video.viewsCount { + videoDetail(label: "Views", value: views, symbol: "eye.fill") } - Text(publishedAt.formatted(date: .abbreviated, time: .omitted)) + + if let likes = video.likesCount { + Divider() + + videoDetail(label: "Likes", value: likes, symbol: "hand.thumbsup.circle.fill") + } + + if let dislikes = video.dislikesCount { + Divider() + + videoDetail(label: "Dislikes", value: dislikes, symbol: "hand.thumbsdown.circle.fill") + } + + Spacer() } + .frame(maxHeight: 35) + .foregroundColor(.secondary) } - .font(.system(size: 12)) - .padding(.bottom, -1) - .foregroundColor(.secondary) + } + } - Divider() + var detailsPage: some View { + Group { + if let video = player.currentItem?.video { + Group { + publishedDateSection - HStack { - Spacer() - - if let views = video.viewsCount { - videoDetail(label: "Views", value: views, symbol: "eye.fill") - } - - if let likes = video.likesCount { Divider() - videoDetail(label: "Likes", value: likes, symbol: "hand.thumbsup.circle.fill") + countsSection } - if let dislikes = video.dislikesCount { - Divider() + Divider() - videoDetail(label: "Dislikes", value: dislikes, symbol: "hand.thumbsdown.circle.fill") - } - - Spacer() - } - .frame(maxHeight: 35) - .foregroundColor(.secondary) - - Divider() - - #if os(macOS) - ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 10) { Text(video.description) .font(.caption) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 100, alignment: .leading) - } - #else - Text(video.description) - .font(.caption) - #endif - ScrollView(.horizontal, showsIndicators: showScrollIndicators) { - HStack { - ForEach(video.keywords, id: \.self) { keyword in - HStack(alignment: .center, spacing: 0) { - Text("#") - .font(.system(size: 11).bold()) + ScrollView(.horizontal, showsIndicators: showScrollIndicators) { + HStack { + ForEach(video.keywords, id: \.self) { keyword in + HStack(alignment: .center, spacing: 0) { + Text("#") + .font(.system(size: 11).bold()) - Text(keyword) - .frame(maxWidth: 500) - }.foregroundColor(.white) - .padding(.vertical, 4) - .padding(.horizontal, 8) - - .background(Color("VideoDetailLikesSymbolColor")) - .mask(RoundedRectangle(cornerRadius: 3)) - - .font(.caption) + Text(keyword) + .frame(maxWidth: 500) + } + .font(.caption) + .foregroundColor(.white) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color("VideoDetailLikesSymbolColor")) + .mask(RoundedRectangle(cornerRadius: 3)) + } + } + .padding(.bottom, 10) } } - .padding(.bottom, 10) } } - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - .padding([.horizontal, .bottom]) - .onAppear { - subscribed = subscriptions.isSubscribing(video.channel.id) - } + .padding(.horizontal) } func videoDetail(label: String, value: String, symbol: String) -> some View { @@ -185,7 +330,7 @@ struct VideoDetails: View { struct VideoDetails_Previews: PreviewProvider { static var previews: some View { - VideoDetails(video: Video.fixture) + VideoDetails(sidebarQueue: .constant(false)) .injectFixtureEnvironmentObjects() } } diff --git a/Shared/Player/VideoDetailsPaddingModifier.swift b/Shared/Player/VideoDetailsPaddingModifier.swift index b826db04..ebbc34c3 100644 --- a/Shared/Player/VideoDetailsPaddingModifier.swift +++ b/Shared/Player/VideoDetailsPaddingModifier.swift @@ -2,21 +2,32 @@ import Foundation import SwiftUI struct VideoDetailsPaddingModifier: ViewModifier { + static var defaultAdditionalDetailsPadding: Double { + #if os(macOS) + 20 + #else + 35 + #endif + } + let geometry: GeometryProxy let aspectRatio: Double? let minimumHeightLeft: Double let additionalPadding: Double + let fullScreen: Bool init( geometry: GeometryProxy, aspectRatio: Double? = nil, minimumHeightLeft: Double? = nil, - additionalPadding: Double = 35.00 + additionalPadding: Double? = nil, + fullScreen: Bool = false ) { self.geometry = geometry self.aspectRatio = aspectRatio ?? VideoPlayerView.defaultAspectRatio self.minimumHeightLeft = minimumHeightLeft ?? VideoPlayerView.defaultMinimumHeightLeft - self.additionalPadding = additionalPadding + self.additionalPadding = additionalPadding ?? VideoDetailsPaddingModifier.defaultAdditionalDetailsPadding + self.fullScreen = fullScreen } var usedAspectRatio: Double { @@ -32,7 +43,7 @@ struct VideoDetailsPaddingModifier: ViewModifier { } var topPadding: Double { - playerHeight + additionalPadding + fullScreen ? 0 : (playerHeight + additionalPadding) } func body(content: Content) -> some View { diff --git a/Shared/Player/VideoPlayerSizeModifier.swift b/Shared/Player/VideoPlayerSizeModifier.swift index 9b5d5682..b1f9217d 100644 --- a/Shared/Player/VideoPlayerSizeModifier.swift +++ b/Shared/Player/VideoPlayerSizeModifier.swift @@ -2,7 +2,7 @@ import Foundation import SwiftUI struct VideoPlayerSizeModifier: ViewModifier { - let geometry: GeometryProxy + let geometry: GeometryProxy! let aspectRatio: Double? let minimumHeightLeft: Double @@ -11,7 +11,7 @@ struct VideoPlayerSizeModifier: ViewModifier { #endif init( - geometry: GeometryProxy, + geometry: GeometryProxy? = nil, aspectRatio: Double? = nil, minimumHeightLeft: Double? = nil ) { @@ -21,10 +21,15 @@ struct VideoPlayerSizeModifier: ViewModifier { } func body(content: Content) -> some View { - content - .frame(maxHeight: maxHeight) - .aspectRatio(usedAspectRatio, contentMode: usedAspectRatioContentMode) - .edgesIgnoringSafeArea(edgesIgnoringSafeArea) + // TODO: verify if optional GeometryProxy is still used + if geometry != nil { + content + .frame(maxHeight: maxHeight) + .aspectRatio(usedAspectRatio, contentMode: usedAspectRatioContentMode) + .edgesIgnoringSafeArea(edgesIgnoringSafeArea) + } else { + content.edgesIgnoringSafeArea(edgesIgnoringSafeArea) + } } var usedAspectRatio: Double { diff --git a/Shared/Player/VideoPlayerView.swift b/Shared/Player/VideoPlayerView.swift index c947a924..4cd250ba 100644 --- a/Shared/Player/VideoPlayerView.swift +++ b/Shared/Player/VideoPlayerView.swift @@ -1,6 +1,10 @@ import AVKit +import Defaults import Siesta import SwiftUI +#if !os(tvOS) + import SwiftUIKit +#endif struct VideoPlayerView: View { static let defaultAspectRatio: Double = 1.77777778 @@ -12,103 +16,154 @@ struct VideoPlayerView: View { #endif } - @StateObject private var store = Store