mirror of
https://github.com/yattee/yattee.git
synced 2024-12-22 13:33:42 +00:00
Add Piped support
This commit is contained in:
parent
a68d89cb6f
commit
62e17d5a18
@ -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
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
@ -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
45
Model/AccountsModel.swift
Normal file
@ -0,0 +1,45 @@
|
||||
import Combine
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
final class AccountsModel: ObservableObject {
|
||||
@Published private(set) var account: Instance.Account!
|
||||
|
||||
@Published private(set) var invidious = InvidiousAPI()
|
||||
@Published private(set) var piped = PipedAPI()
|
||||
|
||||
private var cancellables = [AnyCancellable]()
|
||||
|
||||
var all: [Instance.Account] {
|
||||
Defaults[.instances].map(\.anonymousAccount) + Defaults[.accounts]
|
||||
}
|
||||
|
||||
var signedIn: Bool {
|
||||
!account.isNil && !account.anonymous
|
||||
}
|
||||
|
||||
init() {
|
||||
cancellables.append(
|
||||
invidious.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||
)
|
||||
|
||||
cancellables.append(
|
||||
piped.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||
)
|
||||
}
|
||||
|
||||
func setAccount(_ account: Instance.Account) {
|
||||
guard account != self.account else {
|
||||
return
|
||||
}
|
||||
|
||||
self.account = account
|
||||
|
||||
switch account.instance.app {
|
||||
case .invidious:
|
||||
invidious.setAccount(account)
|
||||
case .piped:
|
||||
piped.setAccount(account)
|
||||
}
|
||||
}
|
||||
}
|
@ -2,22 +2,32 @@ import Defaults
|
||||
import Foundation
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
99
Model/PipedAPI.swift
Normal file
@ -0,0 +1,99 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PipedAPI: Service, ObservableObject {
|
||||
@Published var account: Instance.Account!
|
||||
|
||||
var anonymousAccount: Instance.Account {
|
||||
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.url)
|
||||
}
|
||||
|
||||
init(account: Instance.Account? = nil) {
|
||||
super.init()
|
||||
|
||||
guard account != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
setAccount(account!)
|
||||
}
|
||||
|
||||
func setAccount(_ account: Instance.Account) {
|
||||
self.account = account
|
||||
|
||||
configure()
|
||||
}
|
||||
|
||||
func configure() {
|
||||
configure {
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("streams/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Stream] in
|
||||
self.extractStreams(content)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractStreams(_ content: Entity<JSON>) -> [Stream] {
|
||||
var streams = [Stream]()
|
||||
|
||||
if let hlsURL = content.json.dictionaryValue["hls"]?.url {
|
||||
streams.append(Stream(hlsURL: hlsURL))
|
||||
}
|
||||
|
||||
guard let audioStream = compatibleAudioStreams(content).first else {
|
||||
return streams
|
||||
}
|
||||
|
||||
let videoStreams = compatibleVideoStream(content)
|
||||
|
||||
videoStreams.forEach { videoStream in
|
||||
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
|
||||
let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!)
|
||||
|
||||
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true
|
||||
let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue)
|
||||
|
||||
if videoOnly {
|
||||
streams.append(
|
||||
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive)
|
||||
)
|
||||
} else {
|
||||
streams.append(
|
||||
SingleAssetStream(avAsset: videoAsset, resolution: resolution, kind: .stream)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
||||
|
||||
private func compatibleAudioStreams(_ content: Entity<JSON>) -> [JSON] {
|
||||
content
|
||||
.json
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
|
||||
} ?? []
|
||||
}
|
||||
|
||||
private func compatibleVideoStream(_ content: Entity<JSON>) -> [JSON] {
|
||||
content
|
||||
.json
|
||||
.dictionaryValue["videoStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(path)"
|
||||
}
|
||||
|
||||
func streams(id: Video.ID) -> Resource {
|
||||
resource(baseURL: account.instance.url, path: "streams/\(id)")
|
||||
}
|
||||
}
|
@ -5,6 +5,8 @@ import Logging
|
||||
#if !os(macOS)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -10,5 +10,4 @@ struct PlayerQueueItem: Hashable, Identifiable {
|
||||
}
|
||||
|
||||
var playerItems = [AVPlayerItem]()
|
||||
var compositions = [Stream: AVMutableComposition]()
|
||||
}
|
||||
|
100
Model/PlayerStreams.swift
Normal file
100
Model/PlayerStreams.swift
Normal file
@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import Siesta
|
||||
|
||||
extension PlayerModel {
|
||||
var isLoadingAvailableStreams: Bool {
|
||||
streamSelection.isNil || availableStreams.isEmpty
|
||||
}
|
||||
|
||||
var isLoadingStream: Bool {
|
||||
!stream.isNil && stream != streamSelection
|
||||
}
|
||||
|
||||
func loadAvailableStreams(
|
||||
_ video: Video,
|
||||
completionHandler: @escaping ([Stream]) -> Void = { _ in }
|
||||
) {
|
||||
availableStreams = []
|
||||
var instancesWithLoadedStreams = [Instance]()
|
||||
|
||||
instances.all.forEach { instance in
|
||||
switch instance.app {
|
||||
case .piped:
|
||||
fetchPipedStreams(instance, video: video) { _ in
|
||||
self.completeIfAllInstancesLoaded(
|
||||
instance: instance,
|
||||
streams: self.availableStreams,
|
||||
instancesWithLoadedStreams: &instancesWithLoadedStreams,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
|
||||
case .invidious:
|
||||
fetchInvidiousStreams(instance, video: video) { _ in
|
||||
self.completeIfAllInstancesLoaded(
|
||||
instance: instance,
|
||||
streams: self.availableStreams,
|
||||
instancesWithLoadedStreams: &instancesWithLoadedStreams,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchInvidiousStreams(
|
||||
_ instance: Instance,
|
||||
video: Video,
|
||||
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
|
||||
) {
|
||||
invidious(instance)
|
||||
.video(video.videoID)
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
if let video: Video = response.typedContent() {
|
||||
self.availableStreams += self.streamsWithAssetsFromInstance(instance: instance, streams: video.streams)
|
||||
}
|
||||
}
|
||||
.onCompletion(onCompletion)
|
||||
}
|
||||
|
||||
private func fetchPipedStreams(
|
||||
_ instance: Instance,
|
||||
video: Video,
|
||||
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
|
||||
) {
|
||||
piped(instance)
|
||||
.streams(id: video.videoID)
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
if let pipedStreams: [Stream] = response.typedContent() {
|
||||
self.availableStreams += self.streamsWithInstance(instance: instance, streams: pipedStreams)
|
||||
}
|
||||
}
|
||||
.onCompletion(onCompletion)
|
||||
}
|
||||
|
||||
private func completeIfAllInstancesLoaded(
|
||||
instance: Instance,
|
||||
streams: [Stream],
|
||||
instancesWithLoadedStreams: inout [Instance],
|
||||
completionHandler: @escaping ([Stream]) -> Void
|
||||
) {
|
||||
instancesWithLoadedStreams.append(instance)
|
||||
|
||||
if instances.all.count == instancesWithLoadedStreams.count {
|
||||
completionHandler(streams.sorted { $0.kind < $1.kind })
|
||||
}
|
||||
}
|
||||
|
||||
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
|
||||
streams.map { stream in
|
||||
stream.instance = instance
|
||||
return stream
|
||||
}
|
||||
}
|
||||
|
||||
func streamsWithAssetsFromInstance(instance: Instance, streams: [Stream]) -> [Stream] {
|
||||
streams.map { stream in stream.withAssetsFrom(instance) }
|
||||
}
|
||||
}
|
@ -4,10 +4,15 @@ import SwiftUI
|
||||
|
||||
final class PlaylistsModel: ObservableObject {
|
||||
@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
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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] {
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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 */,
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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")"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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"))
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -4,9 +4,9 @@ import SwiftUI
|
||||
struct VideoDetailsPaddingModifier: ViewModifier {
|
||||
static var defaultAdditionalDetailsPadding: Double {
|
||||
#if os(macOS)
|
||||
20
|
||||
30
|
||||
#else
|
||||
35
|
||||
40
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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) {
|
||||
|
5
Shared/SiestaConfiguration.swift
Normal file
5
Shared/SiestaConfiguration.swift
Normal file
@ -0,0 +1,5 @@
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
let SwiftyJSONTransformer =
|
||||
ResponseContentTransformer(transformErrors: true) { JSON($0.content as AnyObject) }
|
@ -11,14 +11,14 @@ struct TrendingView: View {
|
||||
|
||||
@State private var presentingCountrySelection = false
|
||||
|
||||
@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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user