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 {
func next(after element: Element) -> Element? {
let idx = firstIndex(of: element)
func next(after element: Element?) -> Element? {
if element.isNil {
return first
}
let idx = firstIndex(of: element!)
if idx.isNil {
return first

View File

@ -2,6 +2,6 @@ import Foundation
extension 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
final class AccountValidator: Service {
let app: Binding<Instance.App>
let url: String
let account: Instance.Account?
@ -13,6 +14,7 @@ final class AccountValidator: Service {
var error: Binding<String?>?
init(
app: Binding<Instance.App>,
url: String,
account: Instance.Account? = nil,
id: Binding<String>,
@ -21,6 +23,7 @@ final class AccountValidator: Service {
isValidating: Binding<Bool>,
error: Binding<String?>? = nil
) {
self.app = app
self.url = url
self.account = account
formObjectID = id
@ -34,6 +37,10 @@ final class AccountValidator: Service {
}
func configure() {
configure {
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configure("/api/v1/auth/feed", requestMethods: [.get]) {
guard self.account != nil else {
return
@ -46,15 +53,35 @@ final class AccountValidator: Service {
func validateInstance() {
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
.load()
.onSuccess { _ in
.onSuccess { response in
guard self.url == self.formObjectID.wrappedValue else {
return
}
self.isValid.wrappedValue = true
self.error?.wrappedValue = nil
if response
.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
guard self.url == self.formObjectID.wrappedValue else {
@ -70,7 +97,7 @@ final class AccountValidator: Service {
}
}
func validateAccount() {
func validateInvidiousAccount() {
reset()
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
struct Instance: Defaults.Serializable, Hashable, Identifiable {
enum App: String, CaseIterable {
case invidious, piped
var name: String {
rawValue.capitalized
}
}
struct Account: Defaults.Serializable, Hashable, Identifiable {
static var bridge = AccountsBridge()
static var empty = Account(instanceID: UUID(), name: "Signed Out", url: "", sid: "")
let id: UUID
let id: String
let instanceID: UUID
var name: String?
let url: String
let sid: String
let anonymous: Bool
init(id: UUID? = nil, instanceID: UUID, name: String? = nil, url: String, sid: String) {
self.id = id ?? UUID()
init(id: String? = nil, instanceID: UUID, name: String? = nil, url: String, sid: String? = nil, anonymous: Bool = false) {
self.anonymous = anonymous
self.id = id ?? (anonymous ? "anonymous-\(instanceID)" : UUID().uuidString)
self.instanceID = instanceID
self.name = name
self.url = url
self.sid = sid
self.sid = sid ?? ""
}
var instance: Instance {
@ -37,10 +47,6 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
}
var isEmpty: Bool {
self == Account.empty
}
func hash(into hasher: inout Hasher) {
hasher.combine(sid)
}
@ -55,7 +61,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
}
return [
"id": value.id.uuidString,
"id": value.id,
"instanceID": value.instanceID.uuidString,
"name": value.name ?? "",
"url": value.url,
@ -74,37 +80,46 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
return nil
}
let uuid = UUID(uuidString: id)
let instanceUUID = UUID(uuidString: instanceID)!
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()
let app: App
let id: UUID
let name: 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.name = name
self.url = url
}
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 {
name.isEmpty ? url : name
}
var supportsAccounts: Bool {
app == .invidious
}
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 {
@ -117,6 +132,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
}
return [
"app": value.app.rawValue,
"id": value.id.uuidString,
"name": value.name,
"url": value.url
@ -126,6 +142,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let app = App(rawValue: object["app"] ?? ""),
let id = object["id"],
let url = object["url"]
else {
@ -135,7 +152,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
let uuid = UUID(uuidString: id)
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 {
@Published var defaultAccount: Instance.Account?
var all: [Instance] {
Defaults[.instances]
}
init() {
guard let id = Defaults[.defaultAccountID],
let uuid = UUID(uuidString: id)
else {
guard let id = Defaults[.defaultAccountID] else {
return
}
defaultAccount = findAccount(uuid)
defaultAccount = findAccount(id)
}
func find(_ id: Instance.ID?) -> Instance? {
@ -26,8 +28,8 @@ final class InstancesModel: ObservableObject {
Defaults[.accounts].filter { $0.instanceID == id }
}
func add(name: String, url: String) -> Instance {
let instance = Instance(name: name, url: url)
func add(app: Instance.App, name: String, url: String) -> Instance {
let instance = Instance(app: app, name: name, url: url)
Defaults[.instances].append(instance)
return instance
@ -59,7 +61,7 @@ final class InstancesModel: ObservableObject {
}
func setDefaultAccount(_ account: Instance.Account?) {
Defaults[.defaultAccountID] = account?.id.uuidString
Defaults[.defaultAccountID] = account?.id
defaultAccount = account
}

View File

@ -6,11 +6,21 @@ import SwiftyJSON
final class InvidiousAPI: Service, ObservableObject {
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
init(account: Instance.Account? = nil) {
super.init()
guard !account.isNil else {
return
}
setAccount(account!)
}
func setAccount(_ account: Instance.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() {
SiestaLog.Category.enabled = .common
let SwiftyJSONTransformer =
ResponseContentTransformer(transformErrors: true) { JSON($0.content as AnyObject) }
configure {
$0.headers["Cookie"] = self.cookieHeader
if !self.account.sid.isEmpty {
$0.headers["Cookie"] = self.cookieHeader
}
$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)
import UIKit
#endif
import Siesta
import SwiftyJSON
final class PlayerModel: ObservableObject {
let logger = Logger(label: "net.arekf.Pearvidious.ps")
@ -20,6 +22,9 @@ final class PlayerModel: ObservableObject {
@Published var stream: Stream?
@Published var currentRate: Float?
@Published var availableStreams = [Stream]()
@Published var streamSelection: Stream?
@Published var queue = [PlayerQueueItem]()
@Published var currentItem: PlayerQueueItem!
@Published var live = false
@ -27,24 +32,32 @@ final class PlayerModel: ObservableObject {
@Published var history = [PlayerQueueItem]()
var api: InvidiousAPI
var timeObserver: Any?
@Published var savedTime: CMTime?
@Published var composition = AVMutableComposition()
var accounts: AccountsModel
var instances: InstancesModel
var timeObserver: Any?
private var shouldResumePlaying = true
private var statusObservation: NSKeyValueObservation?
var isPlaying: Bool {
stream != nil && currentRate != 0.0
}
init(api: InvidiousAPI? = nil) {
self.api = api ?? InvidiousAPI()
init(accounts: AccountsModel? = nil, instances: InstancesModel? = nil) {
self.accounts = accounts ?? AccountsModel()
self.instances = instances ?? InstancesModel()
addItemDidPlayToEndTimeObserver()
addTimeObserver()
}
func presentPlayer() {
presentingPlayer = true
}
var isPlaying: Bool {
player.timeControlStatus == .playing
}
func togglePlay() {
isPlaying ? pause() : play()
}
@ -66,118 +79,156 @@ final class PlayerModel: ObservableObject {
}
func playVideo(_ video: Video) {
if video.live {
self.stream = nil
savedTime = nil
shouldResumePlaying = true
playHlsUrl(video)
return
loadAvailableStreams(video) { streams in
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 {
return
func upgradeToStream(_ stream: Stream) {
if !self.stream.isNil, self.stream != stream {
playStream(stream, of: currentItem.video, preservingTime: true)
}
}
if stream.oneMeaningfullAsset {
playStream(stream, for: video)
func piped(_ instance: Instance) -> PipedAPI {
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 {
logger.info("playing stream with many assets:")
logger.info("composition audio asset: \(stream.audioAsset.url)")
logger.info("composition video asset: \(stream.videoAsset.url)")
Task {
await playComposition(video, for: stream)
await self.loadComposition(stream, of: video, forcePlay: forcePlay, preservingTime: preservingTime)
}
}
}
private func playHlsUrl(_ video: Video) {
player.replaceCurrentItem(with: playerItemWithMetadata(video))
player.playImmediately(atRate: 1.0)
}
private func insertPlayerItem(
_ stream: Stream,
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 {
return
}
if let index = queue.firstIndex(where: { $0.video.id == video.id }) {
queue[index].playerItems.append(playerItem)
}
attachMetadata(to: playerItem!, video: video, for: stream)
DispatchQueue.main.async {
self.stream = stream
self.player.replaceCurrentItem(with: playerItem)
self.composition = AVMutableComposition()
}
if timeObserver.isNil {
addTimeObserver()
}
}
shouldResumePlaying = forcePlay || isPlaying
private func playComposition(_ video: Video, for stream: Stream) async {
async let assetAudioTrack = stream.audioAsset.loadTracks(withMediaType: .audio)
async let assetVideoTrack = stream.videoAsset.loadTracks(withMediaType: .video)
if preservingTime {
saveTime {
self.player.replaceCurrentItem(with: playerItem)
logger.info("loading audio track")
if let audioTrack = composition(video, for: stream)?.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
let assetTrack = try? await assetAudioTrack.first
{
try! audioTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)),
of: assetTrack,
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))
self.seekToSavedTime { finished in
guard finished else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
forcePlay || self.shouldResumePlaying ? self.play() : self.pause()
self.shouldResumePlaying = false
}
}
}
if let composition = composition(video, for: stream!) {
logger.info("stream has MANY assets, using composition")
return AVPlayerItem(asset: composition)
} else {
return nil
} else {
player.replaceCurrentItem(with: playerItem)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
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? {
logger.info("building player item metadata")
let playerItemWithMetadata: AVPlayerItem! = playerItem(video, for: stream)
guard playerItemWithMetadata != nil else {
return nil
private func loadComposition(
_ stream: Stream,
of video: Video,
forcePlay: Bool = false,
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 = [
makeMetadataItem(.commonIdentifierTitle, value: video.title),
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre),
makeMetadataItem(.commonIdentifierDescription, value: video.description)
]
insertPlayerItem(stream, for: video, forcePlay: forcePlay, preservingTime: preservingTime)
}
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)
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)!),
let image = UIImage(data: thumbnailData),
let pngData = image.pngData()
@ -186,28 +237,41 @@ final class PlayerModel: ObservableObject {
externalMetadata.append(artworkItem)
}
playerItemWithMetadata.externalMetadata = externalMetadata
item.externalMetadata = externalMetadata
#endif
playerItemWithMetadata.preferredForwardBufferDuration = 15
item.preferredForwardBufferDuration = 5
statusObservation?.invalidate()
statusObservation = playerItemWithMetadata.observe(\.status, options: [.old, .new]) { playerItem, _ in
statusObservation = item.observe(\.status, options: [.old, .new]) { playerItem, _ in
switch playerItem.status {
case .readyToPlay:
if self.isAutoplaying(playerItem) {
self.player.play()
if self.isAutoplaying(playerItem), self.shouldResumePlaying {
self.play()
}
case .failed:
print("item error: \(String(describing: item.error))")
print((item.asset as! AVURLAsset).url)
default:
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(
self,
selector: #selector(itemDidPlayToEndTime),
@ -230,15 +294,30 @@ final class PlayerModel: ObservableObject {
}
}
private func composition(_ video: Video, for stream: Stream) -> AVMutableComposition? {
if let index = queue.firstIndex(where: { $0.video == video }) {
if queue[index].compositions[stream].isNil {
queue[index].compositions[stream] = AVMutableComposition()
}
return queue[index].compositions[stream]!
private func saveTime(completionHandler: @escaping () -> Void = {}) {
let currentTime = player.currentTime()
guard currentTime.seconds > 0 else {
return
}
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() {
@ -250,14 +329,4 @@ final class PlayerModel: ObservableObject {
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 Foundation
import Siesta
extension PlayerModel {
var currentVideo: Video? {
@ -20,7 +21,7 @@ extension PlayerModel {
func playNext(_ video: Video) {
enqueueVideo(video, prepending: true) { _, item in
if self.currentItem == nil {
if self.currentItem.isNil {
self.advanceToItem(item)
}
}
@ -103,6 +104,10 @@ extension PlayerModel {
return item
}
func videoResource(_ id: Video.ID) -> Resource {
accounts.invidious.video(id)
}
private func loadDetails(_ video: Video?, onSuccess: @escaping (Video) -> Void) {
guard video != nil else {
return
@ -114,7 +119,7 @@ extension PlayerModel {
return
}
api.video(video!.videoID).load().onSuccess { response in
videoResource(video!.videoID).load().onSuccess { response in
if let video: Video = response.typedContent() {
onSuccess(video)
}

View File

@ -10,5 +10,4 @@ struct PlayerQueueItem: Hashable, Identifiable {
}
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 {
@Published var playlists = [Playlist]()
@Published var api = InvidiousAPI()
@Published var selectedPlaylistID: Playlist.ID = ""
var accounts = AccountsModel()
var api: InvidiousAPI {
accounts.invidious
}
init(_ playlists: [Playlist] = [Playlist]()) {
self.playlists = playlists
}

View File

@ -5,7 +5,7 @@ import SwiftUI
final class SearchModel: ObservableObject {
@Published var store = Store<[Video]>()
@Published var api = InvidiousAPI()
var accounts = AccountsModel()
@Published var query = SearchQuery()
@Published var queryText = ""
@Published var querySuggestions = Store<[String]>()
@ -17,6 +17,10 @@ final class SearchModel: ObservableObject {
resource?.isLoading ?? false
}
var api: InvidiousAPI {
accounts.invidious
}
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
changeHandler(query)

View File

@ -4,7 +4,7 @@ import Foundation
final class SingleAssetStream: Stream {
var avAsset: AVURLAsset
init(avAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String) {
init(avAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String = "") {
self.avAsset = avAsset
super.init(audioAsset: avAsset, videoAsset: avAsset, resolution: resolution, kind: kind, encoding: encoding)

View File

@ -3,7 +3,7 @@ import Defaults
import Foundation
// swiftlint:disable:next final_class
class Stream: Equatable, Hashable {
class Stream: Equatable, Hashable, Identifiable {
enum ResolutionSetting: String, Defaults.Serializable, CaseIterable {
case hd720pFirstThenBest, hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p
@ -21,20 +21,38 @@ class Stream: Equatable, Hashable {
case .hd720pFirstThenBest:
return "Default: adaptive"
default:
return "\(value.height)p".replacingOccurrences(of: " ", with: "")
return value.name
}
}
}
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 {
Int(rawValue.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
var name: String {
"\(height)p\(refreshRate != -1 ? ", \(refreshRate) fps" : "")"
}
static func from(resolution: String) -> Resolution? {
allCases.first { "\($0)".contains(resolution) }
var height: Int {
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 {
@ -43,14 +61,16 @@ class Stream: Equatable, Hashable {
}
enum Kind: String, Comparable {
case stream, adaptive
case stream, adaptive, hls
private var sortOrder: Int {
switch self {
case .stream:
case .hls:
return 0
case .adaptive:
case .stream:
return 1
case .adaptive:
return 2
}
}
@ -59,39 +79,98 @@ class Stream: Equatable, Hashable {
}
}
var audioAsset: AVURLAsset
var videoAsset: AVURLAsset
let id = UUID()
var resolution: Resolution
var kind: Kind
var instance: Instance!
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.videoAsset = videoAsset
self.hlsURL = hlsURL
self.resolution = resolution
self.kind = kind
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 {
"\(resolution.height)p"
"\(quality) - \(instance?.description ?? "")"
}
var assets: [AVURLAsset] {
[audioAsset, videoAsset]
}
var oneMeaningfullAsset: Bool {
var videoAssetContainsAudio: Bool {
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 {
lhs.resolution == rhs.resolution && lhs.kind == rhs.kind
lhs.id == rhs.id
}
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 {
@Published var channels = [Channel]()
@Published var api: InvidiousAPI! = InvidiousAPI()
var accounts: AccountsModel
var api: InvidiousAPI {
accounts.invidious
}
var resource: Resource {
api.subscriptions
}
init(api: InvidiousAPI? = nil) {
self.api = api
init(accounts: AccountsModel? = nil) {
self.accounts = accounts ?? AccountsModel()
}
var all: [Channel] {

View File

@ -22,7 +22,6 @@ struct Video: Identifiable, Equatable, Hashable {
var upcoming: Bool
var streams = [Stream]()
var hlsUrl: URL?
var publishedAt: Date?
var likes: Int?
@ -104,10 +103,13 @@ struct Video: Identifiable, Equatable, Hashable {
publishedAt = Date(timeIntervalSince1970: publishedInterval)
}
if let hlsURL = json["hlsUrl"].url {
streams.append(.init(hlsURL: hlsURL))
}
streams = Video.extractFormatStreams(from: json["formatStreams"].arrayValue)
streams.append(contentsOf: Video.extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue))
hlsUrl = json["hlsUrl"].url
channel = Channel(json: json)
}
@ -179,8 +181,8 @@ struct Video: Identifiable, Equatable, Hashable {
private static func extractFormatStreams(from streams: [JSON]) -> [Stream] {
streams.map {
SingleAssetStream(
avAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset($0["url"].stringValue)!),
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue)!,
avAsset: AVURLAsset(url: $0["url"].url!),
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
kind: .stream,
encoding: $0["encoding"].stringValue
)
@ -197,9 +199,9 @@ struct Video: Identifiable, Equatable, Hashable {
return videoAssetsURLs.map {
Stream(
audioAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset(audioAssetURL!["url"].stringValue)!),
videoAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset($0["url"].stringValue)!),
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue)!,
audioAsset: AVURLAsset(url: audioAssetURL!["url"].url!),
videoAsset: AVURLAsset(url: $0["url"].url!),
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
kind: .adaptive,
encoding: $0["encoding"].stringValue
)

View File

@ -32,6 +32,15 @@
/* End PBXAggregateTarget 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 */; };
3705B182267B4E4900704544 /* 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 */; };
37D4B19926717E1500C925CA /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B19626717E1500C925CA /* Video.swift */; };
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 */; };
37E2EEAC270656EC00170416 /* 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 */
/* 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>"; };
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>"; };
@ -449,6 +464,7 @@
37D4B18B26717B3800C925CA /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = "<group>"; };
37D4B19626717E1500C925CA /* Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Video.swift; sourceTree = "<group>"; };
37D4B1AE26729DEB00C925CA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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>"; };
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>"; };
@ -744,6 +760,7 @@
372915E52687E3B900F5A35B /* Defaults.swift */,
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */,
37D4B0C22671614700C925CA /* PearvidiousApp.swift */,
3700155E271B12DD0049C794 /* SiestaConfiguration.swift */,
37D4B0C42671614800C925CA /* Assets.xcassets */,
37BD07C42698ADEE003EBB87 /* Pearvidious.entitlements */,
);
@ -803,6 +820,7 @@
37D4B1B72672CFE300C925CA /* Model */ = {
isa = PBXGroup;
children = (
37001562271B1F250049C794 /* AccountsModel.swift */,
37484C3026FCB8F900287258 /* AccountValidator.swift */,
37AAF28F26740715007FC770 /* Channel.swift */,
37141672267A8E10006CA35D /* Country.swift */,
@ -810,9 +828,11 @@
375DFB5726F9DA010013F468 /* InstancesModel.swift */,
37977582268922F600DD52A8 /* InvidiousAPI.swift */,
371F2F19269B43D300E4A7AB /* NavigationModel.swift */,
3700155A271B0D4D0049C794 /* PipedAPI.swift */,
37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */,
37319F0427103F94004ECCD0 /* PlayerQueue.swift */,
37CC3F44270CE30600608308 /* PlayerQueueItem.swift */,
37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */,
376578882685471400D4EA09 /* Playlist.swift */,
37BA794226DBA973002A0235 /* PlaylistsModel.swift */,
37C194C626F6A9C8005D3B96 /* RecentsModel.swift */,
@ -1252,6 +1272,7 @@
3711403F26B206A6005B3555 /* SearchModel.swift in Sources */,
37F64FE426FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */,
37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */,
37DD87C7271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
37BE0BD326A1D4780092E2DB /* Player.swift in Sources */,
37A9965E26D6F9B9006E3224 /* WatchNowView.swift in Sources */,
37CEE4C12677B697005A1EFE /* Stream.swift in Sources */,
@ -1263,6 +1284,7 @@
37FD43DE2704717F0073EE42 /* DefaultAccountHint.swift in Sources */,
37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */,
3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */,
3700155F271B12DD0049C794 /* SiestaConfiguration.swift in Sources */,
37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */,
375168D62700FAFF008F96A6 /* Debounce.swift in Sources */,
37E64DD126D597EB00C71877 /* SubscriptionsModel.swift in Sources */,
@ -1271,6 +1293,7 @@
3788AC2B26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */,
3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */,
373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
3700155B271B0D4D0049C794 /* PipedAPI.swift in Sources */,
37B044B726F7AB9000E1419D /* SettingsView.swift in Sources */,
377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */,
37E2EEAB270656EC00170416 /* PlayerControlsView.swift in Sources */,
@ -1311,6 +1334,7 @@
37484C2926FC83FF00287258 /* AccountFormView.swift in Sources */,
371F2F1A269B43D300E4A7AB /* NavigationModel.swift in Sources */,
37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */,
37001563271B1F250049C794 /* AccountsModel.swift in Sources */,
37CC3F50270D010D00608308 /* VideoBanner.swift in Sources */,
378E50FB26FE8B9F00F49626 /* Instance.swift in Sources */,
37484C1D26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
@ -1334,6 +1358,7 @@
37EAD86C267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
37CEE4C22677B697005A1EFE /* Stream.swift in Sources */,
371F2F1B269B43D300E4A7AB /* NavigationModel.swift in Sources */,
37001564271B1F250049C794 /* AccountsModel.swift in Sources */,
37FD43DF2704717F0073EE42 /* DefaultAccountHint.swift in Sources */,
3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */,
37BA795026DC3E0E002A0235 /* Int+Format.swift in Sources */,
@ -1367,6 +1392,7 @@
37BD07BC2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */,
3748186F26A769D60084E870 /* DetailBadge.swift in Sources */,
372915E72687E3B900F5A35B /* Defaults.swift in Sources */,
37DD87C8271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
376578922685490700D4EA09 /* PlaylistsView.swift in Sources */,
37484C2626FC83E000287258 /* InstanceFormView.swift in Sources */,
377FC7E4267A084E00A6BBAF /* SearchView.swift in Sources */,
@ -1376,6 +1402,7 @@
376578862685429C00D4EA09 /* CaseIterable+Next.swift in Sources */,
37A9965F26D6F9B9006E3224 /* WatchNowView.swift in Sources */,
37F4AE7326828F0900BD60EA /* VideosCellsVertical.swift in Sources */,
37001560271B12DD0049C794 /* SiestaConfiguration.swift in Sources */,
37B81AFD26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */,
37A9965B26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */,
3761AC1026F0F9A600AA496F /* UnsubscribeAlertModifier.swift in Sources */,
@ -1394,6 +1421,7 @@
37732FF12703A26300F04329 /* ValidationStatusView.swift in Sources */,
37BA794C26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */,
37CC3F46270CE30600608308 /* PlayerQueueItem.swift in Sources */,
3700155C271B0D4D0049C794 /* PipedAPI.swift in Sources */,
37D4B19826717E1500C925CA /* Video.swift in Sources */,
375168D72700FDB8008F96A6 /* Debounce.swift in Sources */,
37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */,
@ -1445,6 +1473,7 @@
37CEE4BF2677B670005A1EFE /* SingleAssetStream.swift in Sources */,
37BE0BD426A1D47D0092E2DB /* Player.swift in Sources */,
37977585268922F600DD52A8 /* InvidiousAPI.swift in Sources */,
3700155D271B0D4D0049C794 /* PipedAPI.swift in Sources */,
375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */,
37F4AE7426828F0900BD60EA /* VideosCellsVertical.swift in Sources */,
376578872685429C00D4EA09 /* CaseIterable+Next.swift in Sources */,
@ -1456,6 +1485,7 @@
37141671267A8ACC006CA35D /* TrendingView.swift in Sources */,
3788AC2926F6840700F6BAA9 /* WatchNowSection.swift in Sources */,
37319F0727103F94004ECCD0 /* PlayerQueue.swift in Sources */,
37DD87C9271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
375168D82700FDB9008F96A6 /* Debounce.swift in Sources */,
37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
37AAF29226740715007FC770 /* Channel.swift in Sources */,
@ -1475,10 +1505,12 @@
37AAF27E26737323007FC770 /* PopularView.swift in Sources */,
3743CA50270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */,
37A9966026D6F9B9006E3224 /* WatchNowView.swift in Sources */,
37001565271B1F250049C794 /* AccountsModel.swift in Sources */,
37484C1F26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
376578932685490700D4EA09 /* PlaylistsView.swift in Sources */,
37CC3F47270CE30600608308 /* PlayerQueueItem.swift in Sources */,
37001561271B12DD0049C794 /* SiestaConfiguration.swift in Sources */,
37BA795126DC3E0E002A0235 /* Int+Format.swift in Sources */,
3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,

View File

@ -1,7 +1,10 @@
import Defaults
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 defaultAccountID = Key<String?>("defaultAccountID")

View File

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

View File

@ -4,8 +4,7 @@ import SwiftUI
#endif
struct AppSidebarNavigation: View {
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
#if os(iOS)
@EnvironmentObject<NavigationModel> private var navigation
@State private var didApplyPrimaryViewWorkAround = false
@ -58,8 +57,8 @@ struct AppSidebarNavigation: View {
.help(
"Switch Instances and Accounts\n" +
"Current Instance: \n" +
"\(api.account?.url ?? "Not Set")\n" +
"Current User: \(api.account?.description ?? "Not set")"
"\(accounts.account?.url ?? "Not Set")\n" +
"Current User: \(accounts.account?.description ?? "Not set")"
)
}
}

View File

@ -1,8 +1,9 @@
import Defaults
import Siesta
import SwiftUI
struct ContentView: View {
@StateObject private var api = InvidiousAPI()
@StateObject private var accounts = AccountsModel()
@StateObject private var instances = InstancesModel()
@StateObject private var navigation = NavigationModel()
@StateObject private var player = PlayerModel()
@ -29,8 +30,8 @@ struct ContentView: View {
TVNavigationView()
#endif
}
.onAppear(perform: configureAPI)
.environmentObject(api)
.onAppear(perform: configure)
.environmentObject(accounts)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
@ -41,7 +42,7 @@ struct ContentView: View {
#if os(iOS)
.fullScreenCover(isPresented: $player.presentingPlayer) {
VideoPlayerView()
.environmentObject(api)
.environmentObject(accounts)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
@ -51,7 +52,7 @@ struct ContentView: View {
.sheet(isPresented: $player.presentingPlayer) {
VideoPlayerView()
.frame(minWidth: 900, minHeight: 800)
.environmentObject(api)
.environmentObject(accounts)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
@ -61,31 +62,30 @@ struct ContentView: View {
#if !os(tvOS)
.sheet(isPresented: $navigation.presentingAddToPlaylist) {
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
.environmentObject(api)
.environmentObject(playlists)
}
.sheet(isPresented: $navigation.presentingPlaylistForm) {
PlaylistFormView(playlist: $navigation.editedPlaylist)
.environmentObject(api)
.environmentObject(playlists)
}
.sheet(isPresented: $navigation.presentingSettings) {
SettingsView()
.environmentObject(api)
.environmentObject(instances)
}
#endif
}
func configureAPI() {
if let account = instances.defaultAccount, api.account.isEmpty {
api.setAccount(account)
func configure() {
SiestaLog.Category.enabled = .common
if let account = instances.defaultAccount {
accounts.setAccount(account)
}
player.api = api
playlists.api = api
search.api = api
subscriptions.api = api
player.accounts = accounts
playlists.accounts = accounts
search.accounts = accounts
subscriptions.accounts = accounts
}
}

View File

@ -1,7 +1,7 @@
import SwiftUI
struct Sidebar: View {
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
var body: some View {
@ -12,7 +12,7 @@ struct Sidebar: View {
AppSidebarRecents()
.id("recentlyOpened")
if api.signedIn {
if accounts.signedIn {
AppSidebarSubscriptions()
AppSidebarPlaylists()
}
@ -31,7 +31,7 @@ struct Sidebar: View {
.accessibility(label: Text("Watch Now"))
}
if api.signedIn {
if accounts.signedIn {
NavigationLink(destination: LazyView(SubscriptionsView()), tag: TabSelection.subscriptions, selection: $navigation.tabSelection) {
Label("Subscriptions", systemImage: "star.circle")
.accessibility(label: Text("Subscriptions"))

View File

@ -1,3 +1,4 @@
import Defaults
import Foundation
import SwiftUI
@ -5,47 +6,71 @@ struct PlaybackBar: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.inNavigationView) private var inNavigationView
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<PlayerModel> private var player
var body: some View {
HStack {
closeButton
.frame(width: 80, alignment: .leading)
if player.currentItem != nil {
Text(playbackStatus)
.foregroundColor(.gray)
.font(.caption2)
.frame(minWidth: 130, maxWidth: .infinity)
VStack {
if player.stream != nil {
Text(currentStreamString)
} else {
if player.currentVideo!.live {
Image(systemName: "dot.radiowaves.left.and.right")
} else {
Image(systemName: "bolt.horizontal.fill")
}
Spacer()
HStack(spacing: 4) {
if player.currentVideo!.live {
Image(systemName: "dot.radiowaves.left.and.right")
} else if player.isLoadingAvailableStreams || player.isLoadingStream {
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)
.font(.caption2)
.frame(width: 80, alignment: .trailing)
.fixedSize(horizontal: true, vertical: true)
} else {
Spacer()
}
}
.frame(minWidth: 0, maxWidth: .infinity)
.padding(4)
.background(.black)
}
var currentStreamString: String {
"\(player.stream!.resolution.height)p"
private var closeButton: some View {
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 {
return "LIVE"
}
@ -66,17 +91,61 @@ struct PlaybackBar: View {
return "ends at \(timeFinishAtString)"
}
var closeButton: some View {
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)
private var streamControl: some View {
#if os(macOS)
Picker("", selection: $player.streamSelection) {
ForEach(instances.all) { instance in
let instanceStreams = availableStreamsForInstance(instance)
if !instanceStreams.values.isEmpty {
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
Section(header: Text(instance.longDescription)) {
ForEach(kinds, id: \.self) { key in
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
struct Player: UIViewControllerRepresentable {
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<PlayerModel> private var player
var controller: PlayerViewController?
@ -18,11 +17,8 @@ struct Player: UIViewControllerRepresentable {
let controller = PlayerViewController()
player.controller = controller
controller.playerModel = player
controller.api = api
controller.resolution = Defaults[.quality]
player.controller = controller
return controller
}

View File

@ -3,11 +3,9 @@ import Logging
import SwiftUI
final class PlayerViewController: UIViewController {
var api: InvidiousAPI!
var playerLoaded = false
var playerModel: PlayerModel!
var playerViewController = AVPlayerViewController()
var resolution: Stream.ResolutionSetting!
var shouldResume = false
override func viewWillAppear(_ animated: Bool) {
@ -81,7 +79,7 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {
if shouldResume {
playerModel.player.play()
playerModel.play()
}
dismiss(animated: false)

View File

@ -4,9 +4,9 @@ import SwiftUI
struct VideoDetailsPaddingModifier: ViewModifier {
static var defaultAdditionalDetailsPadding: Double {
#if os(macOS)
20
30
#else
35
40
#endif
}

View File

@ -57,10 +57,12 @@ struct VideoPlayerSizeModifier: ViewModifier {
var maxHeight: Double {
#if os(iOS)
verticalSizeClass == .regular ? geometry.size.height - minimumHeightLeft : .infinity
let height = verticalSizeClass == .regular ? geometry.size.height - minimumHeightLeft : .infinity
#else
geometry.size.height - minimumHeightLeft
let height = geometry.size.height - minimumHeightLeft
#endif
return [height, 0].max()!
}
var edgesIgnoringSafeArea: Edge.Set {

View File

@ -115,7 +115,7 @@ struct AccountFormView: View {
isValidating = true
validationDebounce.debouncing(1) {
validator.validateAccount()
validator.validateInvidiousAccount()
}
}
@ -132,6 +132,7 @@ struct AccountFormView: View {
private var validator: AccountValidator {
AccountValidator(
app: .constant(instance.app),
url: instance.url,
account: Instance.Account(instanceID: instance.id, url: instance.url, sid: sid),
id: $sid,

View File

@ -13,6 +13,18 @@ struct AccountsSettingsView: 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 {
Section(header: Text("Accounts"), footer: sectionFooter) {
ForEach(instances.accounts(instanceID), id: \.self) { account in
@ -59,7 +71,6 @@ struct AccountsSettingsView: View {
}
}
}
.navigationTitle(instance.shortDescription)
.sheet(isPresented: $presentingAccountForm, onDismiss: { accountsChanged.toggle() }) {
AccountFormView(instance: instance)
}

View File

@ -5,6 +5,7 @@ struct InstanceFormView: View {
@State private var name = ""
@State private var url = ""
@State private var app = Instance.App.invidious
@State private var isValid = false
@State private var isValidated = false
@ -37,7 +38,7 @@ struct InstanceFormView: View {
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.background(.thickMaterial)
#else
.frame(width: 400, height: 150)
.frame(width: 400, height: 190)
#endif
}
@ -73,6 +74,13 @@ struct InstanceFormView: View {
private var formFields: some View {
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)"))
.focused($nameFieldFocused)
@ -104,6 +112,7 @@ struct InstanceFormView: View {
var validator: AccountValidator {
AccountValidator(
app: $app,
url: url,
id: $url,
isValid: $isValid,
@ -137,7 +146,7 @@ struct InstanceFormView: View {
return
}
savedInstanceID = instancesModel.add(name: name, url: url).id
savedInstanceID = instancesModel.add(app: app, name: name, url: url).id
dismiss()
}

View File

@ -19,8 +19,10 @@ struct InstancesSettingsView: View {
Group {
Section(header: Text("Instances"), footer: DefaultAccountHint()) {
ForEach(instances) { instance in
NavigationLink(instance.description) {
AccountsSettingsView(instanceID: instance.id)
Group {
NavigationLink(instance.longDescription) {
AccountsSettingsView(instanceID: instance.id)
}
}
#if os(iOS)
.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
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
init(_ videos: [Video] = [Video]()) {
self.videos = videos
}
var resource: Resource {
let resource = api.trending(category: category, country: country)
let resource = accounts.invidious.trending(category: category, country: country)
resource.addObserver(store)
return resource

View File

@ -6,7 +6,7 @@ struct ChannelVideosView: View {
@StateObject private var store = Store<Channel>()
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@ -99,7 +99,7 @@ struct ChannelVideosView: View {
}
var resource: Resource {
let resource = api.channel(channel.id)
let resource = accounts.invidious.channel(channel.id)
resource.addObserver(store)
return resource

View File

@ -52,6 +52,10 @@ struct PlayerControlsView<Content: View>: View {
.padding(.vertical, 20)
.contentShape(Rectangle())
}
#if !os(tvOS)
.keyboardShortcut("o")
#endif
Group {
if model.isPlaying {
Button(action: {
@ -65,7 +69,7 @@ struct PlayerControlsView<Content: View>: View {
}) {
Label("Play", systemImage: "play.fill")
}
.disabled(model.player.currentItem == nil)
.disabled(model.player.currentItem.isNil)
}
}
.frame(minWidth: 30)

View File

@ -4,10 +4,10 @@ import SwiftUI
struct PopularView: View {
@StateObject private var store = Store<[Video]>()
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
var resource: Resource {
api.popular
accounts.invidious.popular
}
var body: some View {

View File

@ -5,12 +5,14 @@ struct SignInRequiredView<Content: View>: View {
let title: String
let content: Content
@EnvironmentObject<InvidiousAPI> private var api
@Default(.instances) private var instances
@EnvironmentObject<AccountsModel> private var accounts
#if !os(macOS)
@EnvironmentObject<NavigationModel> private var navigation
#endif
@Default(.instances) private var instances
init(title: String, @ViewBuilder content: @escaping () -> Content) {
self.title = title
self.content = content()
@ -18,7 +20,7 @@ struct SignInRequiredView<Content: View>: View {
var body: some View {
Group {
if api.signedIn {
if accounts.signedIn {
content
} else {
prompt

View File

@ -4,7 +4,11 @@ import SwiftUI
struct SubscriptionsView: View {
@StateObject private var store = Store<[Video]>()
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
var api: InvidiousAPI {
accounts.invidious
}
var feed: Resource {
api.feed
@ -17,10 +21,7 @@ struct SubscriptionsView: View {
.onAppear {
loadResources()
}
.onChange(of: api.account) { _ in
loadResources(force: true)
}
.onChange(of: feed) { _ in
.onChange(of: accounts.account) { _ in
loadResources(force: true)
}
}

View File

@ -8,7 +8,7 @@ struct WatchNowSection: View {
@StateObject private var store = Store<[Video]>()
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
init(resource: Resource, label: String) {
self.resource = resource
@ -21,7 +21,7 @@ struct WatchNowSection: View {
resource.addObserver(store)
resource.loadIfNeeded()
}
.onChange(of: api.account) { _ in
.onChange(of: accounts.account) { _ in
resource.load()
}
}

View File

@ -3,12 +3,16 @@ import Siesta
import SwiftUI
struct WatchNowView: View {
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
var api: InvidiousAPI! {
accounts.invidious
}
var body: some View {
PlayerControlsView {
ScrollView(.vertical, showsIndicators: false) {
if api.validInstance {
if !accounts.account.isNil {
VStack(alignment: .leading, spacing: 0) {
if api.signedIn {
WatchNowSection(resource: api.feed, label: "Subscriptions")

View File

@ -21,7 +21,7 @@ struct InstancesSettingsView: View {
if !instances.isEmpty {
Picker("Instance", selection: $selectedInstanceID) {
ForEach(instances) { instance in
Text(instance.description).tag(Optional(instance.id))
Text(instance.longDescription).tag(Optional(instance.id))
}
}
.labelsHidden()
@ -31,7 +31,7 @@ struct InstancesSettingsView: View {
.foregroundColor(.secondary)
}
if !selectedInstance.isNil {
if !selectedInstance.isNil, selectedInstance.supportsAccounts {
Text("Accounts")
List(selection: $selectedAccount) {
if accounts.isEmpty {
@ -46,11 +46,21 @@ struct InstancesSettingsView: View {
.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 {
HStack {
Button("Add Account...") {
selectedAccount = nil
presentingAccountForm = true
if selectedInstance.supportsAccounts {
Button("Add Account...") {
selectedAccount = nil
presentingAccountForm = true
}
}
Spacer()
@ -59,7 +69,7 @@ struct InstancesSettingsView: View {
presentingConfirmationDialog = true
}
.confirmationDialog(
"Are you sure you want to remove \(selectedInstance!.description) instance?",
"Are you sure you want to remove \(selectedInstance!.longDescription) instance?",
isPresented: $presentingConfirmationDialog
) {
Button("Remove Instance", role: .destructive) {

View File

@ -4,46 +4,40 @@ import SwiftUI
struct AccountSelectionView: View {
@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
var body: some View {
Section(header: Text("Current Account")) {
Button(api.account?.name ?? "Not selected") {
Button(accountButtonTitle(account: accounts.account)) {
if let account = nextAccount {
api.setAccount(account)
accounts.setAccount(account)
}
}
.disabled(instances.isEmpty)
.contextMenu {
ForEach(instances) { instance in
Button(accountButtonTitle(instance: instance, account: instance.anonymousAccount)) {
api.setAccount(instance.anonymousAccount)
}
ForEach(instancesModel.accounts(instance.id)) { account in
Button(accountButtonTitle(instance: instance, account: account)) {
api.setAccount(account)
}
ForEach(accounts.all) { account in
Button(accountButtonTitle(account: account)) {
accounts.setAccount(account)
}
}
Button("Cancel", role: .cancel) {}
}
}
.id(UUID())
}
private var nextAccount: Instance.Account? {
guard api.account != nil else {
return accounts.first
accounts.all.next(after: accounts.account)
}
func accountButtonTitle(account: Instance.Account! = nil) -> String {
guard account != nil else {
return "Not selected"
}
return accounts.next(after: api.account!)
}
func accountButtonTitle(instance: Instance, account: Instance.Account) -> String {
instances.count > 1 ? "\(account.description)\(instance.shortDescription)" : account.description
return instances.count > 1 ? "\(account.description)\(account.instance.shortDescription)" : account.description
}
}