mirror of
https://github.com/yattee/yattee.git
synced 2025-08-06 10:44:06 +00:00
Settings for iOS/macOS
This commit is contained in:
146
Model/Instance.swift
Normal file
146
Model/Instance.swift
Normal 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)
|
||||
}
|
||||
}
|
108
Model/InstanceAccountValidator.swift
Normal file
108
Model/InstanceAccountValidator.swift
Normal 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")
|
||||
}
|
||||
}
|
51
Model/InstancesModel.swift
Normal file
51
Model/InstancesModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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())
|
||||
}
|
||||
|
||||
|
@@ -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
|
@@ -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?
|
@@ -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()
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
35
Model/PlaylistsModel.swift
Normal file
35
Model/PlaylistsModel.swift
Normal 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 = []
|
||||
}
|
||||
}
|
||||
}
|
@@ -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)!
|
||||
}
|
||||
}
|
||||
}
|
@@ -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"],
|
||||
|
@@ -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
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
53
Model/SubscriptionsModel.swift
Normal file
53
Model/SubscriptionsModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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? {
|
||||
|
Reference in New Issue
Block a user