mirror of
https://github.com/yattee/yattee.git
synced 2024-11-09 15:58:20 +00:00
Add Piped support
This commit is contained in:
parent
a68d89cb6f
commit
62e17d5a18
@ -1,6 +1,10 @@
|
|||||||
extension Array where Element: Equatable {
|
extension Array where Element: Equatable {
|
||||||
func next(after element: Element) -> Element? {
|
func next(after element: Element?) -> Element? {
|
||||||
let idx = firstIndex(of: element)
|
if element.isNil {
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = firstIndex(of: element!)
|
||||||
|
|
||||||
if idx.isNil {
|
if idx.isNil {
|
||||||
return first
|
return first
|
||||||
|
@ -2,6 +2,6 @@ import Foundation
|
|||||||
|
|
||||||
extension Instance {
|
extension Instance {
|
||||||
static var fixture: Instance {
|
static var fixture: Instance {
|
||||||
Instance(name: "Home", url: "https://invidious.home.net")
|
Instance(app: .invidious, name: "Home", url: "https://invidious.home.net")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import Siesta
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
final class AccountValidator: Service {
|
final class AccountValidator: Service {
|
||||||
|
let app: Binding<Instance.App>
|
||||||
let url: String
|
let url: String
|
||||||
let account: Instance.Account?
|
let account: Instance.Account?
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ final class AccountValidator: Service {
|
|||||||
var error: Binding<String?>?
|
var error: Binding<String?>?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
|
app: Binding<Instance.App>,
|
||||||
url: String,
|
url: String,
|
||||||
account: Instance.Account? = nil,
|
account: Instance.Account? = nil,
|
||||||
id: Binding<String>,
|
id: Binding<String>,
|
||||||
@ -21,6 +23,7 @@ final class AccountValidator: Service {
|
|||||||
isValidating: Binding<Bool>,
|
isValidating: Binding<Bool>,
|
||||||
error: Binding<String?>? = nil
|
error: Binding<String?>? = nil
|
||||||
) {
|
) {
|
||||||
|
self.app = app
|
||||||
self.url = url
|
self.url = url
|
||||||
self.account = account
|
self.account = account
|
||||||
formObjectID = id
|
formObjectID = id
|
||||||
@ -34,6 +37,10 @@ final class AccountValidator: Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func configure() {
|
func configure() {
|
||||||
|
configure {
|
||||||
|
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||||
|
}
|
||||||
|
|
||||||
configure("/api/v1/auth/feed", requestMethods: [.get]) {
|
configure("/api/v1/auth/feed", requestMethods: [.get]) {
|
||||||
guard self.account != nil else {
|
guard self.account != nil else {
|
||||||
return
|
return
|
||||||
@ -46,15 +53,35 @@ final class AccountValidator: Service {
|
|||||||
func validateInstance() {
|
func validateInstance() {
|
||||||
reset()
|
reset()
|
||||||
|
|
||||||
|
// TODO: validation for Piped instances
|
||||||
|
guard app.wrappedValue == .invidious else {
|
||||||
|
isValid.wrappedValue = true
|
||||||
|
error?.wrappedValue = nil
|
||||||
|
isValidated.wrappedValue = true
|
||||||
|
isValidating.wrappedValue = false
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
stats
|
stats
|
||||||
.load()
|
.load()
|
||||||
.onSuccess { _ in
|
.onSuccess { response in
|
||||||
guard self.url == self.formObjectID.wrappedValue else {
|
guard self.url == self.formObjectID.wrappedValue else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.isValid.wrappedValue = true
|
if response
|
||||||
self.error?.wrappedValue = nil
|
.json
|
||||||
|
.dictionaryValue["software"]?
|
||||||
|
.dictionaryValue["name"]?
|
||||||
|
.stringValue == "invidious"
|
||||||
|
{
|
||||||
|
self.isValid.wrappedValue = true
|
||||||
|
self.error?.wrappedValue = nil
|
||||||
|
} else {
|
||||||
|
self.isValid.wrappedValue = false
|
||||||
|
self.error?.wrappedValue = "Not an Invidious Instance"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onFailure { error in
|
.onFailure { error in
|
||||||
guard self.url == self.formObjectID.wrappedValue else {
|
guard self.url == self.formObjectID.wrappedValue else {
|
||||||
@ -70,7 +97,7 @@ final class AccountValidator: Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAccount() {
|
func validateInvidiousAccount() {
|
||||||
reset()
|
reset()
|
||||||
|
|
||||||
feed
|
feed
|
||||||
|
45
Model/AccountsModel.swift
Normal file
45
Model/AccountsModel.swift
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import Combine
|
||||||
|
import Defaults
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class AccountsModel: ObservableObject {
|
||||||
|
@Published private(set) var account: Instance.Account!
|
||||||
|
|
||||||
|
@Published private(set) var invidious = InvidiousAPI()
|
||||||
|
@Published private(set) var piped = PipedAPI()
|
||||||
|
|
||||||
|
private var cancellables = [AnyCancellable]()
|
||||||
|
|
||||||
|
var all: [Instance.Account] {
|
||||||
|
Defaults[.instances].map(\.anonymousAccount) + Defaults[.accounts]
|
||||||
|
}
|
||||||
|
|
||||||
|
var signedIn: Bool {
|
||||||
|
!account.isNil && !account.anonymous
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
cancellables.append(
|
||||||
|
invidious.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||||
|
)
|
||||||
|
|
||||||
|
cancellables.append(
|
||||||
|
piped.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAccount(_ account: Instance.Account) {
|
||||||
|
guard account != self.account else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.account = account
|
||||||
|
|
||||||
|
switch account.instance.app {
|
||||||
|
case .invidious:
|
||||||
|
invidious.setAccount(account)
|
||||||
|
case .piped:
|
||||||
|
piped.setAccount(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,22 +2,32 @@ import Defaults
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||||
|
enum App: String, CaseIterable {
|
||||||
|
case invidious, piped
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
rawValue.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Account: Defaults.Serializable, Hashable, Identifiable {
|
struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||||
static var bridge = AccountsBridge()
|
static var bridge = AccountsBridge()
|
||||||
static var empty = Account(instanceID: UUID(), name: "Signed Out", url: "", sid: "")
|
|
||||||
|
|
||||||
let id: UUID
|
let id: String
|
||||||
let instanceID: UUID
|
let instanceID: UUID
|
||||||
var name: String?
|
var name: String?
|
||||||
let url: String
|
let url: String
|
||||||
let sid: String
|
let sid: String
|
||||||
|
let anonymous: Bool
|
||||||
|
|
||||||
init(id: UUID? = nil, instanceID: UUID, name: String? = nil, url: String, sid: String) {
|
init(id: String? = nil, instanceID: UUID, name: String? = nil, url: String, sid: String? = nil, anonymous: Bool = false) {
|
||||||
self.id = id ?? UUID()
|
self.anonymous = anonymous
|
||||||
|
|
||||||
|
self.id = id ?? (anonymous ? "anonymous-\(instanceID)" : UUID().uuidString)
|
||||||
self.instanceID = instanceID
|
self.instanceID = instanceID
|
||||||
self.name = name
|
self.name = name
|
||||||
self.url = url
|
self.url = url
|
||||||
self.sid = sid
|
self.sid = sid ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var instance: Instance {
|
var instance: Instance {
|
||||||
@ -37,10 +47,6 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
|||||||
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
|
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
|
||||||
}
|
}
|
||||||
|
|
||||||
var isEmpty: Bool {
|
|
||||||
self == Account.empty
|
|
||||||
}
|
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(sid)
|
hasher.combine(sid)
|
||||||
}
|
}
|
||||||
@ -55,7 +61,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"id": value.id.uuidString,
|
"id": value.id,
|
||||||
"instanceID": value.instanceID.uuidString,
|
"instanceID": value.instanceID.uuidString,
|
||||||
"name": value.name ?? "",
|
"name": value.name ?? "",
|
||||||
"url": value.url,
|
"url": value.url,
|
||||||
@ -74,37 +80,46 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let uuid = UUID(uuidString: id)
|
|
||||||
let instanceUUID = UUID(uuidString: instanceID)!
|
let instanceUUID = UUID(uuidString: instanceID)!
|
||||||
let name = object["name"] ?? ""
|
let name = object["name"] ?? ""
|
||||||
|
|
||||||
return Account(id: uuid, instanceID: instanceUUID, name: name, url: url, sid: sid)
|
return Account(id: id, instanceID: instanceUUID, name: name, url: url, sid: sid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static var bridge = InstancesBridge()
|
static var bridge = InstancesBridge()
|
||||||
|
|
||||||
|
let app: App
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let name: String
|
let name: String
|
||||||
let url: String
|
let url: String
|
||||||
|
|
||||||
init(id: UUID? = nil, name: String, url: String) {
|
init(app: App, id: UUID? = nil, name: String, url: String) {
|
||||||
|
self.app = app
|
||||||
self.id = id ?? UUID()
|
self.id = id ?? UUID()
|
||||||
self.name = name
|
self.name = name
|
||||||
self.url = url
|
self.url = url
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
name.isEmpty ? url : "\(name) (\(url))"
|
"\(app.name) - \(shortDescription)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var longDescription: String {
|
||||||
|
name.isEmpty ? "\(app.name) - \(url)" : "\(app.name) - \(name) (\(url))"
|
||||||
}
|
}
|
||||||
|
|
||||||
var shortDescription: String {
|
var shortDescription: String {
|
||||||
name.isEmpty ? url : name
|
name.isEmpty ? url : name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var supportsAccounts: Bool {
|
||||||
|
app == .invidious
|
||||||
|
}
|
||||||
|
|
||||||
var anonymousAccount: Account {
|
var anonymousAccount: Account {
|
||||||
Account(instanceID: id, name: "Anonymous", url: url, sid: "")
|
Account(instanceID: id, name: "Anonymous", url: url, sid: "", anonymous: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct InstancesBridge: Defaults.Bridge {
|
struct InstancesBridge: Defaults.Bridge {
|
||||||
@ -117,6 +132,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
"app": value.app.rawValue,
|
||||||
"id": value.id.uuidString,
|
"id": value.id.uuidString,
|
||||||
"name": value.name,
|
"name": value.name,
|
||||||
"url": value.url
|
"url": value.url
|
||||||
@ -126,6 +142,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
|||||||
func deserialize(_ object: Serializable?) -> Value? {
|
func deserialize(_ object: Serializable?) -> Value? {
|
||||||
guard
|
guard
|
||||||
let object = object,
|
let object = object,
|
||||||
|
let app = App(rawValue: object["app"] ?? ""),
|
||||||
let id = object["id"],
|
let id = object["id"],
|
||||||
let url = object["url"]
|
let url = object["url"]
|
||||||
else {
|
else {
|
||||||
@ -135,7 +152,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
|||||||
let uuid = UUID(uuidString: id)
|
let uuid = UUID(uuidString: id)
|
||||||
let name = object["name"] ?? ""
|
let name = object["name"] ?? ""
|
||||||
|
|
||||||
return Instance(id: uuid, name: name, url: url)
|
return Instance(app: app, id: uuid, name: name, url: url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,14 +4,16 @@ import Foundation
|
|||||||
final class InstancesModel: ObservableObject {
|
final class InstancesModel: ObservableObject {
|
||||||
@Published var defaultAccount: Instance.Account?
|
@Published var defaultAccount: Instance.Account?
|
||||||
|
|
||||||
|
var all: [Instance] {
|
||||||
|
Defaults[.instances]
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
guard let id = Defaults[.defaultAccountID],
|
guard let id = Defaults[.defaultAccountID] else {
|
||||||
let uuid = UUID(uuidString: id)
|
|
||||||
else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultAccount = findAccount(uuid)
|
defaultAccount = findAccount(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func find(_ id: Instance.ID?) -> Instance? {
|
func find(_ id: Instance.ID?) -> Instance? {
|
||||||
@ -26,8 +28,8 @@ final class InstancesModel: ObservableObject {
|
|||||||
Defaults[.accounts].filter { $0.instanceID == id }
|
Defaults[.accounts].filter { $0.instanceID == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
func add(name: String, url: String) -> Instance {
|
func add(app: Instance.App, name: String, url: String) -> Instance {
|
||||||
let instance = Instance(name: name, url: url)
|
let instance = Instance(app: app, name: name, url: url)
|
||||||
Defaults[.instances].append(instance)
|
Defaults[.instances].append(instance)
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
@ -59,7 +61,7 @@ final class InstancesModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setDefaultAccount(_ account: Instance.Account?) {
|
func setDefaultAccount(_ account: Instance.Account?) {
|
||||||
Defaults[.defaultAccountID] = account?.id.uuidString
|
Defaults[.defaultAccountID] = account?.id
|
||||||
defaultAccount = account
|
defaultAccount = account
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,11 +6,21 @@ import SwiftyJSON
|
|||||||
final class InvidiousAPI: Service, ObservableObject {
|
final class InvidiousAPI: Service, ObservableObject {
|
||||||
static let basePath = "/api/v1"
|
static let basePath = "/api/v1"
|
||||||
|
|
||||||
@Published var account: Instance.Account! = .empty
|
@Published var account: Instance.Account!
|
||||||
|
|
||||||
@Published var validInstance = false
|
@Published var validInstance = true
|
||||||
@Published var signedIn = false
|
@Published var signedIn = false
|
||||||
|
|
||||||
|
init(account: Instance.Account? = nil) {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
guard !account.isNil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAccount(account!)
|
||||||
|
}
|
||||||
|
|
||||||
func setAccount(_ account: Instance.Account) {
|
func setAccount(_ account: Instance.Account) {
|
||||||
self.account = account
|
self.account = account
|
||||||
|
|
||||||
@ -56,26 +66,11 @@ final class InvidiousAPI: Service, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
func configure() {
|
||||||
SiestaLog.Category.enabled = .common
|
|
||||||
|
|
||||||
let SwiftyJSONTransformer =
|
|
||||||
ResponseContentTransformer(transformErrors: true) { JSON($0.content as AnyObject) }
|
|
||||||
|
|
||||||
configure {
|
configure {
|
||||||
$0.headers["Cookie"] = self.cookieHeader
|
if !self.account.sid.isEmpty {
|
||||||
|
$0.headers["Cookie"] = self.cookieHeader
|
||||||
|
}
|
||||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
99
Model/PipedAPI.swift
Normal file
99
Model/PipedAPI.swift
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import Siesta
|
||||||
|
import SwiftyJSON
|
||||||
|
|
||||||
|
final class PipedAPI: Service, ObservableObject {
|
||||||
|
@Published var account: Instance.Account!
|
||||||
|
|
||||||
|
var anonymousAccount: Instance.Account {
|
||||||
|
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(account: Instance.Account? = nil) {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
guard account != nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAccount(account!)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAccount(_ account: Instance.Account) {
|
||||||
|
self.account = account
|
||||||
|
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure() {
|
||||||
|
configure {
|
||||||
|
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||||
|
}
|
||||||
|
|
||||||
|
configureTransformer(pathPattern("streams/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Stream] in
|
||||||
|
self.extractStreams(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractStreams(_ content: Entity<JSON>) -> [Stream] {
|
||||||
|
var streams = [Stream]()
|
||||||
|
|
||||||
|
if let hlsURL = content.json.dictionaryValue["hls"]?.url {
|
||||||
|
streams.append(Stream(hlsURL: hlsURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let audioStream = compatibleAudioStreams(content).first else {
|
||||||
|
return streams
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoStreams = compatibleVideoStream(content)
|
||||||
|
|
||||||
|
videoStreams.forEach { videoStream in
|
||||||
|
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
|
||||||
|
let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!)
|
||||||
|
|
||||||
|
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true
|
||||||
|
let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue)
|
||||||
|
|
||||||
|
if videoOnly {
|
||||||
|
streams.append(
|
||||||
|
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
streams.append(
|
||||||
|
SingleAssetStream(avAsset: videoAsset, resolution: resolution, kind: .stream)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return streams
|
||||||
|
}
|
||||||
|
|
||||||
|
private func compatibleAudioStreams(_ content: Entity<JSON>) -> [JSON] {
|
||||||
|
content
|
||||||
|
.json
|
||||||
|
.dictionaryValue["audioStreams"]?
|
||||||
|
.arrayValue
|
||||||
|
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
|
||||||
|
.sorted {
|
||||||
|
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
|
||||||
|
} ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func compatibleVideoStream(_ content: Entity<JSON>) -> [JSON] {
|
||||||
|
content
|
||||||
|
.json
|
||||||
|
.dictionaryValue["videoStreams"]?
|
||||||
|
.arrayValue
|
||||||
|
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pathPattern(_ path: String) -> String {
|
||||||
|
"**\(path)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func streams(id: Video.ID) -> Resource {
|
||||||
|
resource(baseURL: account.instance.url, path: "streams/\(id)")
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,8 @@ import Logging
|
|||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
import Siesta
|
||||||
|
import SwiftyJSON
|
||||||
|
|
||||||
final class PlayerModel: ObservableObject {
|
final class PlayerModel: ObservableObject {
|
||||||
let logger = Logger(label: "net.arekf.Pearvidious.ps")
|
let logger = Logger(label: "net.arekf.Pearvidious.ps")
|
||||||
@ -20,6 +22,9 @@ final class PlayerModel: ObservableObject {
|
|||||||
@Published var stream: Stream?
|
@Published var stream: Stream?
|
||||||
@Published var currentRate: Float?
|
@Published var currentRate: Float?
|
||||||
|
|
||||||
|
@Published var availableStreams = [Stream]()
|
||||||
|
@Published var streamSelection: Stream?
|
||||||
|
|
||||||
@Published var queue = [PlayerQueueItem]()
|
@Published var queue = [PlayerQueueItem]()
|
||||||
@Published var currentItem: PlayerQueueItem!
|
@Published var currentItem: PlayerQueueItem!
|
||||||
@Published var live = false
|
@Published var live = false
|
||||||
@ -27,24 +32,32 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
@Published var history = [PlayerQueueItem]()
|
@Published var history = [PlayerQueueItem]()
|
||||||
|
|
||||||
var api: InvidiousAPI
|
@Published var savedTime: CMTime?
|
||||||
var timeObserver: Any?
|
|
||||||
|
|
||||||
|
@Published var composition = AVMutableComposition()
|
||||||
|
|
||||||
|
var accounts: AccountsModel
|
||||||
|
var instances: InstancesModel
|
||||||
|
|
||||||
|
var timeObserver: Any?
|
||||||
|
private var shouldResumePlaying = true
|
||||||
private var statusObservation: NSKeyValueObservation?
|
private var statusObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
var isPlaying: Bool {
|
init(accounts: AccountsModel? = nil, instances: InstancesModel? = nil) {
|
||||||
stream != nil && currentRate != 0.0
|
self.accounts = accounts ?? AccountsModel()
|
||||||
}
|
self.instances = instances ?? InstancesModel()
|
||||||
|
|
||||||
init(api: InvidiousAPI? = nil) {
|
|
||||||
self.api = api ?? InvidiousAPI()
|
|
||||||
addItemDidPlayToEndTimeObserver()
|
addItemDidPlayToEndTimeObserver()
|
||||||
|
addTimeObserver()
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentPlayer() {
|
func presentPlayer() {
|
||||||
presentingPlayer = true
|
presentingPlayer = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isPlaying: Bool {
|
||||||
|
player.timeControlStatus == .playing
|
||||||
|
}
|
||||||
|
|
||||||
func togglePlay() {
|
func togglePlay() {
|
||||||
isPlaying ? pause() : play()
|
isPlaying ? pause() : play()
|
||||||
}
|
}
|
||||||
@ -66,118 +79,156 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func playVideo(_ video: Video) {
|
func playVideo(_ video: Video) {
|
||||||
if video.live {
|
savedTime = nil
|
||||||
self.stream = nil
|
shouldResumePlaying = true
|
||||||
|
|
||||||
playHlsUrl(video)
|
loadAvailableStreams(video) { streams in
|
||||||
return
|
guard let stream = streams.first else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.streamSelection = stream
|
||||||
|
self.playStream(stream, of: video, forcePlay: true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
guard let stream = video.streamWithResolution(Defaults[.quality].value) ?? video.defaultStream else {
|
func upgradeToStream(_ stream: Stream) {
|
||||||
return
|
if !self.stream.isNil, self.stream != stream {
|
||||||
|
playStream(stream, of: currentItem.video, preservingTime: true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if stream.oneMeaningfullAsset {
|
func piped(_ instance: Instance) -> PipedAPI {
|
||||||
playStream(stream, for: video)
|
PipedAPI(account: instance.anonymousAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invidious(_ instance: Instance) -> InvidiousAPI {
|
||||||
|
InvidiousAPI(account: instance.anonymousAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playStream(
|
||||||
|
_ stream: Stream,
|
||||||
|
of video: Video,
|
||||||
|
forcePlay: Bool = false,
|
||||||
|
preservingTime: Bool = false
|
||||||
|
) {
|
||||||
|
if let url = stream.singleAssetURL {
|
||||||
|
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||||
|
|
||||||
|
insertPlayerItem(stream, for: video, forcePlay: forcePlay, preservingTime: preservingTime)
|
||||||
} else {
|
} else {
|
||||||
|
logger.info("playing stream with many assets:")
|
||||||
|
logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||||
|
logger.info("composition video asset: \(stream.videoAsset.url)")
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await playComposition(video, for: stream)
|
await self.loadComposition(stream, of: video, forcePlay: forcePlay, preservingTime: preservingTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playHlsUrl(_ video: Video) {
|
private func insertPlayerItem(
|
||||||
player.replaceCurrentItem(with: playerItemWithMetadata(video))
|
_ stream: Stream,
|
||||||
player.playImmediately(atRate: 1.0)
|
for video: Video,
|
||||||
}
|
forcePlay: Bool = false,
|
||||||
|
preservingTime: Bool = false
|
||||||
|
) {
|
||||||
|
let playerItem = playerItem(stream)
|
||||||
|
|
||||||
private func playStream(_ stream: Stream, for video: Video) {
|
|
||||||
logger.warning("loading \(stream.description) to player")
|
|
||||||
|
|
||||||
let playerItem: AVPlayerItem! = playerItemWithMetadata(video, for: stream)
|
|
||||||
guard playerItem != nil else {
|
guard playerItem != nil else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let index = queue.firstIndex(where: { $0.video.id == video.id }) {
|
attachMetadata(to: playerItem!, video: video, for: stream)
|
||||||
queue[index].playerItems.append(playerItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.stream = stream
|
self.stream = stream
|
||||||
self.player.replaceCurrentItem(with: playerItem)
|
self.composition = AVMutableComposition()
|
||||||
}
|
}
|
||||||
|
|
||||||
if timeObserver.isNil {
|
shouldResumePlaying = forcePlay || isPlaying
|
||||||
addTimeObserver()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func playComposition(_ video: Video, for stream: Stream) async {
|
if preservingTime {
|
||||||
async let assetAudioTrack = stream.audioAsset.loadTracks(withMediaType: .audio)
|
saveTime {
|
||||||
async let assetVideoTrack = stream.videoAsset.loadTracks(withMediaType: .video)
|
self.player.replaceCurrentItem(with: playerItem)
|
||||||
|
|
||||||
logger.info("loading audio track")
|
self.seekToSavedTime { finished in
|
||||||
if let audioTrack = composition(video, for: stream)?.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
|
guard finished else {
|
||||||
let assetTrack = try? await assetAudioTrack.first
|
return
|
||||||
{
|
}
|
||||||
try! audioTrack.insertTimeRange(
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)),
|
forcePlay || self.shouldResumePlaying ? self.play() : self.pause()
|
||||||
of: assetTrack,
|
self.shouldResumePlaying = false
|
||||||
at: .zero
|
}
|
||||||
)
|
}
|
||||||
logger.critical("audio loaded")
|
|
||||||
} else {
|
|
||||||
logger.critical("NO audio track")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("loading video track")
|
|
||||||
if let videoTrack = composition(video, for: stream)?.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid),
|
|
||||||
let assetTrack = try? await assetVideoTrack.first
|
|
||||||
{
|
|
||||||
try! videoTrack.insertTimeRange(
|
|
||||||
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)),
|
|
||||||
of: assetTrack,
|
|
||||||
at: .zero
|
|
||||||
)
|
|
||||||
logger.critical("video loaded")
|
|
||||||
playStream(stream, for: video)
|
|
||||||
} else {
|
|
||||||
logger.critical("NO video track")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func playerItem(_ video: Video, for stream: Stream? = nil) -> AVPlayerItem? {
|
|
||||||
if stream != nil {
|
|
||||||
if stream!.oneMeaningfullAsset {
|
|
||||||
logger.info("stream has one meaningfull asset")
|
|
||||||
return AVPlayerItem(asset: AVURLAsset(url: stream!.videoAsset.url))
|
|
||||||
}
|
}
|
||||||
if let composition = composition(video, for: stream!) {
|
} else {
|
||||||
logger.info("stream has MANY assets, using composition")
|
player.replaceCurrentItem(with: playerItem)
|
||||||
return AVPlayerItem(asset: composition)
|
|
||||||
} else {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
return nil
|
forcePlay || self.shouldResumePlaying ? self.play() : self.pause()
|
||||||
|
self.shouldResumePlaying = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return AVPlayerItem(url: video.hlsUrl!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playerItemWithMetadata(_ video: Video, for stream: Stream? = nil) -> AVPlayerItem? {
|
private func loadComposition(
|
||||||
logger.info("building player item metadata")
|
_ stream: Stream,
|
||||||
let playerItemWithMetadata: AVPlayerItem! = playerItem(video, for: stream)
|
of video: Video,
|
||||||
guard playerItemWithMetadata != nil else {
|
forcePlay: Bool = false,
|
||||||
return nil
|
preservingTime: Bool = false
|
||||||
|
) async {
|
||||||
|
await loadCompositionAsset(stream.audioAsset, type: .audio, of: video)
|
||||||
|
await loadCompositionAsset(stream.videoAsset, type: .video, of: video)
|
||||||
|
|
||||||
|
guard streamSelection == stream else {
|
||||||
|
logger.critical("IGNORING LOADED")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var externalMetadata = [
|
insertPlayerItem(stream, for: video, forcePlay: forcePlay, preservingTime: preservingTime)
|
||||||
makeMetadataItem(.commonIdentifierTitle, value: video.title),
|
}
|
||||||
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre),
|
|
||||||
makeMetadataItem(.commonIdentifierDescription, value: video.description)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
private func loadCompositionAsset(_ asset: AVURLAsset, type: AVMediaType, of video: Video) async {
|
||||||
|
async let assetTracks = asset.loadTracks(withMediaType: type)
|
||||||
|
|
||||||
|
logger.info("loading \(type.rawValue) track")
|
||||||
|
guard let compositionTrack = composition.addMutableTrack(
|
||||||
|
withMediaType: type,
|
||||||
|
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||||
|
) else {
|
||||||
|
logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let assetTrack = try? await assetTracks.first else {
|
||||||
|
logger.critical("asset \(type.rawValue) track FAILED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try! compositionTrack.insertTimeRange(
|
||||||
|
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)),
|
||||||
|
of: assetTrack,
|
||||||
|
at: .zero
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.critical("\(type.rawValue) LOADED")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playerItem(_ stream: Stream) -> AVPlayerItem? {
|
||||||
|
if let url = stream.singleAssetURL {
|
||||||
|
return AVPlayerItem(asset: AVURLAsset(url: url))
|
||||||
|
} else {
|
||||||
|
return AVPlayerItem(asset: composition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attachMetadata(to item: AVPlayerItem, video: Video, for _: Stream? = nil) {
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
|
var externalMetadata = [
|
||||||
|
makeMetadataItem(.commonIdentifierTitle, value: video.title),
|
||||||
|
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre),
|
||||||
|
makeMetadataItem(.commonIdentifierDescription, value: video.description)
|
||||||
|
]
|
||||||
if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!),
|
if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!),
|
||||||
let image = UIImage(data: thumbnailData),
|
let image = UIImage(data: thumbnailData),
|
||||||
let pngData = image.pngData()
|
let pngData = image.pngData()
|
||||||
@ -186,28 +237,41 @@ final class PlayerModel: ObservableObject {
|
|||||||
externalMetadata.append(artworkItem)
|
externalMetadata.append(artworkItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
playerItemWithMetadata.externalMetadata = externalMetadata
|
item.externalMetadata = externalMetadata
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
playerItemWithMetadata.preferredForwardBufferDuration = 15
|
item.preferredForwardBufferDuration = 5
|
||||||
|
|
||||||
statusObservation?.invalidate()
|
statusObservation?.invalidate()
|
||||||
statusObservation = playerItemWithMetadata.observe(\.status, options: [.old, .new]) { playerItem, _ in
|
statusObservation = item.observe(\.status, options: [.old, .new]) { playerItem, _ in
|
||||||
switch playerItem.status {
|
switch playerItem.status {
|
||||||
case .readyToPlay:
|
case .readyToPlay:
|
||||||
if self.isAutoplaying(playerItem) {
|
if self.isAutoplaying(playerItem), self.shouldResumePlaying {
|
||||||
self.player.play()
|
self.play()
|
||||||
}
|
}
|
||||||
|
case .failed:
|
||||||
|
print("item error: \(String(describing: item.error))")
|
||||||
|
print((item.asset as! AVURLAsset).url)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("item metadata retrieved")
|
|
||||||
return playerItemWithMetadata
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func addItemDidPlayToEndTimeObserver() {
|
#if !os(macOS)
|
||||||
|
private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
|
||||||
|
let item = AVMutableMetadataItem()
|
||||||
|
|
||||||
|
item.identifier = identifier
|
||||||
|
item.value = value as? NSCopying & NSObjectProtocol
|
||||||
|
item.extendedLanguageTag = "und"
|
||||||
|
|
||||||
|
return item.copy() as! AVMetadataItem
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private func addItemDidPlayToEndTimeObserver() {
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(itemDidPlayToEndTime),
|
selector: #selector(itemDidPlayToEndTime),
|
||||||
@ -230,15 +294,30 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func composition(_ video: Video, for stream: Stream) -> AVMutableComposition? {
|
private func saveTime(completionHandler: @escaping () -> Void = {}) {
|
||||||
if let index = queue.firstIndex(where: { $0.video == video }) {
|
let currentTime = player.currentTime()
|
||||||
if queue[index].compositions[stream].isNil {
|
|
||||||
queue[index].compositions[stream] = AVMutableComposition()
|
guard currentTime.seconds > 0 else {
|
||||||
}
|
return
|
||||||
return queue[index].compositions[stream]!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
DispatchQueue.main.async {
|
||||||
|
self.savedTime = currentTime
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func seekToSavedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
|
||||||
|
guard let time = savedTime else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
player.seek(
|
||||||
|
to: time,
|
||||||
|
toleranceBefore: .init(seconds: 1, preferredTimescale: 1000),
|
||||||
|
toleranceAfter: .zero,
|
||||||
|
completionHandler: completionHandler
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addTimeObserver() {
|
private func addTimeObserver() {
|
||||||
@ -250,14 +329,4 @@ final class PlayerModel: ObservableObject {
|
|||||||
self.time = self.player.currentTime()
|
self.time = self.player.currentTime()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
|
|
||||||
let item = AVMutableMetadataItem()
|
|
||||||
|
|
||||||
item.identifier = identifier
|
|
||||||
item.value = value as? NSCopying & NSObjectProtocol
|
|
||||||
item.extendedLanguageTag = "und"
|
|
||||||
|
|
||||||
return item.copy() as! AVMetadataItem
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Siesta
|
||||||
|
|
||||||
extension PlayerModel {
|
extension PlayerModel {
|
||||||
var currentVideo: Video? {
|
var currentVideo: Video? {
|
||||||
@ -20,7 +21,7 @@ extension PlayerModel {
|
|||||||
|
|
||||||
func playNext(_ video: Video) {
|
func playNext(_ video: Video) {
|
||||||
enqueueVideo(video, prepending: true) { _, item in
|
enqueueVideo(video, prepending: true) { _, item in
|
||||||
if self.currentItem == nil {
|
if self.currentItem.isNil {
|
||||||
self.advanceToItem(item)
|
self.advanceToItem(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -103,6 +104,10 @@ extension PlayerModel {
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func videoResource(_ id: Video.ID) -> Resource {
|
||||||
|
accounts.invidious.video(id)
|
||||||
|
}
|
||||||
|
|
||||||
private func loadDetails(_ video: Video?, onSuccess: @escaping (Video) -> Void) {
|
private func loadDetails(_ video: Video?, onSuccess: @escaping (Video) -> Void) {
|
||||||
guard video != nil else {
|
guard video != nil else {
|
||||||
return
|
return
|
||||||
@ -114,7 +119,7 @@ extension PlayerModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
api.video(video!.videoID).load().onSuccess { response in
|
videoResource(video!.videoID).load().onSuccess { response in
|
||||||
if let video: Video = response.typedContent() {
|
if let video: Video = response.typedContent() {
|
||||||
onSuccess(video)
|
onSuccess(video)
|
||||||
}
|
}
|
||||||
|
@ -10,5 +10,4 @@ struct PlayerQueueItem: Hashable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var playerItems = [AVPlayerItem]()
|
var playerItems = [AVPlayerItem]()
|
||||||
var compositions = [Stream: AVMutableComposition]()
|
|
||||||
}
|
}
|
||||||
|
100
Model/PlayerStreams.swift
Normal file
100
Model/PlayerStreams.swift
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import Foundation
|
||||||
|
import Siesta
|
||||||
|
|
||||||
|
extension PlayerModel {
|
||||||
|
var isLoadingAvailableStreams: Bool {
|
||||||
|
streamSelection.isNil || availableStreams.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLoadingStream: Bool {
|
||||||
|
!stream.isNil && stream != streamSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAvailableStreams(
|
||||||
|
_ video: Video,
|
||||||
|
completionHandler: @escaping ([Stream]) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
availableStreams = []
|
||||||
|
var instancesWithLoadedStreams = [Instance]()
|
||||||
|
|
||||||
|
instances.all.forEach { instance in
|
||||||
|
switch instance.app {
|
||||||
|
case .piped:
|
||||||
|
fetchPipedStreams(instance, video: video) { _ in
|
||||||
|
self.completeIfAllInstancesLoaded(
|
||||||
|
instance: instance,
|
||||||
|
streams: self.availableStreams,
|
||||||
|
instancesWithLoadedStreams: &instancesWithLoadedStreams,
|
||||||
|
completionHandler: completionHandler
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .invidious:
|
||||||
|
fetchInvidiousStreams(instance, video: video) { _ in
|
||||||
|
self.completeIfAllInstancesLoaded(
|
||||||
|
instance: instance,
|
||||||
|
streams: self.availableStreams,
|
||||||
|
instancesWithLoadedStreams: &instancesWithLoadedStreams,
|
||||||
|
completionHandler: completionHandler
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchInvidiousStreams(
|
||||||
|
_ instance: Instance,
|
||||||
|
video: Video,
|
||||||
|
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
invidious(instance)
|
||||||
|
.video(video.videoID)
|
||||||
|
.load()
|
||||||
|
.onSuccess { response in
|
||||||
|
if let video: Video = response.typedContent() {
|
||||||
|
self.availableStreams += self.streamsWithAssetsFromInstance(instance: instance, streams: video.streams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onCompletion(onCompletion)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchPipedStreams(
|
||||||
|
_ instance: Instance,
|
||||||
|
video: Video,
|
||||||
|
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
piped(instance)
|
||||||
|
.streams(id: video.videoID)
|
||||||
|
.load()
|
||||||
|
.onSuccess { response in
|
||||||
|
if let pipedStreams: [Stream] = response.typedContent() {
|
||||||
|
self.availableStreams += self.streamsWithInstance(instance: instance, streams: pipedStreams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onCompletion(onCompletion)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeIfAllInstancesLoaded(
|
||||||
|
instance: Instance,
|
||||||
|
streams: [Stream],
|
||||||
|
instancesWithLoadedStreams: inout [Instance],
|
||||||
|
completionHandler: @escaping ([Stream]) -> Void
|
||||||
|
) {
|
||||||
|
instancesWithLoadedStreams.append(instance)
|
||||||
|
|
||||||
|
if instances.all.count == instancesWithLoadedStreams.count {
|
||||||
|
completionHandler(streams.sorted { $0.kind < $1.kind })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
|
||||||
|
streams.map { stream in
|
||||||
|
stream.instance = instance
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamsWithAssetsFromInstance(instance: Instance, streams: [Stream]) -> [Stream] {
|
||||||
|
streams.map { stream in stream.withAssetsFrom(instance) }
|
||||||
|
}
|
||||||
|
}
|
@ -4,10 +4,15 @@ import SwiftUI
|
|||||||
|
|
||||||
final class PlaylistsModel: ObservableObject {
|
final class PlaylistsModel: ObservableObject {
|
||||||
@Published var playlists = [Playlist]()
|
@Published var playlists = [Playlist]()
|
||||||
@Published var api = InvidiousAPI()
|
|
||||||
|
|
||||||
@Published var selectedPlaylistID: Playlist.ID = ""
|
@Published var selectedPlaylistID: Playlist.ID = ""
|
||||||
|
|
||||||
|
var accounts = AccountsModel()
|
||||||
|
|
||||||
|
var api: InvidiousAPI {
|
||||||
|
accounts.invidious
|
||||||
|
}
|
||||||
|
|
||||||
init(_ playlists: [Playlist] = [Playlist]()) {
|
init(_ playlists: [Playlist] = [Playlist]()) {
|
||||||
self.playlists = playlists
|
self.playlists = playlists
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import SwiftUI
|
|||||||
final class SearchModel: ObservableObject {
|
final class SearchModel: ObservableObject {
|
||||||
@Published var store = Store<[Video]>()
|
@Published var store = Store<[Video]>()
|
||||||
|
|
||||||
@Published var api = InvidiousAPI()
|
var accounts = AccountsModel()
|
||||||
@Published var query = SearchQuery()
|
@Published var query = SearchQuery()
|
||||||
@Published var queryText = ""
|
@Published var queryText = ""
|
||||||
@Published var querySuggestions = Store<[String]>()
|
@Published var querySuggestions = Store<[String]>()
|
||||||
@ -17,6 +17,10 @@ final class SearchModel: ObservableObject {
|
|||||||
resource?.isLoading ?? false
|
resource?.isLoading ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var api: InvidiousAPI {
|
||||||
|
accounts.invidious
|
||||||
|
}
|
||||||
|
|
||||||
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
|
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
|
||||||
changeHandler(query)
|
changeHandler(query)
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import Foundation
|
|||||||
final class SingleAssetStream: Stream {
|
final class SingleAssetStream: Stream {
|
||||||
var avAsset: AVURLAsset
|
var avAsset: AVURLAsset
|
||||||
|
|
||||||
init(avAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String) {
|
init(avAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String = "") {
|
||||||
self.avAsset = avAsset
|
self.avAsset = avAsset
|
||||||
|
|
||||||
super.init(audioAsset: avAsset, videoAsset: avAsset, resolution: resolution, kind: kind, encoding: encoding)
|
super.init(audioAsset: avAsset, videoAsset: avAsset, resolution: resolution, kind: kind, encoding: encoding)
|
||||||
|
@ -3,7 +3,7 @@ import Defaults
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// swiftlint:disable:next final_class
|
// swiftlint:disable:next final_class
|
||||||
class Stream: Equatable, Hashable {
|
class Stream: Equatable, Hashable, Identifiable {
|
||||||
enum ResolutionSetting: String, Defaults.Serializable, CaseIterable {
|
enum ResolutionSetting: String, Defaults.Serializable, CaseIterable {
|
||||||
case hd720pFirstThenBest, hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p
|
case hd720pFirstThenBest, hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p
|
||||||
|
|
||||||
@ -21,20 +21,38 @@ class Stream: Equatable, Hashable {
|
|||||||
case .hd720pFirstThenBest:
|
case .hd720pFirstThenBest:
|
||||||
return "Default: adaptive"
|
return "Default: adaptive"
|
||||||
default:
|
default:
|
||||||
return "\(value.height)p".replacingOccurrences(of: " ", with: "")
|
return value.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
|
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||||
case hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p
|
case hd1440p60, hd1440p, hd1080p60, hd1080p, hd720p60, hd720p, sd480p, sd360p, sd240p, sd144p, unknown
|
||||||
|
|
||||||
var height: Int {
|
var name: String {
|
||||||
Int(rawValue.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
|
"\(height)p\(refreshRate != -1 ? ", \(refreshRate) fps" : "")"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func from(resolution: String) -> Resolution? {
|
var height: Int {
|
||||||
allCases.first { "\($0)".contains(resolution) }
|
if self == .unknown {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolutionPart = rawValue.components(separatedBy: "p").first!
|
||||||
|
return Int(resolutionPart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshRate: Int {
|
||||||
|
if self == .unknown {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshRatePart = rawValue.components(separatedBy: "p")[1]
|
||||||
|
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(resolution: String) -> Resolution {
|
||||||
|
allCases.first { "\($0)".contains(resolution) } ?? .unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
static func < (lhs: Resolution, rhs: Resolution) -> Bool {
|
static func < (lhs: Resolution, rhs: Resolution) -> Bool {
|
||||||
@ -43,14 +61,16 @@ class Stream: Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum Kind: String, Comparable {
|
enum Kind: String, Comparable {
|
||||||
case stream, adaptive
|
case stream, adaptive, hls
|
||||||
|
|
||||||
private var sortOrder: Int {
|
private var sortOrder: Int {
|
||||||
switch self {
|
switch self {
|
||||||
case .stream:
|
case .hls:
|
||||||
return 0
|
return 0
|
||||||
case .adaptive:
|
case .stream:
|
||||||
return 1
|
return 1
|
||||||
|
case .adaptive:
|
||||||
|
return 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,39 +79,98 @@ class Stream: Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var audioAsset: AVURLAsset
|
let id = UUID()
|
||||||
var videoAsset: AVURLAsset
|
|
||||||
|
|
||||||
var resolution: Resolution
|
var instance: Instance!
|
||||||
var kind: Kind
|
var audioAsset: AVURLAsset!
|
||||||
|
var videoAsset: AVURLAsset!
|
||||||
|
var hlsURL: URL!
|
||||||
|
|
||||||
var encoding: String
|
var resolution: Resolution!
|
||||||
|
var kind: Kind!
|
||||||
|
|
||||||
init(audioAsset: AVURLAsset, videoAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String) {
|
var encoding: String!
|
||||||
|
|
||||||
|
init(
|
||||||
|
instance: Instance? = nil,
|
||||||
|
audioAsset: AVURLAsset? = nil,
|
||||||
|
videoAsset: AVURLAsset? = nil,
|
||||||
|
hlsURL: URL? = nil,
|
||||||
|
resolution: Resolution? = nil,
|
||||||
|
kind: Kind = .hls,
|
||||||
|
encoding: String? = nil
|
||||||
|
) {
|
||||||
|
self.instance = instance
|
||||||
self.audioAsset = audioAsset
|
self.audioAsset = audioAsset
|
||||||
self.videoAsset = videoAsset
|
self.videoAsset = videoAsset
|
||||||
|
self.hlsURL = hlsURL
|
||||||
self.resolution = resolution
|
self.resolution = resolution
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.encoding = encoding
|
self.encoding = encoding
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var shortQuality: String {
|
||||||
|
kind == .hls ? "adaptive" : resolution.name
|
||||||
|
}
|
||||||
|
|
||||||
|
var quality: String {
|
||||||
|
kind == .hls ? "adaptive (HLS)" : "\(resolution.name) \(kind == .stream ? "(\(kind.rawValue))" : "")"
|
||||||
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
"\(resolution.height)p"
|
"\(quality) - \(instance?.description ?? "")"
|
||||||
}
|
}
|
||||||
|
|
||||||
var assets: [AVURLAsset] {
|
var assets: [AVURLAsset] {
|
||||||
[audioAsset, videoAsset]
|
[audioAsset, videoAsset]
|
||||||
}
|
}
|
||||||
|
|
||||||
var oneMeaningfullAsset: Bool {
|
var videoAssetContainsAudio: Bool {
|
||||||
assets.dropFirst().allSatisfy { $0.url == assets.first!.url }
|
assets.dropFirst().allSatisfy { $0.url == assets.first!.url }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var singleAssetURL: URL? {
|
||||||
|
if kind == .hls {
|
||||||
|
return hlsURL
|
||||||
|
} else if videoAssetContainsAudio {
|
||||||
|
return videoAsset.url
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
static func == (lhs: Stream, rhs: Stream) -> Bool {
|
static func == (lhs: Stream, rhs: Stream) -> Bool {
|
||||||
lhs.resolution == rhs.resolution && lhs.kind == rhs.kind
|
lhs.id == rhs.id
|
||||||
}
|
}
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(videoAsset.url)
|
hasher.combine(videoAsset?.url)
|
||||||
|
hasher.combine(audioAsset?.url)
|
||||||
|
hasher.combine(hlsURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAssetsFrom(_ instance: Instance) -> Stream {
|
||||||
|
if kind == .hls {
|
||||||
|
return Stream(instance: instance, hlsURL: hlsURL)
|
||||||
|
} else {
|
||||||
|
return Stream(
|
||||||
|
instance: instance,
|
||||||
|
audioAsset: AVURLAsset(url: assetURLFrom(instance: instance, url: (audioAsset ?? videoAsset).url)!),
|
||||||
|
videoAsset: AVURLAsset(url: assetURLFrom(instance: instance, url: videoAsset.url)!),
|
||||||
|
resolution: resolution,
|
||||||
|
kind: kind,
|
||||||
|
encoding: encoding
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assetURLFrom(instance: Instance, url: URL) -> URL? {
|
||||||
|
guard let instanceURLComponents = URLComponents(string: instance.url),
|
||||||
|
var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
|
||||||
|
|
||||||
|
urlComponents.scheme = instanceURLComponents.scheme
|
||||||
|
urlComponents.host = instanceURLComponents.host
|
||||||
|
|
||||||
|
return urlComponents.url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,14 +4,18 @@ import SwiftUI
|
|||||||
|
|
||||||
final class SubscriptionsModel: ObservableObject {
|
final class SubscriptionsModel: ObservableObject {
|
||||||
@Published var channels = [Channel]()
|
@Published var channels = [Channel]()
|
||||||
@Published var api: InvidiousAPI! = InvidiousAPI()
|
var accounts: AccountsModel
|
||||||
|
|
||||||
|
var api: InvidiousAPI {
|
||||||
|
accounts.invidious
|
||||||
|
}
|
||||||
|
|
||||||
var resource: Resource {
|
var resource: Resource {
|
||||||
api.subscriptions
|
api.subscriptions
|
||||||
}
|
}
|
||||||
|
|
||||||
init(api: InvidiousAPI? = nil) {
|
init(accounts: AccountsModel? = nil) {
|
||||||
self.api = api
|
self.accounts = accounts ?? AccountsModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
var all: [Channel] {
|
var all: [Channel] {
|
||||||
|
@ -22,7 +22,6 @@ struct Video: Identifiable, Equatable, Hashable {
|
|||||||
var upcoming: Bool
|
var upcoming: Bool
|
||||||
|
|
||||||
var streams = [Stream]()
|
var streams = [Stream]()
|
||||||
var hlsUrl: URL?
|
|
||||||
|
|
||||||
var publishedAt: Date?
|
var publishedAt: Date?
|
||||||
var likes: Int?
|
var likes: Int?
|
||||||
@ -104,10 +103,13 @@ struct Video: Identifiable, Equatable, Hashable {
|
|||||||
publishedAt = Date(timeIntervalSince1970: publishedInterval)
|
publishedAt = Date(timeIntervalSince1970: publishedInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let hlsURL = json["hlsUrl"].url {
|
||||||
|
streams.append(.init(hlsURL: hlsURL))
|
||||||
|
}
|
||||||
|
|
||||||
streams = Video.extractFormatStreams(from: json["formatStreams"].arrayValue)
|
streams = Video.extractFormatStreams(from: json["formatStreams"].arrayValue)
|
||||||
streams.append(contentsOf: Video.extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue))
|
streams.append(contentsOf: Video.extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue))
|
||||||
|
|
||||||
hlsUrl = json["hlsUrl"].url
|
|
||||||
channel = Channel(json: json)
|
channel = Channel(json: json)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,8 +181,8 @@ struct Video: Identifiable, Equatable, Hashable {
|
|||||||
private static func extractFormatStreams(from streams: [JSON]) -> [Stream] {
|
private static func extractFormatStreams(from streams: [JSON]) -> [Stream] {
|
||||||
streams.map {
|
streams.map {
|
||||||
SingleAssetStream(
|
SingleAssetStream(
|
||||||
avAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset($0["url"].stringValue)!),
|
avAsset: AVURLAsset(url: $0["url"].url!),
|
||||||
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue)!,
|
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
|
||||||
kind: .stream,
|
kind: .stream,
|
||||||
encoding: $0["encoding"].stringValue
|
encoding: $0["encoding"].stringValue
|
||||||
)
|
)
|
||||||
@ -197,9 +199,9 @@ struct Video: Identifiable, Equatable, Hashable {
|
|||||||
|
|
||||||
return videoAssetsURLs.map {
|
return videoAssetsURLs.map {
|
||||||
Stream(
|
Stream(
|
||||||
audioAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset(audioAssetURL!["url"].stringValue)!),
|
audioAsset: AVURLAsset(url: audioAssetURL!["url"].url!),
|
||||||
videoAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset($0["url"].stringValue)!),
|
videoAsset: AVURLAsset(url: $0["url"].url!),
|
||||||
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue)!,
|
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
|
||||||
kind: .adaptive,
|
kind: .adaptive,
|
||||||
encoding: $0["encoding"].stringValue
|
encoding: $0["encoding"].stringValue
|
||||||
)
|
)
|
||||||
|
@ -32,6 +32,15 @@
|
|||||||
/* End PBXAggregateTarget section */
|
/* End PBXAggregateTarget section */
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
3700155B271B0D4D0049C794 /* PipedAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155A271B0D4D0049C794 /* PipedAPI.swift */; };
|
||||||
|
3700155C271B0D4D0049C794 /* PipedAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155A271B0D4D0049C794 /* PipedAPI.swift */; };
|
||||||
|
3700155D271B0D4D0049C794 /* PipedAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155A271B0D4D0049C794 /* PipedAPI.swift */; };
|
||||||
|
3700155F271B12DD0049C794 /* SiestaConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155E271B12DD0049C794 /* SiestaConfiguration.swift */; };
|
||||||
|
37001560271B12DD0049C794 /* SiestaConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155E271B12DD0049C794 /* SiestaConfiguration.swift */; };
|
||||||
|
37001561271B12DD0049C794 /* SiestaConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155E271B12DD0049C794 /* SiestaConfiguration.swift */; };
|
||||||
|
37001563271B1F250049C794 /* AccountsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37001562271B1F250049C794 /* AccountsModel.swift */; };
|
||||||
|
37001564271B1F250049C794 /* AccountsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37001562271B1F250049C794 /* AccountsModel.swift */; };
|
||||||
|
37001565271B1F250049C794 /* AccountsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37001562271B1F250049C794 /* AccountsModel.swift */; };
|
||||||
3705B180267B4DFB00704544 /* TrendingCountry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B17F267B4DFB00704544 /* TrendingCountry.swift */; };
|
3705B180267B4DFB00704544 /* TrendingCountry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B17F267B4DFB00704544 /* TrendingCountry.swift */; };
|
||||||
3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B181267B4E4900704544 /* TrendingCategory.swift */; };
|
3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B181267B4E4900704544 /* TrendingCategory.swift */; };
|
||||||
3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B181267B4E4900704544 /* TrendingCategory.swift */; };
|
3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B181267B4E4900704544 /* TrendingCategory.swift */; };
|
||||||
@ -284,6 +293,9 @@
|
|||||||
37D4B19826717E1500C925CA /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B19626717E1500C925CA /* Video.swift */; };
|
37D4B19826717E1500C925CA /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B19626717E1500C925CA /* Video.swift */; };
|
||||||
37D4B19926717E1500C925CA /* 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 */; };
|
37D4B19D2671817900C925CA /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 37D4B19C2671817900C925CA /* SwiftyJSON */; };
|
||||||
|
37DD87C7271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */; };
|
||||||
|
37DD87C8271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */; };
|
||||||
|
37DD87C9271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */; };
|
||||||
37E2EEAB270656EC00170416 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */; };
|
37E2EEAB270656EC00170416 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */; };
|
||||||
37E2EEAC270656EC00170416 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */; };
|
37E2EEAC270656EC00170416 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */; };
|
||||||
37E2EEAD270656EC00170416 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */; };
|
37E2EEAD270656EC00170416 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */; };
|
||||||
@ -348,6 +360,9 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
3700155A271B0D4D0049C794 /* PipedAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipedAPI.swift; sourceTree = "<group>"; };
|
||||||
|
3700155E271B12DD0049C794 /* SiestaConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiestaConfiguration.swift; sourceTree = "<group>"; };
|
||||||
|
37001562271B1F250049C794 /* AccountsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsModel.swift; sourceTree = "<group>"; };
|
||||||
3705B17F267B4DFB00704544 /* TrendingCountry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingCountry.swift; sourceTree = "<group>"; };
|
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>"; };
|
3705B181267B4E4900704544 /* TrendingCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingCategory.swift; sourceTree = "<group>"; };
|
||||||
3711403E26B206A6005B3555 /* SearchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModel.swift; sourceTree = "<group>"; };
|
3711403E26B206A6005B3555 /* SearchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModel.swift; sourceTree = "<group>"; };
|
||||||
@ -449,6 +464,7 @@
|
|||||||
37D4B18B26717B3800C925CA /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
37D4B1AE26729DEB00C925CA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStreams.swift; sourceTree = "<group>"; };
|
||||||
37E2EEAA270656EC00170416 /* PlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsView.swift; sourceTree = "<group>"; };
|
37E2EEAA270656EC00170416 /* PlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsView.swift; sourceTree = "<group>"; };
|
||||||
37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsModel.swift; sourceTree = "<group>"; };
|
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>"; };
|
37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockAPI.swift; sourceTree = "<group>"; };
|
||||||
@ -744,6 +760,7 @@
|
|||||||
372915E52687E3B900F5A35B /* Defaults.swift */,
|
372915E52687E3B900F5A35B /* Defaults.swift */,
|
||||||
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */,
|
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */,
|
||||||
37D4B0C22671614700C925CA /* PearvidiousApp.swift */,
|
37D4B0C22671614700C925CA /* PearvidiousApp.swift */,
|
||||||
|
3700155E271B12DD0049C794 /* SiestaConfiguration.swift */,
|
||||||
37D4B0C42671614800C925CA /* Assets.xcassets */,
|
37D4B0C42671614800C925CA /* Assets.xcassets */,
|
||||||
37BD07C42698ADEE003EBB87 /* Pearvidious.entitlements */,
|
37BD07C42698ADEE003EBB87 /* Pearvidious.entitlements */,
|
||||||
);
|
);
|
||||||
@ -803,6 +820,7 @@
|
|||||||
37D4B1B72672CFE300C925CA /* Model */ = {
|
37D4B1B72672CFE300C925CA /* Model */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
37001562271B1F250049C794 /* AccountsModel.swift */,
|
||||||
37484C3026FCB8F900287258 /* AccountValidator.swift */,
|
37484C3026FCB8F900287258 /* AccountValidator.swift */,
|
||||||
37AAF28F26740715007FC770 /* Channel.swift */,
|
37AAF28F26740715007FC770 /* Channel.swift */,
|
||||||
37141672267A8E10006CA35D /* Country.swift */,
|
37141672267A8E10006CA35D /* Country.swift */,
|
||||||
@ -810,9 +828,11 @@
|
|||||||
375DFB5726F9DA010013F468 /* InstancesModel.swift */,
|
375DFB5726F9DA010013F468 /* InstancesModel.swift */,
|
||||||
37977582268922F600DD52A8 /* InvidiousAPI.swift */,
|
37977582268922F600DD52A8 /* InvidiousAPI.swift */,
|
||||||
371F2F19269B43D300E4A7AB /* NavigationModel.swift */,
|
371F2F19269B43D300E4A7AB /* NavigationModel.swift */,
|
||||||
|
3700155A271B0D4D0049C794 /* PipedAPI.swift */,
|
||||||
37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */,
|
37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */,
|
||||||
37319F0427103F94004ECCD0 /* PlayerQueue.swift */,
|
37319F0427103F94004ECCD0 /* PlayerQueue.swift */,
|
||||||
37CC3F44270CE30600608308 /* PlayerQueueItem.swift */,
|
37CC3F44270CE30600608308 /* PlayerQueueItem.swift */,
|
||||||
|
37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */,
|
||||||
376578882685471400D4EA09 /* Playlist.swift */,
|
376578882685471400D4EA09 /* Playlist.swift */,
|
||||||
37BA794226DBA973002A0235 /* PlaylistsModel.swift */,
|
37BA794226DBA973002A0235 /* PlaylistsModel.swift */,
|
||||||
37C194C626F6A9C8005D3B96 /* RecentsModel.swift */,
|
37C194C626F6A9C8005D3B96 /* RecentsModel.swift */,
|
||||||
@ -1252,6 +1272,7 @@
|
|||||||
3711403F26B206A6005B3555 /* SearchModel.swift in Sources */,
|
3711403F26B206A6005B3555 /* SearchModel.swift in Sources */,
|
||||||
37F64FE426FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */,
|
37F64FE426FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */,
|
||||||
37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */,
|
37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */,
|
||||||
|
37DD87C7271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
|
||||||
37BE0BD326A1D4780092E2DB /* Player.swift in Sources */,
|
37BE0BD326A1D4780092E2DB /* Player.swift in Sources */,
|
||||||
37A9965E26D6F9B9006E3224 /* WatchNowView.swift in Sources */,
|
37A9965E26D6F9B9006E3224 /* WatchNowView.swift in Sources */,
|
||||||
37CEE4C12677B697005A1EFE /* Stream.swift in Sources */,
|
37CEE4C12677B697005A1EFE /* Stream.swift in Sources */,
|
||||||
@ -1263,6 +1284,7 @@
|
|||||||
37FD43DE2704717F0073EE42 /* DefaultAccountHint.swift in Sources */,
|
37FD43DE2704717F0073EE42 /* DefaultAccountHint.swift in Sources */,
|
||||||
37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */,
|
37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */,
|
||||||
3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */,
|
3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */,
|
||||||
|
3700155F271B12DD0049C794 /* SiestaConfiguration.swift in Sources */,
|
||||||
37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */,
|
37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */,
|
||||||
375168D62700FAFF008F96A6 /* Debounce.swift in Sources */,
|
375168D62700FAFF008F96A6 /* Debounce.swift in Sources */,
|
||||||
37E64DD126D597EB00C71877 /* SubscriptionsModel.swift in Sources */,
|
37E64DD126D597EB00C71877 /* SubscriptionsModel.swift in Sources */,
|
||||||
@ -1271,6 +1293,7 @@
|
|||||||
3788AC2B26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */,
|
3788AC2B26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */,
|
||||||
3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */,
|
3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */,
|
||||||
373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
|
373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
|
||||||
|
3700155B271B0D4D0049C794 /* PipedAPI.swift in Sources */,
|
||||||
37B044B726F7AB9000E1419D /* SettingsView.swift in Sources */,
|
37B044B726F7AB9000E1419D /* SettingsView.swift in Sources */,
|
||||||
377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */,
|
377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */,
|
||||||
37E2EEAB270656EC00170416 /* PlayerControlsView.swift in Sources */,
|
37E2EEAB270656EC00170416 /* PlayerControlsView.swift in Sources */,
|
||||||
@ -1311,6 +1334,7 @@
|
|||||||
37484C2926FC83FF00287258 /* AccountFormView.swift in Sources */,
|
37484C2926FC83FF00287258 /* AccountFormView.swift in Sources */,
|
||||||
371F2F1A269B43D300E4A7AB /* NavigationModel.swift in Sources */,
|
371F2F1A269B43D300E4A7AB /* NavigationModel.swift in Sources */,
|
||||||
37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */,
|
37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */,
|
||||||
|
37001563271B1F250049C794 /* AccountsModel.swift in Sources */,
|
||||||
37CC3F50270D010D00608308 /* VideoBanner.swift in Sources */,
|
37CC3F50270D010D00608308 /* VideoBanner.swift in Sources */,
|
||||||
378E50FB26FE8B9F00F49626 /* Instance.swift in Sources */,
|
378E50FB26FE8B9F00F49626 /* Instance.swift in Sources */,
|
||||||
37484C1D26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
|
37484C1D26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
|
||||||
@ -1334,6 +1358,7 @@
|
|||||||
37EAD86C267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
|
37EAD86C267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
|
||||||
37CEE4C22677B697005A1EFE /* Stream.swift in Sources */,
|
37CEE4C22677B697005A1EFE /* Stream.swift in Sources */,
|
||||||
371F2F1B269B43D300E4A7AB /* NavigationModel.swift in Sources */,
|
371F2F1B269B43D300E4A7AB /* NavigationModel.swift in Sources */,
|
||||||
|
37001564271B1F250049C794 /* AccountsModel.swift in Sources */,
|
||||||
37FD43DF2704717F0073EE42 /* DefaultAccountHint.swift in Sources */,
|
37FD43DF2704717F0073EE42 /* DefaultAccountHint.swift in Sources */,
|
||||||
3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */,
|
3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */,
|
||||||
37BA795026DC3E0E002A0235 /* Int+Format.swift in Sources */,
|
37BA795026DC3E0E002A0235 /* Int+Format.swift in Sources */,
|
||||||
@ -1367,6 +1392,7 @@
|
|||||||
37BD07BC2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */,
|
37BD07BC2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */,
|
||||||
3748186F26A769D60084E870 /* DetailBadge.swift in Sources */,
|
3748186F26A769D60084E870 /* DetailBadge.swift in Sources */,
|
||||||
372915E72687E3B900F5A35B /* Defaults.swift in Sources */,
|
372915E72687E3B900F5A35B /* Defaults.swift in Sources */,
|
||||||
|
37DD87C8271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
|
||||||
376578922685490700D4EA09 /* PlaylistsView.swift in Sources */,
|
376578922685490700D4EA09 /* PlaylistsView.swift in Sources */,
|
||||||
37484C2626FC83E000287258 /* InstanceFormView.swift in Sources */,
|
37484C2626FC83E000287258 /* InstanceFormView.swift in Sources */,
|
||||||
377FC7E4267A084E00A6BBAF /* SearchView.swift in Sources */,
|
377FC7E4267A084E00A6BBAF /* SearchView.swift in Sources */,
|
||||||
@ -1376,6 +1402,7 @@
|
|||||||
376578862685429C00D4EA09 /* CaseIterable+Next.swift in Sources */,
|
376578862685429C00D4EA09 /* CaseIterable+Next.swift in Sources */,
|
||||||
37A9965F26D6F9B9006E3224 /* WatchNowView.swift in Sources */,
|
37A9965F26D6F9B9006E3224 /* WatchNowView.swift in Sources */,
|
||||||
37F4AE7326828F0900BD60EA /* VideosCellsVertical.swift in Sources */,
|
37F4AE7326828F0900BD60EA /* VideosCellsVertical.swift in Sources */,
|
||||||
|
37001560271B12DD0049C794 /* SiestaConfiguration.swift in Sources */,
|
||||||
37B81AFD26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */,
|
37B81AFD26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */,
|
||||||
37A9965B26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */,
|
37A9965B26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */,
|
||||||
3761AC1026F0F9A600AA496F /* UnsubscribeAlertModifier.swift in Sources */,
|
3761AC1026F0F9A600AA496F /* UnsubscribeAlertModifier.swift in Sources */,
|
||||||
@ -1394,6 +1421,7 @@
|
|||||||
37732FF12703A26300F04329 /* ValidationStatusView.swift in Sources */,
|
37732FF12703A26300F04329 /* ValidationStatusView.swift in Sources */,
|
||||||
37BA794C26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */,
|
37BA794C26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */,
|
||||||
37CC3F46270CE30600608308 /* PlayerQueueItem.swift in Sources */,
|
37CC3F46270CE30600608308 /* PlayerQueueItem.swift in Sources */,
|
||||||
|
3700155C271B0D4D0049C794 /* PipedAPI.swift in Sources */,
|
||||||
37D4B19826717E1500C925CA /* Video.swift in Sources */,
|
37D4B19826717E1500C925CA /* Video.swift in Sources */,
|
||||||
375168D72700FDB8008F96A6 /* Debounce.swift in Sources */,
|
375168D72700FDB8008F96A6 /* Debounce.swift in Sources */,
|
||||||
37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */,
|
37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */,
|
||||||
@ -1445,6 +1473,7 @@
|
|||||||
37CEE4BF2677B670005A1EFE /* SingleAssetStream.swift in Sources */,
|
37CEE4BF2677B670005A1EFE /* SingleAssetStream.swift in Sources */,
|
||||||
37BE0BD426A1D47D0092E2DB /* Player.swift in Sources */,
|
37BE0BD426A1D47D0092E2DB /* Player.swift in Sources */,
|
||||||
37977585268922F600DD52A8 /* InvidiousAPI.swift in Sources */,
|
37977585268922F600DD52A8 /* InvidiousAPI.swift in Sources */,
|
||||||
|
3700155D271B0D4D0049C794 /* PipedAPI.swift in Sources */,
|
||||||
375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */,
|
375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */,
|
||||||
37F4AE7426828F0900BD60EA /* VideosCellsVertical.swift in Sources */,
|
37F4AE7426828F0900BD60EA /* VideosCellsVertical.swift in Sources */,
|
||||||
376578872685429C00D4EA09 /* CaseIterable+Next.swift in Sources */,
|
376578872685429C00D4EA09 /* CaseIterable+Next.swift in Sources */,
|
||||||
@ -1456,6 +1485,7 @@
|
|||||||
37141671267A8ACC006CA35D /* TrendingView.swift in Sources */,
|
37141671267A8ACC006CA35D /* TrendingView.swift in Sources */,
|
||||||
3788AC2926F6840700F6BAA9 /* WatchNowSection.swift in Sources */,
|
3788AC2926F6840700F6BAA9 /* WatchNowSection.swift in Sources */,
|
||||||
37319F0727103F94004ECCD0 /* PlayerQueue.swift in Sources */,
|
37319F0727103F94004ECCD0 /* PlayerQueue.swift in Sources */,
|
||||||
|
37DD87C9271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
|
||||||
375168D82700FDB9008F96A6 /* Debounce.swift in Sources */,
|
375168D82700FDB9008F96A6 /* Debounce.swift in Sources */,
|
||||||
37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
|
37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
|
||||||
37AAF29226740715007FC770 /* Channel.swift in Sources */,
|
37AAF29226740715007FC770 /* Channel.swift in Sources */,
|
||||||
@ -1475,10 +1505,12 @@
|
|||||||
37AAF27E26737323007FC770 /* PopularView.swift in Sources */,
|
37AAF27E26737323007FC770 /* PopularView.swift in Sources */,
|
||||||
3743CA50270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */,
|
3743CA50270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */,
|
||||||
37A9966026D6F9B9006E3224 /* WatchNowView.swift in Sources */,
|
37A9966026D6F9B9006E3224 /* WatchNowView.swift in Sources */,
|
||||||
|
37001565271B1F250049C794 /* AccountsModel.swift in Sources */,
|
||||||
37484C1F26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
|
37484C1F26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
|
||||||
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
|
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
|
||||||
376578932685490700D4EA09 /* PlaylistsView.swift in Sources */,
|
376578932685490700D4EA09 /* PlaylistsView.swift in Sources */,
|
||||||
37CC3F47270CE30600608308 /* PlayerQueueItem.swift in Sources */,
|
37CC3F47270CE30600608308 /* PlayerQueueItem.swift in Sources */,
|
||||||
|
37001561271B12DD0049C794 /* SiestaConfiguration.swift in Sources */,
|
||||||
37BA795126DC3E0E002A0235 /* Int+Format.swift in Sources */,
|
37BA795126DC3E0E002A0235 /* Int+Format.swift in Sources */,
|
||||||
3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
|
3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
|
||||||
377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
|
377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import Defaults
|
import Defaults
|
||||||
|
|
||||||
extension Defaults.Keys {
|
extension Defaults.Keys {
|
||||||
static let instances = Key<[Instance]>("instances", default: [])
|
static let instances = Key<[Instance]>("instances", default: [
|
||||||
|
.init(app: .piped, name: "Public", url: "https://pipedapi.kavin.rocks"),
|
||||||
|
.init(app: .invidious, name: "Private", url: "https://invidious.home.arekf.net")
|
||||||
|
])
|
||||||
static let accounts = Key<[Instance.Account]>("accounts", default: [])
|
static let accounts = Key<[Instance.Account]>("accounts", default: [])
|
||||||
static let defaultAccountID = Key<String?>("defaultAccountID")
|
static let defaultAccountID = Key<String?>("defaultAccountID")
|
||||||
|
|
||||||
|
@ -2,33 +2,27 @@ import Defaults
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AccountsMenuView: View {
|
struct AccountsMenuView: View {
|
||||||
|
@EnvironmentObject<AccountsModel> private var model
|
||||||
@EnvironmentObject<InstancesModel> private var instancesModel
|
@EnvironmentObject<InstancesModel> private var instancesModel
|
||||||
@EnvironmentObject<InvidiousAPI> private var api
|
|
||||||
|
|
||||||
@Default(.instances) private var instances
|
@Default(.instances) private var instances
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(instances) { instance in
|
ForEach(model.all, id: \.id) { account in
|
||||||
Button(accountButtonTitle(instance: instance, account: instance.anonymousAccount)) {
|
Button(accountButtonTitle(account: account)) {
|
||||||
api.setAccount(instance.anonymousAccount)
|
model.setAccount(account)
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(instancesModel.accounts(instance.id)) { account in
|
|
||||||
Button(accountButtonTitle(instance: instance, account: account)) {
|
|
||||||
api.setAccount(account)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label(api.account?.name ?? "Accounts", systemImage: "person.crop.circle")
|
Label(model.account?.name ?? "Select Account", systemImage: "person.crop.circle")
|
||||||
.labelStyle(.titleAndIcon)
|
.labelStyle(.titleAndIcon)
|
||||||
}
|
}
|
||||||
.disabled(instances.isEmpty)
|
.disabled(instances.isEmpty)
|
||||||
.transaction { t in t.animation = .none }
|
.transaction { t in t.animation = .none }
|
||||||
}
|
}
|
||||||
|
|
||||||
func accountButtonTitle(instance: Instance, account: Instance.Account) -> String {
|
func accountButtonTitle(account: Instance.Account) -> String {
|
||||||
instances.count > 1 ? "\(account.description) — \(instance.shortDescription)" : account.description
|
instances.count > 1 ? "\(account.description) — \(account.instance.description)" : account.description
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,7 @@ import SwiftUI
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
struct AppSidebarNavigation: View {
|
struct AppSidebarNavigation: View {
|
||||||
@EnvironmentObject<InvidiousAPI> private var api
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@EnvironmentObject<NavigationModel> private var navigation
|
@EnvironmentObject<NavigationModel> private var navigation
|
||||||
@State private var didApplyPrimaryViewWorkAround = false
|
@State private var didApplyPrimaryViewWorkAround = false
|
||||||
@ -58,8 +57,8 @@ struct AppSidebarNavigation: View {
|
|||||||
.help(
|
.help(
|
||||||
"Switch Instances and Accounts\n" +
|
"Switch Instances and Accounts\n" +
|
||||||
"Current Instance: \n" +
|
"Current Instance: \n" +
|
||||||
"\(api.account?.url ?? "Not Set")\n" +
|
"\(accounts.account?.url ?? "Not Set")\n" +
|
||||||
"Current User: \(api.account?.description ?? "Not set")"
|
"Current User: \(accounts.account?.description ?? "Not set")"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import Defaults
|
import Defaults
|
||||||
|
import Siesta
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@StateObject private var api = InvidiousAPI()
|
@StateObject private var accounts = AccountsModel()
|
||||||
@StateObject private var instances = InstancesModel()
|
@StateObject private var instances = InstancesModel()
|
||||||
@StateObject private var navigation = NavigationModel()
|
@StateObject private var navigation = NavigationModel()
|
||||||
@StateObject private var player = PlayerModel()
|
@StateObject private var player = PlayerModel()
|
||||||
@ -29,8 +30,8 @@ struct ContentView: View {
|
|||||||
TVNavigationView()
|
TVNavigationView()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.onAppear(perform: configureAPI)
|
.onAppear(perform: configure)
|
||||||
.environmentObject(api)
|
.environmentObject(accounts)
|
||||||
.environmentObject(instances)
|
.environmentObject(instances)
|
||||||
.environmentObject(navigation)
|
.environmentObject(navigation)
|
||||||
.environmentObject(player)
|
.environmentObject(player)
|
||||||
@ -41,7 +42,7 @@ struct ContentView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.fullScreenCover(isPresented: $player.presentingPlayer) {
|
.fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||||
VideoPlayerView()
|
VideoPlayerView()
|
||||||
.environmentObject(api)
|
.environmentObject(accounts)
|
||||||
.environmentObject(instances)
|
.environmentObject(instances)
|
||||||
.environmentObject(navigation)
|
.environmentObject(navigation)
|
||||||
.environmentObject(player)
|
.environmentObject(player)
|
||||||
@ -51,7 +52,7 @@ struct ContentView: View {
|
|||||||
.sheet(isPresented: $player.presentingPlayer) {
|
.sheet(isPresented: $player.presentingPlayer) {
|
||||||
VideoPlayerView()
|
VideoPlayerView()
|
||||||
.frame(minWidth: 900, minHeight: 800)
|
.frame(minWidth: 900, minHeight: 800)
|
||||||
.environmentObject(api)
|
.environmentObject(accounts)
|
||||||
.environmentObject(instances)
|
.environmentObject(instances)
|
||||||
.environmentObject(navigation)
|
.environmentObject(navigation)
|
||||||
.environmentObject(player)
|
.environmentObject(player)
|
||||||
@ -61,31 +62,30 @@ struct ContentView: View {
|
|||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
.sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
||||||
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
|
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
|
||||||
.environmentObject(api)
|
|
||||||
.environmentObject(playlists)
|
.environmentObject(playlists)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $navigation.presentingPlaylistForm) {
|
.sheet(isPresented: $navigation.presentingPlaylistForm) {
|
||||||
PlaylistFormView(playlist: $navigation.editedPlaylist)
|
PlaylistFormView(playlist: $navigation.editedPlaylist)
|
||||||
.environmentObject(api)
|
|
||||||
.environmentObject(playlists)
|
.environmentObject(playlists)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $navigation.presentingSettings) {
|
.sheet(isPresented: $navigation.presentingSettings) {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.environmentObject(api)
|
|
||||||
.environmentObject(instances)
|
.environmentObject(instances)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func configureAPI() {
|
func configure() {
|
||||||
if let account = instances.defaultAccount, api.account.isEmpty {
|
SiestaLog.Category.enabled = .common
|
||||||
api.setAccount(account)
|
|
||||||
|
if let account = instances.defaultAccount {
|
||||||
|
accounts.setAccount(account)
|
||||||
}
|
}
|
||||||
|
|
||||||
player.api = api
|
player.accounts = accounts
|
||||||
playlists.api = api
|
playlists.accounts = accounts
|
||||||
search.api = api
|
search.accounts = accounts
|
||||||
subscriptions.api = api
|
subscriptions.accounts = accounts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct Sidebar: View {
|
struct Sidebar: View {
|
||||||
@EnvironmentObject<InvidiousAPI> private var api
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
@EnvironmentObject<NavigationModel> private var navigation
|
@EnvironmentObject<NavigationModel> private var navigation
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -12,7 +12,7 @@ struct Sidebar: View {
|
|||||||
AppSidebarRecents()
|
AppSidebarRecents()
|
||||||
.id("recentlyOpened")
|
.id("recentlyOpened")
|
||||||
|
|
||||||
if api.signedIn {
|
if accounts.signedIn {
|
||||||
AppSidebarSubscriptions()
|
AppSidebarSubscriptions()
|
||||||
AppSidebarPlaylists()
|
AppSidebarPlaylists()
|
||||||
}
|
}
|
||||||
@ -31,7 +31,7 @@ struct Sidebar: View {
|
|||||||
.accessibility(label: Text("Watch Now"))
|
.accessibility(label: Text("Watch Now"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if api.signedIn {
|
if accounts.signedIn {
|
||||||
NavigationLink(destination: LazyView(SubscriptionsView()), tag: TabSelection.subscriptions, selection: $navigation.tabSelection) {
|
NavigationLink(destination: LazyView(SubscriptionsView()), tag: TabSelection.subscriptions, selection: $navigation.tabSelection) {
|
||||||
Label("Subscriptions", systemImage: "star.circle")
|
Label("Subscriptions", systemImage: "star.circle")
|
||||||
.accessibility(label: Text("Subscriptions"))
|
.accessibility(label: Text("Subscriptions"))
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@ -5,47 +6,71 @@ struct PlaybackBar: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.inNavigationView) private var inNavigationView
|
@Environment(\.inNavigationView) private var inNavigationView
|
||||||
|
|
||||||
|
@EnvironmentObject<InstancesModel> private var instances
|
||||||
@EnvironmentObject<PlayerModel> private var player
|
@EnvironmentObject<PlayerModel> private var player
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
closeButton
|
closeButton
|
||||||
.frame(width: 80, alignment: .leading)
|
|
||||||
|
|
||||||
if player.currentItem != nil {
|
if player.currentItem != nil {
|
||||||
Text(playbackStatus)
|
Text(playbackStatus)
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.frame(minWidth: 130, maxWidth: .infinity)
|
|
||||||
|
|
||||||
VStack {
|
Spacer()
|
||||||
if player.stream != nil {
|
|
||||||
Text(currentStreamString)
|
HStack(spacing: 4) {
|
||||||
} else {
|
if player.currentVideo!.live {
|
||||||
if player.currentVideo!.live {
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
} else if player.isLoadingAvailableStreams || player.isLoadingStream {
|
||||||
} else {
|
Image(systemName: "bolt.horizontal.fill")
|
||||||
Image(systemName: "bolt.horizontal.fill")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
streamControl
|
||||||
|
.disabled(player.isLoadingAvailableStreams)
|
||||||
|
.frame(alignment: .trailing)
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
.onChange(of: player.streamSelection) { selection in
|
||||||
|
guard !selection.isNil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
player.upgradeToStream(selection!)
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(maxWidth: 180)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
.transaction { t in t.animation = .none }
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.frame(width: 80, alignment: .trailing)
|
|
||||||
.fixedSize(horizontal: true, vertical: true)
|
|
||||||
} else {
|
} else {
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
.padding(4)
|
.padding(4)
|
||||||
.background(.black)
|
.background(.black)
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentStreamString: String {
|
private var closeButton: some View {
|
||||||
"\(player.stream!.resolution.height)p"
|
Button {
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
"Close",
|
||||||
|
systemImage: inNavigationView ? "chevron.backward.circle.fill" : "chevron.down.circle.fill"
|
||||||
|
)
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(Text("Close"))
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
var playbackStatus: String {
|
private var playbackStatus: String {
|
||||||
if player.live {
|
if player.live {
|
||||||
return "LIVE"
|
return "LIVE"
|
||||||
}
|
}
|
||||||
@ -66,17 +91,61 @@ struct PlaybackBar: View {
|
|||||||
return "ends at \(timeFinishAtString)"
|
return "ends at \(timeFinishAtString)"
|
||||||
}
|
}
|
||||||
|
|
||||||
var closeButton: some View {
|
private var streamControl: some View {
|
||||||
Button {
|
#if os(macOS)
|
||||||
dismiss()
|
Picker("", selection: $player.streamSelection) {
|
||||||
} label: {
|
ForEach(instances.all) { instance in
|
||||||
Label("Close", systemImage: inNavigationView ? "chevron.backward.circle.fill" : "chevron.down.circle.fill")
|
let instanceStreams = availableStreamsForInstance(instance)
|
||||||
.labelStyle(.iconOnly)
|
if !instanceStreams.values.isEmpty {
|
||||||
}
|
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||||
.accessibilityLabel(Text("Close"))
|
|
||||||
.buttonStyle(.borderless)
|
Section(header: Text(instance.longDescription)) {
|
||||||
.foregroundColor(.gray)
|
ForEach(kinds, id: \.self) { key in
|
||||||
.keyboardShortcut(.cancelAction)
|
ForEach(instanceStreams[key] ?? []) { stream in
|
||||||
|
Text(stream.quality).tag(Stream?.some(stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
if kinds.count > 1 {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
Menu {
|
||||||
|
ForEach(instances.all) { instance in
|
||||||
|
let instanceStreams = availableStreamsForInstance(instance)
|
||||||
|
if !instanceStreams.values.isEmpty {
|
||||||
|
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||||
|
Picker("", selection: $player.streamSelection) {
|
||||||
|
ForEach(kinds, id: \.self) { key in
|
||||||
|
ForEach(instanceStreams[key] ?? []) { stream in
|
||||||
|
Text(stream.description).tag(Stream?.some(stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
if kinds.count > 1 {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(player.streamSelection?.quality ?? "")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func availableStreamsForInstance(_ instance: Instance) -> [Stream.Kind: [Stream]] {
|
||||||
|
let streams = player.availableStreams.filter { $0.instance == instance }.sorted(by: streamsSorter)
|
||||||
|
|
||||||
|
return Dictionary(grouping: streams, by: \.kind!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool {
|
||||||
|
lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,7 +2,6 @@ import Defaults
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct Player: UIViewControllerRepresentable {
|
struct Player: UIViewControllerRepresentable {
|
||||||
@EnvironmentObject<InvidiousAPI> private var api
|
|
||||||
@EnvironmentObject<PlayerModel> private var player
|
@EnvironmentObject<PlayerModel> private var player
|
||||||
|
|
||||||
var controller: PlayerViewController?
|
var controller: PlayerViewController?
|
||||||
@ -18,11 +17,8 @@ struct Player: UIViewControllerRepresentable {
|
|||||||
|
|
||||||
let controller = PlayerViewController()
|
let controller = PlayerViewController()
|
||||||
|
|
||||||
player.controller = controller
|
|
||||||
controller.playerModel = player
|
controller.playerModel = player
|
||||||
controller.api = api
|
player.controller = controller
|
||||||
|
|
||||||
controller.resolution = Defaults[.quality]
|
|
||||||
|
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
|
@ -3,11 +3,9 @@ import Logging
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
final class PlayerViewController: UIViewController {
|
final class PlayerViewController: UIViewController {
|
||||||
var api: InvidiousAPI!
|
|
||||||
var playerLoaded = false
|
var playerLoaded = false
|
||||||
var playerModel: PlayerModel!
|
var playerModel: PlayerModel!
|
||||||
var playerViewController = AVPlayerViewController()
|
var playerViewController = AVPlayerViewController()
|
||||||
var resolution: Stream.ResolutionSetting!
|
|
||||||
var shouldResume = false
|
var shouldResume = false
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
@ -81,7 +79,7 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
|
|||||||
|
|
||||||
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {
|
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {
|
||||||
if shouldResume {
|
if shouldResume {
|
||||||
playerModel.player.play()
|
playerModel.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
dismiss(animated: false)
|
dismiss(animated: false)
|
||||||
|
@ -4,9 +4,9 @@ import SwiftUI
|
|||||||
struct VideoDetailsPaddingModifier: ViewModifier {
|
struct VideoDetailsPaddingModifier: ViewModifier {
|
||||||
static var defaultAdditionalDetailsPadding: Double {
|
static var defaultAdditionalDetailsPadding: Double {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
20
|
30
|
||||||
#else
|
#else
|
||||||
35
|
40
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,10 +57,12 @@ struct VideoPlayerSizeModifier: ViewModifier {
|
|||||||
|
|
||||||
var maxHeight: Double {
|
var maxHeight: Double {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
verticalSizeClass == .regular ? geometry.size.height - minimumHeightLeft : .infinity
|
let height = verticalSizeClass == .regular ? geometry.size.height - minimumHeightLeft : .infinity
|
||||||
#else
|
#else
|
||||||
geometry.size.height - minimumHeightLeft
|
let height = geometry.size.height - minimumHeightLeft
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
return [height, 0].max()!
|
||||||
}
|
}
|
||||||
|
|
||||||
var edgesIgnoringSafeArea: Edge.Set {
|
var edgesIgnoringSafeArea: Edge.Set {
|
||||||
|
@ -115,7 +115,7 @@ struct AccountFormView: View {
|
|||||||
isValidating = true
|
isValidating = true
|
||||||
|
|
||||||
validationDebounce.debouncing(1) {
|
validationDebounce.debouncing(1) {
|
||||||
validator.validateAccount()
|
validator.validateInvidiousAccount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,6 +132,7 @@ struct AccountFormView: View {
|
|||||||
|
|
||||||
private var validator: AccountValidator {
|
private var validator: AccountValidator {
|
||||||
AccountValidator(
|
AccountValidator(
|
||||||
|
app: .constant(instance.app),
|
||||||
url: instance.url,
|
url: instance.url,
|
||||||
account: Instance.Account(instanceID: instance.id, url: instance.url, sid: sid),
|
account: Instance.Account(instanceID: instance.id, url: instance.url, sid: sid),
|
||||||
id: $sid,
|
id: $sid,
|
||||||
|
@ -13,6 +13,18 @@ struct AccountsSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if instance.supportsAccounts {
|
||||||
|
accounts
|
||||||
|
} else {
|
||||||
|
Text("Accounts are not supported for the application of this instance")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(instance.shortDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
var accounts: some View {
|
||||||
List {
|
List {
|
||||||
Section(header: Text("Accounts"), footer: sectionFooter) {
|
Section(header: Text("Accounts"), footer: sectionFooter) {
|
||||||
ForEach(instances.accounts(instanceID), id: \.self) { account in
|
ForEach(instances.accounts(instanceID), id: \.self) { account in
|
||||||
@ -59,7 +71,6 @@ struct AccountsSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(instance.shortDescription)
|
|
||||||
.sheet(isPresented: $presentingAccountForm, onDismiss: { accountsChanged.toggle() }) {
|
.sheet(isPresented: $presentingAccountForm, onDismiss: { accountsChanged.toggle() }) {
|
||||||
AccountFormView(instance: instance)
|
AccountFormView(instance: instance)
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ struct InstanceFormView: View {
|
|||||||
|
|
||||||
@State private var name = ""
|
@State private var name = ""
|
||||||
@State private var url = ""
|
@State private var url = ""
|
||||||
|
@State private var app = Instance.App.invidious
|
||||||
|
|
||||||
@State private var isValid = false
|
@State private var isValid = false
|
||||||
@State private var isValidated = false
|
@State private var isValidated = false
|
||||||
@ -37,7 +38,7 @@ struct InstanceFormView: View {
|
|||||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||||
.background(.thickMaterial)
|
.background(.thickMaterial)
|
||||||
#else
|
#else
|
||||||
.frame(width: 400, height: 150)
|
.frame(width: 400, height: 190)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +74,13 @@ struct InstanceFormView: View {
|
|||||||
|
|
||||||
private var formFields: some View {
|
private var formFields: some View {
|
||||||
Group {
|
Group {
|
||||||
|
Picker("Application", selection: $app) {
|
||||||
|
ForEach(Instance.App.allCases, id: \.self) { app in
|
||||||
|
Text(app.rawValue.capitalized).tag(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
TextField("Name", text: $name, prompt: Text("Instance Name (optional)"))
|
TextField("Name", text: $name, prompt: Text("Instance Name (optional)"))
|
||||||
.focused($nameFieldFocused)
|
.focused($nameFieldFocused)
|
||||||
|
|
||||||
@ -104,6 +112,7 @@ struct InstanceFormView: View {
|
|||||||
|
|
||||||
var validator: AccountValidator {
|
var validator: AccountValidator {
|
||||||
AccountValidator(
|
AccountValidator(
|
||||||
|
app: $app,
|
||||||
url: url,
|
url: url,
|
||||||
id: $url,
|
id: $url,
|
||||||
isValid: $isValid,
|
isValid: $isValid,
|
||||||
@ -137,7 +146,7 @@ struct InstanceFormView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
savedInstanceID = instancesModel.add(name: name, url: url).id
|
savedInstanceID = instancesModel.add(app: app, name: name, url: url).id
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
@ -19,8 +19,10 @@ struct InstancesSettingsView: View {
|
|||||||
Group {
|
Group {
|
||||||
Section(header: Text("Instances"), footer: DefaultAccountHint()) {
|
Section(header: Text("Instances"), footer: DefaultAccountHint()) {
|
||||||
ForEach(instances) { instance in
|
ForEach(instances) { instance in
|
||||||
NavigationLink(instance.description) {
|
Group {
|
||||||
AccountsSettingsView(instanceID: instance.id)
|
NavigationLink(instance.longDescription) {
|
||||||
|
AccountsSettingsView(instanceID: instance.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
5
Shared/SiestaConfiguration.swift
Normal file
5
Shared/SiestaConfiguration.swift
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import Siesta
|
||||||
|
import SwiftyJSON
|
||||||
|
|
||||||
|
let SwiftyJSONTransformer =
|
||||||
|
ResponseContentTransformer(transformErrors: true) { JSON($0.content as AnyObject) }
|
@ -11,14 +11,14 @@ struct TrendingView: View {
|
|||||||
|
|
||||||
@State private var presentingCountrySelection = false
|
@State private var presentingCountrySelection = false
|
||||||
|
|
||||||
@EnvironmentObject<InvidiousAPI> private var api
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
|
|
||||||
init(_ videos: [Video] = [Video]()) {
|
init(_ videos: [Video] = [Video]()) {
|
||||||
self.videos = videos
|
self.videos = videos
|
||||||
}
|
}
|
||||||
|
|
||||||
var resource: Resource {
|
var resource: Resource {
|
||||||
let resource = api.trending(category: category, country: country)
|
let resource = accounts.invidious.trending(category: category, country: country)
|
||||||
resource.addObserver(store)
|
resource.addObserver(store)
|
||||||
|
|
||||||
return resource
|
return resource
|
||||||
|
@ -6,7 +6,7 @@ struct ChannelVideosView: View {
|
|||||||
|
|
||||||
@StateObject private var store = Store<Channel>()
|
@StateObject private var store = Store<Channel>()
|
||||||
|
|
||||||
@EnvironmentObject<InvidiousAPI> private var api
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
@EnvironmentObject<NavigationModel> private var navigation
|
@EnvironmentObject<NavigationModel> private var navigation
|
||||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||||
|
|
||||||
@ -99,7 +99,7 @@ struct ChannelVideosView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resource: Resource {
|
var resource: Resource {
|
||||||
let resource = api.channel(channel.id)
|
let resource = accounts.invidious.channel(channel.id)
|
||||||
resource.addObserver(store)
|
resource.addObserver(store)
|
||||||
|
|
||||||
return resource
|
return resource
|
||||||
|
@ -52,6 +52,10 @@ struct PlayerControlsView<Content: View>: View {
|
|||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
#if !os(tvOS)
|
||||||
|
.keyboardShortcut("o")
|
||||||
|
#endif
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
if model.isPlaying {
|
if model.isPlaying {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@ -65,7 +69,7 @@ struct PlayerControlsView<Content: View>: View {
|
|||||||
}) {
|
}) {
|
||||||
Label("Play", systemImage: "play.fill")
|
Label("Play", systemImage: "play.fill")
|
||||||
}
|
}
|
||||||
.disabled(model.player.currentItem == nil)
|
.disabled(model.player.currentItem.isNil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 30)
|
.frame(minWidth: 30)
|
||||||
|
@ -4,10 +4,10 @@ import SwiftUI
|
|||||||
struct PopularView: View {
|
struct PopularView: View {
|
||||||
@StateObject private var store = Store<[Video]>()
|
@StateObject private var store = Store<[Video]>()
|
||||||
|
|
||||||
@EnvironmentObject<InvidiousAPI> private var api
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
|
|
||||||
var resource: Resource {
|
var resource: Resource {
|
||||||
api.popular
|
accounts.invidious.popular
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
@ -5,12 +5,14 @@ struct SignInRequiredView<Content: View>: View {
|
|||||||
let title: String
|
let title: String
|
||||||
let content: Content
|
let content: Content
|
||||||
|
|
||||||
@EnvironmentObject<InvidiousAPI> private var api
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
@Default(.instances) private var instances
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@EnvironmentObject<NavigationModel> private var navigation
|
@EnvironmentObject<NavigationModel> private var navigation
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@Default(.instances) private var instances
|
||||||
|
|
||||||
init(title: String, @ViewBuilder content: @escaping () -> Content) {
|
init(title: String, @ViewBuilder content: @escaping () -> Content) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.content = content()
|
self.content = content()
|
||||||
@ -18,7 +20,7 @@ struct SignInRequiredView<Content: View>: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if api.signedIn {
|
if accounts.signedIn {
|
||||||
content
|
content
|
||||||
} else {
|
} else {
|
||||||
prompt
|
prompt
|
||||||
|
@ -4,7 +4,11 @@ import SwiftUI
|
|||||||
struct SubscriptionsView: View {
|
struct SubscriptionsView: View {
|
||||||
@StateObject private var store = Store<[Video]>()
|
@StateObject private var store = Store<[Video]>()
|
||||||
|
|
||||||
@EnvironmentObject<InvidiousAPI> private var api
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
|
|
||||||
|
var api: InvidiousAPI {
|
||||||
|
accounts.invidious
|
||||||
|
}
|
||||||
|
|
||||||
var feed: Resource {
|
var feed: Resource {
|
||||||
api.feed
|
api.feed
|
||||||
@ -17,10 +21,7 @@ struct SubscriptionsView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
loadResources()
|
loadResources()
|
||||||
}
|
}
|
||||||
.onChange(of: api.account) { _ in
|
.onChange(of: accounts.account) { _ in
|
||||||
loadResources(force: true)
|
|
||||||
}
|
|
||||||
.onChange(of: feed) { _ in
|
|
||||||
loadResources(force: true)
|
loadResources(force: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,7 @@ struct WatchNowSection: View {
|
|||||||
|
|
||||||
@StateObject private var store = Store<[Video]>()
|
@StateObject private var store = Store<[Video]>()
|
||||||
|
|
||||||
@EnvironmentObject<InvidiousAPI> private var api
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
|
|
||||||
init(resource: Resource, label: String) {
|
init(resource: Resource, label: String) {
|
||||||
self.resource = resource
|
self.resource = resource
|
||||||
@ -21,7 +21,7 @@ struct WatchNowSection: View {
|
|||||||
resource.addObserver(store)
|
resource.addObserver(store)
|
||||||
resource.loadIfNeeded()
|
resource.loadIfNeeded()
|
||||||
}
|
}
|
||||||
.onChange(of: api.account) { _ in
|
.onChange(of: accounts.account) { _ in
|
||||||
resource.load()
|
resource.load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,12 +3,16 @@ import Siesta
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct WatchNowView: View {
|
struct WatchNowView: View {
|
||||||
@EnvironmentObject<InvidiousAPI> private var api
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
|
|
||||||
|
var api: InvidiousAPI! {
|
||||||
|
accounts.invidious
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
PlayerControlsView {
|
PlayerControlsView {
|
||||||
ScrollView(.vertical, showsIndicators: false) {
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
if api.validInstance {
|
if !accounts.account.isNil {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
if api.signedIn {
|
if api.signedIn {
|
||||||
WatchNowSection(resource: api.feed, label: "Subscriptions")
|
WatchNowSection(resource: api.feed, label: "Subscriptions")
|
||||||
|
@ -21,7 +21,7 @@ struct InstancesSettingsView: View {
|
|||||||
if !instances.isEmpty {
|
if !instances.isEmpty {
|
||||||
Picker("Instance", selection: $selectedInstanceID) {
|
Picker("Instance", selection: $selectedInstanceID) {
|
||||||
ForEach(instances) { instance in
|
ForEach(instances) { instance in
|
||||||
Text(instance.description).tag(Optional(instance.id))
|
Text(instance.longDescription).tag(Optional(instance.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
@ -31,7 +31,7 @@ struct InstancesSettingsView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !selectedInstance.isNil {
|
if !selectedInstance.isNil, selectedInstance.supportsAccounts {
|
||||||
Text("Accounts")
|
Text("Accounts")
|
||||||
List(selection: $selectedAccount) {
|
List(selection: $selectedAccount) {
|
||||||
if accounts.isEmpty {
|
if accounts.isEmpty {
|
||||||
@ -46,11 +46,21 @@ struct InstancesSettingsView: View {
|
|||||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if selectedInstance != nil, !selectedInstance.supportsAccounts {
|
||||||
|
Text("Accounts are not supported for the application of this instance")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
if selectedInstance != nil {
|
if selectedInstance != nil {
|
||||||
HStack {
|
HStack {
|
||||||
Button("Add Account...") {
|
if selectedInstance.supportsAccounts {
|
||||||
selectedAccount = nil
|
Button("Add Account...") {
|
||||||
presentingAccountForm = true
|
selectedAccount = nil
|
||||||
|
presentingAccountForm = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -59,7 +69,7 @@ struct InstancesSettingsView: View {
|
|||||||
presentingConfirmationDialog = true
|
presentingConfirmationDialog = true
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Are you sure you want to remove \(selectedInstance!.description) instance?",
|
"Are you sure you want to remove \(selectedInstance!.longDescription) instance?",
|
||||||
isPresented: $presentingConfirmationDialog
|
isPresented: $presentingConfirmationDialog
|
||||||
) {
|
) {
|
||||||
Button("Remove Instance", role: .destructive) {
|
Button("Remove Instance", role: .destructive) {
|
||||||
|
@ -4,46 +4,40 @@ import SwiftUI
|
|||||||
|
|
||||||
struct AccountSelectionView: View {
|
struct AccountSelectionView: View {
|
||||||
@EnvironmentObject<InstancesModel> private var instancesModel
|
@EnvironmentObject<InstancesModel> private var instancesModel
|
||||||
@EnvironmentObject<InvidiousAPI> private var api
|
@EnvironmentObject<AccountsModel> private var accounts
|
||||||
|
|
||||||
@Default(.accounts) private var accounts
|
|
||||||
@Default(.instances) private var instances
|
@Default(.instances) private var instances
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section(header: Text("Current Account")) {
|
Section(header: Text("Current Account")) {
|
||||||
Button(api.account?.name ?? "Not selected") {
|
Button(accountButtonTitle(account: accounts.account)) {
|
||||||
if let account = nextAccount {
|
if let account = nextAccount {
|
||||||
api.setAccount(account)
|
accounts.setAccount(account)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(instances.isEmpty)
|
.disabled(instances.isEmpty)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
ForEach(instances) { instance in
|
ForEach(accounts.all) { account in
|
||||||
Button(accountButtonTitle(instance: instance, account: instance.anonymousAccount)) {
|
Button(accountButtonTitle(account: account)) {
|
||||||
api.setAccount(instance.anonymousAccount)
|
accounts.setAccount(account)
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(instancesModel.accounts(instance.id)) { account in
|
|
||||||
Button(accountButtonTitle(instance: instance, account: account)) {
|
|
||||||
api.setAccount(account)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.id(UUID())
|
||||||
}
|
}
|
||||||
|
|
||||||
private var nextAccount: Instance.Account? {
|
private var nextAccount: Instance.Account? {
|
||||||
guard api.account != nil else {
|
accounts.all.next(after: accounts.account)
|
||||||
return accounts.first
|
}
|
||||||
|
|
||||||
|
func accountButtonTitle(account: Instance.Account! = nil) -> String {
|
||||||
|
guard account != nil else {
|
||||||
|
return "Not selected"
|
||||||
}
|
}
|
||||||
|
|
||||||
return accounts.next(after: api.account!)
|
return instances.count > 1 ? "\(account.description) — \(account.instance.shortDescription)" : account.description
|
||||||
}
|
|
||||||
|
|
||||||
func accountButtonTitle(instance: Instance, account: Instance.Account) -> String {
|
|
||||||
instances.count > 1 ? "\(account.description) — \(instance.shortDescription)" : account.description
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user