Settings for iOS/macOS

This commit is contained in:
Arkadiusz Fal 2021-09-25 10:18:22 +02:00
parent 433725c5e8
commit a7da3b9468
64 changed files with 1998 additions and 665 deletions

View File

@ -0,0 +1,10 @@
import Foundation
extension Instance {
static var fixture: Instance {
Instance(name: "Home", url: "https://invidious.home.net", accounts: [
.init(id: UUID(), name: "Evelyn", url: "https://invidious.home.net", sid: "abc"),
.init(id: UUID(), name: "Jake", url: "https://invidious.home.net", sid: "xyz")
])
}
}

146
Model/Instance.swift Normal file
View File

@ -0,0 +1,146 @@
import Defaults
import Foundation
struct Instance: Defaults.Serializable, Hashable, Identifiable {
struct Account: Defaults.Serializable, Hashable, Identifiable {
static var bridge = AccountsBridge()
let id: UUID?
var name: String?
let url: String
let sid: String
init(id: UUID? = nil, name: String? = nil, url: String, sid: String) {
self.id = id ?? UUID()
self.name = name
self.url = url
self.sid = sid
}
var anonymizedSID: String {
guard sid.count > 3 else {
return ""
}
let index = sid.index(sid.startIndex, offsetBy: 4)
return String(sid[..<index])
}
var description: String {
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
}
func hash(into hasher: inout Hasher) {
hasher.combine(sid)
}
}
struct AccountsBridge: Defaults.Bridge {
typealias Value = Account
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value = value else {
return nil
}
return [
"id": value.id?.uuidString ?? "",
"name": value.name ?? "",
"url": value.url,
"sid": value.sid
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let url = object["url"],
let sid = object["sid"]
else {
return nil
}
let name = object["name"] ?? ""
return Account(name: name, url: url, sid: sid)
}
}
static var bridge = InstancesBridge()
let id: UUID?
let name: String
let url: String
var accounts = [Account]()
init(id: UUID? = nil, name: String, url: String, accounts: [Account] = []) {
self.id = id ?? UUID()
self.name = name
self.url = url
self.accounts = accounts
}
var description: String {
name.isEmpty ? url : "\(name) (\(url))"
}
var shortDescription: String {
name.isEmpty ? url : name
}
var anonymousAccount: Account {
Account(name: "Anonymous", url: url, sid: "")
}
struct InstancesBridge: Defaults.Bridge {
typealias Value = Instance
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value = value else {
return nil
}
return [
"id": value.id?.uuidString ?? "",
"name": value.name,
"url": value.url,
"accounts": value.accounts.map { "\($0.id!):\($0.name ?? ""):\($0.sid)" }.joined(separator: ";")
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let id = object["id"],
let url = object["url"]
else {
return nil
}
let name = object["name"] ?? ""
let accounts = object["accounts"] ?? ""
let uuid = UUID(uuidString: id)
var instance = Instance(id: uuid, name: name, url: url)
accounts.split(separator: ";").forEach { sid in
let components = sid.components(separatedBy: ":")
let id = components[0]
let name = components[1]
let sid = components[2]
let uuid = UUID(uuidString: id)
instance.accounts.append(Account(id: uuid, name: name, url: instance.url, sid: sid))
}
return instance
}
}
func hash(into hasher: inout Hasher) {
hasher.combine(url)
}
}

View File

@ -0,0 +1,108 @@
import Foundation
import Siesta
import SwiftUI
final class InstanceAccountValidator: Service {
let url: String
let account: Instance.Account?
var formObjectID: Binding<String>
var valid: Binding<Bool>
var validated: Binding<Bool>
var error: Binding<String?>?
init(
url: String,
account: Instance.Account? = nil,
formObjectID: Binding<String>,
valid: Binding<Bool>,
validated: Binding<Bool>,
error: Binding<String?>? = nil
) {
self.url = url
self.account = account
self.formObjectID = formObjectID
self.valid = valid
self.validated = validated
self.error = error
super.init(baseURL: url)
configure()
}
func configure() {
configure("/api/v1/auth/feed", requestMethods: [.get]) {
guard self.account != nil else {
return
}
$0.headers["Cookie"] = self.cookieHeader
}
}
func validateInstance() {
reset()
stats
.load()
.onSuccess { _ in
guard self.url == self.formObjectID.wrappedValue else {
return
}
self.valid.wrappedValue = true
self.error?.wrappedValue = nil
self.validated.wrappedValue = true
}
.onFailure { error in
guard self.url == self.formObjectID.wrappedValue else {
return
}
self.valid.wrappedValue = false
self.error?.wrappedValue = error.userMessage
self.validated.wrappedValue = true
}
}
func validateAccount() {
reset()
feed
.load()
.onSuccess { _ in
guard self.account!.sid == self.formObjectID.wrappedValue else {
return
}
self.valid.wrappedValue = true
self.validated.wrappedValue = true
}
.onFailure { _ in
guard self.account!.sid == self.formObjectID.wrappedValue else {
return
}
self.valid.wrappedValue = false
self.validated.wrappedValue = true
}
}
func reset() {
valid.wrappedValue = false
validated.wrappedValue = false
error?.wrappedValue = nil
}
var cookieHeader: String {
"SID=\(account!.sid)"
}
var stats: Resource {
resource("/api/v1/stats")
}
var feed: Resource {
resource("/api/v1/auth/feed")
}
}

View File

@ -0,0 +1,51 @@
import Defaults
import Foundation
final class InstancesModel: ObservableObject {
var defaultAccount: Instance.Account! {
Defaults[.instances].first?.accounts.first
}
func find(_ id: Instance.ID?) -> Instance? {
guard id != nil else {
return nil
}
return Defaults[.instances].first { $0.id == id }
}
func accounts(_ id: Instance.ID?) -> [Instance.Account] {
find(id)?.accounts ?? []
}
func add(name: String, url: String) -> Instance {
let instance = Instance(name: name, url: url)
Defaults[.instances].append(instance)
return instance
}
func remove(_ instance: Instance) {
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
Defaults[.instances].remove(at: index)
}
}
func addAccount(instance: Instance, name: String, sid: String) -> Instance.Account {
let account = Instance.Account(name: name, url: instance.url, sid: sid)
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
Defaults[.instances][index].accounts.append(account)
}
return account
}
func removeAccount(instance: Instance, account: Instance.Account) {
if let instanceIndex = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
if let accountIndex = Defaults[.instances][instanceIndex].accounts.firstIndex(where: { $0.id == account.id }) {
Defaults[.instances][instanceIndex].accounts.remove(at: accountIndex)
}
}
}
}

View File

