mirror of
				https://github.com/yattee/yattee.git
				synced 2025-10-31 04:31:54 +00:00 
			
		
		
		
	Initial functionality of player items queue
Fix environment objects Hide video player placeholder on tvOS Queue improvements
This commit is contained in:
		
							
								
								
									
										16
									
								
								Extensions/View+Borders.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								Extensions/View+Borders.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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<TabSelection> { | ||||
|         Binding<TabSelection>( | ||||
|             get: { | ||||
|   | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										145
									
								
								Model/PlayerQueue.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								Model/PlayerQueue.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										14
									
								
								Model/PlayerQueueItem.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								Model/PlayerQueueItem.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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]() | ||||
| } | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 = "<group>"; }; | ||||
| 		371F2F19269B43D300E4A7AB /* NavigationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationModel.swift; sourceTree = "<group>"; }; | ||||
| 		372915E52687E3B900F5A35B /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = "<group>"; }; | ||||
| 		3730D89F2712E2B70020ED53 /* NowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingView.swift; sourceTree = "<group>"; }; | ||||
| 		37319F0427103F94004ECCD0 /* PlayerQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueue.swift; sourceTree = "<group>"; }; | ||||
| 		373CFACA26966264003CB2C6 /* SearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQuery.swift; sourceTree = "<group>"; }; | ||||
| 		373CFADA269663F1003CB2C6 /* Thumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnail.swift; sourceTree = "<group>"; }; | ||||
| 		373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistFormView.swift; sourceTree = "<group>"; }; | ||||
| 		373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToPlaylistView.swift; sourceTree = "<group>"; }; | ||||
| 		3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueRow.swift; sourceTree = "<group>"; }; | ||||
| 		3743CA51270F284F00E4D32B /* View+Borders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Borders.swift"; sourceTree = "<group>"; }; | ||||
| 		3748186526A7627F0084E870 /* Video+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Video+Fixtures.swift"; sourceTree = "<group>"; }; | ||||
| 		3748186926A764FB0084E870 /* Thumbnail+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Thumbnail+Fixtures.swift"; sourceTree = "<group>"; }; | ||||
| 		3748186D26A769D60084E870 /* DetailBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailBadge.swift; sourceTree = "<group>"; }; | ||||
| @@ -384,7 +409,6 @@ | ||||
| 		37B81AFB26D2C9C900675966 /* VideoDetailsPaddingModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDetailsPaddingModifier.swift; sourceTree = "<group>"; }; | ||||
| 		37B81AFE26D2CA3700675966 /* VideoDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDetails.swift; sourceTree = "<group>"; }; | ||||
| 		37B81B0126D2CAE700675966 /* PlaybackBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBar.swift; sourceTree = "<group>"; }; | ||||
| 		37B81B0426D2CEDA00675966 /* PlaybackModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackModel.swift; sourceTree = "<group>"; }; | ||||
| 		37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistVideosView.swift; sourceTree = "<group>"; }; | ||||
| 		37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelVideosView.swift; sourceTree = "<group>"; }; | ||||
| 		37BA794226DBA973002A0235 /* PlaylistsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsModel.swift; sourceTree = "<group>"; }; | ||||
| @@ -404,6 +428,9 @@ | ||||
| 		37BE0BDB26A2367F0092E2DB /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = "<group>"; }; | ||||
| 		37C194C626F6A9C8005D3B96 /* RecentsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentsModel.swift; sourceTree = "<group>"; }; | ||||
| 		37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockSegment.swift; sourceTree = "<group>"; }; | ||||
| 		37CC3F44270CE30600608308 /* PlayerQueueItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueItem.swift; sourceTree = "<group>"; }; | ||||
| 		37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueView.swift; sourceTree = "<group>"; }; | ||||
| 		37CC3F4F270D010D00608308 /* VideoBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoBanner.swift; sourceTree = "<group>"; }; | ||||
| 		37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleAssetStream.swift; sourceTree = "<group>"; }; | ||||
| 		37CEE4C02677B697005A1EFE /* Stream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stream.swift; sourceTree = "<group>"; }; | ||||
| 		37D4B0C22671614700C925CA /* PearvidiousApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PearvidiousApp.swift; sourceTree = "<group>"; }; | ||||
| @@ -422,6 +449,7 @@ | ||||
| 		37D4B18B26717B3800C925CA /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = "<group>"; }; | ||||
| 		37D4B19626717E1500C925CA /* Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Video.swift; sourceTree = "<group>"; }; | ||||
| 		37D4B1AE26729DEB00C925CA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; | ||||
| 		37E2EEAA270656EC00170416 /* PlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsView.swift; sourceTree = "<group>"; }; | ||||
| 		37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsModel.swift; sourceTree = "<group>"; }; | ||||
| 		37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockAPI.swift; sourceTree = "<group>"; }; | ||||
| 		37EAD86E267B9ED100D9E01B /* Segment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Segment.swift; sourceTree = "<group>"; }; | ||||
| @@ -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 = "<group>"; | ||||
| @@ -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" */; | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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 | ||||
|   } | ||||
| } | ||||
| @@ -2,6 +2,7 @@ import SwiftUI | ||||
|  | ||||
| struct AppSidebarPlaylists: View { | ||||
|     @EnvironmentObject<NavigationModel> private var navigation | ||||
|     @EnvironmentObject<PlayerModel> private var player | ||||
|     @EnvironmentObject<PlaylistsModel> 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)) | ||||
|                     } | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<PlayerModel> 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) | ||||
|   | ||||
| @@ -3,15 +3,23 @@ import SwiftUI | ||||
|  | ||||
| struct Player: UIViewControllerRepresentable { | ||||
|     @EnvironmentObject<InvidiousAPI> private var api | ||||
|     @EnvironmentObject<PlaybackModel> private var playback | ||||
|     @EnvironmentObject<PlayerModel> 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] | ||||
|   | ||||
							
								
								
									
										37
									
								
								Shared/Player/PlayerQueueRow.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								Shared/Player/PlayerQueueRow.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| import Foundation | ||||
