Add Piped support

This commit is contained in:
Arkadiusz Fal 2021-10-17 00:48:58 +02:00
parent a68d89cb6f
commit 62e17d5a18
44 changed files with 919 additions and 327 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,5 +10,4 @@ struct PlayerQueueItem: Hashable, Identifiable {
} }
var playerItems = [AVPlayerItem]() var playerItems = [AVPlayerItem]()
var compositions = [Stream: AVMutableComposition]()
} }

100
Model/PlayerStreams.swift Normal file
View 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) }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import Siesta
import SwiftyJSON
let SwiftyJSONTransformer =
ResponseContentTransformer(transformErrors: true) { JSON($0.content as AnyObject) }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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