Initial functionality of player items queue

Fix environment objects

Hide video player placeholder on tvOS

Queue improvements
This commit is contained in:
Arkadiusz Fal 2021-10-05 22:20:09 +02:00
parent d6b3c6637d
commit 70c089e696
44 changed files with 1711 additions and 689 deletions

View 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)
}
}

View File

@ -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,

View File

@ -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 {

View File

@ -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: {

View File

@ -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
}
}

View File

@ -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
View 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()
}
}

View 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]()
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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" */;

View File

@ -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",

View File

@ -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
}
}

View File

@ -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))
}

View File

@ -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 {

View File

@ -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)
}
}
}

View File

@ -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)

View File

@ -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]

View 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)
}
}
}

View 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()
}
}

View File

@ -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) {}
}

View File

@ -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()
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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
}

View File

@ -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)

View 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)
}
}

View File

@ -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 {

View File

@ -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)

View File

@ -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 {

View 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()
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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()
}
}
}

View File

@ -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())
}
}

View File

@ -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)

View File

@ -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")
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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
View 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()
}
}

View File

@ -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 {