| import SwiftUI | ||||
|  | ||||
| struct PlayerQueueRow: View { | ||||
|     let item: PlayerQueueItem | ||||
|     var history = false | ||||
|     @Binding var fullScreen: Bool | ||||
|  | ||||
|     @EnvironmentObject<PlayerModel> 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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										100
									
								
								Shared/Player/PlayerQueueView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								Shared/Player/PlayerQueueView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| import Foundation | ||||
| import SwiftUI | ||||
|  | ||||
| struct PlayerQueueView: View { | ||||
|     @Binding var fullScreen: Bool | ||||
|  | ||||
|     @EnvironmentObject<PlayerModel> 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() | ||||
|     } | ||||
| } | ||||
| @@ -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<AnyView> { | ||||
|             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) {} | ||||
| } | ||||
|   | ||||
| @@ -2,160 +2,305 @@ import Foundation | ||||
| import SwiftUI | ||||
|  | ||||
| struct VideoDetails: View { | ||||
|     @EnvironmentObject<SubscriptionsModel> 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<PlayerModel> private var player | ||||
|     @EnvironmentObject<SubscriptionsModel> private var subscriptions | ||||
|  | ||||
|     init( | ||||
|         sidebarQueue: Binding<Bool>? = nil, | ||||
|         fullScreen: Binding<Bool>? = 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() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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<Video>() | ||||
|     @State private var playerSize: CGSize = .zero | ||||
|     @State private var fullScreen = false | ||||
|  | ||||
|     #if os(iOS) | ||||
|         @Environment(\.dismiss) private var dismiss | ||||
|         @Environment(\.horizontalSizeClass) private var horizontalSizeClass | ||||
|         @Environment(\.verticalSizeClass) private var verticalSizeClass | ||||
|     #endif | ||||
|  | ||||
|     @EnvironmentObject<InvidiousAPI> private var api | ||||
|     @EnvironmentObject<PlaybackModel> private var playback | ||||
|  | ||||
|     var resource: Resource { | ||||
|         api.video(video.id) | ||||
|     } | ||||
|  | ||||
|     var video: Video | ||||
|  | ||||
|     init(_ video: Video) { | ||||
|         self.video = video | ||||
|     } | ||||
|     @EnvironmentObject<PlayerModel> private var player | ||||
|  | ||||
|     var body: some View { | ||||
|         VStack(spacing: 0) { | ||||
|             #if os(tvOS) | ||||
|                 Player(video: video) | ||||
|                     .environmentObject(playback) | ||||
|             #else | ||||
|                 GeometryReader { geometry in | ||||
|                     VStack(spacing: 0) { | ||||
|                         #if os(iOS) | ||||
|                             if verticalSizeClass == .regular { | ||||
|                                 PlaybackBar(video: video) | ||||
|                             } | ||||
|                         #elseif os(macOS) | ||||
|                             PlaybackBar(video: video) | ||||
|                         #endif | ||||
|         #if os(macOS) | ||||
|             HSplitView { | ||||
|                 content | ||||
|             } | ||||
|             .frame(idealWidth: 1000, maxWidth: 1100, minHeight: 700) | ||||
|         #else | ||||
|             HStack(spacing: 0) { | ||||
|                 content | ||||
|             } | ||||
|             #if os(iOS) | ||||
|                 .navigationBarHidden(true) | ||||
|             #endif | ||||
|         #endif | ||||
|     } | ||||
|  | ||||
|                         Player(video: video) | ||||
|                             .environmentObject(playback) | ||||
|                             .modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: playback.aspectRatio)) | ||||
|                     } | ||||
|                     .background(.black) | ||||
|  | ||||
|                     VStack(spacing: 0) { | ||||
|                         #if os(iOS) | ||||
|                             if verticalSizeClass == .regular { | ||||
|                                 ScrollView(.vertical, showsIndicators: showScrollIndicators) { | ||||
|                                     if let video = store.item { | ||||
|                                         VideoDetails(video: video) | ||||
|                                     } else { | ||||
|                                         VideoDetails(video: video) | ||||
|                                     } | ||||
|     var content: some View { | ||||
|         Group { | ||||
|             VStack(alignment: .leading, spacing: 0) { | ||||
|                 #if os(tvOS) | ||||
|                     player() | ||||
|                 #else | ||||
|                     GeometryReader { geometry in | ||||
|                         VStack(spacing: 0) { | ||||
|                             #if os(iOS) | ||||
|                                 if verticalSizeClass == .regular { | ||||
|                                     PlaybackBar() | ||||
|                                 } | ||||
|                             } | ||||
|                         #else | ||||
|                             if let video = store.item { | ||||
|                                 VideoDetails(video: video) | ||||
|                             #elseif os(macOS) | ||||
|                                 PlaybackBar() | ||||
|                             #endif | ||||
|  | ||||
|                             if player.currentItem.isNil { | ||||
|                                 playerPlaceholder(geometry: geometry) | ||||
|                             } else { | ||||
|                                 VideoDetails(video: video) | ||||
|                                 player(geometry: geometry) | ||||
|                             } | ||||
|                         } | ||||
|                         #if os(iOS) | ||||
|                             .onSwipeGesture( | ||||
|                                 up: { | ||||
|                                     withAnimation { | ||||
|                                         fullScreen = true | ||||
|                                     } | ||||
|                                 }, | ||||
|                                 down: { dismiss() } | ||||
|                             ) | ||||
|                         #endif | ||||
|  | ||||
|                         .background(.black) | ||||
|                             .onAppear { | ||||
|                                 self.playerSize = geometry.size | ||||
|                             } | ||||
|                             .onChange(of: geometry.size) { size in | ||||
|                                 self.playerSize = size | ||||
|                             } | ||||
|  | ||||
|                         Group { | ||||
|                             #if os(iOS) | ||||
|                                 if verticalSizeClass == .regular { | ||||
|                                     VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreen) | ||||
|                                 } | ||||
|  | ||||
|                             #else | ||||
|                                 VideoDetails(fullScreen: $fullScreen) | ||||
|                             #endif | ||||
|                         } | ||||
|                         .background() | ||||
|                         .modifier(VideoDetailsPaddingModifier(geometry: geometry, fullScreen: fullScreen)) | ||||
|                     } | ||||
|                     .modifier(VideoDetailsPaddingModifier(geometry: geometry, aspectRatio: playback.aspectRatio)) | ||||
|                 #endif | ||||
|             } | ||||
|             #if os(macOS) | ||||
|                 .frame(minWidth: 650) | ||||
|             #endif | ||||
|             #if os(iOS) | ||||
|                 if sidebarQueue { | ||||
|                     PlayerQueueView(fullScreen: $fullScreen) | ||||
|                         .frame(maxWidth: 350) | ||||
|                 } | ||||
|                 .animation(.linear(duration: 0.2), value: playback.aspectRatio) | ||||
|             #elseif os(macOS) | ||||
|                 PlayerQueueView(fullScreen: $fullScreen) | ||||
|                     .frame(minWidth: 250) | ||||
|             #endif | ||||
|         } | ||||
|         .onAppear { | ||||
|             resource.addObserver(store) | ||||
|             resource.loadIfNeeded() | ||||
|     } | ||||
|  | ||||
|     func playerPlaceholder(geometry: GeometryProxy) -> some View { | ||||
|         HStack { | ||||
|             Spacer() | ||||
|             VStack { | ||||
|                 Spacer() | ||||
|                 VStack(spacing: 10) { | ||||
|                     #if !os(tvOS) | ||||
|                         Image(systemName: "ticket") | ||||
|                             .font(.system(size: 80)) | ||||
|                         Text("What are we watching next?") | ||||
|                     #endif | ||||
|                 } | ||||
|                 Spacer() | ||||
|             } | ||||
|             .foregroundColor(.gray) | ||||
|             Spacer() | ||||
|         } | ||||
|         .onDisappear { | ||||
|             resource.removeObservers(ownedBy: store) | ||||
|             resource.invalidate() | ||||
|         } | ||||
|         #if os(macOS) | ||||
|             .frame(maxWidth: 1000, minHeight: 700) | ||||
|         #elseif os(iOS) | ||||
|             .navigationBarHidden(true) | ||||
|         .contentShape(Rectangle()) | ||||
|         .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio) | ||||
|     } | ||||
|  | ||||
|     func player(geometry: GeometryProxy? = nil) -> some View { | ||||
|         Player() | ||||
|         #if !os(tvOS) | ||||
|             .modifier(VideoPlayerSizeModifier(geometry: geometry)) | ||||
|         #endif | ||||
|     } | ||||
|  | ||||
|     var showScrollIndicators: Bool { | ||||
|         #if os(macOS) | ||||
|             false | ||||
|         #else | ||||
|             true | ||||
|         #endif | ||||
|     } | ||||
|     #if os(iOS) | ||||
|         var sidebarQueue: Bool { | ||||
|             horizontalSizeClass == .regular && playerSize.width > 750 | ||||
|         } | ||||
|  | ||||
|         var sidebarQueueBinding: Binding<Bool> { | ||||
|             Binding( | ||||
|                 get: { self.sidebarQueue }, | ||||
|                 set: { _ in } | ||||
|             ) | ||||
|         } | ||||
|     #endif | ||||
| } | ||||
|  | ||||
| struct VideoPlayerView_Previews: PreviewProvider { | ||||
|     static var previews: some View { | ||||
|         VStack { | ||||
|             Spacer() | ||||
|         } | ||||
|         .sheet(isPresented: .constant(true)) { | ||||
|             VideoPlayerView(Video.fixture) | ||||
|                 .injectFixtureEnvironmentObjects() | ||||
|         } | ||||
|         VideoPlayerView() | ||||
| //            .frame(minWidth: 1200, minHeight: 1400) | ||||
|             .injectFixtureEnvironmentObjects() | ||||
|  | ||||
|         VideoPlayerView() | ||||
|             .injectFixtureEnvironmentObjects() | ||||
|             .previewInterfaceOrientation(.landscapeRight) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -79,14 +79,8 @@ struct AddToPlaylistView: View { | ||||
|  | ||||
|     private var form: some View { | ||||
|         VStack(alignment: formAlignment) { | ||||
|             VStack(alignment: .leading, spacing: 10) { | ||||
|                 Text(video.title) | ||||
|                     .font(.headline) | ||||
|                 Text(video.author) | ||||
|                     .foregroundColor(.secondary) | ||||
|             } | ||||
|             .frame(maxWidth: .infinity, alignment: .leading) | ||||
|             .padding(.vertical, 40) | ||||
|             VideoBanner(video: video) | ||||
|                 .padding(.vertical, 40) | ||||
|  | ||||
|             VStack(alignment: formAlignment) { | ||||
|                 #if os(tvOS) | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import Siesta | ||||
| import SwiftUI | ||||
|  | ||||
| struct PlaylistsView: View { | ||||
|     @EnvironmentObject<PlayerModel> private var player | ||||
|     @EnvironmentObject<PlaylistsModel> private var model | ||||
|  | ||||
|     @State private var showingNewPlaylist = false | ||||
| @@ -18,24 +19,26 @@ struct PlaylistsView: View { | ||||
|     } | ||||
|  | ||||
|     var body: some View { | ||||
|         SignInRequiredView(title: "Playlists") { | ||||
|             VStack { | ||||
|                 #if os(tvOS) | ||||
|                     toolbar | ||||
|                 #endif | ||||
|  | ||||
|                 if model.currentPlaylist != nil, videos.isEmpty { | ||||
|                     hintText("Playlist is empty\n\nTap and hold on a video and then tap \"Add to Playlist\"") | ||||
|                 } else if model.all.isEmpty { | ||||
|                     hintText("You have no playlists\n\nTap on \"New Playlist\" to create one") | ||||
|                 } else { | ||||
|         PlayerControlsView { | ||||
|             SignInRequiredView(title: "Playlists") { | ||||
|                 VStack { | ||||
|                     #if os(tvOS) | ||||
|                         VideosCellsHorizontal(videos: videos) | ||||
|                             .padding(.top, 40) | ||||
|                         Spacer() | ||||
|                     #else | ||||
|                         VideosCellsVertical(videos: videos) | ||||
|                         toolbar | ||||
|                     #endif | ||||
|  | ||||
|                     if model.currentPlaylist != nil, videos.isEmpty { | ||||
|                         hintText("Playlist is empty\n\nTap and hold on a video and then tap \"Add to Playlist\"") | ||||
|                     } else if model.all.isEmpty { | ||||
|                         hintText("You have no playlists\n\nTap on \"New Playlist\" to create one") | ||||
|                     } else { | ||||
|                         #if os(tvOS) | ||||
|                             VideosCellsHorizontal(videos: videos) | ||||
|                                 .padding(.top, 40) | ||||
|                             Spacer() | ||||
|                         #else | ||||
|                             VideosCellsVertical(videos: videos) | ||||
|                         #endif | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| @@ -113,6 +116,16 @@ struct PlaylistsView: View { | ||||
|                     selectPlaylistButton | ||||
|                 } | ||||
|  | ||||
|                 Button { | ||||
|                     player.playAll(videos) | ||||
|                     player.presentPlayer() | ||||
|                 } label: { | ||||
|                     HStack(spacing: 15) { | ||||
|                         Image(systemName: "play.fill") | ||||
|                         Text("Play All") | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if model.currentPlaylist != nil { | ||||
|                     editPlaylistButton | ||||
|                 } | ||||
|   | ||||
| @@ -25,17 +25,19 @@ struct TrendingView: View { | ||||
|     } | ||||
|  | ||||
|     var body: some View { | ||||
|         Section { | ||||
|             VStack(alignment: .center, spacing: 0) { | ||||
|                 #if os(tvOS) | ||||
|                     toolbar | ||||
|                     VideosCellsHorizontal(videos: store.collection) | ||||
|                         .padding(.top, 40) | ||||
|         PlayerControlsView { | ||||
|             Section { | ||||
|                 VStack(alignment: .center, spacing: 0) { | ||||
|                     #if os(tvOS) | ||||
|                         toolbar | ||||
|                         VideosCellsHorizontal(videos: store.collection) | ||||
|                             .padding(.top, 40) | ||||
|  | ||||
|                     Spacer() | ||||
|                 #else | ||||
|                     VideosCellsVertical(videos: store.collection) | ||||
|                 #endif | ||||
|                         Spacer() | ||||
|                     #else | ||||
|                         VideosCellsVertical(videos: store.collection) | ||||
|                     #endif | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         #if os(tvOS) | ||||
|   | ||||
							
								
								
									
										71
									
								
								Shared/Videos/VideoBanner.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								Shared/Videos/VideoBanner.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| import Foundation | ||||
| import SwiftUI | ||||
|  | ||||
| struct VideoBanner: View { | ||||
|     let video: Video | ||||
|  | ||||
|     var body: some View { | ||||
|         HStack(alignment: .center, spacing: 12) { | ||||
|             smallThumbnail | ||||
|             VStack(alignment: .leading, spacing: 4) { | ||||
|                 Text(video.title) | ||||
|                     .truncationMode(.middle) | ||||
|                     .lineLimit(2) | ||||
|                     .font(.headline) | ||||
|                     .frame(alignment: .leading) | ||||
|  | ||||
|                 HStack { | ||||
|                     Text(video.author) | ||||
|                         .lineLimit(1) | ||||
|  | ||||
|                     Spacer() | ||||
|  | ||||
|                     if let time = video.playTime { | ||||
|                         Text(time) | ||||
|                             .fontWeight(.light) | ||||
|                     } | ||||
|                 } | ||||
|                 .foregroundColor(.secondary) | ||||
|             } | ||||
|         } | ||||
|         .contentShape(Rectangle()) | ||||
|         .buttonStyle(.plain) | ||||
|         .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 100, alignment: .center) | ||||
|     } | ||||
|  | ||||
|     var smallThumbnail: some View { | ||||
|         Group { | ||||
|             if let url = video.thumbnailURL(quality: .medium) { | ||||
|                 AsyncImage(url: url) { image in | ||||
|                     image | ||||
|                         .resizable() | ||||
|                 } placeholder: { | ||||
|                     HStack { | ||||
|                         ProgressView() | ||||
|                             .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) | ||||
|                     } | ||||
|                 } | ||||
|             } else { | ||||
|                 Image(systemName: "exclamationmark.square") | ||||
|             } | ||||
|         } | ||||
|         .background(.gray) | ||||
|         #if os(tvOS) | ||||
|             .frame(width: 177, height: 100) | ||||
|             .mask(RoundedRectangle(cornerRadius: 12)) | ||||
|         #else | ||||
|             .frame(width: 88, height: 50) | ||||
|             .mask(RoundedRectangle(cornerRadius: 6)) | ||||
|         #endif | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct VideoBanner_Previews: PreviewProvider { | ||||
|     static var previews: some View { | ||||
|         VStack(spacing: 20) { | ||||
|             VideoBanner(video: Video.fixture) | ||||
|             VideoBanner(video: Video.fixtureUpcomingWithoutPublishedOrViews) | ||||
|         } | ||||
|         .frame(maxWidth: 900) | ||||
|     } | ||||
| } | ||||
| @@ -2,32 +2,43 @@ import Defaults | ||||
| import SwiftUI | ||||
|  | ||||
| struct VideoView: View { | ||||
|     @EnvironmentObject<NavigationModel> private var navigation | ||||
|     var video: Video | ||||
|  | ||||
|     @State private var playerNavigationLinkActive = false | ||||
|  | ||||
|     @Environment(\.inNavigationView) private var inNavigationView | ||||
|  | ||||
|     #if os(iOS) | ||||
|         @Environment(\.verticalSizeClass) private var verticalSizeClass | ||||
|         @Environment(\.horizontalCells) private var horizontalCells | ||||
|     #endif | ||||
|  | ||||
|     @Environment(\.inNavigationView) private var inNavigationView | ||||
|  | ||||
|     var video: Video | ||||
|     @EnvironmentObject<PlayerModel> private var player | ||||
|  | ||||
|     var body: some View { | ||||
|         Group { | ||||
|             if inNavigationView { | ||||
|                 NavigationLink(destination: VideoPlayerView(video)) { | ||||
|                     content | ||||
|                 } | ||||
|             } else { | ||||
|                 Button(action: { navigation.playVideo(video) }) { | ||||
|                     content | ||||
|             Button(action: { | ||||
|                 player.playNow(video) | ||||
|  | ||||
|                 if inNavigationView { | ||||
|                     playerNavigationLinkActive = true | ||||
|                 } else { | ||||
|                     player.presentPlayer() | ||||
|                 } | ||||
|             }) { | ||||
|                 content | ||||
|             } | ||||
|  | ||||
|             NavigationLink(isActive: $playerNavigationLinkActive, destination: { | ||||
|                 VideoPlayerView() | ||||
|                     .environment(\.inNavigationView, true) | ||||
|             }) { | ||||
|                 EmptyView() | ||||
|             } | ||||
|         } | ||||
|         .buttonStyle(.plain) | ||||
|         .contentShape(RoundedRectangle(cornerRadius: 12)) | ||||
|         .contextMenu { VideoContextMenuView(video: video) } | ||||
|         .contextMenu { VideoContextMenuView(video: video, playerNavigationLinkActive: $playerNavigationLinkActive) } | ||||
|     } | ||||
|  | ||||
|     var content: some View { | ||||
| @@ -131,7 +142,7 @@ struct VideoView: View { | ||||
|                 #else | ||||
|                     .frame(minHeight: 50, alignment: .top) | ||||
|                 #endif | ||||
|                 .padding(.bottom) | ||||
|                 .padding(.bottom, 4) | ||||
|  | ||||
|                 Group { | ||||
|                     if additionalDetailsAvailable { | ||||
|   | ||||
| @@ -28,14 +28,14 @@ struct VideosCellsHorizontal: View { | ||||
|                 .padding(.vertical, 30) | ||||
|             #else | ||||
|                 .padding(.horizontal, 15) | ||||
|                 .padding(.vertical, 20) | ||||
|                 .padding(.vertical, 10) | ||||
|             #endif | ||||
|         } | ||||
|         .id(UUID()) | ||||
|         #if os(tvOS) | ||||
|             .frame(height: 560) | ||||
|         #else | ||||
|             .frame(height: 280) | ||||
|             .frame(height: 250) | ||||
|         #endif | ||||
|  | ||||
|         .edgesIgnoringSafeArea(.horizontal) | ||||
|   | ||||
| @@ -20,6 +20,22 @@ struct ChannelVideosView: View { | ||||
|     @Namespace private var focusNamespace | ||||
|  | ||||
|     var body: some View { | ||||
|         #if os(iOS) | ||||
|             if inNavigationView { | ||||
|                 content | ||||
|             } else { | ||||
|                 PlayerControlsView { | ||||
|                     content | ||||
|                 } | ||||
|             } | ||||
|         #else | ||||
|             PlayerControlsView { | ||||
|                 content | ||||
|             } | ||||
|         #endif | ||||
|     } | ||||
|  | ||||
|     var content: some View { | ||||
|         VStack { | ||||
|             #if os(tvOS) | ||||
|                 HStack { | ||||
|   | ||||
							
								
								
									
										109
									
								
								Shared/Views/PlayerControlsView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								Shared/Views/PlayerControlsView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | ||||
| import Foundation | ||||
| import SwiftUI | ||||
| struct PlayerControlsView<Content: View>: View { | ||||
|     let content: Content | ||||
|  | ||||
|     @Environment(\.navigationStyle) private var navigationStyle | ||||
|     @EnvironmentObject<PlayerModel> private var model | ||||
|     @EnvironmentObject<NavigationModel> private var navigation | ||||
|  | ||||
|     init(@ViewBuilder content: @escaping () -> Content) { | ||||
|         self.content = content() | ||||
|     } | ||||
|  | ||||
|     var body: some View { | ||||
|         ZStack(alignment: .bottomLeading) { | ||||
|             content | ||||
|             #if !os(tvOS) | ||||
|                 .frame(minHeight: 0, maxHeight: .infinity) | ||||
|                 .padding(.bottom, 50) | ||||
|             #endif | ||||
|  | ||||
|             #if !os(tvOS) | ||||
|                 controls | ||||
|             #endif | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private var controls: some View { | ||||
|         HStack { | ||||
|             Button(action: { | ||||
|                 model.presentingPlayer.toggle() | ||||
|             }) { | ||||
|                 HStack { | ||||
|                     if let item = model.currentItem { | ||||
|                         HStack(spacing: 3) { | ||||
|                             Text(item.video.title) | ||||
|                                 .fontWeight(.bold) | ||||
|                                 .foregroundColor(.accentColor) | ||||
|                                 .lineLimit(1) | ||||
|  | ||||
|                             Text("— \(item.video.author)") | ||||
|                                 .fontWeight(.semibold) | ||||
|                                 .foregroundColor(.secondary) | ||||
|                                 .lineLimit(1) | ||||
|                         } | ||||
|                     } else { | ||||
|                         Text("Not playing") | ||||
|                             .foregroundColor(.secondary) | ||||
|                     } | ||||
|                     Spacer() | ||||
|                 } | ||||
|                 .padding(.vertical, 20) | ||||
|                 .contentShape(Rectangle()) | ||||
|             } | ||||
|             Group { | ||||
|                 if model.isPlaying { | ||||
|                     Button(action: { | ||||
|                         model.pause() | ||||
|                     }) { | ||||
|                         Label("Pause", systemImage: "pause.fill") | ||||
|                     } | ||||
|                 } else { | ||||
|                     Button(action: { | ||||
|                         model.play() | ||||
|                     }) { | ||||
|                         Label("Play", systemImage: "play.fill") | ||||
|                     } | ||||
|                     .disabled(model.player.currentItem == nil) | ||||
|                 } | ||||
|             } | ||||
|             .frame(minWidth: 30) | ||||
|             .scaleEffect(1.7) | ||||
|             #if !os(tvOS) | ||||
|                 .keyboardShortcut("p") | ||||
|             #endif | ||||
|  | ||||
|             Button(action: { model.advanceToNextItem() }) { | ||||
|                 Label("Next", systemImage: "forward.fill") | ||||
|             } | ||||
|             .disabled(model.queue.isEmpty) | ||||
|         } | ||||
|         .buttonStyle(.plain) | ||||
|         .labelStyle(.iconOnly) | ||||
|         .padding(.horizontal) | ||||
|         .frame(minWidth: 0, maxWidth: .infinity) | ||||
|         .padding(.vertical, 0) | ||||
|         .background(.ultraThinMaterial) | ||||
|         .borderTop(height: 0.4, color: Color("PlayerControlsBorderColor")) | ||||
|         .borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("PlayerControlsBorderColor")) | ||||
|         #if !os(tvOS) | ||||
|             .onSwipeGesture(up: { | ||||
|                 model.presentingPlayer = true | ||||
|             }) | ||||
|         #endif | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct PlayerControlsView_Previews: PreviewProvider { | ||||
|     static var previews: some View { | ||||
|         PlayerControlsView { | ||||
|             VStack { | ||||
|                 Spacer() | ||||
|                 Text("Hello") | ||||
|                 Spacer() | ||||
|             } | ||||
|         } | ||||
|         .injectFixtureEnvironmentObjects() | ||||
|     } | ||||
| } | ||||
| @@ -9,9 +9,11 @@ struct PlaylistVideosView: View { | ||||
|     } | ||||
|  | ||||
|     var body: some View { | ||||
|         VideosCellsVertical(videos: playlist.videos) | ||||
|         #if !os(tvOS) | ||||
|             .navigationTitle("\(playlist.title) Playlist") | ||||
|         #endif | ||||
|         PlayerControlsView { | ||||
|             VideosCellsVertical(videos: playlist.videos) | ||||
|             #if !os(tvOS) | ||||
|                 .navigationTitle("\(playlist.title) Playlist") | ||||
|             #endif | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -11,13 +11,15 @@ struct PopularView: View { | ||||
|     } | ||||
|  | ||||
|     var body: some View { | ||||
|         VideosCellsVertical(videos: store.collection) | ||||
|             .onAppear { | ||||
|                 resource.addObserver(store) | ||||
|                 resource.loadIfNeeded() | ||||
|             } | ||||
|         #if !os(tvOS) | ||||
|             .navigationTitle("Popular") | ||||
|         #endif | ||||
|         PlayerControlsView { | ||||
|             VideosCellsVertical(videos: store.collection) | ||||
|                 .onAppear { | ||||
|                     resource.addObserver(store) | ||||
|                     resource.loadIfNeeded() | ||||
|                 } | ||||
|             #if !os(tvOS) | ||||
|                 .navigationTitle("Popular") | ||||
|             #endif | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -30,29 +30,31 @@ struct SearchView: View { | ||||
|     } | ||||
|  | ||||
|     var body: some View { | ||||
|         VStack { | ||||
|             if showRecentQueries { | ||||
|                 recentQueries | ||||
|             } else { | ||||
|                 #if os(tvOS) | ||||
|                     ScrollView(.vertical, showsIndicators: false) { | ||||
|                         filtersHorizontalStack | ||||
|         PlayerControlsView { | ||||
|             VStack { | ||||
|                 if showRecentQueries { | ||||
|                     recentQueries | ||||
|                 } else { | ||||
|                     #if os(tvOS) | ||||
|                         ScrollView(.vertical, showsIndicators: false) { | ||||
|                             filtersHorizontalStack | ||||
|  | ||||
|                         VideosCellsHorizontal(videos: state.store.collection) | ||||
|                             VideosCellsHorizontal(videos: state.store.collection) | ||||
|                         } | ||||
|                         .edgesIgnoringSafeArea(.horizontal) | ||||
|                     #else | ||||
|                         VideosCellsVertical(videos: state.store.collection) | ||||
|                     #endif | ||||
|  | ||||
|                     if noResults { | ||||
|                         Text("No results") | ||||
|  | ||||
|                         if searchFiltersActive { | ||||
|                             Button("Reset search filters", action: resetFilters) | ||||
|                         } | ||||
|  | ||||
|                         Spacer() | ||||
|                     } | ||||
|                     .edgesIgnoringSafeArea(.horizontal) | ||||
|                 #else | ||||
|                     VideosCellsVertical(videos: state.store.collection) | ||||
|                 #endif | ||||
|  | ||||
|                 if noResults { | ||||
|                     Text("No results") | ||||
|  | ||||
|                     if searchFiltersActive { | ||||
|                         Button("Reset search filters", action: resetFilters) | ||||
|                     } | ||||
|  | ||||
|                     Spacer() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|   | ||||
| @@ -56,6 +56,7 @@ struct SignInRequiredView<Content: View>: View { | ||||
|                 openSettingsButton | ||||
|             #endif | ||||
|         } | ||||
|         .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) | ||||
|     } | ||||
|  | ||||
|     var openSettingsButton: some View { | ||||
| @@ -74,9 +75,12 @@ struct SignInRequiredView<Content: View>: View { | ||||
|  | ||||
| struct SignInRequiredView_Previews: PreviewProvider { | ||||
|     static var previews: some View { | ||||
|         SignInRequiredView(title: "Subscriptions") { | ||||
|             Text("Only when signed in") | ||||
|         PlayerControlsView { | ||||
|             SignInRequiredView(title: "Subscriptions") { | ||||
|                 Text("Only when signed in") | ||||
|             } | ||||
|         } | ||||
|         .environmentObject(PlayerModel()) | ||||
|         .environmentObject(InvidiousAPI()) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -11,17 +11,19 @@ struct SubscriptionsView: View { | ||||
|     } | ||||
|  | ||||
|     var body: some View { | ||||
|         SignInRequiredView(title: "Subscriptions") { | ||||
|             VideosCellsVertical(videos: store.collection) | ||||
|                 .onAppear { | ||||
|                     loadResources() | ||||
|                 } | ||||
|                 .onChange(of: api.account) { _ in | ||||
|                     loadResources(force: true) | ||||
|                 } | ||||
|                 .onChange(of: feed) { _ in | ||||
|                     loadResources(force: true) | ||||
|                 } | ||||
|         PlayerControlsView { | ||||
|             SignInRequiredView(title: "Subscriptions") { | ||||
|                 VideosCellsVertical(videos: store.collection) | ||||
|                     .onAppear { | ||||
|                         loadResources() | ||||
|                     } | ||||
|                     .onChange(of: api.account) { _ in | ||||
|                         loadResources(force: true) | ||||
|                     } | ||||
|                     .onChange(of: feed) { _ in | ||||
|                         loadResources(force: true) | ||||
|                     } | ||||
|             } | ||||
|         } | ||||
|         .refreshable { | ||||
|             loadResources(force: true) | ||||
|   | ||||
| @@ -2,26 +2,72 @@ import Defaults | ||||
| import SwiftUI | ||||
|  | ||||
| struct VideoContextMenuView: View { | ||||
|     let video: Video | ||||
|  | ||||
|     @Binding var playerNavigationLinkActive: Bool | ||||
|  | ||||
|     @Environment(\.inNavigationView) private var inNavigationView | ||||
|  | ||||
|     @EnvironmentObject<NavigationModel> private var navigation | ||||
|     @EnvironmentObject<PlayerModel> private var player | ||||
|     @EnvironmentObject<PlaylistsModel> private var playlists | ||||
|     @EnvironmentObject<RecentsModel> private var recents | ||||
|     @EnvironmentObject<SubscriptionsModel> private var subscriptions | ||||
|  | ||||
|     let video: Video | ||||
|  | ||||
|     var body: some View { | ||||
|         openChannelButton | ||||
|  | ||||
|         subscriptionButton | ||||
|  | ||||
|         if navigation.tabSelection != .playlists { | ||||
|             addToPlaylistButton | ||||
|         } else if let playlist = playlists.currentPlaylist { | ||||
|             removeFromPlaylistButton(playlistID: playlist.id) | ||||
|         Section { | ||||
|             playNowButton | ||||
|         } | ||||
|         Section { | ||||
|             playNextButton | ||||
|             addToQueueButton | ||||
|         } | ||||
|  | ||||
|         if case let .playlist(id) = navigation.tabSelection { | ||||
|             removeFromPlaylistButton(playlistID: id) | ||||
|         Section { | ||||
|             openChannelButton | ||||
|             subscriptionButton | ||||
|         } | ||||
|  | ||||
|         Section { | ||||
|             if navigation.tabSelection != .playlists { | ||||
|                 addToPlaylistButton | ||||
|             } else if let playlist = playlists.currentPlaylist { | ||||
|                 removeFromPlaylistButton(playlistID: playlist.id) | ||||
|             } | ||||
|  | ||||
|             if case let .playlist(id) = navigation.tabSelection { | ||||
|                 removeFromPlaylistButton(playlistID: id) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     var playNowButton: some View { | ||||
|         Button { | ||||
|             player.playNow(video) | ||||
|  | ||||
|             if inNavigationView { | ||||
|                 playerNavigationLinkActive = true | ||||
|             } else { | ||||
|                 player.presentPlayer() | ||||
|             } | ||||
|         } label: { | ||||
|             Label("Play Now", systemImage: "play") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     var playNextButton: some View { | ||||
|         Button { | ||||
|             player.playNext(video) | ||||
|         } label: { | ||||
|             Label("Play Next", systemImage: "text.insert") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     var addToQueueButton: some View { | ||||
|         Button { | ||||
|             player.enqueueVideo(video) | ||||
|         } label: { | ||||
|             Label("Play Last", systemImage: "text.append") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -6,33 +6,35 @@ struct WatchNowView: View { | ||||
|     @EnvironmentObject<InvidiousAPI> private var api | ||||
|  | ||||
|     var body: some View { | ||||
|         ScrollView(.vertical, showsIndicators: false) { | ||||
|             if api.validInstance { | ||||
|                 VStack(alignment: .leading, spacing: 0) { | ||||
|                     if api.signedIn { | ||||
|                         WatchNowSection(resource: api.feed, label: "Subscriptions") | ||||
|                     } | ||||
|                     WatchNowSection(resource: api.popular, label: "Popular") | ||||
|                     WatchNowSection(resource: api.trending(category: .default, country: .pl), label: "Trending") | ||||
|                     WatchNowSection(resource: api.trending(category: .movies, country: .pl), label: "Movies") | ||||
|                     WatchNowSection(resource: api.trending(category: .music, country: .pl), label: "Music") | ||||
|         PlayerControlsView { | ||||
|             ScrollView(.vertical, showsIndicators: false) { | ||||
|                 if api.validInstance { | ||||
|                     VStack(alignment: .leading, spacing: 0) { | ||||
|                         if api.signedIn { | ||||
|                             WatchNowSection(resource: api.feed, label: "Subscriptions") | ||||
|                         } | ||||
|                         WatchNowSection(resource: api.popular, label: "Popular") | ||||
|                         WatchNowSection(resource: api.trending(category: .default, country: .pl), label: "Trending") | ||||
|                         WatchNowSection(resource: api.trending(category: .movies, country: .pl), label: "Movies") | ||||
|                         WatchNowSection(resource: api.trending(category: .music, country: .pl), label: "Music") | ||||
|  | ||||
| //                  TODO: adding sections to view | ||||
| //                  =================== | ||||
| //                  WatchNowPlaylistSection(id: "IVPLmRFYLGYZpq61SpujNw3EKbzzGNvoDmH") | ||||
| //                  WatchNowSection(resource: api.channelVideos("UCBJycsmduvYEL83R_U4JriQ"), label: "MKBHD") | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             #if os(tvOS) | ||||
|                 .edgesIgnoringSafeArea(.horizontal) | ||||
|             #else | ||||
|                 .navigationTitle("Watch Now") | ||||
|             #endif | ||||
|             #if os(macOS) | ||||
|                 .background() | ||||
|                 .frame(minWidth: 360) | ||||
|             #endif | ||||
|         } | ||||
|         #if os(tvOS) | ||||
|             .edgesIgnoringSafeArea(.horizontal) | ||||
|         #else | ||||
|             .navigationTitle("Watch Now") | ||||
|         #endif | ||||
|         #if os(macOS) | ||||
|             .background() | ||||
|             .frame(minWidth: 360) | ||||
|         #endif | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -2,19 +2,22 @@ import Defaults | ||||
| import SwiftUI | ||||
|  | ||||
| struct Player: NSViewControllerRepresentable { | ||||
|     var video: Video! | ||||
|     @EnvironmentObject<PlayerModel> private var player | ||||
|  | ||||
|     @EnvironmentObject<InvidiousAPI> private var api | ||||
|     @EnvironmentObject<PlaybackModel> private var playback | ||||
|     var controller: PlayerViewController? | ||||
|  | ||||
|     init(controller: PlayerViewController? = nil) { | ||||
|         self.controller = controller | ||||
|     } | ||||
|  | ||||
|     func makeNSViewController(context _: Context) -> PlayerViewController { | ||||
|         if self.controller != nil { | ||||
|             return self.controller! | ||||
|         } | ||||
|  | ||||
|         let controller = PlayerViewController() | ||||
|  | ||||
|         controller.video = video | ||||
|         controller.playback = playback | ||||
|         controller.api = api | ||||
|  | ||||
|         controller.resolution = Defaults[.quality] | ||||
|         controller.playerModel = player | ||||
|  | ||||
|         return controller | ||||
|     } | ||||
|   | ||||
| @@ -2,42 +2,20 @@ import AVKit | ||||
| import SwiftUI | ||||
|  | ||||
| final class PlayerViewController: NSViewController { | ||||
|     var video: Video! | ||||
|  | ||||
|     var api: InvidiousAPI! | ||||
|     var player = AVPlayer() | ||||
|     var playerModel: PlayerModel! | ||||
|     var playback: PlaybackModel! | ||||
|     var playerView = AVPlayerView() | ||||
|     var resolution: Stream.ResolutionSetting! | ||||
|  | ||||
|     override func viewDidDisappear() { | ||||
|         playerView.player?.replaceCurrentItem(with: nil) | ||||
|         playerView.player = nil | ||||
|  | ||||
|         playerModel.player = nil | ||||
|         playerModel = nil | ||||
|  | ||||
|         // TODO: pause on disappear settings | ||||
|         super.viewDidDisappear() | ||||
|     } | ||||
|  | ||||
|     override func loadView() { | ||||
|         playerModel = PlayerModel(playback: playback, api: api, resolution: resolution) | ||||
|  | ||||
|         guard playerModel.player.isNil else { | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         playerModel.player = player | ||||
|         playerView.player = playerModel.player | ||||
|  | ||||
|         playerView.allowsPictureInPicturePlayback = true | ||||
|         playerView.showsFullScreenToggleButton = true | ||||
|  | ||||
|         view = playerView | ||||
|  | ||||
|         DispatchQueue.main.async { | ||||
|             self.playerModel.loadVideo(self.video) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										81
									
								
								tvOS/NowPlayingView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								tvOS/NowPlayingView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| import SwiftUI | ||||
|  | ||||
| struct NowPlayingView: View { | ||||
|     var infoViewController = false | ||||
|  | ||||
|     @EnvironmentObject<PlayerModel> private var player | ||||
|  | ||||
|     var body: some View { | ||||
|         if infoViewController { | ||||
|             content | ||||
|                 .background(.thinMaterial) | ||||
|                 .mask(RoundedRectangle(cornerRadius: 24)) | ||||
|         } else { | ||||
|             content | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     var content: some View { | ||||
|         ScrollView(.vertical) { | ||||
|             VStack(alignment: .leading) { | ||||
|                 if !infoViewController, let item = player.currentItem { | ||||
|                     Group { | ||||
|                         header("Now Playing") | ||||
|  | ||||
|                         Button { | ||||
|                             player.presentPlayer() | ||||
|                         } label: { | ||||
|                             VideoBanner(video: item.video) | ||||
|                         } | ||||
|                     } | ||||
|                     .onPlayPauseCommand(perform: player.togglePlay) | ||||
|  | ||||
|                     .padding(.bottom, 20) | ||||
|                 } | ||||
|  | ||||
|                 if !infoViewController { | ||||
|                     header("Playing Next") | ||||
|                 } | ||||
|  | ||||
|                 if player.queue.isEmpty { | ||||
|                     Spacer() | ||||
|  | ||||
|                     Text("Playback queue is empty") | ||||
|                         .padding(.leading, 40) | ||||
|                         .foregroundColor(.secondary) | ||||
|                 } | ||||
|  | ||||
|                 ForEach(player.queue) { item in | ||||
|                     Button { | ||||
|                         player.advanceToItem(item) | ||||
|                         player.presentPlayer() | ||||
|                     } label: { | ||||
|                         VideoBanner(video: item.video) | ||||
|                     } | ||||
|                     .contextMenu { | ||||
|                         Button("Delete", role: .destructive) { | ||||
|                             player.remove(item) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .padding(.vertical) | ||||
|             .padding(.horizontal, 40) | ||||
|         } | ||||
|         .frame(minWidth: 0, maxWidth: .infinity, minHeight: 260, maxHeight: .infinity, alignment: .leading) | ||||
|     } | ||||
|  | ||||
|     func header(_ text: String) -> some View { | ||||
|         Text(text) | ||||
|             .font(.title3.bold()) | ||||
|             .foregroundColor(.secondary) | ||||
|             .padding(.leading, 40) | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct NowPlayingView_Previews: PreviewProvider { | ||||
|     static var previews: some View { | ||||
|         NowPlayingView() | ||||
|             .injectFixtureEnvironmentObjects() | ||||
|     } | ||||
| } | ||||
| @@ -2,8 +2,8 @@ import Defaults | ||||
| import SwiftUI | ||||
|  | ||||
| struct TVNavigationView: View { | ||||
|     @EnvironmentObject<PlayerModel> private var player | ||||
|     @EnvironmentObject<NavigationModel> private var navigation | ||||
|     @EnvironmentObject<PlaybackModel> private var playback | ||||
|     @EnvironmentObject<RecentsModel> private var recents | ||||
|     @EnvironmentObject<SearchModel> private var search | ||||
|  | ||||
| @@ -29,6 +29,10 @@ struct TVNavigationView: View { | ||||
|                 .tabItem { Text("Playlists") } | ||||
|                 .tag(TabSelection.playlists) | ||||
|  | ||||
|             NowPlayingView() | ||||
|                 .tabItem { Text("Now Playing") } | ||||
|                 .tag(TabSelection.nowPlaying) | ||||
|  | ||||
|             SearchView() | ||||
|                 .tabItem { Image(systemName: "magnifyingglass") } | ||||
|                 .tag(TabSelection.search) | ||||
| @@ -39,11 +43,8 @@ struct TVNavigationView: View { | ||||
|                 AddToPlaylistView(video: video) | ||||
|             } | ||||
|         } | ||||
|         .fullScreenCover(isPresented: $navigation.showingVideo) { | ||||
|             if let video = navigation.video { | ||||
|                 VideoPlayerView(video) | ||||
|                     .environmentObject(playback) | ||||
|             } | ||||
|         .fullScreenCover(isPresented: $player.presentingPlayer) { | ||||
|             VideoPlayerView() | ||||
|         } | ||||
|         .fullScreenCover(isPresented: $navigation.isChannelOpen) { | ||||
|             if let channel = recents.presentedChannel { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Arkadiusz Fal
					Arkadiusz Fal