Settings for iOS/macOS

This commit is contained in:
Arkadiusz Fal
2021-09-25 10:18:22 +02:00
parent 433725c5e8
commit a7da3b9468
64 changed files with 1998 additions and 665 deletions

146
Model/Instance.swift Normal file
View File

@@ -0,0 +1,146 @@
import Defaults
import Foundation
struct Instance: Defaults.Serializable, Hashable, Identifiable {
struct Account: Defaults.Serializable, Hashable, Identifiable {
static var bridge = AccountsBridge()
let id: UUID?
var name: String?
let url: String
let sid: String
init(id: UUID? = nil, name: String? = nil, url: String, sid: String) {
self.id = id ?? UUID()
self.name = name
self.url = url
self.sid = sid
}
var anonymizedSID: String {
guard sid.count > 3 else {
return ""
}
let index = sid.index(sid.startIndex, offsetBy: 4)
return String(sid[..<index])
}
var description: String {
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
}
func hash(into hasher: inout Hasher) {
hasher.combine(sid)
}
}
struct AccountsBridge: Defaults.Bridge {
typealias Value = Account
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value = value else {
return nil
}
return [
"id": value.id?.uuidString ?? "",
"name": value.name ?? "",
"url": value.url,
"sid": value.sid
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let url = object["url"],
let sid = object["sid"]
else {
return nil
}
let name = object["name"] ?? ""
return Account(name: name, url: url, sid: sid)
}
}
static var bridge = InstancesBridge()
let id: UUID?
let name: String
let url: String
var accounts = [Account]()
init(id: UUID? = nil, name: String, url: String, accounts: [Account] = []) {
self.id = id ?? UUID()
self.name = name
self.url = url
self.accounts = accounts
}
var description: String {
name.isEmpty ? url : "\(name) (\(url))"
}
var shortDescription: String {
name.isEmpty ? url : name
}
var anonymousAccount: Account {
Account(name: "Anonymous", url: url, sid: "")
}
struct InstancesBridge: Defaults.Bridge {
typealias Value = Instance
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value = value else {
return nil
}
return [
"id": value.id?.uuidString ?? "",
"name": value.name,
"url": value.url,
"accounts": value.accounts.map { "\($0.id!):\($0.name ?? ""):\($0.sid)" }.joined(separator: ";")
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let id = object["id"],
let url = object["url"]
else {
return nil
}
let name = object["name"] ?? ""
let accounts = object["accounts"] ?? ""
let uuid = UUID(uuidString: id)
var instance = Instance(id: uuid, name: name, url: url)
accounts.split(separator: ";").forEach { sid in
let components = sid.components(separatedBy: ":")
let id = components[0]
let name = components[1]
let sid = components[2]
let uuid = UUID(uuidString: id)
instance.accounts.append(Account(id: uuid, name: name, url: instance.url, sid: sid))
}
return instance
}
}
func hash(into hasher: inout Hasher) {
hasher.combine(url)
}
}

View File

@@ -0,0 +1,108 @@
import Foundation
import Siesta
import SwiftUI
final class InstanceAccountValidator: Service {
let url: String
let account: Instance.Account?
var formObjectID: Binding<String>
var valid: Binding<Bool>
var validated: Binding<Bool>
var error: Binding<String?>?
init(
url: String,
account: Instance.Account? = nil,
formObjectID: Binding<String>,
valid: Binding<Bool>,
validated: Binding<Bool>,
error: Binding<String?>? = nil
) {
self.url = url
self.account = account
self.formObjectID = formObjectID
self.valid = valid
self.validated = validated
self.error = error
super.init(baseURL: url)
configure()
}
func configure() {
configure("/api/v1/auth/feed", requestMethods: [.get]) {
guard self.account != nil else {
return
}
$0.headers["Cookie"] = self.cookieHeader
}
}
func validateInstance() {
reset()
stats
.load()
.onSuccess { _ in
guard self.url == self.formObjectID.wrappedValue else {
return
}
self.valid.wrappedValue = true
self.error?.wrappedValue = nil
self.validated.wrappedValue = true
}
.onFailure { error in
guard self.url == self.formObjectID.wrappedValue else {
return
}
self.valid.wrappedValue = false
self.error?.wrappedValue = error.userMessage
self.validated.wrappedValue = true
}
}
func validateAccount() {
reset()
feed
.load()
.onSuccess { _ in
guard self.account!.sid == self.formObjectID.wrappedValue else {
return
}
self.valid.wrappedValue = true
self.validated.wrappedValue = true
}
.onFailure { _ in
guard self.account!.sid == self.formObjectID.wrappedValue else {
return
}
self.valid.wrappedValue = false
self.validated.wrappedValue = true
}
}
func reset() {
valid.wrappedValue = false
validated.wrappedValue = false
error?.wrappedValue = nil
}
var cookieHeader: String {
"SID=\(account!.sid)"
}
var stats: Resource {
resource("/api/v1/stats")
}
var feed: Resource {
resource("/api/v1/auth/feed")
}
}

