diff --git a/Extensions/Array+Next.swift b/Extensions/Array+Next.swift index 2879596b..d678e62c 100644 --- a/Extensions/Array+Next.swift +++ b/Extensions/Array+Next.swift @@ -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 diff --git a/Fixtures/Instance+Fixtures.swift b/Fixtures/Instance+Fixtures.swift index 10a6597b..fd06a54c 100644 --- a/Fixtures/Instance+Fixtures.swift +++ b/Fixtures/Instance+Fixtures.swift @@ -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") } } diff --git a/Model/AccountValidator.swift b/Model/AccountValidator.swift index 00deda06..0702adf5 100644 --- a/Model/AccountValidator.swift +++ b/Model/AccountValidator.swift @@ -3,6 +3,7 @@ import Siesta import SwiftUI final class AccountValidator: Service { + let app: Binding let url: String let account: Instance.Account? @@ -13,6 +14,7 @@ final class AccountValidator: Service { var error: Binding? init( + app: Binding, url: String, account: Instance.Account? = nil, id: Binding, @@ -21,6 +23,7 @@ final class AccountValidator: Service { isValidating: Binding, error: Binding? = 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 diff --git a/Model/AccountsModel.swift b/Model/AccountsModel.swift new file mode 100644 index 00000000..d156ce97 --- /dev/null +++ b/Model/AccountsModel.swift @@ -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) + } + } +} diff --git a/Model/Instance.swift b/Model/Instance.swift index 354257af..afbebde8 100644 --- a/Model/Instance.swift +++ b/Model/Instance.swift @@ -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) } } diff --git a/Model/InstancesModel.swift b/Model/InstancesModel.swift index 328a87ba..10e49c8e 100644 --- a/Model/InstancesModel.swift +++ b/Model/InstancesModel.swift @@ -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 } diff --git a/Model/InvidiousAPI.swift b/Model/InvidiousAPI.swift index 4256d495..27be1b7f 100644 --- a/Model/InvidiousAPI.swift +++ b/Model/InvidiousAPI.swift @@ -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"]) } diff --git a/Model/PipedAPI.swift b/Model/PipedAPI.swift new file mode 100644 index 00000000..2f3cdd71 --- /dev/null +++ b/Model/PipedAPI.swift @@ -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) -> [Stream] in + self.extractStreams(content) + } + } + + private func extractStreams(_ content: Entity) -> [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] { + 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] { + 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)") + } +} diff --git a/Model/PlayerModel.swift b/Model/PlayerModel.swift index 9c336d39..7b052361 100644 --- a/Model/PlayerModel.swift +++ b/Model/PlayerModel.swift @@ -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 - } } diff --git a/Model/PlayerQueue.swift b/Model/PlayerQueue.swift index edd93620..d83e0373 100644 --- a/Model/PlayerQueue.swift +++ b/Model/PlayerQueue.swift @@ -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) } diff --git a/Model/PlayerQueueItem.swift b/Model/PlayerQueueItem.swift index 1d71cd9d..c067ad7f 100644 --- a/Model/PlayerQueueItem.swift +++ b/Model/PlayerQueueItem.swift @@ -10,5 +10,4 @@ struct PlayerQueueItem: Hashable, Identifiable { } var playerItems = [AVPlayerItem]() - var compositions = [Stream: AVMutableComposition]() } diff --git a/Model/PlayerStreams.swift b/Model/PlayerStreams.swift new file mode 100644 index 00000000..7bfa7e00 --- /dev/null +++ b/Model/PlayerStreams.swift @@ -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) } + } +} diff --git a/Model/PlaylistsModel.swift b/Model/PlaylistsModel.swift index 1d35f25d..6bd622f5 100644 --- a/Model/PlaylistsModel.swift +++ b/Model/PlaylistsModel.swift @@ -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 } diff --git a/Model/SearchModel.swift b/Model/SearchModel.swift index 40b35f91..0fcdc164 100644 --- a/Model/SearchModel.swift +++ b/Model/SearchModel.swift @@ -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) diff --git a/Model/SingleAssetStream.swift b/Model/SingleAssetStream.swift index cb7b938c..266ff7c5 100644 --- a/Model/SingleAssetStream.swift +++ b/Model/SingleAssetStream.swift @@ -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) diff --git a/Model/Stream.swift b/Model/Stream.swift index 7254f6e5..593ad636 100644 --- a/Model/Stream.swift +++ b/Model/Stream.swift @@ -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 } } diff --git a/Model/SubscriptionsModel.swift b/Model/SubscriptionsModel.swift index 36077efb..bae985aa 100644 --- a/Model/SubscriptionsModel.swift +++ b/Model/SubscriptionsModel.swift @@ -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] { diff --git a/Model/Video.swift b/Model/Video.swift index 4af5f4b5..9452ca84 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -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 ) diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index 70076e33..89302705 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -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 = ""; }; + 3700155E271B12DD0049C794 /* SiestaConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiestaConfiguration.swift; sourceTree = ""; }; + 37001562271B1F250049C794 /* AccountsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsModel.swift; sourceTree = ""; }; 3705B17F267B4DFB00704544 /* TrendingCountry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingCountry.swift; sourceTree = ""; }; 3705B181267B4E4900704544 /* TrendingCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingCategory.swift; sourceTree = ""; }; 3711403E26B206A6005B3555 /* SearchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModel.swift; sourceTree = ""; }; @@ -449,6 +464,7 @@ 37D4B18B26717B3800C925CA /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = ""; }; 37D4B19626717E1500C925CA /* Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Video.swift; sourceTree = ""; }; 37D4B1AE26729DEB00C925CA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStreams.swift; sourceTree = ""; }; 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsView.swift; sourceTree = ""; }; 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsModel.swift; sourceTree = ""; }; 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockAPI.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index c5182087..e3537d37 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -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("defaultAccountID") diff --git a/Shared/Navigation/AccountsMenuView.swift b/Shared/Navigation/AccountsMenuView.swift index f161c6ca..ebe82f33 100644 --- a/Shared/Navigation/AccountsMenuView.swift +++ b/Shared/Navigation/AccountsMenuView.swift @@ -2,33 +2,27 @@ import Defaults import SwiftUI struct AccountsMenuView: View { + @EnvironmentObject private var model @EnvironmentObject private var instancesModel - @EnvironmentObject 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 } } diff --git a/Shared/Navigation/AppSidebarNavigation.swift b/Shared/Navigation/AppSidebarNavigation.swift index 953e29c1..75fc5e8e 100644 --- a/Shared/Navigation/AppSidebarNavigation.swift +++ b/Shared/Navigation/AppSidebarNavigation.swift @@ -4,8 +4,7 @@ import SwiftUI #endif struct AppSidebarNavigation: View { - @EnvironmentObject private var api - + @EnvironmentObject private var accounts #if os(iOS) @EnvironmentObject 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")" ) } } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 645a2c41..d20820fd 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -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 } } diff --git a/Shared/Navigation/Sidebar.swift b/Shared/Navigation/Sidebar.swift index 81f64835..b0d5a5ba 100644 --- a/Shared/Navigation/Sidebar.swift +++ b/Shared/Navigation/Sidebar.swift @@ -1,7 +1,7 @@ import SwiftUI struct Sidebar: View { - @EnvironmentObject private var api + @EnvironmentObject private var accounts @EnvironmentObject 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")) diff --git a/Shared/Player/PlaybackBar.swift b/Shared/Player/PlaybackBar.swift index 4cd4d6df..6bb5a4fa 100644 --- a/Shared/Player/PlaybackBar.swift +++ b/Shared/Player/PlaybackBar.swift @@ -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 private var instances @EnvironmentObject 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) } } diff --git a/Shared/Player/Player.swift b/Shared/Player/Player.swift index d83ba60b..7b30c440 100644 --- a/Shared/Player/Player.swift +++ b/Shared/Player/Player.swift @@ -2,7 +2,6 @@ import Defaults import SwiftUI struct Player: UIViewControllerRepresentable { - @EnvironmentObject private var api @EnvironmentObject 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 } diff --git a/Shared/Player/PlayerViewController.swift b/Shared/Player/PlayerViewController.swift index 1ab903d1..d936d110 100644 --- a/Shared/Player/PlayerViewController.swift +++ b/Shared/Player/PlayerViewController.swift @@ -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) diff --git a/Shared/Player/VideoDetailsPaddingModifier.swift b/Shared/Player/VideoDetailsPaddingModifier.swift index ebbc34c3..ce159676 100644 --- a/Shared/Player/VideoDetailsPaddingModifier.swift +++ b/Shared/Player/VideoDetailsPaddingModifier.swift @@ -4,9 +4,9 @@ import SwiftUI struct VideoDetailsPaddingModifier: ViewModifier { static var defaultAdditionalDetailsPadding: Double { #if os(macOS) - 20 + 30 #else - 35 + 40 #endif } diff --git a/Shared/Player/VideoPlayerSizeModifier.swift b/Shared/Player/VideoPlayerSizeModifier.swift index b1f9217d..07a3ad20 100644 --- a/Shared/Player/VideoPlayerSizeModifier.swift +++ b/Shared/Player/VideoPlayerSizeModifier.swift @@ -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 { diff --git a/Shared/Settings/AccountFormView.swift b/Shared/Settings/AccountFormView.swift index 74c8b6bd..b78795c2 100644 --- a/Shared/Settings/AccountFormView.swift +++ b/Shared/Settings/AccountFormView.swift @@ -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, diff --git a/Shared/Settings/AccountsSettingsView.swift b/Shared/Settings/AccountsSettingsView.swift index 3782b339..da79dbbd 100644 --- a/Shared/Settings/AccountsSettingsView.swift +++ b/Shared/Settings/AccountsSettingsView.swift @@ -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) } diff --git a/Shared/Settings/InstanceFormView.swift b/Shared/Settings/InstanceFormView.swift index 9bac8515..d2582a99 100644 --- a/Shared/Settings/InstanceFormView.swift +++ b/Shared/Settings/InstanceFormView.swift @@ -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() } diff --git a/Shared/Settings/InstancesSettingsView.swift b/Shared/Settings/InstancesSettingsView.swift index 999a02fe..36830ef1 100644 --- a/Shared/Settings/InstancesSettingsView.swift +++ b/Shared/Settings/InstancesSettingsView.swift @@ -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) { diff --git a/Shared/SiestaConfiguration.swift b/Shared/SiestaConfiguration.swift new file mode 100644 index 00000000..663909c6 --- /dev/null +++ b/Shared/SiestaConfiguration.swift @@ -0,0 +1,5 @@ +import Siesta +import SwiftyJSON + +let SwiftyJSONTransformer = + ResponseContentTransformer(transformErrors: true) { JSON($0.content as AnyObject) } diff --git a/Shared/Trending/TrendingView.swift b/Shared/Trending/TrendingView.swift index ab12571b..405047ae 100644 --- a/Shared/Trending/TrendingView.swift +++ b/Shared/Trending/TrendingView.swift @@ -11,14 +11,14 @@ struct TrendingView: View { @State private var presentingCountrySelection = false - @EnvironmentObject private var api + @EnvironmentObject 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 diff --git a/Shared/Views/ChannelVideosView.swift b/Shared/Views/ChannelVideosView.swift index 8269fa60..14f2ca29 100644 --- a/Shared/Views/ChannelVideosView.swift +++ b/Shared/Views/ChannelVideosView.swift @@ -6,7 +6,7 @@ struct ChannelVideosView: View { @StateObject private var store = Store() - @EnvironmentObject private var api + @EnvironmentObject private var accounts @EnvironmentObject private var navigation @EnvironmentObject 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 diff --git a/Shared/Views/PlayerControlsView.swift b/Shared/Views/PlayerControlsView.swift index eb8b7bee..20859382 100644 --- a/Shared/Views/PlayerControlsView.swift +++ b/Shared/Views/PlayerControlsView.swift @@ -52,6 +52,10 @@ struct PlayerControlsView: View { .padding(.vertical, 20) .contentShape(Rectangle()) } + #if !os(tvOS) + .keyboardShortcut("o") + #endif + Group { if model.isPlaying { Button(action: { @@ -65,7 +69,7 @@ struct PlayerControlsView: View { }) { Label("Play", systemImage: "play.fill") } - .disabled(model.player.currentItem == nil) + .disabled(model.player.currentItem.isNil) } } .frame(minWidth: 30) diff --git a/Shared/Views/PopularView.swift b/Shared/Views/PopularView.swift index f4f8d961..d3a34f60 100644 --- a/Shared/Views/PopularView.swift +++ b/Shared/Views/PopularView.swift @@ -4,10 +4,10 @@ import SwiftUI struct PopularView: View { @StateObject private var store = Store<[Video]>() - @EnvironmentObject private var api + @EnvironmentObject private var accounts var resource: Resource { - api.popular + accounts.invidious.popular } var body: some View { diff --git a/Shared/Views/SignInRequiredView.swift b/Shared/Views/SignInRequiredView.swift index ce7d19c3..ae0c49d0 100644 --- a/Shared/Views/SignInRequiredView.swift +++ b/Shared/Views/SignInRequiredView.swift @@ -5,12 +5,14 @@ struct SignInRequiredView: View { let title: String let content: Content - @EnvironmentObject private var api - @Default(.instances) private var instances + @EnvironmentObject private var accounts + #if !os(macOS) @EnvironmentObject 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: View { var body: some View { Group { - if api.signedIn { + if accounts.signedIn { content } else { prompt diff --git a/Shared/Views/SubscriptionsView.swift b/Shared/Views/SubscriptionsView.swift index 3509a6ef..ee78a528 100644 --- a/Shared/Views/SubscriptionsView.swift +++ b/Shared/Views/SubscriptionsView.swift @@ -4,7 +4,11 @@ import SwiftUI struct SubscriptionsView: View { @StateObject private var store = Store<[Video]>() - @EnvironmentObject private var api + @EnvironmentObject 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) } } diff --git a/Shared/Watch Now/WatchNowSection.swift b/Shared/Watch Now/WatchNowSection.swift index d98ec011..3fb11938 100644 --- a/Shared/Watch Now/WatchNowSection.swift +++ b/Shared/Watch Now/WatchNowSection.swift @@ -8,7 +8,7 @@ struct WatchNowSection: View { @StateObject private var store = Store<[Video]>() - @EnvironmentObject private var api + @EnvironmentObject 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() } } diff --git a/Shared/Watch Now/WatchNowView.swift b/Shared/Watch Now/WatchNowView.swift index 394f024a..2e6d322d 100644 --- a/Shared/Watch Now/WatchNowView.swift +++ b/Shared/Watch Now/WatchNowView.swift @@ -3,12 +3,16 @@ import Siesta import SwiftUI struct WatchNowView: View { - @EnvironmentObject private var api + @EnvironmentObject 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") diff --git a/macOS/Settings/InstancesSettingsView.swift b/macOS/Settings/InstancesSettingsView.swift index a6baf134..52906a0f 100644 --- a/macOS/Settings/InstancesSettingsView.swift +++ b/macOS/Settings/InstancesSettingsView.swift @@ -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) { diff --git a/tvOS/AccountSelectionView.swift b/tvOS/AccountSelectionView.swift index d03933b3..79802cc8 100644 --- a/tvOS/AccountSelectionView.swift +++ b/tvOS/AccountSelectionView.swift @@ -4,46 +4,40 @@ import SwiftUI struct AccountSelectionView: View { @EnvironmentObject private var instancesModel - @EnvironmentObject private var api + @EnvironmentObject 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 } }