@ -3,55 +3,108 @@ import Foundation
import Siesta
import SwiftyJSON
final class InvidiousAPI: Service {
static let shared = InvidiousAPI()
final class InvidiousAPI: Service, ObservableObject {
static let basePath = "/api/v1"
static let instance = "https://invidious.home.arekf.net"
@Published var account: Instance.Account!
static func proxyURLForAsset(_ url: String) -> URL? {
guard let instanceURLComponents = URLComponents(string: InvidiousAPI.instance),
var urlComponents = URLComponents(string: url) else { return nil }
urlComponents.scheme = instanceURLComponents.scheme
urlComponents.host = instanceURLComponents.host
return urlComponents.url
}
@Published var validInstance = true
@Published var signedIn = true
init() {
super.init()
#if os(tvOS)
// TODO: remove
setAccount(.init(id: UUID(), name: "", url: "https://invidious.home.arekf.net", sid: "RpoS7YPPK2-QS81jJF9z4KSQAjmzsOnMpn84c73-GQ8="))
#endif
}
func setAccount(_ account: Instance.Account) {
self.account = account
validInstance = false
signedIn = false
configure()
validate()
}
func validate() {
validateInstance()
validateSID()
}
func validateInstance() {
guard !validInstance else {
return
}
home
.load()
.onSuccess { _ in
self.validInstance = true
}
.onFailure { _ in
self.validInstance = false
}
}
func validateSID() {
guard !signedIn else {
return
}
feed
.load()
.onSuccess { _ in
self.signedIn = true
}
.onFailure { _ in
self.signedIn = false
}
}
static func proxyURLForAsset(_ url: String) -> URL? {
URL(string: url)
// TODO: Switching instances, move up to player
// guard let instanceURLComponents = URLComponents(string: InvidiousAPI.instance),
// var urlComponents = URLComponents(string: url) else { return nil }
//
// urlComponents.scheme = instanceURLComponents.scheme
// urlComponents.host = instanceURLComponents.host
//
// return urlComponents.url
}
func configure() {
SiestaLog.Category.enabled = .common
let SwiftyJSONTransformer =
ResponseContentTransformer(transformErrors: true) { JSON($0.content as AnyObject) }
super.init(baseURL: "\(InvidiousAPI.instance)/api/v1")
configure {
$0.headers["Cookie"] = self.cookieHeader
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configure(requestMethods: [.get]) {
$0.headers["Cookie"] = self.authHeader
}
configure("**", requestMethods: [.post]) {
$0.headers["Cookie"] = self.authHeader
$0.pipeline[.parsing].removeTransformers()
}
configureTransformer("/popular", requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(Video.init)
}
configureTransformer("/trending", requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(Video.init)
}
configureTransformer("/search", requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(Video.init)
}
configureTransformer("/search/suggestions", requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
if let suggestions = content.json.dictionaryValue["suggestions"] {
return suggestions.arrayValue.map(String.init)
}
@ -59,20 +112,20 @@ final class InvidiousAPI: Service {
return []
}
configureTransformer("/auth/playlists", requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
content.json.arrayValue.map(Playlist.init)
}
configureTransformer("/auth/playlists/*", requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
Playlist(content.json)
}
configureTransformer("/auth/playlists", requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
// hacky, to verify if possible to get it in easier way
Playlist(JSON(parseJSON: String(data: content.content, encoding: .utf8)!))
}
configureTransformer("/auth/feed", requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
if let feedVideos = content.json.dictionaryValue["videos"] {
return feedVideos.arrayValue.map(Video.init)
}
@ -80,69 +133,83 @@ final class InvidiousAPI: Service {
return []
}
configureTransformer("/auth/subscriptions", requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.map(Channel.init)
}
configureTransformer("/channels/*", requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
Channel(json: content.json)
}
configureTransformer("/channels/*/latest", requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(Video.init)
}
configureTransformer("/videos/*", requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
Video(content.json)
}
}
var authHeader: String? = "SID=\(Profile().sid)"
fileprivate func pathPattern(_ path: String) -> String {
"**\(InvidiousAPI.basePath)/\(path)"
}
fileprivate func basePathAppending(_ path: String) -> String {
"\(InvidiousAPI.basePath)/\(path)"
}
private var cookieHeader: String {
"SID=\(account.sid)"
}
var popular: Resource {
resource("/popular")
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/popular")
}
func trending(category: TrendingCategory, country: Country) -> Resource {
resource("/trending")
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/trending")
.withParam("type", category.name)
.withParam("region", country.rawValue)
}
var home: Resource {
resource(baseURL: InvidiousAPI.instance, path: "/feed/subscriptions")
resource(baseURL: account.url, path: "/feed/subscriptions")
}
var feed: Resource {
resource("/auth/feed")
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/auth/feed")
}
var stats: Resource {
resource(baseURL: account.url, path: basePathAppending("stats"))
}
var subscriptions: Resource {
resource("/auth/subscriptions")
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
}
func channelSubscription(_ id: String) -> Resource {
resource("/auth/subscriptions").child(id)
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")).child(id)
}
func channel(_ id: String) -> Resource {
resource("/channels/\(id)")
resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
}
func channelVideos(_ id: String) -> Resource {
resource("/channels/\(id)/latest")
resource(baseURL: account.url, path: basePathAppending("channels/\(id)/latest"))
}
func video(_ id: String) -> Resource {
resource("/videos/\(id)")
resource(baseURL: account.url, path: basePathAppending("videos/\(id)"))
}
var playlists: Resource {
resource("/auth/playlists")
resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
}
func playlist(_ id: String) -> Resource {
resource("/auth/playlists/\(id)")
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
}
func playlistVideos(_ id: String) -> Resource {
@ -154,7 +221,7 @@ final class InvidiousAPI: Service {
}
func search(_ query: SearchQuery) -> Resource {
var resource = resource("/search")
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
.withParam("q", searchQuery(query.query))
.withParam("sort_by", query.sortBy.parameter)
@ -170,7 +237,7 @@ final class InvidiousAPI: Service {
}
func searchSuggestions(query: String) -> Resource {
resource("/search/suggestions")
resource(baseURL: account.url, path: basePathAppending("search/suggestions"))
.withParam("q", query.lowercased())
}

View File

@ -1,7 +1,7 @@
import Foundation
import SwiftUI
final class NavigationState: ObservableObject {
final class NavigationModel: ObservableObject {
enum TabSelection: Hashable {
case watchNow, subscriptions, popular, trending, playlists, channel(String), playlist(String), recentlyOpened(String), search
}
@ -22,6 +22,8 @@ final class NavigationState: ObservableObject {
@Published var isChannelOpen = false
@Published var sidebarSectionChanged = false
@Published var presentingSettings = false
func playVideo(_ video: Video) {
self.video = video
showingVideo = true
@ -56,4 +58,4 @@ final class NavigationState: ObservableObject {
}
}
typealias TabSelection = NavigationState.TabSelection
typealias TabSelection = NavigationModel.TabSelection

View File

@ -1,7 +1,7 @@
import CoreMedia
import Foundation
final class PlaybackState: ObservableObject {
final class PlaybackModel: ObservableObject {
@Published var live = false
@Published var stream: Stream?
@Published var time: CMTime?

View File

@ -5,7 +5,7 @@ import Logging
import UIKit
#endif
final class PlayerState: ObservableObject {
final class PlayerModel: ObservableObject {
let logger = Logger(label: "net.arekf.Pearvidious.ps")
var video: Video!
@ -19,17 +19,19 @@ final class PlayerState: ObservableObject {
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]
var playbackState: PlaybackState
var api: InvidiousAPI
var playback: PlaybackModel
var timeObserver: Any?
let maxResolution: Stream.Resolution?
let resolution: Stream.ResolutionSetting?
var playingOutsideViewController = false
init(_ video: Video? = nil, playbackState: PlaybackState, maxResolution: Stream.Resolution? = nil) {
init(_ video: Video? = nil, playback: PlaybackModel, api: InvidiousAPI, resolution: Stream.ResolutionSetting? = nil) {
self.video = video
self.playbackState = playbackState
self.maxResolution = maxResolution
self.playback = playback
self.api = api
self.resolution = resolution
}
deinit {
@ -41,7 +43,7 @@ final class PlayerState: ObservableObject {
return
}
playbackState.reset()
playback.reset()
loadExtendedVideoDetails(video) { video in
self.video = video
@ -54,22 +56,26 @@ final class PlayerState: ObservableObject {
return
}
InvidiousAPI.shared.video(video!.id).load().onSuccess { response in
api.video(video!.id).load().onSuccess { response in
if let video: Video = response.typedContent() {
onSuccess(video)
}
}
}
var requestedResolution: Bool {
resolution != nil && resolution != .hd720pFirstThenBest
}
fileprivate func playVideo(_ video: Video) {
playbackState.live = video.live
playback.live = video.live
if video.live {
playHlsUrl()
return
}
let stream = maxResolution != nil ? video.streamWithResolution(maxResolution!) : video.defaultStream
let stream = requestedResolution ? video.streamWithResolution(resolution!.value) : video.defaultStream
guard stream != nil else {
return
@ -78,7 +84,7 @@ final class PlayerState: ObservableObject {
Task {
await self.loadStream(stream!)
if stream != video.bestStream {
if resolution == .hd720pFirstThenBest {
await self.loadBestStream()
}
}
@ -91,9 +97,7 @@ final class PlayerState: ObservableObject {
fileprivate func loadStream(_ stream: Stream) async {
if stream.oneMeaningfullAsset {
DispatchQueue.main.async {
self.playStream(stream)
}
playStream(stream)
return
} else {
@ -111,11 +115,11 @@ final class PlayerState: ObservableObject {
DispatchQueue.main.async {
self.saveTime()
self.player?.replaceCurrentItem(with: self.playerItemWithMetadata(for: stream))
self.playbackState.stream = stream
self.playback.stream = stream
if self.timeObserver == nil {
self.addTimeObserver()
}
self.player?.playImmediately(atRate: 1.0)
self.player?.play()
self.seekToSavedTime()
}
}
@ -267,7 +271,7 @@ final class PlayerState: ObservableObject {
self.player.rate = self.currentRate
}
self.playbackState.time = self.player.currentTime()
self.playback.time = self.player.currentTime()
}
}

View File

@ -1,35 +0,0 @@
import Foundation
import Siesta
import SwiftUI
final class Playlists: ObservableObject {
@Published var playlists = [Playlist]()
var resource: Resource {
InvidiousAPI.shared.playlists
}
init() {
load()
}
var all: [Playlist] {
playlists.sorted { $0.title.lowercased() < $1.title.lowercased() }
}
func find(id: Playlist.ID) -> Playlist? {
all.first { $0.id == id }
}
func reload() {
load()
}
fileprivate func load() {
resource.load().onSuccess { resource in
if let playlists: [Playlist] = resource.typedContent() {
self.playlists = playlists
}
}
}
}

View File

@ -0,0 +1,35 @@
import Foundation
import Siesta
import SwiftUI
final class PlaylistsModel: ObservableObject {
@Published var playlists = [Playlist]()
@Published var api: InvidiousAPI!
var resource: Resource {
api.playlists
}
var all: [Playlist] {
playlists.sorted { $0.title.lowercased() < $1.title.lowercased() }
}
func find(id: Playlist.ID) -> Playlist? {
all.first { $0.id == id }
}
func load(force: Bool = false) {
let request = force ? resource.load() : resource.loadIfNeeded()
request?
.onSuccess { resource in
if let playlists: [Playlist] = resource.typedContent() {
self.playlists = playlists
}
}
.onFailure { _ in
self.playlists = []
}
}
}

View File

@ -1,25 +0,0 @@
import Defaults
import Foundation
struct Profile {
var defaultStreamResolution: DefaultStreamResolution = .hd1080p
var skippedSegmentsCategories = [String]() // SponsorBlockSegmentsProvider.categories
var sid = "RpoS7YPPK2-QS81jJF9z4KSQAjmzsOnMpn84c73-GQ8="
var cellsColumns = 3
}
enum DefaultStreamResolution: String {
case hd720pFirstThenBest, hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p
var value: Stream.Resolution {
switch self {
case .hd720pFirstThenBest:
return .hd720p
default:
return Stream.Resolution(rawValue: rawValue)!
}
}
}

View File

@ -103,7 +103,7 @@ struct RecentItemBridge: Defaults.Bridge {
]
}
func deserialize(_ object: Serializable?) -> RecentItem? {
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let type = object["type"],

View File

@ -2,30 +2,23 @@ import Defaults
import Siesta
import SwiftUI
final class SearchState: ObservableObject {
final class SearchModel: ObservableObject {
@Published var store = Store<[Video]>()
@Published var api: InvidiousAPI!
@Published var query = SearchQuery()
@Published var queryText = ""
@Published var querySuggestions = Store<[String]>()
private var previousResource: Resource?
private var resource: Resource!
init() {
let newQuery = query
query = newQuery
resource = InvidiousAPI.shared.search(newQuery)
}
var isLoading: Bool {
resource.isLoading
}
func loadQuerySuggestions(_ query: String) {
let resource = InvidiousAPI.shared.searchSuggestions(query: query)
func loadSuggestions(_ query: String) {
let resource = api.searchSuggestions(query: query)
resource.addObserver(querySuggestions)
resource.loadIfNeeded()
@ -44,7 +37,7 @@ final class SearchState: ObservableObject {
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
changeHandler(query)
let newResource = InvidiousAPI.shared.search(query)
let newResource = api.search(query)
guard newResource != previousResource else {
return
}
@ -60,7 +53,7 @@ final class SearchState: ObservableObject {
func resetQuery(_ query: SearchQuery) {
self.query = query
let newResource = InvidiousAPI.shared.search(query)
let newResource = api.search(query)
guard newResource != previousResource else {
return
}

View File

@ -1,9 +1,32 @@
import AVFoundation
import Defaults
import Foundation
// swiftlint:disable:next final_class
class Stream: Equatable, Hashable {
enum Resolution: String, CaseIterable, Comparable {
enum ResolutionSetting: String, Defaults.Serializable, CaseIterable {
case hd720pFirstThenBest, hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p
var value: Stream.Resolution {
switch self {
case .hd720pFirstThenBest:
return .hd720p
default:
return Stream.Resolution(rawValue: rawValue)!
}
}
var description: String {
switch self {
case .hd720pFirstThenBest:
return "Default: adaptive"
default:
return "\(value.height)p".replacingOccurrences(of: " ", with: "")
}
}
}
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
case hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p
var height: Int {

View File

@ -1,46 +0,0 @@
import Foundation
import Siesta
import SwiftUI
final class Subscriptions: ObservableObject {
@Published var channels = [Channel]()
var resource: Resource {
InvidiousAPI.shared.subscriptions
}
init() {
load()
}
var all: [Channel] {
channels.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performChannelSubscriptionRequest(channelID, method: .post, onSuccess: onSuccess)
}
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performChannelSubscriptionRequest(channelID, method: .delete, onSuccess: onSuccess)
}
func isSubscribing(_ channelID: String) -> Bool {
channels.contains { $0.id == channelID }
}
fileprivate func load(onSuccess: @escaping () -> Void = {}) {
resource.load().onSuccess { resource in
if let channels: [Channel] = resource.typedContent() {
self.channels = channels
onSuccess()
}
}
}
fileprivate func performChannelSubscriptionRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) {
InvidiousAPI.shared.channelSubscription(channelID).request(method).onCompletion { _ in
self.load(onSuccess: onSuccess)
}
}
}

View File

@ -0,0 +1,53 @@
import Foundation
import Siesta
import SwiftUI
final class SubscriptionsModel: ObservableObject {
@Published var channels = [Channel]()
@Published var api: InvidiousAPI!
var resource: Resource {
api.subscriptions
}
init(api: InvidiousAPI? = nil) {
self.api = api
}
var all: [Channel] {
channels.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performRequest(channelID, method: .post, onSuccess: onSuccess)
}
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performRequest(channelID, method: .delete, onSuccess: onSuccess)
}
func isSubscribing(_ channelID: String) -> Bool {
channels.contains { $0.id == channelID }
}
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
let request = force ? resource.load() : resource.loadIfNeeded()
request?
.onSuccess { resource in
if let channels: [Channel] = resource.typedContent() {
self.channels = channels
onSuccess()
}
}
.onFailure { _ in
self.channels = []
}
}
fileprivate func performRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) {
api.channelSubscription(channelID).request(method).onCompletion { _ in
self.load(force: true, onSuccess: onSuccess)
}
}
}

View File

@ -160,11 +160,7 @@ struct Video: Identifiable, Equatable {
}
func streamWithResolution(_ resolution: Stream.Resolution) -> Stream? {
selectableStreams.first { $0.resolution == resolution }
}
func defaultStreamForProfile(_ profile: Profile) -> Stream? {
streamWithResolution(profile.defaultStreamResolution.value) ?? streams.first
selectableStreams.first { $0.resolution == resolution } ?? defaultStream
}
func thumbnailURL(quality: Thumbnail.Quality) -> URL? {

View File

@ -11,9 +11,9 @@
3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B181267B4E4900704544 /* TrendingCategory.swift */; };
3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B181267B4E4900704544 /* TrendingCategory.swift */; };
3705B184267B4E4900704544 /* TrendingCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B181267B4E4900704544 /* TrendingCategory.swift */; };
3711403F26B206A6005B3555 /* SearchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3711403E26B206A6005B3555 /* SearchState.swift */; };
3711404026B206A6005B3555 /* SearchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3711403E26B206A6005B3555 /* SearchState.swift */; };
3711404126B206A6005B3555 /* SearchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3711403E26B206A6005B3555 /* SearchState.swift */; };
3711403F26B206A6005B3555 /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3711403E26B206A6005B3555 /* SearchModel.swift */; };
3711404026B206A6005B3555 /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3711403E26B206A6005B3555 /* SearchModel.swift */; };
3711404126B206A6005B3555 /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3711403E26B206A6005B3555 /* SearchModel.swift */; };
3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3714166E267A8ACC006CA35D /* TrendingView.swift */; };
37141670267A8ACC006CA35D /* TrendingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3714166E267A8ACC006CA35D /* TrendingView.swift */; };
37141671267A8ACC006CA35D /* TrendingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3714166E267A8ACC006CA35D /* TrendingView.swift */; };
@ -23,9 +23,9 @@
37152EEA26EFEB95004FB96D /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37152EE926EFEB95004FB96D /* LazyView.swift */; };
37152EEB26EFEB95004FB96D /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37152EE926EFEB95004FB96D /* LazyView.swift */; };
37152EEC26EFEB95004FB96D /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37152EE926EFEB95004FB96D /* LazyView.swift */; };
371F2F1A269B43D300E4A7AB /* NavigationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371F2F19269B43D300E4A7AB /* NavigationState.swift */; };
371F2F1B269B43D300E4A7AB /* NavigationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371F2F19269B43D300E4A7AB /* NavigationState.swift */; };
371F2F1C269B43D300E4A7AB /* NavigationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371F2F19269B43D300E4A7AB /* NavigationState.swift */; };
371F2F1A269B43D300E4A7AB /* NavigationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371F2F19269B43D300E4A7AB /* NavigationModel.swift */; };
371F2F1B269B43D300E4A7AB /* NavigationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371F2F19269B43D300E4A7AB /* NavigationModel.swift */; };
371F2F1C269B43D300E4A7AB /* NavigationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371F2F19269B43D300E4A7AB /* NavigationModel.swift */; };
372915E42687E33E00F5A35B /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 372915E32687E33E00F5A35B /* Defaults */; };
372915E62687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; };
372915E72687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; };
@ -57,6 +57,30 @@
3748186E26A769D60084E870 /* DetailBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186D26A769D60084E870 /* DetailBadge.swift */; };
3748186F26A769D60084E870 /* DetailBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186D26A769D60084E870 /* DetailBadge.swift */; };
3748187026A769D60084E870 /* DetailBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186D26A769D60084E870 /* DetailBadge.swift */; };
37484C1926FC837400287258 /* PlaybackSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C1826FC837400287258 /* PlaybackSettingsView.swift */; };
37484C1A26FC837400287258 /* PlaybackSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C1826FC837400287258 /* PlaybackSettingsView.swift */; };
37484C1B26FC837400287258 /* PlaybackSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C1826FC837400287258 /* PlaybackSettingsView.swift */; };
37484C1D26FC83A400287258 /* InstancesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C1C26FC83A400287258 /* InstancesSettingsView.swift */; };
37484C1E26FC83A400287258 /* InstancesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C1C26FC83A400287258 /* InstancesSettingsView.swift */; };
37484C1F26FC83A400287258 /* InstancesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C1C26FC83A400287258 /* InstancesSettingsView.swift */; };
37484C2126FC83C400287258 /* AccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2026FC83C400287258 /* AccountSettingsView.swift */; };
37484C2226FC83C400287258 /* AccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2026FC83C400287258 /* AccountSettingsView.swift */; };
37484C2326FC83C400287258 /* AccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2026FC83C400287258 /* AccountSettingsView.swift */; };
37484C2526FC83E000287258 /* InstanceFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2426FC83E000287258 /* InstanceFormView.swift */; };
37484C2626FC83E000287258 /* InstanceFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2426FC83E000287258 /* InstanceFormView.swift */; };
37484C2726FC83E000287258 /* InstanceFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2426FC83E000287258 /* InstanceFormView.swift */; };
37484C2926FC83FF00287258 /* AccountFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2826FC83FF00287258 /* AccountFormView.swift */; };
37484C2A26FC83FF00287258 /* AccountFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2826FC83FF00287258 /* AccountFormView.swift */; };
37484C2B26FC83FF00287258 /* AccountFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2826FC83FF00287258 /* AccountFormView.swift */; };
37484C2D26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */; };
37484C2E26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */; };
37484C2F26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */; };
37484C3126FCB8F900287258 /* InstanceAccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* InstanceAccountValidator.swift */; };
37484C3226FCB8F900287258 /* InstanceAccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* InstanceAccountValidator.swift */; };
37484C3326FCB8F900287258 /* InstanceAccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* InstanceAccountValidator.swift */; };
375DFB5826F9DA010013F468 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; };
375DFB5926F9DA010013F468 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; };
375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; };
3761ABFD26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
3761ABFF26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
@ -74,6 +98,12 @@
376578912685490700D4EA09 /* PlaylistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578902685490700D4EA09 /* PlaylistsView.swift */; };
376578922685490700D4EA09 /* PlaylistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578902685490700D4EA09 /* PlaylistsView.swift */; };
376578932685490700D4EA09 /* PlaylistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578902685490700D4EA09 /* PlaylistsView.swift */; };
376B2E0726F920D600B1D64D /* SignInRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376B2E0626F920D600B1D64D /* SignInRequiredView.swift */; };
376B2E0826F920D600B1D64D /* SignInRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376B2E0626F920D600B1D64D /* SignInRequiredView.swift */; };
376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376B2E0626F920D600B1D64D /* SignInRequiredView.swift */; };
376CD21626FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */; };
376CD21726FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */; };
376CD21826FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */; };
37754C9D26B7500000DBD602 /* VideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37754C9C26B7500000DBD602 /* VideosView.swift */; };
37754C9E26B7500000DBD602 /* VideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37754C9C26B7500000DBD602 /* VideosView.swift */; };
37754C9F26B7500000DBD602 /* VideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37754C9C26B7500000DBD602 /* VideosView.swift */; };
@ -90,15 +120,17 @@
377FC7E5267A084E00A6BBAF /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27F26737550007FC770 /* SearchView.swift */; };
377FC7ED267A0A0800A6BBAF /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 377FC7EC267A0A0800A6BBAF /* SwiftyJSON */; };
377FC7F3267A0A0800A6BBAF /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 377FC7F2267A0A0800A6BBAF /* Logging */; };
3788AC2326F683DE00F6BAA9 /* WatchNowPlaylistSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3788AC2226F683DE00F6BAA9 /* WatchNowPlaylistSection.swift */; };
3788AC2426F683DE00F6BAA9 /* WatchNowPlaylistSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3788AC2226F683DE00F6BAA9 /* WatchNowPlaylistSection.swift */; };
3788AC2526F683DE00F6BAA9 /* WatchNowPlaylistSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3788AC2226F683DE00F6BAA9 /* WatchNowPlaylistSection.swift */; };
3788AC2726F6840700F6BAA9 /* WatchNowSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3788AC2626F6840700F6BAA9 /* WatchNowSection.swift */; };
3788AC2826F6840700F6BAA9 /* WatchNowSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3788AC2626F6840700F6BAA9 /* WatchNowSection.swift */; };
3788AC2926F6840700F6BAA9 /* WatchNowSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3788AC2626F6840700F6BAA9 /* WatchNowSection.swift */; };
3788AC2B26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3788AC2A26F6842D00F6BAA9 /* WatchNowSectionBody.swift */; };
3788AC2C26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3788AC2A26F6842D00F6BAA9 /* WatchNowSectionBody.swift */; };
3788AC2D26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3788AC2A26F6842D00F6BAA9 /* WatchNowSectionBody.swift */; };
378E50FB26FE8B9F00F49626 /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378E50FA26FE8B9F00F49626 /* Instance.swift */; };
378E50FC26FE8B9F00F49626 /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378E50FA26FE8B9F00F49626 /* Instance.swift */; };
378E50FD26FE8B9F00F49626 /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378E50FA26FE8B9F00F49626 /* Instance.swift */; };
378E50FF26FE8EEE00F49626 /* AccountsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378E50FE26FE8EEE00F49626 /* AccountsMenuView.swift */; };
378E510026FE8EEE00F49626 /* AccountsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378E50FE26FE8EEE00F49626 /* AccountsMenuView.swift */; };
3797757D268922D100DD52A8 /* Siesta in Frameworks */ = {isa = PBXBuildFile; productRef = 3797757C268922D100DD52A8 /* Siesta */; };
37977583268922F600DD52A8 /* InvidiousAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37977582268922F600DD52A8 /* InvidiousAPI.swift */; };
37977584268922F600DD52A8 /* InvidiousAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37977582268922F600DD52A8 /* InvidiousAPI.swift */; };
@ -124,12 +156,15 @@
37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */; };
37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */; };
37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */; };
37B044B726F7AB9000E1419D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B044B626F7AB9000E1419D /* SettingsView.swift */; };
37B044B826F7AB9000E1419D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B044B626F7AB9000E1419D /* SettingsView.swift */; };
37B044B926F7AB9000E1419D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B044B626F7AB9000E1419D /* SettingsView.swift */; };
37B17DA0268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B17D9F268A1F25006AEE9B /* VideoContextMenuView.swift */; };
37B17DA1268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B17D9F268A1F25006AEE9B /* VideoContextMenuView.swift */; };
37B17DA2268A1F8A006AEE9B /* VideoContextMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B17D9F268A1F25006AEE9B /* VideoContextMenuView.swift */; };
37B767DB2677C3CA0098BAA8 /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */; };
37B767DC2677C3CA0098BAA8 /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */; };
37B767DD2677C3CA0098BAA8 /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */; };
37B767DB2677C3CA0098BAA8 /* PlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */; };
37B767DC2677C3CA0098BAA8 /* PlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */; };
37B767DD2677C3CA0098BAA8 /* PlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */; };
37B767E02678C5BF0098BAA8 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 37B767DF2678C5BF0098BAA8 /* Logging */; };
37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */; };
37B81AFA26D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */; };
@ -139,18 +174,18 @@
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 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackState.swift */; };
37B81B0626D2CEDA00675966 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackState.swift */; };
37B81B0726D2D6CF00675966 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackState.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 */; };
37BA793F26DB8F97002A0235 /* ChannelVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */; };
37BA794026DB8F97002A0235 /* ChannelVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */; };
37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */; };
37BA794326DBA973002A0235 /* Playlists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794226DBA973002A0235 /* Playlists.swift */; };
37BA794426DBA973002A0235 /* Playlists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794226DBA973002A0235 /* Playlists.swift */; };
37BA794526DBA973002A0235 /* Playlists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794226DBA973002A0235 /* Playlists.swift */; };
37BA794326DBA973002A0235 /* PlaylistsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794226DBA973002A0235 /* PlaylistsModel.swift */; };
37BA794426DBA973002A0235 /* PlaylistsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794226DBA973002A0235 /* PlaylistsModel.swift */; };
37BA794526DBA973002A0235 /* PlaylistsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794226DBA973002A0235 /* PlaylistsModel.swift */; };
37BA794726DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794626DC2E56002A0235 /* AppSidebarSubscriptions.swift */; };
37BA794826DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794626DC2E56002A0235 /* AppSidebarSubscriptions.swift */; };
37BA794C26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794A26DC30EC002A0235 /* AppSidebarPlaylists.swift */; };
@ -193,9 +228,6 @@
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 */; };
37C7A1DC267CE9D90010EAD6 /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1DB267CE9D90010EAD6 /* Profile.swift */; };
37C7A1DD267CE9D90010EAD6 /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1DB267CE9D90010EAD6 /* Profile.swift */; };
37C7A1DE267CE9D90010EAD6 /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1DB267CE9D90010EAD6 /* Profile.swift */; };
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 */; };
@ -217,9 +249,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 */; };
37E64DD126D597EB00C71877 /* Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* Subscriptions.swift */; };
37E64DD226D597EB00C71877 /* Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* Subscriptions.swift */; };
37E64DD326D597EB00C71877 /* Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* Subscriptions.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 */; };
37EAD86B267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */; };
37EAD86C267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */; };
37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */; };
@ -233,6 +265,9 @@
37F4AE7226828F0900BD60EA /* VideosCellsVertical.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VideosCellsVertical.swift */; };
37F4AE7326828F0900BD60EA /* VideosCellsVertical.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VideosCellsVertical.swift */; };
37F4AE7426828F0900BD60EA /* VideosCellsVertical.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VideosCellsVertical.swift */; };
37F64FE426FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F64FE326FE70A60081B69E /* RedrawOnViewModifier.swift */; };
37F64FE526FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F64FE326FE70A60081B69E /* RedrawOnViewModifier.swift */; };
37F64FE626FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F64FE326FE70A60081B69E /* RedrawOnViewModifier.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -269,11 +304,11 @@
/* Begin PBXFileReference section */
3705B17F267B4DFB00704544 /* TrendingCountry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingCountry.swift; sourceTree = "<group>"; };
3705B181267B4E4900704544 /* TrendingCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingCategory.swift; sourceTree = "<group>"; };
3711403E26B206A6005B3555 /* SearchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchState.swift; sourceTree = "<group>"; };
3711403E26B206A6005B3555 /* SearchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModel.swift; sourceTree = "<group>"; };
3714166E267A8ACC006CA35D /* TrendingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingView.swift; sourceTree = "<group>"; };
37141672267A8E10006CA35D /* Country.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Country.swift; sourceTree = "<group>"; };
37152EE926EFEB95004FB96D /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = "<group>"; };
371F2F19269B43D300E4A7AB /* NavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationState.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>"; };
373CFABD26966115003CB2C6 /* CoverSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverSectionView.swift; sourceTree = "<group>"; };
373CFAC126966159003CB2C6 /* CoverSectionRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverSectionRowView.swift; sourceTree = "<group>"; };
@ -285,17 +320,28 @@
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>"; };
37484C1826FC837400287258 /* PlaybackSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSettingsView.swift; sourceTree = "<group>"; };
37484C1C26FC83A400287258 /* InstancesSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesSettingsView.swift; sourceTree = "<group>"; };
37484C2026FC83C400287258 /* AccountSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingsView.swift; sourceTree = "<group>"; };
37484C2426FC83E000287258 /* InstanceFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceFormView.swift; sourceTree = "<group>"; };
37484C2826FC83FF00287258 /* AccountFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFormView.swift; sourceTree = "<group>"; };
37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceDetailsSettingsView.swift; sourceTree = "<group>"; };
37484C3026FCB8F900287258 /* InstanceAccountValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceAccountValidator.swift; sourceTree = "<group>"; };
375DFB5726F9DA010013F468 /* InstancesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesModel.swift; sourceTree = "<group>"; };
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = "<group>"; };
3761AC0E26F0F9A600AA496F /* UnsubscribeAlertModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnsubscribeAlertModifier.swift; sourceTree = "<group>"; };
3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarRecents.swift; sourceTree = "<group>"; };
376578842685429C00D4EA09 /* CaseIterable+Next.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaseIterable+Next.swift"; sourceTree = "<group>"; };
376578882685471400D4EA09 /* Playlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playlist.swift; sourceTree = "<group>"; };
376578902685490700D4EA09 /* PlaylistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsView.swift; sourceTree = "<group>"; };
376B2E0626F920D600B1D64D /* SignInRequiredView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInRequiredView.swift; sourceTree = "<group>"; };
376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Instance+Fixtures.swift"; sourceTree = "<group>"; };
37754C9C26B7500000DBD602 /* VideosView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideosView.swift; sourceTree = "<group>"; };
377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedContentAccessors.swift; sourceTree = "<group>"; };
3788AC2226F683DE00F6BAA9 /* WatchNowPlaylistSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchNowPlaylistSection.swift; sourceTree = "<group>"; };
3788AC2626F6840700F6BAA9 /* WatchNowSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchNowSection.swift; sourceTree = "<group>"; };
3788AC2A26F6842D00F6BAA9 /* WatchNowSectionBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchNowSectionBody.swift; sourceTree = "<group>"; };
378E50FA26FE8B9F00F49626 /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = "<group>"; };
378E50FE26FE8EEE00F49626 /* AccountsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsMenuView.swift; sourceTree = "<group>"; };
37977582268922F600DD52A8 /* InvidiousAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvidiousAPI.swift; sourceTree = "<group>"; };
3797758A2689345500DD52A8 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
379775922689365600DD52A8 /* Array+Next.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Next.swift"; sourceTree = "<group>"; };
@ -307,17 +353,18 @@
37AAF28F26740715007FC770 /* Channel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Channel.swift; sourceTree = "<group>"; };
37AAF29926740A01007FC770 /* VideosListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosListView.swift; sourceTree = "<group>"; };
37AAF29F26741C97007FC770 /* SubscriptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsView.swift; sourceTree = "<group>"; };
37B044B626F7AB9000E1419D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
37B17D9F268A1F25006AEE9B /* VideoContextMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoContextMenuView.swift; sourceTree = "<group>"; };
37B767DA2677C3CA0098BAA8 /* PlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerState.swift; sourceTree = "<group>"; };
37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerModel.swift; sourceTree = "<group>"; };
37B76E95268747C900CE5671 /* OptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsView.swift; sourceTree = "<group>"; };
37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSizeModifier.swift; sourceTree = "<group>"; };
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 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.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 /* Playlists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playlists.swift; sourceTree = "<group>"; };
37BA794226DBA973002A0235 /* PlaylistsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsModel.swift; sourceTree = "<group>"; };
37BA794626DC2E56002A0235 /* AppSidebarSubscriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarSubscriptions.swift; sourceTree = "<group>"; };
37BA794A26DC30EC002A0235 /* AppSidebarPlaylists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarPlaylists.swift; sourceTree = "<group>"; };
37BA794E26DC3E0E002A0235 /* Int+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Format.swift"; sourceTree = "<group>"; };
@ -334,7 +381,6 @@
37BE0BDB26A2367F0092E2DB /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = "<group>"; };
37C194C626F6A9C8005D3B96 /* Recents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recents.swift; sourceTree = "<group>"; };
37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockSegment.swift; sourceTree = "<group>"; };
37C7A1DB267CE9D90010EAD6 /* Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profile.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>"; };
@ -353,11 +399,12 @@
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>"; };
37E64DD026D597EB00C71877 /* Subscriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscriptions.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>"; };
37F49BA226CAA59B00304AC0 /* Playlist+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playlist+Fixtures.swift"; sourceTree = "<group>"; };
37F4AE7126828F0900BD60EA /* VideosCellsVertical.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosCellsVertical.swift; sourceTree = "<group>"; };
37F64FE326FE70A60081B69E /* RedrawOnViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedrawOnViewModifier.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -432,6 +479,7 @@
371AAE2326CEB9E800901972 /* Navigation */ = {
isa = PBXGroup;
children = (
378E50FE26FE8EEE00F49626 /* AccountsMenuView.swift */,
37BD07BA2698AB60003EBB87 /* AppSidebarNavigation.swift */,
37BA794A26DC30EC002A0235 /* AppSidebarPlaylists.swift */,
3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */,
@ -497,6 +545,7 @@
37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */,
37AAF27D26737323007FC770 /* PopularView.swift */,
37AAF27F26737550007FC770 /* SearchView.swift */,
376B2E0626F920D600B1D64D /* SignInRequiredView.swift */,
37AAF29F26741C97007FC770 /* SubscriptionsView.swift */,
37B17D9F268A1F25006AEE9B /* VideoContextMenuView.swift */,
);
@ -515,6 +564,7 @@
3748186426A762300084E870 /* Fixtures */ = {
isa = PBXGroup;
children = (
376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */,
37F49BA226CAA59B00304AC0 /* Playlist+Fixtures.swift */,
3748186926A764FB0084E870 /* Thumbnail+Fixtures.swift */,
3748186526A7627F0084E870 /* Video+Fixtures.swift */,
@ -522,9 +572,24 @@
path = Fixtures;
sourceTree = "<group>";
};
37484C1726FC836500287258 /* Settings */ = {
isa = PBXGroup;
children = (
37484C2826FC83FF00287258 /* AccountFormView.swift */,
37484C2026FC83C400287258 /* AccountSettingsView.swift */,
37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */,
37484C2426FC83E000287258 /* InstanceFormView.swift */,
37484C1C26FC83A400287258 /* InstancesSettingsView.swift */,
37484C1826FC837400287258 /* PlaybackSettingsView.swift */,
37B044B626F7AB9000E1419D /* SettingsView.swift */,
);
path = Settings;
sourceTree = "<group>";
};
3761AC0526F0F96100AA496F /* Modifiers */ = {
isa = PBXGroup;
children = (
37F64FE326FE70A60081B69E /* RedrawOnViewModifier.swift */,
3761AC0E26F0F9A600AA496F /* UnsubscribeAlertModifier.swift */,
);
path = Modifiers;
@ -540,7 +605,6 @@
3788AC2126F683AB00F6BAA9 /* Watch Now */ = {
isa = PBXGroup;
children = (
3788AC2226F683DE00F6BAA9 /* WatchNowPlaylistSection.swift */,
3788AC2626F6840700F6BAA9 /* WatchNowSection.swift */,
3788AC2A26F6842D00F6BAA9 /* WatchNowSectionBody.swift */,
37A9965D26D6F9B9006E3224 /* WatchNowView.swift */,
@ -618,6 +682,7 @@
371AAE2326CEB9E800901972 /* Navigation */,
371AAE2426CEBA4100901972 /* Player */,
371AAE2626CEBF1600901972 /* Playlists */,
37484C1726FC836500287258 /* Settings */,
371AAE2526CEBF0B00901972 /* Trending */,
371AAE2726CEBF4700901972 /* Videos */,
371AAE2826CEC7D900901972 /* Views */,
@ -686,23 +751,25 @@
children = (
37AAF28F26740715007FC770 /* Channel.swift */,
37141672267A8E10006CA35D /* Country.swift */,
378E50FA26FE8B9F00F49626 /* Instance.swift */,
37484C3026FCB8F900287258 /* InstanceAccountValidator.swift */,
375DFB5726F9DA010013F468 /* InstancesModel.swift */,
37977582268922F600DD52A8 /* InvidiousAPI.swift */,
371F2F19269B43D300E4A7AB /* NavigationState.swift */,
37B81B0426D2CEDA00675966 /* PlaybackState.swift */,
37B767DA2677C3CA0098BAA8 /* PlayerState.swift */,
371F2F19269B43D300E4A7AB /* NavigationModel.swift */,
37B81B0426D2CEDA00675966 /* PlaybackModel.swift */,
37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */,
376578882685471400D4EA09 /* Playlist.swift */,
37BA794226DBA973002A0235 /* Playlists.swift */,
37C7A1DB267CE9D90010EAD6 /* Profile.swift */,
37BA794226DBA973002A0235 /* PlaylistsModel.swift */,
37C194C626F6A9C8005D3B96 /* Recents.swift */,
3711403E26B206A6005B3555 /* SearchModel.swift */,
373CFACA26966264003CB2C6 /* SearchQuery.swift */,
3711403E26B206A6005B3555 /* SearchState.swift */,
37EAD86E267B9ED100D9E01B /* Segment.swift */,
37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */,
37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */,
37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */,
3797758A2689345500DD52A8 /* Store.swift */,
37CEE4C02677B697005A1EFE /* Stream.swift */,
37E64DD026D597EB00C71877 /* Subscriptions.swift */,
37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */,
373CFADA269663F1003CB2C6 /* Thumbnail.swift */,
3705B181267B4E4900704544 /* TrendingCategory.swift */,
37D4B19626717E1500C925CA /* Video.swift */,
@ -1001,19 +1068,24 @@
37BD07C82698B71C003EBB87 /* AppTabNavigation.swift in Sources */,
37EAD86B267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
3763495126DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */,
376CD21626FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */,
37BA793B26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */,
37BD07B52698AA4D003EBB87 /* ContentView.swift in Sources */,
37152EEA26EFEB95004FB96D /* LazyView.swift in Sources */,
3761ABFD26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */,
378E50FF26FE8EEE00F49626 /* AccountsMenuView.swift in Sources */,
37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */,
37977583268922F600DD52A8 /* InvidiousAPI.swift in Sources */,
37BE0BD626A1D4A90092E2DB /* PlayerViewController.swift in Sources */,
37BA793F26DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
37754C9D26B7500000DBD602 /* VideosView.swift in Sources */,
37C194C726F6A9C8005D3B96 /* Recents.swift in Sources */,
3711403F26B206A6005B3555 /* SearchState.swift in Sources */,
37484C1926FC837400287258 /* PlaybackSettingsView.swift in Sources */,
3711403F26B206A6005B3555 /* SearchModel.swift in Sources */,
37F64FE426FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */,
37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */,
37BE0BD326A1D4780092E2DB /* Player.swift in Sources */,
37484C2126FC83C400287258 /* AccountSettingsView.swift in Sources */,
37A9965E26D6F9B9006E3224 /* WatchNowView.swift in Sources */,
37CEE4C12677B697005A1EFE /* Stream.swift in Sources */,
37F4AE7226828F0900BD60EA /* VideosCellsVertical.swift in Sources */,
@ -1023,25 +1095,27 @@
377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */,
3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */,
37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */,
37E64DD126D597EB00C71877 /* Subscriptions.swift in Sources */,
37E64DD126D597EB00C71877 /* SubscriptionsModel.swift in Sources */,
376578892685471400D4EA09 /* Playlist.swift in Sources */,
37B81B0526D2CEDA00675966 /* PlaybackState.swift in Sources */,
37B81B0526D2CEDA00675966 /* PlaybackModel.swift in Sources */,
373CFADB269663F1003CB2C6 /* Thumbnail.swift in Sources */,
37C7A1DC267CE9D90010EAD6 /* Profile.swift in Sources */,
3788AC2B26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */,
373CFAC026966149003CB2C6 /* CoverSectionView.swift in Sources */,
3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */,
373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
37B044B726F7AB9000E1419D /* SettingsView.swift in Sources */,
377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */,
37BA794326DBA973002A0235 /* Playlists.swift in Sources */,
37BA794326DBA973002A0235 /* PlaylistsModel.swift in Sources */,
37AAF29026740715007FC770 /* Channel.swift in Sources */,
3748186A26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
3761AC0F26F0F9A600AA496F /* UnsubscribeAlertModifier.swift in Sources */,
3788AC2326F683DE00F6BAA9 /* WatchNowPlaylistSection.swift in Sources */,
37B81AFF26D2CA3700675966 /* VideoDetails.swift in Sources */,
377FC7E5267A084E00A6BBAF /* SearchView.swift in Sources */,
376578912685490700D4EA09 /* PlaylistsView.swift in Sources */,
37484C2D26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */,
377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
37484C3126FCB8F900287258 /* InstanceAccountValidator.swift in Sources */,
376B2E0726F920D600B1D64D /* SignInRequiredView.swift in Sources */,
37BD672426F13D65004BE0C1 /* AppSidebarPlaylists.swift in Sources */,
37B17DA2268A1F8A006AEE9B /* VideoContextMenuView.swift in Sources */,
379775932689365600DD52A8 /* Array+Next.swift in Sources */,
@ -1050,8 +1124,10 @@
37BA794F26DC3E0E002A0235 /* Int+Format.swift in Sources */,
37F49BA326CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */,
37A9965A26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */,
37B767DB2677C3CA0098BAA8 /* PlayerState.swift in Sources */,
37484C2526FC83E000287258 /* InstanceFormView.swift in Sources */,
37B767DB2677C3CA0098BAA8 /* PlayerModel.swift in Sources */,
3788AC2726F6840700F6BAA9 /* WatchNowSection.swift in Sources */,
375DFB5826F9DA010013F468 /* InstancesModel.swift in Sources */,
373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */,
373CFAC226966159003CB2C6 /* CoverSectionRowView.swift in Sources */,
37141673267A8E10006CA35D /* Country.swift in Sources */,
@ -1061,8 +1137,11 @@
37B81B0226D2CAE700675966 /* PlaybackBar.swift in Sources */,
372915E62687E3B900F5A35B /* Defaults.swift in Sources */,
37D4B19726717E1500C925CA /* Video.swift in Sources */,
371F2F1A269B43D300E4A7AB /* NavigationState.swift in Sources */,
37484C2926FC83FF00287258 /* AccountFormView.swift in Sources */,
371F2F1A269B43D300E4A7AB /* NavigationModel.swift in Sources */,
37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */,
378E50FB26FE8B9F00F49626 /* Instance.swift in Sources */,
37484C1D26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
37BD07BB2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */,
37D4B0E42671614900C925CA /* PearvidiousApp.swift in Sources */,
3797758B2689345500DD52A8 /* Store.swift in Sources */,
@ -1081,22 +1160,31 @@
37BA794826DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */,
37EAD86C267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
37CEE4C22677B697005A1EFE /* Stream.swift in Sources */,
371F2F1B269B43D300E4A7AB /* NavigationState.swift in Sources */,
371F2F1B269B43D300E4A7AB /* NavigationModel.swift in Sources */,
3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */,
37BA795026DC3E0E002A0235 /* Int+Format.swift in Sources */,
37484C1E26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
377FC7DD267A081A00A6BBAF /* PopularView.swift in Sources */,
37484C2E26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */,
375DFB5926F9DA010013F468 /* InstancesModel.swift in Sources */,
3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */,
376B2E0826F920D600B1D64D /* SignInRequiredView.swift in Sources */,
37B81B0026D2CA3700675966 /* VideoDetails.swift in Sources */,
37484C1A26FC837400287258 /* PlaybackSettingsView.swift in Sources */,
37BD07C32698AD4F003EBB87 /* ContentView.swift in Sources */,
37484C3226FCB8F900287258 /* InstanceAccountValidator.swift in Sources */,
37F49BA426CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */,
3788AC2426F683DE00F6BAA9 /* WatchNowPlaylistSection.swift in Sources */,
37EAD870267B9ED100D9E01B /* Segment.swift in Sources */,
3788AC2826F6840700F6BAA9 /* WatchNowSection.swift in Sources */,
37F64FE526FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */,
37BA793C26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */,
378E510026FE8EEE00F49626 /* AccountsMenuView.swift in Sources */,
37141670267A8ACC006CA35D /* TrendingView.swift in Sources */,
37152EEB26EFEB95004FB96D /* LazyView.swift in Sources */,
377FC7E2267A084A00A6BBAF /* VideoView.swift in Sources */,
37B81B0626D2CEDA00675966 /* PlaybackState.swift in Sources */,
378E50FC26FE8B9F00F49626 /* Instance.swift in Sources */,
37B81B0626D2CEDA00675966 /* PlaybackModel.swift in Sources */,
37B044B826F7AB9000E1419D /* SettingsView.swift in Sources */,
3765788A2685471400D4EA09 /* Playlist.swift in Sources */,
373CFACC26966264003CB2C6 /* SearchQuery.swift in Sources */,
373CFAC32696616C003CB2C6 /* CoverSectionRowView.swift in Sources */,
@ -1105,6 +1193,7 @@
3748186F26A769D60084E870 /* DetailBadge.swift in Sources */,
372915E72687E3B900F5A35B /* Defaults.swift in Sources */,
376578922685490700D4EA09 /* PlaylistsView.swift in Sources */,
37484C2626FC83E000287258 /* InstanceFormView.swift in Sources */,
377FC7E4267A084E00A6BBAF /* SearchView.swift in Sources */,
37B81AFA26D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */,
377A20AA2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
@ -1118,10 +1207,9 @@
379775942689365600DD52A8 /* Array+Next.swift in Sources */,
3748186726A7627F0084E870 /* Video+Fixtures.swift in Sources */,
37BE0BDA26A214630092E2DB /* PlayerViewController.swift in Sources */,
37E64DD226D597EB00C71877 /* Subscriptions.swift in Sources */,
37E64DD226D597EB00C71877 /* SubscriptionsModel.swift in Sources */,
37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
37C7A1DD267CE9D90010EAD6 /* Profile.swift in Sources */,
37B767DC2677C3CA0098BAA8 /* PlayerState.swift in Sources */,
37B767DC2677C3CA0098BAA8 /* PlayerModel.swift in Sources */,
37754C9E26B7500000DBD602 /* VideosView.swift in Sources */,
3797758C2689345500DD52A8 /* Store.swift in Sources */,
37141674267A8E10006CA35D /* Country.swift in Sources */,
@ -1131,16 +1219,19 @@
37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */,
37BD07C12698AD3B003EBB87 /* TrendingCountry.swift in Sources */,
37BA794026DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
3711404026B206A6005B3555 /* SearchState.swift in Sources */,
3711404026B206A6005B3555 /* SearchModel.swift in Sources */,
37484C2A26FC83FF00287258 /* AccountFormView.swift in Sources */,
37484C2226FC83C400287258 /* AccountSettingsView.swift in Sources */,
37BE0BD026A0E2D50092E2DB /* VideoPlayerView.swift in Sources */,
373CFAEC26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */,
37977584268922F600DD52A8 /* InvidiousAPI.swift in Sources */,
376CD21726FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */,
37B17DA1268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */,
3748186B26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
373CFADC269663F1003CB2C6 /* Thumbnail.swift in Sources */,
373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
3763495226DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */,
37BA794426DBA973002A0235 /* Playlists.swift in Sources */,
37BA794426DBA973002A0235 /* PlaylistsModel.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1169,9 +1260,11 @@
3788AC2D26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */,
37EAD871267B9ED100D9E01B /* Segment.swift in Sources */,
37F49BA526CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */,
376CD21826FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */,
37CEE4BF2677B670005A1EFE /* SingleAssetStream.swift in Sources */,
37BE0BD426A1D47D0092E2DB /* Player.swift in Sources */,
37977585268922F600DD52A8 /* InvidiousAPI.swift in Sources */,
375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */,
37F4AE7426828F0900BD60EA /* VideosCellsVertical.swift in Sources */,
376578872685429C00D4EA09 /* CaseIterable+Next.swift in Sources */,
37D4B1802671650A00C925CA /* PearvidiousApp.swift in Sources */,
@ -1179,24 +1272,26 @@
373CFAC926966188003CB2C6 /* SearchOptionsView.swift in Sources */,
37A9965C26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */,
37BD07C92698FBDB003EBB87 /* ContentView.swift in Sources */,
376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */,
37141671267A8ACC006CA35D /* TrendingView.swift in Sources */,
3788AC2926F6840700F6BAA9 /* WatchNowSection.swift in Sources */,
37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
37AAF29226740715007FC770 /* Channel.swift in Sources */,
37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
37B81B0726D2D6CF00675966 /* PlaybackState.swift in Sources */,
37B81B0726D2D6CF00675966 /* PlaybackModel.swift in Sources */,
3765788B2685471400D4EA09 /* Playlist.swift in Sources */,
373CFADD269663F1003CB2C6 /* Thumbnail.swift in Sources */,
37E64DD326D597EB00C71877 /* Subscriptions.swift in Sources */,
37C7A1DE267CE9D90010EAD6 /* Profile.swift in Sources */,
37E64DD326D597EB00C71877 /* SubscriptionsModel.swift in Sources */,
37B044B926F7AB9000E1419D /* SettingsView.swift in Sources */,
373CFABE26966148003CB2C6 /* CoverSectionView.swift in Sources */,
37B767DD2677C3CA0098BAA8 /* PlayerState.swift in Sources */,
37B767DD2677C3CA0098BAA8 /* PlayerModel.swift in Sources */,
373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
3761AC1126F0F9A600AA496F /* UnsubscribeAlertModifier.swift in Sources */,
37D4B18E26717B3800C925CA /* VideoView.swift in Sources */,
37BE0BD126A0E2D50092E2DB /* VideoPlayerView.swift in Sources */,
37AAF27E26737323007FC770 /* PopularView.swift in Sources */,
37A9966026D6F9B9006E3224 /* WatchNowView.swift in Sources */,
37484C1F26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
37AAF29A26740A01007FC770 /* VideosListView.swift in Sources */,
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
376578932685490700D4EA09 /* PlaylistsView.swift in Sources */,
@ -1204,30 +1299,37 @@
3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
3748186826A7627F0084E870 /* Video+Fixtures.swift in Sources */,
371F2F1C269B43D300E4A7AB /* NavigationState.swift in Sources */,
37BA794526DBA973002A0235 /* Playlists.swift in Sources */,
371F2F1C269B43D300E4A7AB /* NavigationModel.swift in Sources */,
37BA794526DBA973002A0235 /* PlaylistsModel.swift in Sources */,
37B17DA0268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */,
37BE0BD726A1D4A90092E2DB /* PlayerViewController.swift in Sources */,
37484C3326FCB8F900287258 /* InstanceAccountValidator.swift in Sources */,
37CEE4C32677B697005A1EFE /* Stream.swift in Sources */,
37484C2326FC83C400287258 /* AccountSettingsView.swift in Sources */,
37C194C926F6A9C8005D3B96 /* Recents.swift in Sources */,
37F64FE626FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */,
37BE0BE526A336910092E2DB /* OptionsView.swift in Sources */,
37484C2B26FC83FF00287258 /* AccountFormView.swift in Sources */,
37BA793D26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */,
3711404126B206A6005B3555 /* SearchState.swift in Sources */,
3711404126B206A6005B3555 /* SearchModel.swift in Sources */,
379775952689365600DD52A8 /* Array+Next.swift in Sources */,
3705B180267B4DFB00704544 /* TrendingCountry.swift in Sources */,
373CFACD26966264003CB2C6 /* SearchQuery.swift in Sources */,
37141675267A8E10006CA35D /* Country.swift in Sources */,
37152EEC26EFEB95004FB96D /* LazyView.swift in Sources */,
37484C2726FC83E000287258 /* InstanceFormView.swift in Sources */,
37F49BA826CB0FCE00304AC0 /* PlaylistFormView.swift in Sources */,
373CFAC42696616C003CB2C6 /* CoverSectionRowView.swift in Sources */,
37D4B19926717E1500C925CA /* Video.swift in Sources */,
378E50FD26FE8B9F00F49626 /* Instance.swift in Sources */,
3705B184267B4E4900704544 /* TrendingCategory.swift in Sources */,
3788AC2526F683DE00F6BAA9 /* WatchNowPlaylistSection.swift in Sources */,
3761ABFF26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */,
37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */,
37484C1B26FC837400287258 /* PlaybackSettingsView.swift in Sources */,
372915E82687E3B900F5A35B /* Defaults.swift in Sources */,
37BAB54C269B39FD00E75ED1 /* TVNavigationView.swift in Sources */,
3797758D2689345500DD52A8 /* Store.swift in Sources */,
37484C2F26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -1,6 +1,15 @@
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.416",
"green" : "0.256",
"red" : "0.837"
}
},
"idiom" : "universal"
}
],

View File

@ -5,6 +5,8 @@ extension Defaults.Keys {
static let layout = Key<ListingLayout>("listingLayout", default: .cells)
#endif
static let instances = Key<[Instance]>("instances", default: [])
static let searchSortOrder = Key<SearchQuery.SortOrder>("searchSortOrder", default: .relevance)
static let searchDate = Key<SearchQuery.Date?>("searchDate")
static let searchDuration = Key<SearchQuery.Duration?>("searchDuration")
@ -14,6 +16,7 @@ extension Defaults.Keys {
static let videoIDToAddToPlaylist = Key<String?>("videoIDToAddToPlaylist")
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
static let quality = Key<Stream.ResolutionSetting>("quality", default: .hd720pFirstThenBest)
}
enum ListingLayout: String, CaseIterable, Identifiable, Defaults.Serializable {

View File

@ -0,0 +1,19 @@
import SwiftUI
struct RedrawOnViewModifier: ViewModifier {
@State private var changeFlag: Bool
init(changeFlag: Bool) {
self.changeFlag = changeFlag
}
func body(content: Content) -> some View {
content.opacity(changeFlag ? 1 : 1)
}
}
extension View {
func redrawOn(change flag: Bool) -> some View {
modifier(RedrawOnViewModifier(changeFlag: flag))
}
}

View File

@ -2,13 +2,13 @@ import Foundation
import SwiftUI
struct UnsubscribeAlertModifier: ViewModifier {
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<Subscriptions> private var subscriptions
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<SubscriptionsModel> private var subscriptions
func body(content: Content) -> some View {
content
.alert(unsubscribeAlertTitle, isPresented: $navigationState.presentingUnsubscribeAlert) {
if let channel = navigationState.channelToUnsubscribe {
.alert(unsubscribeAlertTitle, isPresented: $navigation.presentingUnsubscribeAlert) {
if let channel = navigation.channelToUnsubscribe {
Button("Unsubscribe", role: .destructive) {
subscriptions.unsubscribe(channel.id)
}
@ -17,7 +17,7 @@ struct UnsubscribeAlertModifier: ViewModifier {
}
var unsubscribeAlertTitle: String {
if let channel = navigationState.channelToUnsubscribe {
if let channel = navigation.channelToUnsubscribe {
return "Unsubscribe from \(channel.name)"
}

View File

@ -0,0 +1,33 @@
import Defaults
import SwiftUI
struct AccountsMenuView: View {
@EnvironmentObject<InvidiousAPI> private var api
@Default(.instances) private var instances
var body: some View {
Menu {
ForEach(instances, id: \.self) { instance in
Button(accountButtonTitle(instance: instance, account: instance.anonymousAccount)) {
api.setAccount(instance.anonymousAccount)
}
ForEach(instance.accounts, id: \.self) { account in
Button(accountButtonTitle(instance: instance, account: account)) {
api.setAccount(account)
}
}
}
} label: {
Label(api.account?.name ?? "Accounts", systemImage: "person.crop.circle")
.labelStyle(.titleAndIcon)
}
.disabled(instances.isEmpty)
.transaction { t in t.animation = .none }
}
func accountButtonTitle(instance: Instance, account: Instance.Account) -> String {
instances.count > 1 ? "\(account.description)\(instance.shortDescription)" : account.description
}
}

View File

@ -12,16 +12,18 @@ struct AppSidebarNavigation: View {
}
}
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<Playlists> private var playlists
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<Recents> private var recents
@EnvironmentObject<SearchState> private var searchState
@EnvironmentObject<Subscriptions> private var subscriptions
@EnvironmentObject<SearchModel> private var search
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@State private var didApplyPrimaryViewWorkAround = false
var selection: Binding<TabSelection?> {
navigationState.tabSelectionOptionalBinding
navigation.tabSelectionOptionalBinding
}
var body: some View {
@ -30,11 +32,10 @@ struct AppSidebarNavigation: View {
// workaround for an empty supplementary view on launch
// the supplementary view is determined by the default selection inside the
// primary view, but the primary view is not loaded so its selection is not read
// We work around that by briefly showing the primary view.
// We work around that by showing the primary view
if !didApplyPrimaryViewWorkAround, let splitVC = viewController.children.first as? UISplitViewController {
UIView.performWithoutAnimation {
splitVC.show(.primary)
splitVC.hide(.primary)
}
didApplyPrimaryViewWorkAround = true
}
@ -44,31 +45,33 @@ struct AppSidebarNavigation: View {
#endif
}
let sidebarMinWidth: Double = 280
var content: some View {
NavigationView {
sidebar
.frame(minWidth: 180)
.toolbar { toolbarContent }
.frame(minWidth: sidebarMinWidth)
Text("Select section")
}
.environment(\.navigationStyle, .sidebar)
.searchable(text: $searchState.queryText, placement: .sidebar) {
ForEach(searchState.querySuggestions.collection, id: \.self) { suggestion in
.searchable(text: $search.queryText, placement: .sidebar) {
ForEach(search.querySuggestions.collection, id: \.self) { suggestion in
Text(suggestion)
.searchCompletion(suggestion)
}
}
.onChange(of: searchState.queryText) { query in
searchState.loadQuerySuggestions(query)
.onChange(of: search.queryText) { query in
search.loadSuggestions(query)
}
.onSubmit(of: .search) {
searchState.changeQuery { query in
query.query = searchState.queryText
search.changeQuery { query in
query.query = search.queryText
}
recents.open(RecentItem(from: search.queryText))
recents.open(RecentItem(from: searchState.queryText))
navigationState.tabSelection = .search
navigation.tabSelection = .search
}
}
@ -80,8 +83,8 @@ struct AppSidebarNavigation: View {
.id(group)
}
.onChange(of: navigationState.sidebarSectionChanged) { _ in
scrollScrollViewToItem(scrollView: scrollView, for: navigationState.tabSelection)
.onChange(of: navigation.sidebarSectionChanged) { _ in
scrollScrollViewToItem(scrollView: scrollView, for: navigation.tabSelection)
}
}
.background {
@ -93,13 +96,7 @@ struct AppSidebarNavigation: View {
.listStyle(.sidebar)
}
.toolbar {
#if os(macOS)
ToolbarItemGroup {
Button(action: toggleSidebar) {
Image(systemName: "sidebar.left").help("Toggle Sidebar")
}
}
#endif
toolbarContent
}
}
@ -115,26 +112,28 @@ struct AppSidebarNavigation: View {
AppSidebarRecents(selection: selection)
.id("recentlyOpened")
if api.signedIn {
AppSidebarSubscriptions(selection: selection)
AppSidebarPlaylists(selection: selection)
}
}
}
}
var mainNavigationLinks: some View {
Section("Videos") {
NavigationLink(tag: TabSelection.watchNow, selection: selection) {
WatchNowView()
}
label: {
NavigationLink(destination: LazyView(WatchNowView()), tag: TabSelection.watchNow, selection: selection) {
Label("Watch Now", systemImage: "play.circle")
.accessibility(label: Text("Watch Now"))
}
if api.signedIn {
NavigationLink(destination: LazyView(SubscriptionsView()), tag: TabSelection.subscriptions, selection: selection) {
Label("Subscriptions", systemImage: "star.circle")
.accessibility(label: Text("Subscriptions"))
}
}
NavigationLink(destination: LazyView(PopularView()), tag: TabSelection.popular, selection: selection) {
Label("Popular", systemImage: "chart.bar")
@ -145,11 +144,6 @@ struct AppSidebarNavigation: View {
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")
.accessibility(label: Text("Trending"))
}
NavigationLink(destination: LazyView(PlaylistsView()), tag: TabSelection.playlists, selection: selection) {
Label("Playlists", systemImage: "list.and.film")
.accessibility(label: Text("Playlists"))
}
}
}
@ -165,6 +159,36 @@ struct AppSidebarNavigation: View {
}
}
var toolbarContent: some ToolbarContent {
Group {
#if os(iOS)
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button(action: { navigation.presentingSettings = true }) {
Image(systemName: "gearshape.2")
}
}
#endif
ToolbarItem(placement: accountsMenuToolbarItemPlacement) {
AccountsMenuView()
.help(
"Switch Instances and Accounts\n" +
"Current Instance: \n" +
"\(api.account?.url ?? "Not Set")\n" +
"Current User: \(api.account?.description ?? "Not set")"
)
}
}
}
var accountsMenuToolbarItemPlacement: ToolbarItemPlacement {
#if os(iOS)
return .bottomBar
#else
return .automatic
#endif
}
#if os(macOS)
private func toggleSidebar() {
NSApp.keyWindow?.contentViewController?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
@ -177,6 +201,6 @@ struct AppSidebarNavigation: View {
let symbolName = firstLetter?.range(of: regex, options: .regularExpression) != nil ? firstLetter! : "questionmark"
return "\(symbolName).square"
return "\(symbolName).circle"
}
}

View File

@ -1,14 +1,14 @@
import SwiftUI
struct AppSidebarPlaylists: View {
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<Playlists> private var playlists
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlaylistsModel> private var playlists
@Binding var selection: TabSelection?
var body: some View {
Section(header: Text("Playlists")) {
ForEach(playlists.all) { playlist in
ForEach(playlists.playlists.sorted { $0.title.lowercased() < $1.title.lowercased() }) { playlist in
NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $selection) {
LazyView(PlaylistVideosView(playlist))
} label: {
@ -18,7 +18,7 @@ struct AppSidebarPlaylists: View {
.id(playlist.id)
.contextMenu {
Button("Edit") {
navigationState.presentEditPlaylistForm(playlists.find(id: playlist.id))
navigation.presentEditPlaylistForm(playlists.find(id: playlist.id))
}
}
}
@ -26,11 +26,14 @@ struct AppSidebarPlaylists: View {
newPlaylistButton
.padding(.top, 8)
}
.onAppear {
playlists.load()
}
}
var newPlaylistButton: some View {
Button(action: { navigationState.presentNewPlaylistForm() }) {
Label("New Playlist", systemImage: "plus.square")
Button(action: { navigation.presentNewPlaylistForm() }) {
Label("New Playlist", systemImage: "plus.circle")
}
.foregroundColor(.secondary)
.buttonStyle(.plain)

View File

@ -4,7 +4,7 @@ import SwiftUI
struct AppSidebarRecents: View {
@Binding var selection: TabSelection?
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<Recents> private var recents
@Default(.recentlyOpened) private var recentItems
@ -18,7 +18,7 @@ struct AppSidebarRecents: View {
switch recent.type {
case .channel:
RecentNavigationLink(recent: recent, selection: $selection) {
LazyView(ChannelVideosView(Channel(id: recent.id, name: recent.title)))
LazyView(ChannelVideosView(channel: Channel(id: recent.id, name: recent.title)))
}
case .query:
RecentNavigationLink(recent: recent, selection: $selection, systemImage: "magnifyingglass") {

View File

@ -1,8 +1,9 @@
import Defaults
import SwiftUI
struct AppSidebarSubscriptions: View {
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<Subscriptions> private var subscriptions
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@Binding var selection: TabSelection?
@ -10,22 +11,25 @@ struct AppSidebarSubscriptions: View {
Section(header: Text("Subscriptions")) {
ForEach(subscriptions.all) { channel in
NavigationLink(tag: TabSelection.channel(channel.id), selection: $selection) {
LazyView(ChannelVideosView(channel))
LazyView(ChannelVideosView(channel: channel))
} label: {
Label(channel.name, systemImage: AppSidebarNavigation.symbolSystemImage(channel.name))
}
.contextMenu {
Button("Unsubscribe") {
navigationState.presentUnsubscribeAlert(channel)
navigation.presentUnsubscribeAlert(channel)
}
}
.modifier(UnsubscribeAlertModifier())
}
}
.onAppear {
subscriptions.load()
}
}
var unsubscribeAlertTitle: String {
if let channel = navigationState.channelToUnsubscribe {
if let channel = navigation.channelToUnsubscribe {
return "Unsubscribe from \(channel.name)"
}

View File

@ -2,14 +2,15 @@ import Defaults
import SwiftUI
struct AppTabNavigation: View {
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<SearchState> private var searchState
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<SearchModel> private var search
@EnvironmentObject<Recents> private var recents
var body: some View {
TabView(selection: $navigationState.tabSelection) {
TabView(selection: $navigation.tabSelection) {
NavigationView {
WatchNowView()
LazyView(WatchNowView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Watch Now", systemImage: "play.circle")
@ -18,7 +19,8 @@ struct AppTabNavigation: View {
.tag(TabSelection.watchNow)
NavigationView {
SubscriptionsView()
LazyView(SubscriptionsView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Subscriptions", systemImage: "star.circle.fill")
@ -29,7 +31,8 @@ struct AppTabNavigation: View {
// TODO: reenable with settings
// ============================
// NavigationView {
// PopularView()
// LazyView(PopularView())
// .toolbar { toolbarContent }
// }
// .tabItem {
// Label("Popular", systemImage: "chart.bar")
@ -38,7 +41,8 @@ struct AppTabNavigation: View {
// .tag(TabSelection.popular)
NavigationView {
TrendingView()
LazyView(TrendingView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")
@ -47,7 +51,8 @@ struct AppTabNavigation: View {
.tag(TabSelection.trending)
NavigationView {
PlaylistsView()
LazyView(PlaylistsView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Playlists", systemImage: "list.and.film")
@ -56,25 +61,28 @@ struct AppTabNavigation: View {
.tag(TabSelection.playlists)
NavigationView {
LazyView(
SearchView()
.searchable(text: $searchState.queryText, placement: .navigationBarDrawer(displayMode: .always)) {
ForEach(searchState.querySuggestions.collection, id: \.self) { suggestion in
.toolbar { toolbarContent }
.searchable(text: $search.queryText, placement: .navigationBarDrawer(displayMode: .always)) {
ForEach(search.querySuggestions.collection, id: \.self) { suggestion in
Text(suggestion)
.searchCompletion(suggestion)
}
}
.onChange(of: searchState.queryText) { query in
searchState.loadQuerySuggestions(query)
.onChange(of: search.queryText) { query in
search.loadSuggestions(query)
}
.onSubmit(of: .search) {
searchState.changeQuery { query in
query.query = searchState.queryText
search.changeQuery { query in
query.query = search.queryText
}
recents.open(RecentItem(from: searchState.queryText))
recents.open(RecentItem(from: search.queryText))
navigationState.tabSelection = .search
navigation.tabSelection = .search
}
)
}
.tabItem {
Label("Search", systemImage: "magnifyingglass")
@ -83,7 +91,7 @@ struct AppTabNavigation: View {
.tag(TabSelection.search)
}
.environment(\.navigationStyle, .tab)
.sheet(isPresented: $navigationState.isChannelOpen, onDismiss: {
.sheet(isPresented: $navigation.isChannelOpen, onDismiss: {
if let channel = recents.presentedChannel {
let recent = RecentItem(from: channel)
recents.close(recent)
@ -91,10 +99,26 @@ struct AppTabNavigation: View {
}) {
if recents.presentedChannel != nil {
NavigationView {
ChannelVideosView(recents.presentedChannel!)
ChannelVideosView(channel: recents.presentedChannel!)
.environment(\.inNavigationView, true)
}
}
}
}
var toolbarContent: some ToolbarContent {
#if os(iOS)
Group {
ToolbarItemGroup(placement: .navigationBarLeading) {
Button(action: { navigation.presentingSettings = true }) {
Image(systemName: "gearshape.2")
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
AccountsMenuView()
}
}
#endif
}
}

View File

@ -1,12 +1,13 @@
import Defaults
import SwiftUI
struct ContentView: View {
@StateObject private var navigationState = NavigationState()
@StateObject private var playbackState = PlaybackState()
@StateObject private var playlists = Playlists()
@StateObject private var navigation = NavigationModel()
@StateObject private var playback = PlaybackModel()
@StateObject private var recents = Recents()
@StateObject private var searchState = SearchState()
@StateObject private var subscriptions = Subscriptions()
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<InstancesModel> private var instances
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@ -26,29 +27,29 @@ struct ContentView: View {
TVNavigationView()
#endif
}
.environmentObject(navigation)
.environmentObject(playback)
.environmentObject(recents)
#if !os(tvOS)
.sheet(isPresented: $navigationState.showingVideo) {
if let video = navigationState.video {
.sheet(isPresented: $navigation.showingVideo) {
if let video = navigation.video {
VideoPlayerView(video)
#if !os(iOS)
.frame(minWidth: 550, minHeight: 720)
.onExitCommand {
navigationState.showingVideo = false
navigation.showingVideo = false
}
#endif
}
}
.sheet(isPresented: $navigationState.presentingPlaylistForm) {
PlaylistFormView(playlist: $navigationState.editedPlaylist)
.sheet(isPresented: $navigation.presentingPlaylistForm) {
PlaylistFormView(playlist: $navigation.editedPlaylist)
}
.sheet(isPresented: $navigation.presentingSettings) {
SettingsView()
}
#endif
.environmentObject(navigationState)
.environmentObject(playbackState)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(searchState)
.environmentObject(subscriptions)
}
}

View File

@ -1,15 +1,50 @@
import Defaults
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 {
SidebarCommands()
}
#endif
#if os(macOS)
Settings {
SettingsView()
.onAppear(perform: configureAPI)
.environmentObject(api)
.environmentObject(instances)
.environmentObject(playlists)
.environmentObject(subscriptions)
}
#endif
}
fileprivate func configureAPI() {
subscriptions.api = api
playlists.api = api
guard api.account == nil, instances.defaultAccount != nil else {
return
}
api.setAccount(instances.defaultAccount)
}
}

View File

@ -5,7 +5,7 @@ struct PlaybackBar: View {
let video: Video
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var playbackState: PlaybackState
@EnvironmentObject private var playback: PlaybackModel
var body: some View {
HStack {
@ -18,7 +18,7 @@ struct PlaybackBar: View {
.frame(minWidth: 60, maxWidth: .infinity)
VStack {
if playbackState.stream != nil {
if playback.stream != nil {
Text(currentStreamString)
} else {
if video.live {
@ -38,19 +38,19 @@ struct PlaybackBar: View {
}
var currentStreamString: String {
playbackState.stream != nil ? "\(playbackState.stream!.resolution.height)p" : ""
playback.stream != nil ? "\(playback.stream!.resolution.height)p" : ""
}
var playbackStatus: String {
guard playbackState.time != nil else {
if playbackState.live {
guard playback.time != nil else {
if playback.live {
return "LIVE"
} else {
return "loading..."
}
}
let remainingSeconds = video.length - playbackState.time!.seconds
let remainingSeconds = video.length - playback.time!.seconds
if remainingSeconds < 60 {
return "less than a minute"

View File

@ -1,7 +1,9 @@
import Defaults
import SwiftUI
struct Player: UIViewControllerRepresentable {
@EnvironmentObject<PlaybackState> private var playbackState
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<PlaybackModel> private var playback
var video: Video?
@ -9,7 +11,10 @@ struct Player: UIViewControllerRepresentable {
let controller = PlayerViewController()
controller.video = video
controller.playbackState = playbackState
controller.playback = playback
controller.api = api
controller.resolution = Defaults[.quality]
return controller
}

View File

@ -5,11 +5,13 @@ import SwiftUI
final class PlayerViewController: UIViewController {
var video: Video!
var api: InvidiousAPI!
var playerLoaded = false
var player = AVPlayer()
var playerState: PlayerState!
var playbackState: PlaybackState!
var playerModel: PlayerModel!
var playback: PlaybackModel!
var playerViewController = AVPlayerViewController()
var resolution: Stream.ResolutionSetting!
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
@ -22,7 +24,7 @@ final class PlayerViewController: UIViewController {
override func viewDidDisappear(_ animated: Bool) {
#if os(iOS)
if !playerState.playingOutsideViewController {
if !playerModel.playingOutsideViewController {
playerViewController.player?.replaceCurrentItem(with: nil)
playerViewController.player = nil
@ -34,15 +36,15 @@ final class PlayerViewController: UIViewController {
}
func loadPlayer() {
playerState = PlayerState(playbackState: playbackState)
playerModel = PlayerModel(playback: playback, api: api, resolution: resolution)
guard !playerLoaded else {
return
}
playerState.player = player
playerViewController.player = playerState.player
playerState.loadVideo(video)
playerModel.player = player
playerViewController.player = playerModel.player
playerModel.loadVideo(video)
#if os(tvOS)
present(playerViewController, animated: false)
@ -95,7 +97,7 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
}
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {
playerState.playingOutsideViewController = false
playerModel.playingOutsideViewController = false
dismiss(animated: false)
}
@ -103,7 +105,7 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
_: AVPlayerViewController,
willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator
) {
playerState.playingOutsideViewController = true
playerModel.playingOutsideViewController = true
}
func playerViewController(
@ -112,7 +114,7 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
) {
coordinator.animate(alongsideTransition: nil) { context in
if !context.isCancelled {
self.playerState.playingOutsideViewController = false
self.playerModel.playingOutsideViewController = false
#if os(iOS)
if self.traitCollection.verticalSizeClass == .compact {
@ -124,10 +126,10 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
}
func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) {
playerState.playingOutsideViewController = true
playerModel.playingOutsideViewController = true
}
func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {
playerState.playingOutsideViewController = false
playerModel.playingOutsideViewController = false
}
}

View File

@ -2,7 +2,7 @@ import Foundation
import SwiftUI
struct VideoDetails: View {
@EnvironmentObject<Subscriptions> private var subscriptions
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@State private var subscribed = false
@State private var confirmationShown = false
@ -186,6 +186,6 @@ struct VideoDetails: View {
struct VideoDetails_Previews: PreviewProvider {
static var previews: some View {
VideoDetails(video: Video.fixture)
.environmentObject(Subscriptions())
.environmentObject(SubscriptionsModel())
}
}

View File

@ -12,31 +12,31 @@ struct VideoPlayerView: View {
#endif
}
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<PlaybackState> private var playbackState
@ObservedObject private var store = Store<Video>()
@StateObject private var store = Store<Video>()
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlaybackModel> private var playback
var resource: Resource {
InvidiousAPI.shared.video(video.id)
api.video(video.id)
}
var video: Video
init(_ video: Video) {
self.video = video
resource.addObserver(store)
}
var body: some View {
VStack(spacing: 0) {
#if os(tvOS)
Player(video: video)
.environmentObject(playbackState)
.environmentObject(playback)
#else
GeometryReader { geometry in
VStack(spacing: 0) {
@ -49,8 +49,8 @@ struct VideoPlayerView: View {
#endif
Player(video: video)
.environmentObject(playbackState)
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: playbackState.aspectRatio))
.environmentObject(playback)
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: playback.aspectRatio))
}
.background(.black)
@ -73,13 +73,13 @@ struct VideoPlayerView: View {
}
#endif
}
.modifier(VideoDetailsPaddingModifier(geometry: geometry, aspectRatio: playbackState.aspectRatio))
.modifier(VideoDetailsPaddingModifier(geometry: geometry, aspectRatio: playback.aspectRatio))
}
.animation(.linear(duration: 0.2), value: playbackState.aspectRatio)
.animation(.linear(duration: 0.2), value: playback.aspectRatio)
#endif
}
.onAppear {
resource.addObserver(store)
resource.loadIfNeeded()
}
.onDisappear {
@ -109,7 +109,7 @@ struct VideoPlayerView_Previews: PreviewProvider {
}
.sheet(isPresented: .constant(true)) {
VideoPlayerView(Video.fixture)
.environmentObject(NavigationState())
.environmentObject(NavigationModel())
}
}
}

View File

@ -2,6 +2,8 @@ import Siesta
import SwiftUI
struct PlaylistFormView: View {
@Binding var playlist: Playlist!
@State private var name = ""
@State private var visibility = Playlist.Visibility.public
@ -10,11 +12,10 @@ struct PlaylistFormView: View {
@FocusState private var focused: Bool
@Binding var playlist: Playlist!
@Environment(\.dismiss) private var dismiss
@EnvironmentObject<Playlists> private var playlists
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<PlaylistsModel> private var playlists
var editing: Bool {
playlist != nil
@ -33,6 +34,8 @@ struct PlaylistFormView: View {
dismiss()
}.keyboardShortcut(.cancelAction)
}
.padding(.horizontal)
Form {
TextField("Name", text: $name, onCommit: validate)
.frame(maxWidth: 450)
@ -46,8 +49,7 @@ struct PlaylistFormView: View {
}
.pickerStyle(.segmented)
}
Divider()
.padding(.vertical, 4)
HStack {
if editing {
deletePlaylistButton
@ -59,11 +61,14 @@ struct PlaylistFormView: View {
.disabled(!valid)
.keyboardShortcut(.defaultAction)
}
.frame(minHeight: 35)
.padding(.horizontal)
}
.onChange(of: name) { _ in validate() }
.onAppear(perform: initializeForm)
.padding(.horizontal)
#if !os(iOS)
#if os(iOS)
.padding(.vertical)
#else
.frame(width: 400, height: 150)
#endif
@ -141,14 +146,14 @@ struct PlaylistFormView: View {
playlist = modifiedPlaylist
}
playlists.reload()
playlists.load(force: true)
dismiss()
}
}
var resource: Resource {
editing ? InvidiousAPI.shared.playlist(playlist.id) : InvidiousAPI.shared.playlists
editing ? api.playlist(playlist.id) : api.playlists
}
var visibilityButton: some View {
@ -189,9 +194,9 @@ struct PlaylistFormView: View {
}
func deletePlaylistAndDismiss() {
let resource = InvidiousAPI.shared.playlist(playlist.id)
resource.request(.delete).onSuccess { _ in
api.playlist(playlist.id).request(.delete).onSuccess { _ in
playlist = nil
playlists.load(force: true)
dismiss()
}
}

View File

@ -3,9 +3,9 @@ import Siesta
import SwiftUI
struct PlaylistsView: View {
@ObservedObject private var store = Store<[Playlist]>()
@StateObject private var store = Store<[Playlist]>()
@Default(.selectedPlaylistID) private var selectedPlaylistID
@EnvironmentObject<InvidiousAPI> private var api
@State private var showingNewPlaylist = false
@State private var createdPlaylist: Playlist?
@ -13,27 +13,18 @@ struct PlaylistsView: View {
@State private var showingEditPlaylist = false
@State private var editedPlaylist: Playlist?
var resource: Resource {
InvidiousAPI.shared.playlists
}
@Default(.selectedPlaylistID) private var selectedPlaylistID
init() {
resource.addObserver(store)
var resource: Resource {
api.playlists
}
var videos: [Video] {
currentPlaylist?.videos ?? []
}
var videosViewMaxHeight: Double {
#if os(tvOS)
videos.isEmpty ? 150 : .infinity
#else
videos.isEmpty ? 0 : .infinity
#endif
}
var body: some View {
SignInRequiredView(title: "Playlists") {
VStack {
#if os(tvOS)
toolbar
@ -47,15 +38,7 @@ struct PlaylistsView: View {
} else {
VideosView(videos: videos)
}
#if os(iOS)
toolbar
.font(.system(size: 14))
.padding(.horizontal)
.padding(.vertical, 10)
.overlay(Divider().offset(x: 0, y: -2), alignment: .topTrailing)
.transaction { t in t.animation = .none }
#endif
}
}
#if os(tvOS)
.fullScreenCover(isPresented: $showingNewPlaylist, onDismiss: selectCreatedPlaylist) {
@ -85,21 +68,37 @@ struct PlaylistsView: View {
#endif
newPlaylistButton
}
#if os(iOS)
ToolbarItemGroup(placement: .bottomBar) {
Group {
if store.collection.isEmpty {
Text("No Playlists")
.foregroundColor(.secondary)
} else {
Text("Current Playlist")
.foregroundColor(.secondary)
selectPlaylistButton
}
Spacer()
if currentPlaylist != nil {
editPlaylistButton
}
}
.transaction { t in t.animation = .none }
}
#endif
}
.onAppear {
resource.addObserver(store)
resource.loadIfNeeded()?.onSuccess { _ in
selectPlaylist(selectedPlaylistID)
}
}
#if !os(tvOS)
.navigationTitle("Playlists")
#elseif os(iOS)
.navigationBarItems(trailing: newPlaylistButton)
#endif
}
var scaledToolbar: some View {
toolbar.scaleEffect(0.85)
}
var toolbar: some View {
@ -233,6 +232,6 @@ struct PlaylistsView: View {
struct PlaylistsView_Provider: PreviewProvider {
static var previews: some View {
PlaylistsView()
.environmentObject(NavigationState())
.environmentObject(NavigationModel())
}
}

View File

@ -0,0 +1,117 @@
import Defaults
import SwiftUI
struct AccountFormView: View {
let instance: Instance
var selectedAccount: Binding<Instance.Account?>?
@State private var name = ""
@State private var sid = ""
@State private var valid = false
@State private var validated = false
@FocusState private var focused: Bool
@Environment(\.dismiss) private var dismiss
@EnvironmentObject<InstancesModel> private var instances
var body: some View {
VStack {
HStack(alignment: .center) {
Text("Add Account")
.font(.title2.bold())
Spacer()
Button("Cancel") {
dismiss()
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
}
.padding(.horizontal)
Form {
TextField("Name", text: $name, prompt: Text("Account Name (optional)"))
.focused($focused)
TextField("SID", text: $sid, prompt: Text("Invidious SID Cookie"))
}
.onAppear(perform: initializeForm)
.onChange(of: sid) { _ in validate() }
#if os(macOS)
.padding(.horizontal)
#endif
HStack {
HStack(spacing: 4) {
Image(systemName: valid ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundColor(valid ? .green : .red)
VStack(alignment: .leading) {
Text(valid ? "Account found" : "Invalid account details")
}
}
.opacity(validated ? 1 : 0)
Spacer()
Button("Save", action: submitForm)
.disabled(!valid)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
}
.frame(minHeight: 35)
.padding(.horizontal)
}
#if os(iOS)
.padding(.vertical)
#else
.frame(width: 400, height: 145)
#endif
}
func initializeForm() {
focused = true
}
func validate() {
guard !sid.isEmpty else {
validator.reset()
return
}
validator.validateAccount()
}
func submitForm() {
guard valid else {
return
}
let account = instances.addAccount(instance: instance, name: name, sid: sid)
selectedAccount?.wrappedValue = account
dismiss()
}
private var validator: InstanceAccountValidator {
InstanceAccountValidator(
url: instance.url,
account: Instance.Account(url: instance.url, sid: sid),
formObjectID: $sid,
valid: $valid,
validated: $validated
)
}
}
struct AccountFormView_Previews: PreviewProvider {
static var previews: some View {
AccountFormView(instance: Instance.fixture)
}
}

View File

@ -0,0 +1,36 @@
import SwiftUI
struct AccountSettingsView: View {
let instance: Instance
let account: Instance.Account
@Binding var selectedAccount: Instance.Account?
@State private var presentingRemovalConfirmationDialog = false
@EnvironmentObject<InstancesModel> private var instances
var body: some View {
HStack {
Text(account.description)
Spacer()
HStack {
Button("Remove", role: .destructive) {
presentingRemovalConfirmationDialog = true
}
.confirmationDialog(
"Are you sure you want to remove \(account.description) account?",
isPresented: $presentingRemovalConfirmationDialog
) {
Button("Remove", role: .destructive) {
instances.removeAccount(instance: instance, account: account)
}
}
#if os(macOS)
.foregroundColor(.red)
#endif
}
.opacity(account == selectedAccount ? 1 : 0)
}
}
}

View File

@ -0,0 +1,45 @@
import SwiftUI
struct InstanceDetailsSettingsView: View {
let instanceID: Instance.ID?
@State private var accountsChanged = false
@State private var presentingAccountForm = false
@EnvironmentObject<InstancesModel> private var instances
var instance: Instance! {
instances.find(instanceID)
}
var body: some View {
List {
Section(header: Text("Accounts")) {
ForEach(instance.accounts, id: \.self) { account in
Text(account.description)
#if !os(tvOS)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button("Remove", role: .destructive) {
instances.removeAccount(instance: instance, account: account)
accountsChanged.toggle()
}
}
#endif
}
.redrawOn(change: accountsChanged)
Button("Add account...") {
presentingAccountForm = true
}
}
}
#if os(iOS)
.listStyle(.insetGrouped)
#endif
.navigationTitle(instance.shortDescription)
.sheet(isPresented: $presentingAccountForm, onDismiss: { accountsChanged.toggle() }) {
AccountFormView(instance: instance)
}
}
}

View File

@ -0,0 +1,126 @@
import SwiftUI
struct InstanceFormView: View {
@Binding var savedInstanceID: Instance.ID?
@State private var name = ""
@State private var url = ""
@State private var valid = false
@State private var validated = false
@State private var validationError: String?
@FocusState private var nameFieldFocused: Bool
@Environment(\.dismiss) private var dismiss
@EnvironmentObject<InstancesModel> private var instancesModel
var body: some View {
VStack(alignment: .leading) {
HStack(alignment: .center) {
Text("Add Instance")
.font(.title2.bold())
Spacer()
Button("Cancel") {
dismiss()
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
}
.padding(.horizontal)
Form {
TextField("Name", text: $name, prompt: Text("Instance Name (optional)"))
.frame(maxWidth: 450)
.focused($nameFieldFocused)
TextField("URL", text: $url, prompt: Text("https://invidious.home.net"))
.frame(maxWidth: 450)
}
#if os(macOS)
.padding(.horizontal)
#endif
HStack {
HStack(spacing: 4) {
Image(systemName: valid ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundColor(valid ? .green : .red)
VStack(alignment: .leading) {
Text(valid ? "Connected successfully" : "Connection failed")
if !valid {
Text(validationError ?? "Unknown Error")
.font(.caption2)
.foregroundColor(.secondary)
.truncationMode(.tail)
.lineLimit(1)
}
}
.frame(minHeight: 40)
}
.opacity(validated ? 1 : 0)
Spacer()
Button("Save", action: submitForm)
.disabled(!valid)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
}
.padding(.horizontal)
}
.onChange(of: url) { _ in validate() }
.onAppear(perform: initializeForm)
#if os(iOS)
.padding(.vertical)
#else
.frame(width: 400, height: 150)
#endif
}
var validator: InstanceAccountValidator {
InstanceAccountValidator(
url: url,
formObjectID: $url,
valid: $valid,
validated: $validated,
error: $validationError
)
}
func validate() {
valid = false
validated = false
validationError = nil
guard !url.isEmpty else {
return
}
validator.validateInstance()
}
func initializeForm() {
nameFieldFocused = true
}
func submitForm() {
guard valid else {
return
}
savedInstanceID = instancesModel.add(name: name, url: url).id
dismiss()
}
}
struct InstanceFormView_Previews: PreviewProvider {
static var previews: some View {
InstanceFormView(savedInstanceID: .constant(nil))
}
}

View File

@ -0,0 +1,168 @@
import Defaults
import SwiftUI
struct InstancesSettingsView: View {
@Default(.instances) private var instances
@EnvironmentObject<InstancesModel> private var instancesModel
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@EnvironmentObject<PlaylistsModel> private var playlists
@State private var selectedInstanceID: Instance.ID?
@State private var selectedAccount: Instance.Account?
@State private var presentingAccountForm = false
@State private var presentingInstanceForm = false
@State private var savedFormInstanceID: Instance.ID?
@State private var presentingConfirmationDialog = false
@State private var presentingInstanceDetails = false
var selectedInstance: Instance! {
instancesModel.find(selectedInstanceID)
}
var body: some View {
Group {
#if os(iOS)
Section(header: instancesHeader) {
ForEach(instances, id: \.self) { instance in
Button(action: {
self.selectedInstanceID = instance.id
self.presentingInstanceDetails = true
}) {
HStack {
Text(instance.description)
Spacer()
NavigationLink(
isActive: .constant(false),
destination: { EmptyView() },
label: { EmptyView() }
)
.frame(maxWidth: 100)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button("Remove", role: .destructive) {
instancesModel.remove(instance)
}
}
.buttonStyle(.plain)
}
Button("Add Instance...") {
presentingInstanceForm = true
}
}
.listStyle(.insetGrouped)
#else
Section {
Text("Instance")
if !instances.isEmpty {
Picker("Instance", selection: $selectedInstanceID) {
ForEach(instances, id: \.url) { instance in
Text(instance.description).tag(Optional(instance.id))
}
}
.labelsHidden()
} else {
Text("You have no instances configured")
.font(.caption)
.foregroundColor(.secondary)
}
if let instance = selectedInstance {
if instance.accounts.isEmpty {
Text("You have no accounts for this instance")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Accounts")
List(selection: $selectedAccount) {
ForEach(instance.accounts, id: \.self) { account in
AccountSettingsView(instance: instance, account: account,
selectedAccount: $selectedAccount)
}
}
#if os(macOS)
.listStyle(.inset(alternatesRowBackgrounds: true))
#endif
}
}
if selectedInstance != nil {
HStack {
Button("Add Account...") {
selectedAccount = nil
presentingAccountForm = true
}
Spacer()
Button("Remove Instance", role: .destructive) {
presentingConfirmationDialog = true
}
.confirmationDialog(
"Are you sure you want to remove \(selectedInstance!.description) instance?",
isPresented: $presentingConfirmationDialog
) {
Button("Remove Instance", role: .destructive) {
instancesModel.remove(selectedInstance!)
selectedInstanceID = instances.last?.id
}
}
#if os(macOS)
.foregroundColor(.red)
#endif
}
}
Button("Add Instance...") {
presentingInstanceForm = true
}
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.onAppear {
selectedInstanceID = instances.first?.id
}
.sheet(isPresented: $presentingAccountForm) {
AccountFormView(instance: selectedInstance, selectedAccount: $selectedAccount)
}
Spacer()
#endif
}
.sheet(isPresented: $presentingInstanceForm, onDismiss: setSelectedInstanceToFormInstance) {
InstanceFormView(savedInstanceID: $savedFormInstanceID)
}
}
var instancesHeader: some View {
Text("Instances").background(instanceDetailsNavigationLink)
}
var instanceDetailsNavigationLink: some View {
NavigationLink(
isActive: $presentingInstanceDetails,
destination: { InstanceDetailsSettingsView(instanceID: selectedInstanceID) },
label: { EmptyView() }
)
}
func setSelectedInstanceToFormInstance() {
if let id = savedFormInstanceID {
selectedInstanceID = id
savedFormInstanceID = nil
}
}
}
struct InstancesSettingsView_Previews: PreviewProvider {
static var previews: some View {
InstancesSettingsView()
}
}

View File

@ -0,0 +1,25 @@
import Defaults
import SwiftUI
struct PlaybackSettingsView: View {
@Default(.quality) private var quality
var body: some View {
Section(header: Text("Quality")) {
Picker("Quality", selection: $quality) {
ForEach(Stream.ResolutionSetting.allCases, id: \.self) { resolution in
Text(resolution.description).tag(resolution)
}
}
.labelsHidden()
#if os(iOS)
.pickerStyle(.automatic)
#endif
#if os(macOS)
Spacer()
#endif
}
}
}

View File

@ -0,0 +1,66 @@
import Defaults
import Foundation
import SwiftUI
struct SettingsView: View {
private enum Tabs: Hashable {
case playback, instances
}
@Environment(\.dismiss) private var dismiss
var body: some View {
#if os(macOS)
TabView {
Form {
InstancesSettingsView()
}
.tabItem {
Label("Instances", systemImage: "server.rack")
}
.tag(Tabs.instances)
Form {
PlaybackSettingsView()
}
.tabItem {
Label("Playback", systemImage: "play.rectangle.on.rectangle.fill")
}
.tag(Tabs.playback)
}
.padding(20)
.frame(width: 400, height: 270)
#else
NavigationView {
List {
InstancesSettingsView()
PlaybackSettingsView()
}
#if os(iOS)
.listStyle(.insetGrouped)
#endif
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
}
}
}
#endif
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
SettingsView()
#if os(macOS)
.frame(width: 600, height: 300)
#endif
}
}

View File

@ -4,7 +4,7 @@ struct TrendingCountry: View {
static let prompt = "Country Name or Code"
@Binding var selectedCountry: Country?
@ObservedObject private var store = Store(Country.allCases)
@StateObject private var store = Store(Country.allCases)
@State private var query: String = ""
@State private var selection: Country?

View File

@ -2,18 +2,85 @@ import Siesta
import SwiftUI
struct TrendingView: View {
@StateObject private var store = Store<[Video]>()
@State private var category: TrendingCategory = .default
@State private var country: Country! = .pl
@State private var selectingCountry = false
@State private var presentingCountrySelection = false
@ObservedObject private var store = Store<[Video]>()
@EnvironmentObject<InvidiousAPI> private var api
var resource: Resource {
InvidiousAPI.shared.trending(category: category, country: country)
let resource = api.trending(category: category, country: country)
resource.addObserver(store)
return resource
}
init() {
var body: some View {
Section {
VStack(alignment: .center, spacing: 2) {
#if os(tvOS)
toolbar
.scaleEffect(0.85)
#endif
if store.collection.isEmpty {
Text("Loading")
}
VideosView(videos: store.collection)
}
}
#if os(tvOS)
.fullScreenCover(isPresented: $presentingCountrySelection) {
TrendingCountry(selectedCountry: $country)
}
#else
.sheet(isPresented: $presentingCountrySelection) {
TrendingCountry(selectedCountry: $country)
#if os(macOS)
.frame(minWidth: 400, minHeight: 400)
#endif
}
.navigationTitle("Trending")
#endif
#if os(macOS)
.toolbar {
ToolbarItemGroup {
categoryButton
countryButton
}
}
#elseif os(iOS)
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Group {
HStack {
Text("Category")
.foregroundColor(.secondary)
categoryButton
}
HStack {
Text("Country")
.foregroundColor(.secondary)
countryButton
}
}
.transaction { t in t.animation = .none }
}
}
#endif
.onChange(of: resource) { resource in
resource.load()
}
.onAppear {
resource.addObserver(store)
resource.loadIfNeeded()
}
}
var toolbar: some View {
@ -38,67 +105,21 @@ struct TrendingView: View {
}
}
var body: some View {
Section {
VStack(alignment: .center, spacing: 2) {
#if os(tvOS)
toolbar
.scaleEffect(0.85)
#endif
VideosView(videos: store.collection)
#if os(iOS)
toolbar
.font(.system(size: 14))
.padding(.horizontal)
.padding(.vertical, 10)
.overlay(Divider().offset(x: 0, y: -2), alignment: .topTrailing)
.transaction { t in t.animation = .none }
#endif
}
}
#if os(tvOS)
.fullScreenCover(isPresented: $selectingCountry, onDismiss: { setCountry(country) }) {
TrendingCountry(selectedCountry: $country)
}
#else
.sheet(isPresented: $selectingCountry, onDismiss: { setCountry(country) }) {
TrendingCountry(selectedCountry: $country)
#if os(macOS)
.frame(minWidth: 400, minHeight: 400)
#endif
}
.navigationTitle("Trending")
#endif
#if os(macOS)
.toolbar {
ToolbarItemGroup {
categoryButton
countryButton
}
}
#endif
.onAppear {
resource.loadIfNeeded()
}
}
var categoryButton: some View {
#if os(tvOS)
Button(category.name) {
setCategory(category.next())
self.category = category.next()
}
.contextMenu {
ForEach(TrendingCategory.allCases) { category in
Button(category.name) { setCategory(category) }
Button(category.name) { self.category = category }
}
}
#else
Menu(category.name) {
ForEach(TrendingCategory.allCases) { category in
Button(action: { setCategory(category) }) {
Button(action: { self.category = category }) {
if category == self.category {
Label(category.name, systemImage: "checkmark")
} else {
@ -112,30 +133,17 @@ struct TrendingView: View {
var countryButton: some View {
Button(action: {
selectingCountry.toggle()
presentingCountrySelection.toggle()
resource.removeObservers(ownedBy: store)
}) {
Text("\(country.flag) \(country.id)")
}
}
fileprivate func setCategory(_ category: TrendingCategory) {
resource.removeObservers(ownedBy: store)
self.category = category
resource.addObserver(store)
resource.loadIfNeeded()
}
fileprivate func setCountry(_ country: Country) {
self.country = country
resource.addObserver(store)
resource.loadIfNeeded()
}
}
struct TrendingView_Previews: PreviewProvider {
static var previews: some View {
TrendingView()
.environmentObject(NavigationState())
.environmentObject(NavigationModel())
}
}

View File

@ -2,7 +2,7 @@ import Defaults
import SwiftUI
struct VideoView: View {
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<NavigationModel> private var navigation
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
@ -21,7 +21,7 @@ struct VideoView: View {
content
}
} else {
Button(action: { navigationState.playVideo(video) }) {
Button(action: { navigation.playVideo(video) }) {
content
}
}
@ -125,7 +125,7 @@ struct VideoView: View {
VStack(alignment: .leading, spacing: 0) {
thumbnail
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 0) {
videoDetail(video.title, lineLimit: additionalDetailsAvailable ? 2 : 3)
#if os(tvOS)
.frame(minHeight: additionalDetailsAvailable ? 80 : 120, alignment: .top)
@ -155,7 +155,9 @@ struct VideoView: View {
}
}
.frame(minHeight: 30, alignment: .top)
#if os(tvOS)
.padding(.bottom, 10)
#endif
}
.padding(.top, 4)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading)

View File

@ -20,7 +20,7 @@ struct VideosCellsHorizontal: View {
.padding(.trailing, 20)
.padding(.bottom, 40)
#else
.frame(maxWidth: 300)
.frame(width: 300)
#endif
}
}
@ -32,6 +32,11 @@ struct VideosCellsHorizontal: View {
.padding(.vertical, 20)
#endif
}
.onAppear {
if let video = videos.first {
scrollView.scrollTo(video.id, anchor: .leading)
}
}
.onChange(of: videos) { [videos] newVideos in
#if !os(tvOS)
guard !videos.isEmpty, let video = newVideos.first else {
@ -45,7 +50,7 @@ struct VideosCellsHorizontal: View {
#if os(tvOS)
.frame(height: 560)
#else
.frame(height: 320)
.frame(height: 280)
#endif
.edgesIgnoringSafeArea(.horizontal)
@ -55,7 +60,7 @@ struct VideosCellsHorizontal: View {
struct VideoCellsHorizontal_Previews: PreviewProvider {
static var previews: some View {
VideosCellsHorizontal(videos: Video.allFixtures)
.environmentObject(NavigationState())
.environmentObject(Subscriptions())
.environmentObject(NavigationModel())
.environmentObject(SubscriptionsModel())
}
}

View File

@ -72,6 +72,6 @@ struct VideoCellsView_Previews: PreviewProvider {
static var previews: some View {
VideosView(videos: Video.allFixtures)
.frame(minWidth: 1000)
.environmentObject(NavigationState())
.environmentObject(NavigationModel())
}
}

View File

@ -2,7 +2,7 @@ import Defaults
import SwiftUI
struct VideosView: View {
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<NavigationModel> private var navigation
#if os(tvOS)
@Default(.layout) private var layout

View File

@ -4,8 +4,11 @@ import SwiftUI
struct ChannelVideosView: View {
let channel: Channel
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<Subscriptions> private var subscriptions
@StateObject private var store = Store<Channel>()
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@Environment(\.inNavigationView) private var inNavigationView
@ -14,16 +17,8 @@ struct ChannelVideosView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#endif
@ObservedObject private var store = Store<Channel>()
@Namespace private var focusNamespace
init(_ channel: Channel) {
self.channel = channel
resource.addObserver(store)
}
var body: some View {
VStack {
#if os(tvOS)
@ -55,12 +50,11 @@ struct ChannelVideosView: View {
#endif
#if !os(tvOS)
.toolbar {
ToolbarItem(placement: subscriptionToolbarItemPlacement) {
ToolbarItem {
HStack {
if let channel = store.item, let subscribers = channel.subscriptionsString {
Text("**\(subscribers)** subscribers")
Text("**\(store.item?.subscriptionsString ?? "loading")** subscribers")
.foregroundColor(.secondary)
}
.opacity(store.item?.subscriptionsString != nil ? 1 : 0)
subscriptionToggleButton
}
@ -77,13 +71,19 @@ struct ChannelVideosView: View {
#endif
.modifier(UnsubscribeAlertModifier())
.onAppear {
resource.loadIfNeeded()
if store.item.isNil {
resource.addObserver(store)
resource.load()
}
}
.navigationTitle(navigationTitle)
}
var resource: Resource {
InvidiousAPI.shared.channel(channel.id)
let resource = api.channel(channel.id)
resource.addObserver(store)
return resource
}
#if !os(tvOS)
@ -94,7 +94,7 @@ struct ChannelVideosView: View {
}
#endif
return .status
return .automatic
}
#endif
@ -102,12 +102,12 @@ struct ChannelVideosView: View {
Group {
if subscriptions.isSubscribing(channel.id) {
Button("Unsubscribe") {
navigationState.presentUnsubscribeAlert(channel)
navigation.presentUnsubscribeAlert(channel)
}
} else {
Button("Subscribe") {
subscriptions.subscribe(channel.id) {
navigationState.sidebarSectionChanged.toggle()
navigation.sidebarSectionChanged.toggle()
}
}
}

View File

@ -2,21 +2,22 @@ import Siesta
import SwiftUI
struct PopularView: View {
@ObservedObject private var store = Store<[Video]>()
@StateObject private var store = Store<[Video]>()
var resource = InvidiousAPI.shared.popular
@EnvironmentObject<InvidiousAPI> private var api
init() {
resource.addObserver(store)
var resource: Resource {
api.popular
}
var body: some View {
VideosView(videos: store.collection)
.onAppear {
resource.addObserver(store)
resource.loadIfNeeded()
}
#if !os(tvOS)
.navigationTitle("Popular")
#endif
.onAppear {
resource.loadIfNeeded()
}
}
}

View File

@ -8,7 +8,7 @@ struct SearchView: View {
@Default(.searchDuration) private var searchDuration
@EnvironmentObject<Recents> private var recents
@EnvironmentObject<SearchState> private var state
@EnvironmentObject<SearchModel> private var state
@Environment(\.navigationStyle) private var navigationStyle
@ -85,7 +85,7 @@ struct SearchView: View {
#endif
}
}
.opacity(recentsChanged ? 1 : 1)
.redrawOn(change: recentsChanged)
clearAllButton
}

View File

@ -0,0 +1,73 @@
import Defaults
import SwiftUI
struct SignInRequiredView<Content: View>: View {
let title: String
let content: Content
@EnvironmentObject<InvidiousAPI> private var api
@Default(.instances) private var instances
@EnvironmentObject<NavigationModel> private var navigation
init(title: String, @ViewBuilder content: @escaping () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
Group {
if api.signedIn {
content
} else {
prompt
}
}
#if !os(tvOS)
.navigationTitle(title)
#endif
}
var prompt: some View {
VStack(spacing: 30) {
Text("Sign In Required")
.font(.title2.bold())
Group {
if instances.isEmpty {
Text("You need to create an instance and accounts\nto access **\(title)** section")
} else {
Text("You need to select an account\nto access **\(title)** section")
}
}
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
.font(.title3)
.padding(.vertical)
if instances.isEmpty {
openSettingsButton
}
}
}
var openSettingsButton: some View {
Button(action: {
#if os(macOS)
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
#else
navigation.presentingSettings = true
#endif
}) {
Text("Open Settings")
}
.buttonStyle(.borderedProminent)
}
}
struct SignInRequiredView_Previews: PreviewProvider {
static var previews: some View {
SignInRequiredView(title: "Subscriptions") {
Text("Only when signed in")
}
}
}

View File

@ -1,30 +1,46 @@
import Siesta
import SwiftUI
struct SubscriptionsView: View {
@ObservedObject private var store = Store<[Video]>()
@StateObject private var store = Store<[Video]>()
var resource = InvidiousAPI.shared.feed
@EnvironmentObject<InvidiousAPI> private var api
init() {
resource.addObserver(store)
var feed: Resource {
api.feed
}
var body: some View {
SignInRequiredView(title: "Subscriptions") {
VideosView(videos: store.collection)
.onAppear {
if let home = InvidiousAPI.shared.home.loadIfNeeded() {
home.onSuccess { _ in
resource.loadIfNeeded()
loadResources()
}
} else {
resource.loadIfNeeded()
.onChange(of: api.account) { _ in
loadResources(force: true)
}
.onChange(of: feed) { _ in
loadResources(force: true)
}
}
.refreshable {
resource.load()
loadResources(force: true)
}
#if !os(tvOS)
.navigationTitle("Subscriptions")
#endif
}
fileprivate func loadResources(force: Bool = false) {
feed.addObserver(store)
if let request = force ? api.home.load() : api.home.loadIfNeeded() {
request.onSuccess { _ in
loadFeed(force: force)
}
} else {
loadFeed(force: force)
}
}
fileprivate func loadFeed(force: Bool = false) {
_ = force ? feed.load() : feed.loadIfNeeded()
}
}

View File

@ -2,25 +2,23 @@ import Defaults
import SwiftUI
struct VideoContextMenuView: View {
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<Recents> private var recents
@EnvironmentObject<Subscriptions> private var subscriptions
@EnvironmentObject<SubscriptionsModel> private var subscriptions
let video: Video
@Default(.showingAddToPlaylist) var showingAddToPlaylist
@Default(.videoIDToAddToPlaylist) var videoIDToAddToPlaylist
@State private var subscribed = false
var body: some View {
Section {
openChannelButton
subscriptionButton
.opacity(subscribed ? 1 : 1)
if navigationState.tabSelection == .playlists {
if navigation.tabSelection == .playlists {
removeFromPlaylistButton
} else {
addToPlaylistButton
@ -32,9 +30,9 @@ struct VideoContextMenuView: View {
Button("\(video.author) Channel") {
let recent = RecentItem(from: video.channel)
recents.open(recent)
navigationState.tabSelection = .recentlyOpened(recent.tag)
navigationState.isChannelOpen = true
navigationState.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag)
navigation.isChannelOpen = true
navigation.sidebarSectionChanged.toggle()
}
}
@ -45,13 +43,13 @@ struct VideoContextMenuView: View {
#if os(tvOS)
subscriptions.unsubscribe(video.channel.id)
#else
navigationState.presentUnsubscribeAlert(video.channel)
navigation.presentUnsubscribeAlert(video.channel)
#endif
}
} else {
Button("Subscribe") {
subscriptions.subscribe(video.channel.id) {
navigationState.sidebarSectionChanged.toggle()
navigation.sidebarSectionChanged.toggle()
}
}
}
@ -67,9 +65,9 @@ struct VideoContextMenuView: View {
var removeFromPlaylistButton: some View {
Button("Remove from playlist", role: .destructive) {
let resource = InvidiousAPI.shared.playlistVideo(Defaults[.selectedPlaylistID]!, video.indexID!)
let resource = api.playlistVideo(Defaults[.selectedPlaylistID]!, video.indexID!)
resource.request(.delete).onSuccess { _ in
InvidiousAPI.shared.playlists.load()
api.playlists.load()
}
}
}

View File

@ -1,25 +0,0 @@
import Siesta
import SwiftUI
struct WatchNowPlaylistSection: View {
@ObservedObject private var store = Store<Playlist>()
let id: String
var resource: Resource {
InvidiousAPI.shared.playlist(id)
}
init(id: String) {
self.id = id
resource.addObserver(store)
}
var body: some View {
WatchNowSectionBody(label: store.item?.title ?? "Loading", videos: store.item?.videos ?? [])
.onAppear {
resource.loadIfNeeded()
}
}
}

View File

@ -1,23 +1,28 @@
import Defaults
import Siesta
import SwiftUI
struct WatchNowSection: View {
@ObservedObject private var store = Store<[Video]>()
let resource: Resource
let label: String
@StateObject private var store = Store<[Video]>()
@EnvironmentObject<InvidiousAPI> private var api
init(resource: Resource, label: String) {
self.resource = resource
self.label = label
self.resource.addObserver(store)
}
var body: some View {
WatchNowSectionBody(label: label, videos: store.collection)
.onAppear {
resource.loadIfNeeded()
resource.addObserver(store)
resource.load()
}
.onChange(of: api.account) { _ in
resource.load()
}
}
}

View File

@ -1,19 +1,22 @@
import Defaults
import Siesta
import SwiftUI
struct WatchNowView: View {
init() {
InvidiousAPI.shared.home.loadIfNeeded()
}
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<NavigationModel> private var navigation
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
if api.validInstance {
VStack(alignment: .leading, spacing: 0) {
WatchNowSection(resource: InvidiousAPI.shared.feed, label: "Subscriptions")
WatchNowSection(resource: InvidiousAPI.shared.popular, label: "Popular")
WatchNowSection(resource: InvidiousAPI.shared.trending(category: .default, country: .pl), label: "Trending")
WatchNowSection(resource: InvidiousAPI.shared.trending(category: .movies, country: .pl), label: "Movies")
WatchNowSection(resource: InvidiousAPI.shared.trending(category: .music, country: .pl), label: "Music")
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
// ===================
@ -21,6 +24,7 @@ struct WatchNowView: View {
// WatchNowSection(resource: InvidiousAPI.shared.channelVideos("UCBJycsmduvYEL83R_U4JriQ"), label: "MKBHD")
}
}
}
#if os(tvOS)
.edgesIgnoringSafeArea(.horizontal)
#else
@ -36,7 +40,7 @@ struct WatchNowView: View {
struct WatchNowView_Previews: PreviewProvider {
static var previews: some View {
WatchNowView()
.environmentObject(Subscriptions())
.environmentObject(NavigationState())
.environmentObject(SubscriptionsModel())
.environmentObject(NavigationModel())
}
}

View File

@ -1,15 +1,20 @@
import Defaults
import SwiftUI
struct Player: NSViewControllerRepresentable {
@EnvironmentObject<PlaybackState> private var playbackState
var video: Video!
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<PlaybackModel> private var playback
func makeNSViewController(context _: Context) -> PlayerViewController {
let controller = PlayerViewController()
controller.video = video
controller.playbackState = playbackState
controller.playback = playback
controller.api = api
controller.resolution = Defaults[.quality]
return controller
}

View File

@ -4,36 +4,40 @@ import SwiftUI
final class PlayerViewController: NSViewController {
var video: Video!
var api: InvidiousAPI!
var player = AVPlayer()
var playerState: PlayerState!
var playbackState: PlaybackState!
var playerModel: PlayerModel!
var playback: PlaybackModel!
var playerView = AVPlayerView()
var resolution: Stream.ResolutionSetting!
override func viewDidDisappear() {
playerView.player?.replaceCurrentItem(with: nil)
playerView.player = nil
playerState.player = nil
playerState = nil
playerModel.player = nil
playerModel = nil
super.viewDidDisappear()
}
override func loadView() {
playerState = PlayerState(playbackState: playbackState)
playerModel = PlayerModel(playback: playback, api: api, resolution: resolution)
guard playerState.player == nil else {
guard playerModel.player == nil else {
return
}
playerState.player = player
playerView.player = playerState.player
playerModel.player = player
playerView.player = playerModel.player
playerView.allowsPictureInPicturePlayback = true
playerView.showsFullScreenToggleButton = true
view = playerView
playerState.loadVideo(video)
DispatchQueue.main.async {
self.playerModel.loadVideo(self.video)
}
}
}

View File

@ -3,7 +3,7 @@ import Siesta
import SwiftUI
struct AddToPlaylistView: View {
@ObservedObject private var store = Store<[Playlist]>()
@StateObject private var store = Store<[Playlist]>()
@State private var selectedPlaylist: Playlist?
@ -11,8 +11,10 @@ struct AddToPlaylistView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject<InvidiousAPI> private var api
var resource: Resource {
InvidiousAPI.shared.playlists
api.playlists
}
init() {
@ -85,12 +87,12 @@ struct AddToPlaylistView: View {
return
}
let resource = InvidiousAPI.shared.playlistVideos(currentPlaylist!.id)
let resource = api.playlistVideos(currentPlaylist!.id)
let body = ["videoId": videoID]
resource.request(.post, json: body).onSuccess { _ in
Defaults.reset(.videoIDToAddToPlaylist)
InvidiousAPI.shared.playlists.load()
api.playlists.load()
dismiss()
}
}

View File

@ -2,7 +2,7 @@ import Defaults
import SwiftUI
struct OptionsView: View {
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<NavigationModel> private var navigation
@Default(.layout) private var layout
@ -28,6 +28,8 @@ struct OptionsView: View {
}
Spacer()
SettingsView()
}
.frame(maxWidth: 800)
@ -42,7 +44,7 @@ struct OptionsView: View {
var tabSelectionOptions: some View {
VStack {
switch navigationState.tabSelection {
switch navigation.tabSelection {
case .search:
SearchOptionsView()

View File

@ -2,17 +2,17 @@ import Defaults
import SwiftUI
struct TVNavigationView: View {
@EnvironmentObject<NavigationState> private var navigationState
@EnvironmentObject<PlaybackState> private var playbackState
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlaybackModel> private var playback
@EnvironmentObject<Recents> private var recents
@EnvironmentObject<SearchState> private var searchState
@EnvironmentObject<SearchModel> private var search
@State private var showingOptions = false
@Default(.showingAddToPlaylist) var showingAddToPlaylist
var body: some View {
TabView(selection: $navigationState.tabSelection) {
TabView(selection: $navigation.tabSelection) {
WatchNowView()
.tabItem { Text("Watch Now") }
.tag(TabSelection.watchNow)
@ -34,30 +34,30 @@ struct TVNavigationView: View {
.tag(TabSelection.playlists)
SearchView()
.searchable(text: $searchState.queryText) {
ForEach(searchState.querySuggestions.collection, id: \.self) { suggestion in
.searchable(text: $search.queryText) {
ForEach(search.querySuggestions.collection, id: \.self) { suggestion in
Text(suggestion)
.searchCompletion(suggestion)
}
}
.onChange(of: searchState.queryText) { newQuery in
searchState.loadQuerySuggestions(newQuery)
searchState.changeQuery { query in query.query = newQuery }
.onChange(of: search.queryText) { newQuery in
search.loadSuggestions(newQuery)
search.changeQuery { query in query.query = newQuery }
}
.tabItem { Image(systemName: "magnifyingglass") }
.tag(TabSelection.search)
}
.fullScreenCover(isPresented: $showingOptions) { OptionsView() }
.fullScreenCover(isPresented: $showingAddToPlaylist) { AddToPlaylistView() }
.fullScreenCover(isPresented: $navigationState.showingVideo) {
if let video = navigationState.video {
.fullScreenCover(isPresented: $navigation.showingVideo) {
if let video = navigation.video {
VideoPlayerView(video)
.environmentObject(playbackState)
.environmentObject(playback)
}
}
.fullScreenCover(isPresented: $navigationState.isChannelOpen) {
.fullScreenCover(isPresented: $navigation.isChannelOpen) {
if let channel = recents.presentedChannel {
ChannelVideosView(channel)
ChannelVideosView(channel: channel)
.background(.thickMaterial)
}
}