View File

@@ -0,0 +1,51 @@
import Defaults
import Foundation
final class InstancesModel: ObservableObject {
var defaultAccount: Instance.Account! {
Defaults[.instances].first?.accounts.first
}
func find(_ id: Instance.ID?) -> Instance? {
guard id != nil else {
return nil
}
return Defaults[.instances].first { $0.id == id }
}
func accounts(_ id: Instance.ID?) -> [Instance.Account] {
find(id)?.accounts ?? []
}
func add(name: String, url: String) -> Instance {
let instance = Instance(name: name, url: url)
Defaults[.instances].append(instance)
return instance
}
func remove(_ instance: Instance) {
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
Defaults[.instances].remove(at: index)
}
}
func addAccount(instance: Instance, name: String, sid: String) -> Instance.Account {
let account = Instance.Account(name: name, url: instance.url, sid: sid)
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
Defaults[.instances][index].accounts.append(account)
}
return account
}
func removeAccount(instance: Instance, account: Instance.Account) {
if let instanceIndex = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
if let accountIndex = Defaults[.instances][instanceIndex].accounts.firstIndex(where: { $0.id == account.id }) {
Defaults[.instances][instanceIndex].accounts.remove(at: accountIndex)
}
}
}
}

View File

@@ -3,55 +3,108 @@ import Foundation
import Siesta
import SwiftyJSON
final class InvidiousAPI: Service {
static let shared = InvidiousAPI()
final class InvidiousAPI: Service, ObservableObject {
static let basePath = "/api/v1"
static let instance = "https://invidious.home.arekf.net"
@Published var account: Instance.Account!
static func proxyURLForAsset(_ url: String) -> URL? {
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
}
@Published var validInstance = true
@Published var signedIn = true
init() {
super.init()
#if os(tvOS)
// TODO: remove
setAccount(.init(id: UUID(), name: "", url: "https://invidious.home.arekf.net", sid: "RpoS7YPPK2-QS81jJF9z4KSQAjmzsOnMpn84c73-GQ8="))
#endif
}
func setAccount(_ account: Instance.Account) {
self.account = account
validInstance = false
signedIn = false
configure()
validate()
}
func validate() {
validateInstance()
validateSID()
}
func validateInstance() {
guard !validInstance else {
return
}
home
.load()
.onSuccess { _ in
self.validInstance = true
}
.onFailure { _ in
self.validInstance = false
}
}
func validateSID() {
guard !signedIn else {
return
}
feed
.load()
.onSuccess { _ in
self.signedIn = true
}
.onFailure { _ in
self.signedIn = false
}
}
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) }
super.init(baseURL: "\(InvidiousAPI.instance)/api/v1")
configure {
$0.headers["Cookie"] = self.cookieHeader
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configure(requestMethods: [.get]) {
$0.headers["Cookie"] = self.authHeader
}
configure("**", requestMethods: [.post]) {
$0.headers["Cookie"] = self.authHeader
$0.pipeline[.parsing].removeTransformers()
}
configureTransformer("/popular", requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(Video.init)
}
configureTransformer("/trending", requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(Video.init)
}
configureTransformer("/search", requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(Video.init)
}
configureTransformer("/search/suggestions", requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
if let suggestions = content.json.dictionaryValue["suggestions"] {
return suggestions.arrayValue.map(String.init)
}
@@ -59,20 +112,20 @@ final class InvidiousAPI: Service {
return []
}
configureTransformer("/auth/playlists", requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
content.json.arrayValue.map(Playlist.init)
}
configureTransformer("/auth/playlists/*", requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
Playlist(content.json)
}
configureTransformer("/auth/playlists", requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
// hacky, to verify if possible to get it in easier way
Playlist(JSON(parseJSON: String(data: content.content, encoding: .utf8)!))
}
configureTransformer("/auth/feed", requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
if let feedVideos = content.json.dictionaryValue["videos"] {
return feedVideos.arrayValue.map(Video.init)
}
@@ -80,69 +133,83 @@ final class InvidiousAPI: Service {
return []
}
configureTransformer("/auth/subscriptions", requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.map(Channel.init)
}
configureTransformer("/channels/*", requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
Channel(json: content.json)
}
configureTransformer("/channels/*/latest", requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(Video.init)
}
configureTransformer("/videos/*", requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
Video(content.json)
}
}
var authHeader: String? = "SID=\(Profile().sid)"
fileprivate func pathPattern(_ path: String) -> String {
"**\(InvidiousAPI.basePath)/\(path)"
}
fileprivate func basePathAppending(_ path: String) -> String {
"\(InvidiousAPI.basePath)/\(path)"
}
private var cookieHeader: String {
"SID=\(account.sid)"
}
var popular: Resource {
resource("/popular")
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/popular")
}
func trending(category: TrendingCategory, country: Country) -> Resource {
resource("/trending")
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/trending")
.withParam("type", category.name)
.withParam("region", country.rawValue)
}
var home: Resource {
resource(baseURL: InvidiousAPI.instance, path: "/feed/subscriptions")
resource(baseURL: account.url, path: "/feed/subscriptions")
}
var feed: Resource {
resource("/auth/feed")
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/auth/feed")
}
var stats: Resource {
resource(baseURL: account.url, path: basePathAppending("stats"))
}
var subscriptions: Resource {
resource("/auth/subscriptions")
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
}
func channelSubscription(_ id: String) -> Resource {
resource("/auth/subscriptions").child(id)
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")).child(id)
}
func channel(_ id: String) -> Resource {
resource("/channels/\(id)")
resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
}
func channelVideos(_ id: String) -> Resource {
resource("/channels/\(id)/latest")
resource(baseURL: account.url, path: basePathAppending("channels/\(id)/latest"))
}
func video(_ id: String) -> Resource {
resource("/videos/\(id)")
resource(baseURL: account.url, path: basePathAppending("videos/\(id)"))
}
var playlists: Resource {
resource("/auth/playlists")
resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
}
func playlist(_ id: String) -> Resource {
resource("/auth/playlists/\(id)")
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
}
func playlistVideos(_ id: String) -> Resource {
@@ -154,7 +221,7 @@ final class InvidiousAPI: Service {
}
func search(_ query: SearchQuery) -> Resource {
var resource = resource("/search")
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
.withParam("q", searchQuery(query.query))
.withParam("sort_by", query.sortBy.parameter)
@@ -170,7 +237,7 @@ final class InvidiousAPI: Service {
}
func searchSuggestions(query: String) -> Resource {
resource("/search/suggestions")
resource(baseURL: account.url, path: basePathAppending("search/suggestions"))
.withParam("q", query.lowercased())
}

View File

@@ -1,7 +1,7 @@
import Foundation
import SwiftUI
final class NavigationState: ObservableObject {
final class NavigationModel: ObservableObject {
enum TabSelection: Hashable {
case watchNow, subscriptions, popular, trending, playlists, channel(String), playlist(String), recentlyOpened(String), search
}
@@ -22,6 +22,8 @@ final class NavigationState: ObservableObject {
@Published var isChannelOpen = false
@Published var sidebarSectionChanged = false
@Published var presentingSettings = false
func playVideo(_ video: Video) {
self.video = video
showingVideo = true
@@ -56,4 +58,4 @@ final class NavigationState: ObservableObject {
}
}
typealias TabSelection = NavigationState.TabSelection
typealias TabSelection = NavigationModel.TabSelection

View File

@@ -1,7 +1,7 @@
import CoreMedia
import Foundation
final class PlaybackState: ObservableObject {
final class PlaybackModel: ObservableObject {
@Published var live = false
@Published var stream: Stream?
@Published var time: CMTime?

View File

@@ -5,7 +5,7 @@ import Logging
import UIKit
#endif
final class PlayerState: ObservableObject {
final class PlayerModel: ObservableObject {
let logger = Logger(label: "net.arekf.Pearvidious.ps")
var video: Video!
@@ -19,17 +19,19 @@ final class PlayerState: ObservableObject {
private(set) var currentRate: Float = 0.0
static let availableRates: [Double] = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
var playbackState: PlaybackState
var api: InvidiousAPI
var playback: PlaybackModel
var timeObserver: Any?
let maxResolution: Stream.Resolution?
let resolution: Stream.ResolutionSetting?
var playingOutsideViewController = false
init(_ video: Video? = nil, playbackState: PlaybackState, maxResolution: Stream.Resolution? = nil) {
init(_ video: Video? = nil, playback: PlaybackModel, api: InvidiousAPI, resolution: Stream.ResolutionSetting? = nil) {
self.video = video
self.playbackState = playbackState
self.maxResolution = maxResolution
self.playback = playback
self.api = api
self.resolution = resolution
}
deinit {
@@ -41,7 +43,7 @@ final class PlayerState: ObservableObject {
return
}
playbackState.reset()
playback.reset()
loadExtendedVideoDetails(video) { video in
self.video = video
@@ -54,22 +56,26 @@ final class PlayerState: ObservableObject {
return
}
InvidiousAPI.shared.video(video!.id).load().onSuccess { response in
api.video(video!.id).load().onSuccess { response in
if let video: Video = response.typedContent() {
onSuccess(video)
}
}
}
var requestedResolution: Bool {
resolution != nil && resolution != .hd720pFirstThenBest
}
fileprivate func playVideo(_ video: Video) {
playbackState.live = video.live
playback.live = video.live
if video.live {
playHlsUrl()
return
}
let stream = maxResolution != nil ? video.streamWithResolution(maxResolution!) : video.defaultStream
let stream = requestedResolution ? video.streamWithResolution(resolution!.value) : video.defaultStream
guard stream != nil else {
return
@@ -78,7 +84,7 @@ final class PlayerState: ObservableObject {
Task {
await self.loadStream(stream!)
if stream != video.bestStream {
if resolution == .hd720pFirstThenBest {
await self.loadBestStream()
}
}
@@ -91,9 +97,7 @@ final class PlayerState: ObservableObject {
fileprivate func loadStream(_ stream: Stream) async {
if stream.oneMeaningfullAsset {
DispatchQueue.main.async {
self.playStream(stream)
}
playStream(stream)
return
} else {
@@ -111,11 +115,11 @@ final class PlayerState: ObservableObject {
DispatchQueue.main.async {
self.saveTime()
self.player?.replaceCurrentItem(with: self.playerItemWithMetadata(for: stream))
self.playbackState.stream = stream
self.playback.stream = stream
if self.timeObserver == nil {
self.addTimeObserver()
}
self.player?.playImmediately(atRate: 1.0)
self.player?.play()
self.seekToSavedTime()
}
}
@@ -267,7 +271,7 @@ final class PlayerState: ObservableObject {
self.player.rate = self.currentRate
}
self.playbackState.time = self.player.currentTime()
self.playback.time = self.player.currentTime()
}
}

View File

@@ -1,35 +0,0 @@
import Foundation
import Siesta
import SwiftUI
final class Playlists: ObservableObject {
@Published var playlists = [Playlist]()
var resource: Resource {
InvidiousAPI.shared.playlists
}
init() {
load()
}
var all: [Playlist] {
playlists.sorted { $0.title.lowercased() < $1.title.lowercased() }
}
func find(id: Playlist.ID) -> Playlist? {
all.first { $0.id == id }
}
func reload() {
load()
}
fileprivate func load() {
resource.load().onSuccess { resource in
if let playlists: [Playlist] = resource.typedContent() {
self.playlists = playlists
}
}
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
import Siesta
import SwiftUI
final class PlaylistsModel: ObservableObject {
@Published var playlists = [Playlist]()
@Published var api: InvidiousAPI!
var resource: Resource {
api.playlists
}
var all: [Playlist] {
playlists.sorted { $0.title.lowercased() < $1.title.lowercased() }
}
func find(id: Playlist.ID) -> Playlist? {
all.first { $0.id == id }
}
func load(force: Bool = false) {
let request = force ? resource.load() : resource.loadIfNeeded()
request?
.onSuccess { resource in
if let playlists: [Playlist] = resource.typedContent() {
self.playlists = playlists
}
}
.onFailure { _ in
self.playlists = []
}
}
}

View File

@@ -1,25 +0,0 @@
import Defaults
import Foundation
struct Profile {
var defaultStreamResolution: DefaultStreamResolution = .hd1080p
var skippedSegmentsCategories = [String]() // SponsorBlockSegmentsProvider.categories
var sid = "RpoS7YPPK2-QS81jJF9z4KSQAjmzsOnMpn84c73-GQ8="
var cellsColumns = 3
}
enum DefaultStreamResolution: String {
case hd720pFirstThenBest, hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p
var value: Stream.Resolution {
switch self {
case .hd720pFirstThenBest:
return .hd720p
default:
return Stream.Resolution(rawValue: rawValue)!
}
}
}

View File

@@ -103,7 +103,7 @@ struct RecentItemBridge: Defaults.Bridge {
]
}
func deserialize(_ object: Serializable?) -> RecentItem? {
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let type = object["type"],

View File

@@ -2,30 +2,23 @@ import Defaults
import Siesta
import SwiftUI
final class SearchState: ObservableObject {
final class SearchModel: ObservableObject {
@Published var store = Store<[Video]>()
@Published var api: InvidiousAPI!
@Published var query = SearchQuery()
@Published var queryText = ""
@Published var querySuggestions = Store<[String]>()
private var previousResource: Resource?
private var resource: Resource!
init() {
let newQuery = query
query = newQuery
resource = InvidiousAPI.shared.search(newQuery)
}
var isLoading: Bool {
resource.isLoading
}
func loadQuerySuggestions(_ query: String) {
let resource = InvidiousAPI.shared.searchSuggestions(query: query)
func loadSuggestions(_ query: String) {
let resource = api.searchSuggestions(query: query)
resource.addObserver(querySuggestions)
resource.loadIfNeeded()
@@ -44,7 +37,7 @@ final class SearchState: ObservableObject {
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
changeHandler(query)
let newResource = InvidiousAPI.shared.search(query)
let newResource = api.search(query)
guard newResource != previousResource else {
return
}
@@ -60,7 +53,7 @@ final class SearchState: ObservableObject {
func resetQuery(_ query: SearchQuery) {
self.query = query
let newResource = InvidiousAPI.shared.search(query)
let newResource = api.search(query)
guard newResource != previousResource else {
return
}

View File

@@ -1,9 +1,32 @@
import AVFoundation
import Defaults
import Foundation
// swiftlint:disable:next final_class
class Stream: Equatable, Hashable {
enum Resolution: String, CaseIterable, Comparable {
enum ResolutionSetting: String, Defaults.Serializable, CaseIterable {
case hd720pFirstThenBest, hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p
var value: Stream.Resolution {
switch self {
case .hd720pFirstThenBest:
return .hd720p
default:
return Stream.Resolution(rawValue: rawValue)!
}
}
var description: String {
switch self {
case .hd720pFirstThenBest:
return "Default: adaptive"
default:
return "\(value.height)p".replacingOccurrences(of: " ", with: "")
}
}
}
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
case hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p
var height: Int {

View File

@@ -1,46 +0,0 @@
import Foundation
import Siesta
import SwiftUI
final class Subscriptions: ObservableObject {
@Published var channels = [Channel]()
var resource: Resource {
InvidiousAPI.shared.subscriptions
}
init() {
load()
}
var all: [Channel] {
channels.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performChannelSubscriptionRequest(channelID, method: .post, onSuccess: onSuccess)
}
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performChannelSubscriptionRequest(channelID, method: .delete, onSuccess: onSuccess)
}
func isSubscribing(_ channelID: String) -> Bool {
channels.contains { $0.id == channelID }
}
fileprivate func load(onSuccess: @escaping () -> Void = {}) {
resource.load().onSuccess { resource in
if let channels: [Channel] = resource.typedContent() {
self.channels = channels
onSuccess()
}
}
}
fileprivate func performChannelSubscriptionRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) {
InvidiousAPI.shared.channelSubscription(channelID).request(method).onCompletion { _ in
self.load(onSuccess: onSuccess)
}
}
}

View File

@@ -0,0 +1,53 @@
import Foundation
import Siesta
import SwiftUI
final class SubscriptionsModel: ObservableObject {
@Published var channels = [Channel]()
@Published var api: InvidiousAPI!
var resource: Resource {
api.subscriptions
}
init(api: InvidiousAPI? = nil) {
self.api = api
}
var all: [Channel] {
channels.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performRequest(channelID, method: .post, onSuccess: onSuccess)
}
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performRequest(channelID, method: .delete, onSuccess: onSuccess)
}
func isSubscribing(_ channelID: String) -> Bool {
channels.contains { $0.id == channelID }
}
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
let request = force ? resource.load() : resource.loadIfNeeded()
request?
.onSuccess { resource in
if let channels: [Channel] = resource.typedContent() {
self.channels = channels
onSuccess()
}
}
.onFailure { _ in
self.channels = []
}
}
fileprivate func performRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) {
api.channelSubscription(channelID).request(method).onCompletion { _ in
self.load(force: true, onSuccess: onSuccess)
}
}
}

View File

@@ -160,11 +160,7 @@ struct Video: Identifiable, Equatable {
}
func streamWithResolution(_ resolution: Stream.Resolution) -> Stream? {
selectableStreams.first { $0.resolution == resolution }
}
func defaultStreamForProfile(_ profile: Profile) -> Stream? {
streamWithResolution(profile.defaultStreamResolution.value) ?? streams.first
selectableStreams.first { $0.resolution == resolution } ?? defaultStream
}
func thumbnailURL(quality: Thumbnail.Quality) -> URL? {