Extended Piped support

This commit is contained in:
Arkadiusz Fal 2021-10-21 00:21:50 +02:00
parent 2d075e7b3a
commit c3326a56af
46 changed files with 706 additions and 458 deletions

View File

@ -14,23 +14,6 @@ extension Thumbnail {
}
private static func fixtureUrl(videoId: String, quality: Thumbnail.Quality) -> URL {
URL(string: "\(fixturesHost)/vi/\(videoId)/\(filenameForQuality(quality)).jpg")!
}
private static func filenameForQuality(_ quality: Thumbnail.Quality) -> String {
switch quality {
case .high:
return "hqdefault"
case .medium:
return "mqdefault"
case .start:
return "1"
case .middle:
return "2"
case .end:
return "3"
default:
return quality.rawValue
}
URL(string: "\(fixturesHost)/vi/\(videoId)/\(quality.filename).jpg")!
}
}

79
Model/Account.swift Normal file
View File

@ -0,0 +1,79 @@
import Defaults
import Foundation
struct Account: Defaults.Serializable, Hashable, Identifiable {
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,
"instanceID": value.instanceID,
"name": value.name ?? "",
"url": value.url,
"sid": value.sid
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let id = object["id"],
let instanceID = object["instanceID"],
let url = object["url"],
let sid = object["sid"]
else {
return nil
}
let name = object["name"] ?? ""
return Account(id: id, instanceID: instanceID, name: name, url: url, sid: sid)
}
}
static var bridge = AccountsBridge()
let id: String
let instanceID: String
var name: String?
let url: String
let sid: String
let anonymous: Bool
init(id: String? = nil, instanceID: String? = nil, name: String? = nil, url: String? = nil, sid: String? = nil, anonymous: Bool = false) {
self.anonymous = anonymous
self.id = id ?? (anonymous ? "anonymous-\(instanceID!)" : UUID().uuidString)
self.instanceID = instanceID ?? UUID().uuidString
self.name = name
self.url = url ?? ""
self.sid = sid ?? ""
}
var instance: Instance {
Defaults[.instances].first { $0.id == instanceID }!
}
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)
}
}

View File

@ -3,9 +3,9 @@ import Siesta
import SwiftUI
final class AccountValidator: Service {
let app: Binding<Instance.App>
let app: Binding<VideosApp>
let url: String
let account: Instance.Account?
let account: Account?
var formObjectID: Binding<String>
var isValid: Binding<Bool>
@ -14,9 +14,9 @@ final class AccountValidator: Service {
var error: Binding<String?>?
init(
app: Binding<Instance.App>,
app: Binding<VideosApp>,
url: String,
account: Instance.Account? = nil,
account: Account? = nil,
id: Binding<String>,
isValid: Binding<Bool>,
isValidated: Binding<Bool>,

View File

@ -3,18 +3,18 @@ import Defaults
import Foundation
final class AccountsModel: ObservableObject {
@Published private(set) var current: Instance.Account!
@Published private(set) var current: Account!
@Published private(set) var invidious = InvidiousAPI()
@Published private(set) var piped = PipedAPI()
@Published private var invidious = InvidiousAPI()
@Published private var piped = PipedAPI()
private var cancellables = [AnyCancellable]()
var all: [Instance.Account] {
var all: [Account] {
Defaults[.accounts]
}
var lastUsed: Instance.Account? {
var lastUsed: Account? {
guard let id = Defaults[.lastAccountID] else {
return nil
}
@ -22,6 +22,14 @@ final class AccountsModel: ObservableObject {
return AccountsModel.find(id)
}
var app: VideosApp {
current?.instance.app ?? .invidious
}
var api: VideosAPI {
app == .piped ? piped : invidious
}
var isEmpty: Bool {
current.isNil
}
@ -40,7 +48,7 @@ final class AccountsModel: ObservableObject {
)
}
func setCurrent(_ account: Instance.Account! = nil) {
func setCurrent(_ account: Account! = nil) {
guard account != current else {
return
}
@ -62,18 +70,18 @@ final class AccountsModel: ObservableObject {
Defaults[.lastInstanceID] = account.instanceID
}
static func find(_ id: Instance.Account.ID) -> Instance.Account? {
static func find(_ id: Account.ID) -> Account? {
Defaults[.accounts].first { $0.id == id }
}
static func add(instance: Instance, name: String, sid: String) -> Instance.Account {
let account = Instance.Account(instanceID: instance.id, name: name, url: instance.url, sid: sid)
static func add(instance: Instance, name: String, sid: String) -> Account {
let account = Account(instanceID: instance.id, name: name, url: instance.url, sid: sid)
Defaults[.accounts].append(account)
return account
}
static func remove(_ account: Instance.Account) {
static func remove(_ account: Account) {
if let accountIndex = Defaults[.accounts].firstIndex(where: { $0.id == account.id }) {
Defaults[.accounts].remove(at: accountIndex)
}

View File

@ -2,125 +2,6 @@ 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()
let id: String
let instanceID: String
var name: String?
let url: String
let sid: String
let anonymous: Bool
init(id: String? = nil, instanceID: String? = nil, name: String? = nil, url: String? = nil, sid: String? = nil, anonymous: Bool = false) {
self.anonymous = anonymous
self.id = id ?? (anonymous ? "anonymous-\(instanceID!)" : UUID().uuidString)
self.instanceID = instanceID ?? UUID().uuidString
self.name = name
self.url = url ?? ""
self.sid = sid ?? ""
}
var instance: Instance {
Defaults[.instances].first { $0.id == instanceID }!
}
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,
"instanceID": value.instanceID,
"name": value.name ?? "",
"url": value.url,
"sid": value.sid
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let id = object["id"],
let instanceID = object["instanceID"],
let url = object["url"],
let sid = object["sid"]
else {
return nil
}
let name = object["name"] ?? ""
return Account(id: id, instanceID: instanceID, name: name, url: url, sid: sid)
}
}
}
static var bridge = InstancesBridge()
let app: App
let id: String
let name: String
let url: String
init(app: App, id: String? = nil, name: String, url: String) {
self.app = app
self.id = id ?? UUID().uuidString
self.name = name
self.url = url
}
var description: String {
"\(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: "", anonymous: true)
}
struct InstancesBridge: Defaults.Bridge {
typealias Value = Instance
typealias Serializable = [String: String]
@ -141,7 +22,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let app = App(rawValue: object["app"] ?? ""),
let app = VideosApp(rawValue: object["app"] ?? ""),
let id = object["id"],
let url = object["url"]
else {
@ -154,6 +35,45 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
}
}
static var bridge = InstancesBridge()
let app: VideosApp
let id: String
let name: String
let url: String
init(app: VideosApp, id: String? = nil, name: String, url: String) {
self.app = app
self.id = id ?? UUID().uuidString
self.name = name
self.url = url
}
var anonymous: VideosAPI {
switch app {
case .invidious:
return InvidiousAPI(account: anonymousAccount)
case .piped:
return PipedAPI(account: anonymousAccount)
}
}
var description: String {
"\(app.name) - \(shortDescription)"
}
var longDescription: String {
name.isEmpty ? "\(app.name) - \(url)" : "\(app.name) - \(name) (\(url))"
}
var shortDescription: String {
name.isEmpty ? url : name
}
var anonymousAccount: Account {
Account(instanceID: id, name: "Anonymous", url: url, anonymous: true)
}
func hash(into hasher: inout Hasher) {
hasher.combine(url)
}

View File

@ -22,11 +22,11 @@ final class InstancesModel: ObservableObject {
return Defaults[.instances].first { $0.id == id }
}
static func accounts(_ id: Instance.ID?) -> [Instance.Account] {
static func accounts(_ id: Instance.ID?) -> [Account] {
Defaults[.accounts].filter { $0.instanceID == id }
}
static func add(app: Instance.App, name: String, url: String) -> Instance {
static func add(app: VideosApp, name: String, url: String) -> Instance {
let instance = Instance(app: app, id: UUID().uuidString, name: name, url: url)
Defaults[.instances].append(instance)
@ -41,7 +41,7 @@ final class InstancesModel: ObservableObject {
}
}
static func setLastAccount(_ account: Instance.Account?) {
static func setLastAccount(_ account: Account?) {
Defaults[.lastAccountID] = account?.id
}
}

View File

@ -3,15 +3,15 @@ import Foundation
import Siesta
import SwiftyJSON
final class InvidiousAPI: Service, ObservableObject {
final class InvidiousAPI: Service, ObservableObject, VideosAPI {
static let basePath = "/api/v1"
@Published var account: Instance.Account!
@Published var account: Account!
@Published var validInstance = true
@Published var signedIn = false
init(account: Instance.Account? = nil) {
init(account: Account? = nil) {
super.init()
guard !account.isNil else {
@ -22,7 +22,7 @@ final class InvidiousAPI: Service, ObservableObject {
setAccount(account!)
}
func setAccount(_ account: Instance.Account) {
func setAccount(_ account: Account) {
self.account = account
validInstance = false
@ -42,7 +42,7 @@ final class InvidiousAPI: Service, ObservableObject {
return
}
home
home?
.load()
.onSuccess { _ in
self.validInstance = true
@ -57,7 +57,7 @@ final class InvidiousAPI: Service, ObservableObject {
return
}
feed
feed?
.load()
.onSuccess { _ in
self.signedIn = true
@ -149,29 +149,29 @@ final class InvidiousAPI: Service, ObservableObject {
"SID=\(account.sid)"
}
var popular: Resource {
var popular: Resource? {
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/popular")
}
func trending(category: TrendingCategory, country: Country) -> Resource {
func trending(country: Country, category: TrendingCategory?) -> Resource {
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/trending")
.withParam("type", category.name)
.withParam("type", category!.name)
.withParam("region", country.rawValue)
}
var home: Resource {
var home: Resource? {
resource(baseURL: account.url, path: "/feed/subscriptions")
}
var feed: Resource {
var feed: Resource? {
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/auth/feed")
}
var subscriptions: Resource {
var subscriptions: Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
}
func channelSubscription(_ id: String) -> Resource {
func channelSubscription(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")).child(id)
}
@ -187,20 +187,20 @@ final class InvidiousAPI: Service, ObservableObject {
resource(baseURL: account.url, path: basePathAppending("videos/\(id)"))
}
var playlists: Resource {
var playlists: Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
}
func playlist(_ id: String) -> Resource {
func playlist(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
}
func playlistVideos(_ id: String) -> Resource {
playlist(id).child("videos")
func playlistVideos(_ id: String) -> Resource? {
playlist(id)?.child("videos")
}
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource {
playlist(playlistID).child("videos").child(videoID)
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? {
playlist(playlistID)?.child("videos").child(videoID)
}
func search(_ query: SearchQuery) -> Resource {

View File

@ -3,14 +3,14 @@ import Foundation
import Siesta
import SwiftyJSON
final class PipedAPI: Service, ObservableObject {
@Published var account: Instance.Account!
final class PipedAPI: Service, ObservableObject, VideosAPI {
@Published var account: Account!
var anonymousAccount: Instance.Account {
var anonymousAccount: Account {
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.url)
}
init(account: Instance.Account? = nil) {
init(account: Account? = nil) {
super.init()
guard account != nil else {
@ -20,7 +20,7 @@ final class PipedAPI: Service, ObservableObject {
setAccount(account!)
}
func setAccount(_ account: Instance.Account) {
func setAccount(_ account: Account) {
self.account = account
configure()
@ -31,15 +31,128 @@ final class PipedAPI: Service, ObservableObject {
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configureTransformer(pathPattern("streams/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Stream] in
self.extractStreams(content)
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
self.extractChannel(content.json)
}
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
self.extractVideo(content.json)
}
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
self.extractVideos(content.json)
}
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [Video] in
self.extractVideos(content.json.dictionaryValue["items"]!)
}
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
content.json.arrayValue.map(String.init)
}
}
private func extractStreams(_ content: Entity<JSON>) -> [Stream] {
private func extractChannel(_ content: JSON) -> Channel? {
Channel(
id: content.dictionaryValue["id"]!.stringValue,
name: content.dictionaryValue["name"]!.stringValue,
subscriptionsCount: content.dictionaryValue["subscriberCount"]!.intValue,
videos: extractVideos(content.dictionaryValue["relatedStreams"]!)
)
}
private func extractVideo(_ content: JSON) -> Video? {
let details = content.dictionaryValue
let url = details["url"]?.string
if !url.isNil {
guard url!.contains("/watch") else {
return nil
}
}
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
if let url = buildThumbnailURL(content, quality: $0) {
return Thumbnail(url: url, quality: $0)
}
return nil
}
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
return Video(
videoID: extractID(content),
title: details["title"]!.stringValue,
author: author,
length: details["duration"]!.doubleValue,
published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue,
views: details["views"]!.intValue,
description: extractDescription(content),
channel: Channel(id: channelId, name: author),
thumbnails: thumbnails,
likes: details["likes"]?.int,
dislikes: details["dislikes"]?.int,
streams: extractStreams(content)
)
}
private func extractID(_ content: JSON) -> Video.ID {
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
extractThumbnailURL(content)!.relativeString.components(separatedBy: "/")[4]
}
private func extractThumbnailURL(_ content: JSON) -> URL? {
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
}
private func buildThumbnailURL(_ content: JSON, quality: Thumbnail.Quality) -> URL? {
let thumbnailURL = extractThumbnailURL(content)
guard !thumbnailURL.isNil else {
return nil
}
return URL(string: thumbnailURL!
.absoluteString
.replacingOccurrences(of: "_webp", with: "")
.replacingOccurrences(of: ".webp", with: ".jpg")
.replacingOccurrences(of: "hqdefault", with: quality.filename)
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
)!
}
private func extractDescription(_ content: JSON) -> String? {
guard var description = content.dictionaryValue["description"]?.string else {
return nil
}
description = description.replacingOccurrences(
of: "<br/>|<br />|<br>",
with: "\n",
options: .regularExpression,
range: nil
)
description = description.replacingOccurrences(
of: "<[^>]+>",
with: "",
options: .regularExpression,
range: nil
)
return description
}
private func extractVideos(_ content: JSON) -> [Video] {
content.arrayValue.compactMap(extractVideo(_:))
}
private func extractStreams(_ content: JSON) -> [Stream] {
var streams = [Stream]()
if let hlsURL = content.json.dictionaryValue["hls"]?.url {
if let hlsURL = content.dictionaryValue["hls"]?.url {
streams.append(Stream(hlsURL: hlsURL))
}
@ -70,9 +183,8 @@ final class PipedAPI: Service, ObservableObject {
return streams
}
private func compatibleAudioStreams(_ content: Entity<JSON>) -> [JSON] {
private func compatibleAudioStreams(_ content: JSON) -> [JSON] {
content
.json
.dictionaryValue["audioStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
@ -81,19 +193,51 @@ final class PipedAPI: Service, ObservableObject {
} ?? []
}
private func compatibleVideoStream(_ content: Entity<JSON>) -> [JSON] {
private func compatibleVideoStream(_ content: JSON) -> [JSON] {
content
.json
.dictionaryValue["videoStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
}
func channel(_ id: String) -> Resource {
resource(baseURL: account.url, path: "channel/\(id)")
}
func trending(country: Country, category _: TrendingCategory? = nil) -> Resource {
resource(baseURL: account.instance.url, path: "trending")
.withParam("region", country.rawValue)
}
func search(_ query: SearchQuery) -> Resource {
resource(baseURL: account.instance.url, path: "search")
.withParam("q", query.query)
.withParam("filter", "")
}
func searchSuggestions(query: String) -> Resource {
resource(baseURL: account.instance.url, path: "suggestions")
.withParam("query", query.lowercased())
}
func video(_ id: Video.ID) -> Resource {
resource(baseURL: account.instance.url, path: "streams/\(id)")
}
var signedIn: Bool { false }
var subscriptions: Resource? { nil }
var feed: Resource? { nil }
var home: Resource? { nil }
var popular: Resource? { nil }
var playlists: Resource? { nil }
func channelSubscription(_: String) -> Resource? { nil }
func playlistVideo(_: String, _: String) -> Resource? { nil }
func playlistVideos(_: String) -> Resource? { nil }
private func pathPattern(_ path: String) -> String {
"**\(path)"
}
func streams(id: Video.ID) -> Resource {
resource(baseURL: account.instance.url, path: "streams/\(id)")
}
}

View File

@ -226,8 +226,8 @@ final class PlayerModel: ObservableObject {
#if !os(macOS)
var externalMetadata = [
makeMetadataItem(.commonIdentifierTitle, value: video.title),
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre),
makeMetadataItem(.commonIdentifierDescription, value: video.description)
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),

View File

@ -104,22 +104,12 @@ 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
}
if !video!.streams.isEmpty {
logger.critical("not loading video details again")
onSuccess(video!)
return
}
videoResource(video!.videoID).load().onSuccess { response in
accounts.api.video(video!.videoID).load().onSuccess { response in
if let video: Video = response.typedContent() {
onSuccess(video)
}

View File

@ -23,19 +23,7 @@ extension PlayerModel {
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
fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video) { _ in
self.completeIfAllInstancesLoaded(
instance: instance,
streams: self.availableStreams,
@ -45,35 +33,18 @@ extension PlayerModel {
}
}
}
}
private func fetchInvidiousStreams(
_ instance: Instance,
private func fetchStreams(
_ resource: Resource,
instance: Instance,
video: Video,
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
) {
invidious(instance)
.video(video.videoID)
resource
.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)
self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams)
}
}
.onCompletion(onCompletion)

View File

@ -9,10 +9,6 @@ final class PlaylistsModel: ObservableObject {
var accounts = AccountsModel()
var api: InvidiousAPI {
accounts.invidious
}
init(_ playlists: [Playlist] = [Playlist]()) {
self.playlists = playlists
}
@ -48,19 +44,19 @@ final class PlaylistsModel: ObservableObject {
}
func addVideoToCurrentPlaylist(videoID: Video.ID, onSuccess: @escaping () -> Void = {}) {
let resource = api.playlistVideos(currentPlaylist!.id)
let resource = accounts.api.playlistVideos(currentPlaylist!.id)
let body = ["videoId": videoID]
resource.request(.post, json: body).onSuccess { _ in
resource?.request(.post, json: body).onSuccess { _ in
self.load(force: true)
onSuccess()
}
}
func removeVideoFromPlaylist(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
let resource = api.playlistVideo(playlistID, videoIndexID)
let resource = accounts.api.playlistVideo(playlistID, videoIndexID)
resource.request(.delete).onSuccess { _ in
resource?.request(.delete).onSuccess { _ in
self.load(force: true)
onSuccess()
}
@ -71,7 +67,7 @@ final class PlaylistsModel: ObservableObject {
}
private var resource: Resource {
api.playlists
accounts.api.playlists!
}
private var selectedPlaylist: Playlist? {

View File

@ -17,14 +17,10 @@ final class SearchModel: ObservableObject {
resource?.isLoading ?? false
}
var api: InvidiousAPI {
accounts.invidious
}
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
changeHandler(query)
let newResource = api.search(query)
let newResource = accounts.api.search(query)
guard newResource != previousResource else {
return
}
@ -43,7 +39,7 @@ final class SearchModel: ObservableObject {
func resetQuery(_ query: SearchQuery = SearchQuery()) {
self.query = query
let newResource = api.search(query)
let newResource = accounts.api.search(query)
guard newResource != previousResource else {
return
}
@ -87,7 +83,7 @@ final class SearchModel: ObservableObject {
suggestionsDebounceTimer?.invalidate()
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
let resource = self.api.searchSuggestions(query: query)
let resource = self.accounts.api.searchSuggestions(query: query)
resource.addObserver(self.querySuggestions)
resource.loadIfNeeded()

View File

@ -6,12 +6,8 @@ final class SubscriptionsModel: ObservableObject {
@Published var channels = [Channel]()
var accounts: AccountsModel
var api: InvidiousAPI {
accounts.invidious
}
var resource: Resource {
api.subscriptions
var resource: Resource? {
accounts.api.subscriptions
}
init(accounts: AccountsModel? = nil) {
@ -35,7 +31,7 @@ final class SubscriptionsModel: ObservableObject {
}
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
let request = force ? resource.load() : resource.loadIfNeeded()
let request = force ? resource?.load() : resource?.loadIfNeeded()
request?
.onSuccess { resource in
@ -50,7 +46,7 @@ final class SubscriptionsModel: ObservableObject {
}
fileprivate func performRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) {
api.channelSubscription(channelID).request(method).onCompletion { _ in
accounts.api.channelSubscription(channelID)?.request(method).onCompletion { _ in
self.load(force: true, onSuccess: onSuccess)
}
}

View File

@ -4,6 +4,29 @@ import SwiftyJSON
struct Thumbnail {
enum Quality: String, CaseIterable {
case maxres, maxresdefault, sddefault, high, medium, `default`, start, middle, end
var filename: String {
switch self {
case .maxres:
return "maxres"
case .maxresdefault:
return "maxresdefault"
case .sddefault:
return "sddefault"
case .high:
return "hqdefault"
case .medium:
return "mqdefault"
case .default:
return "default"
case .start:
return "1"
case .middle:
return "2"
case .end:
return "3"
}
}
}
var url: URL

View File

@ -12,8 +12,8 @@ struct Video: Identifiable, Equatable, Hashable {
var length: TimeInterval
var published: String
var views: Int
var description: String
var genre: String
var description: String?
var genre: String?
// index used when in the Playlist
let indexID: String?
@ -38,8 +38,8 @@ struct Video: Identifiable, Equatable, Hashable {
length: TimeInterval,
published: String,
views: Int,
description: String,
genre: String,
description: String? = nil,
genre: String? = nil,
channel: Channel,
thumbnails: [Thumbnail] = [],
indexID: String? = nil,
@ -48,7 +48,8 @@ struct Video: Identifiable, Equatable, Hashable {
publishedAt: Date? = nil,
likes: Int? = nil,
dislikes: Int? = nil,
keywords: [String] = []
keywords: [String] = [],
streams: [Stream] = []
) {
self.id = id ?? UUID().uuidString
self.videoID = videoID
@ -68,6 +69,7 @@ struct Video: Identifiable, Equatable, Hashable {
self.likes = likes
self.dislikes = dislikes
self.keywords = keywords
self.streams = streams
}
init(_ json: JSON) {
@ -169,7 +171,11 @@ struct Video: Identifiable, Equatable, Hashable {
}
func thumbnailURL(quality: Thumbnail.Quality) -> URL? {
thumbnails.first { $0.quality == quality }?.url
if let url = thumbnails.first(where: { $0.quality == quality })?.url.absoluteString {
return URL(string: url.replacingOccurrences(of: "hqdefault", with: quality.filename))
}
return nil
}
private static func extractThumbnails(from details: JSON) -> [Thumbnail] {

24
Model/VideosAPI.swift Normal file
View File

@ -0,0 +1,24 @@
import Foundation
import Siesta
protocol VideosAPI {
var signedIn: Bool { get }
func channel(_ id: String) -> Resource
func trending(country: Country, category: TrendingCategory?) -> Resource
func search(_ query: SearchQuery) -> Resource
func searchSuggestions(query: String) -> Resource
func video(_ id: Video.ID) -> Resource
var subscriptions: Resource? { get }
var feed: Resource? { get }
var home: Resource? { get }
var popular: Resource? { get }
var playlists: Resource? { get }
func channelSubscription(_ id: String) -> Resource?
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
func playlistVideos(_ id: String) -> Resource?
}

33
Model/VideosApp.swift Normal file
View File

@ -0,0 +1,33 @@
import Foundation
enum VideosApp: String, CaseIterable {
case invidious, piped
var name: String {
rawValue.capitalized
}
var supportsAccounts: Bool {
self == .invidious
}
var supportsPopular: Bool {
self == .invidious
}
var supportsSearchFilters: Bool {
self == .invidious
}
var supportsSubscriptions: Bool {
supportsAccounts
}
var supportsTrendingCategories: Bool {
self == .invidious
}
var supportsUserPlaylists: Bool {
self == .invidious
}
}

View File

@ -79,8 +79,6 @@
373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; };
373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; };
373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; };
3743CA4A270EF79400E4D32B /* SwiftUIKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3743CA49270EF79400E4D32B /* SwiftUIKit */; };
3743CA4C270EF7A500E4D32B /* SwiftUIKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3743CA4B270EF7A500E4D32B /* SwiftUIKit */; };
3743CA4E270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */; };
3743CA4F270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */; };
3743CA50270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */; };
@ -136,6 +134,12 @@
376578922685490700D4EA09 /* PlaylistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578902685490700D4EA09 /* PlaylistsView.swift */; };
376578932685490700D4EA09 /* PlaylistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578902685490700D4EA09 /* PlaylistsView.swift */; };
37666BAA27023AF000F869E5 /* AccountSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37666BA927023AF000F869E5 /* AccountSelectionView.swift */; };
376A33E02720CAD6000C1D6B /* VideosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33DF2720CAD6000C1D6B /* VideosApp.swift */; };
376A33E12720CAD6000C1D6B /* VideosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33DF2720CAD6000C1D6B /* VideosApp.swift */; };
376A33E22720CAD6000C1D6B /* VideosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33DF2720CAD6000C1D6B /* VideosApp.swift */; };
376A33E42720CB35000C1D6B /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33E32720CB35000C1D6B /* Account.swift */; };
376A33E52720CB35000C1D6B /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33E32720CB35000C1D6B /* Account.swift */; };
376A33E62720CB35000C1D6B /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33E32720CB35000C1D6B /* Account.swift */; };
376B2E0726F920D600B1D64D /* SignInRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376B2E0626F920D600B1D64D /* SignInRequiredView.swift */; };
376B2E0826F920D600B1D64D /* SignInRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376B2E0626F920D600B1D64D /* SignInRequiredView.swift */; };
376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376B2E0626F920D600B1D64D /* SignInRequiredView.swift */; };
@ -292,6 +296,11 @@
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 */; };
37D526DE2720AC4400ED2F5E /* VideosAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D526DD2720AC4400ED2F5E /* VideosAPI.swift */; };
37D526DF2720AC4400ED2F5E /* VideosAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D526DD2720AC4400ED2F5E /* VideosAPI.swift */; };
37D526E02720AC4400ED2F5E /* VideosAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D526DD2720AC4400ED2F5E /* VideosAPI.swift */; };
37D526E32720B4BE00ED2F5E /* View+SwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D526E22720B4BE00ED2F5E /* View+SwipeGesture.swift */; };
37D526E42720B4BE00ED2F5E /* View+SwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D526E22720B4BE00ED2F5E /* View+SwipeGesture.swift */; };
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 */; };
@ -399,8 +408,11 @@
376578882685471400D4EA09 /* Playlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playlist.swift; sourceTree = "<group>"; };
376578902685490700D4EA09 /* PlaylistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsView.swift; sourceTree = "<group>"; };
37666BA927023AF000F869E5 /* AccountSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSelectionView.swift; sourceTree = "<group>"; };
376A33DF2720CAD6000C1D6B /* VideosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosApp.swift; sourceTree = "<group>"; };
376A33E32720CB35000C1D6B /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = "<group>"; };
376B2E0626F920D600B1D64D /* SignInRequiredView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInRequiredView.swift; sourceTree = "<group>"; };
376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Instance+Fixtures.swift"; sourceTree = "<group>"; };
37725DF327204139006D4D4B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
37732FEF2703A26300F04329 /* ValidationStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidationStatusView.swift; sourceTree = "<group>"; };
37732FF32703D32400F04329 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = "<group>"; };
377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedContentAccessors.swift; sourceTree = "<group>"; };
@ -465,6 +477,8 @@
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>"; };
37D526DD2720AC4400ED2F5E /* VideosAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosAPI.swift; sourceTree = "<group>"; };
37D526E22720B4BE00ED2F5E /* View+SwipeGesture.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+SwipeGesture.swift"; 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>"; };
@ -491,7 +505,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3743CA4A270EF79400E4D32B /* SwiftUIKit in Frameworks */,
37BD07B72698AB2E003EBB87 /* Defaults in Frameworks */,
37BADCA52699FB72009BE4FB /* Alamofire in Frameworks */,
377FC7D5267A080300A6BBAF /* SwiftyJSON in Frameworks */,
@ -506,7 +519,6 @@
buildActionMask = 2147483647;
files = (
37BD07BE2698AC96003EBB87 /* Defaults in Frameworks */,
3743CA4C270EF7A500E4D32B /* SwiftUIKit in Frameworks */,
37BADCA7269A552E009BE4FB /* Alamofire in Frameworks */,
377FC7ED267A0A0800A6BBAF /* SwiftyJSON in Frameworks */,
37BD07C02698AC97003EBB87 /* Siesta in Frameworks */,
@ -750,6 +762,7 @@
37D4B0C12671614700C925CA /* Shared */ = {
isa = PBXGroup;
children = (
37D526E12720B49200ED2F5E /* Gestures */,
3761AC0526F0F96100AA496F /* Modifiers */,
371AAE2326CEB9E800901972 /* Navigation */,
371AAE2426CEBA4100901972 /* Player */,
@ -823,6 +836,7 @@
37D4B1B72672CFE300C925CA /* Model */ = {
isa = PBXGroup;
children = (
376A33E32720CB35000C1D6B /* Account.swift */,
37001562271B1F250049C794 /* AccountsModel.swift */,
37484C3026FCB8F900287258 /* AccountValidator.swift */,
37AAF28F26740715007FC770 /* Channel.swift */,
@ -851,10 +865,20 @@
373CFADA269663F1003CB2C6 /* Thumbnail.swift */,
3705B181267B4E4900704544 /* TrendingCategory.swift */,
37D4B19626717E1500C925CA /* Video.swift */,
37D526DD2720AC4400ED2F5E /* VideosAPI.swift */,
376A33DF2720CAD6000C1D6B /* VideosApp.swift */,
);
path = Model;
sourceTree = "<group>";
};
37D526E12720B49200ED2F5E /* Gestures */ = {
isa = PBXGroup;
children = (
37D526E22720B4BE00ED2F5E /* View+SwipeGesture.swift */,
);
path = Gestures;
sourceTree = "<group>";
};
37FD43E1270472060073EE42 /* Settings */ = {
isa = PBXGroup;
children = (
@ -905,7 +929,6 @@
37BD07B82698AB2E003EBB87 /* Siesta */,
37BD07C62698B27B003EBB87 /* Introspect */,
37BADCA42699FB72009BE4FB /* Alamofire */,
3743CA49270EF79400E4D32B /* SwiftUIKit */,
);
productName = "Pearvidious (iOS)";
productReference = 37D4B0C92671614900C925CA /* Pearvidious.app */;
@ -931,7 +954,6 @@
37BD07BD2698AC96003EBB87 /* Defaults */,
37BD07BF2698AC97003EBB87 /* Siesta */,
37BADCA6269A552E009BE4FB /* Alamofire */,
3743CA4B270EF7A500E4D32B /* SwiftUIKit */,
);
productName = "Pearvidious (macOS)";
productReference = 37D4B0CF2671614900C925CA /* Pearvidious.app */;
@ -1075,7 +1097,6 @@
3797757B268922D100DD52A8 /* XCRemoteSwiftPackageReference "siesta" */,
37BD07C52698B27B003EBB87 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
37BADCA32699FB72009BE4FB /* XCRemoteSwiftPackageReference "Alamofire" */,
3743CA48270EF79400E4D32B /* XCRemoteSwiftPackageReference "SwiftUIKit" */,
);
productRefGroup = 37D4B0CA2671614900C925CA /* Products */;
projectDirPath = "";
@ -1273,6 +1294,7 @@
37484C1926FC837400287258 /* PlaybackSettingsView.swift in Sources */,
3711403F26B206A6005B3555 /* SearchModel.swift in Sources */,
37F64FE426FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */,
376A33E02720CAD6000C1D6B /* VideosApp.swift in Sources */,
37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */,
37DD87C7271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
37BE0BD326A1D4780092E2DB /* Player.swift in Sources */,
@ -1298,6 +1320,7 @@
37B044B726F7AB9000E1419D /* SettingsView.swift in Sources */,
377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */,
37E2EEAB270656EC00170416 /* PlayerControlsView.swift in Sources */,
376A33E42720CB35000C1D6B /* Account.swift in Sources */,
37BA794326DBA973002A0235 /* PlaylistsModel.swift in Sources */,
37AAF29026740715007FC770 /* Channel.swift in Sources */,
3748186A26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
@ -1319,6 +1342,7 @@
37BA794F26DC3E0E002A0235 /* Int+Format.swift in Sources */,
37F49BA326CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */,
37A9965A26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */,
37D526DE2720AC4400ED2F5E /* VideosAPI.swift in Sources */,
37484C2526FC83E000287258 /* InstanceFormView.swift in Sources */,
37B767DB2677C3CA0098BAA8 /* PlayerModel.swift in Sources */,
3788AC2726F6840700F6BAA9 /* WatchNowSection.swift in Sources */,
@ -1330,6 +1354,7 @@
373CFAEB26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */,
37B81B0226D2CAE700675966 /* PlaybackBar.swift in Sources */,
372915E62687E3B900F5A35B /* Defaults.swift in Sources */,
37D526E32720B4BE00ED2F5E /* View+SwipeGesture.swift in Sources */,
37732FF42703D32400F04329 /* Sidebar.swift in Sources */,
37D4B19726717E1500C925CA /* Video.swift in Sources */,
37484C2926FC83FF00287258 /* AccountFormView.swift in Sources */,
@ -1393,16 +1418,19 @@
37E70924271CD43000D34DDE /* WelcomeScreen.swift in Sources */,
373CFACC26966264003CB2C6 /* SearchQuery.swift in Sources */,
37AAF29126740715007FC770 /* Channel.swift in Sources */,
376A33E12720CAD6000C1D6B /* VideosApp.swift in Sources */,
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 */,
37D526E42720B4BE00ED2F5E /* View+SwipeGesture.swift in Sources */,
377FC7E4267A084E00A6BBAF /* SearchView.swift in Sources */,
37B81AFA26D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */,
377A20AA2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
37B81B0326D2CAE700675966 /* PlaybackBar.swift in Sources */,
376A33E52720CB35000C1D6B /* Account.swift in Sources */,
376578862685429C00D4EA09 /* CaseIterable+Next.swift in Sources */,
37A9965F26D6F9B9006E3224 /* WatchNowView.swift in Sources */,
37F4AE7326828F0900BD60EA /* VideosCellsVertical.swift in Sources */,
@ -1420,6 +1448,7 @@
37B767DC2677C3CA0098BAA8 /* PlayerModel.swift in Sources */,
3797758C2689345500DD52A8 /* Store.swift in Sources */,
37141674267A8E10006CA35D /* Country.swift in Sources */,
37725DF62720420C006D4D4B /* AppDelegate.swift in Sources */,
37FD43E42704847C0073EE42 /* View+Fixtures.swift in Sources */,
37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */,
37732FF12703A26300F04329 /* ValidationStatusView.swift in Sources */,
@ -1428,6 +1457,7 @@
3700155C271B0D4D0049C794 /* PipedAPI.swift in Sources */,
37D4B19826717E1500C925CA /* Video.swift in Sources */,
375168D72700FDB8008F96A6 /* Debounce.swift in Sources */,
37D526DF2720AC4400ED2F5E /* VideosAPI.swift in Sources */,
37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */,
37BD07C12698AD3B003EBB87 /* TrendingCountry.swift in Sources */,
37BA794026DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
@ -1496,6 +1526,7 @@
37732FF22703A26300F04329 /* ValidationStatusView.swift in Sources */,
37666BAA27023AF000F869E5 /* AccountSelectionView.swift in Sources */,
3765788B2685471400D4EA09 /* Playlist.swift in Sources */,
376A33E22720CAD6000C1D6B /* VideosApp.swift in Sources */,
373CFADD269663F1003CB2C6 /* Thumbnail.swift in Sources */,
37E64DD326D597EB00C71877 /* SubscriptionsModel.swift in Sources */,
37B044B926F7AB9000E1419D /* SettingsView.swift in Sources */,
@ -1510,6 +1541,7 @@
37A9966026D6F9B9006E3224 /* WatchNowView.swift in Sources */,
37001565271B1F250049C794 /* AccountsModel.swift in Sources */,
37E70929271CDDAE00D34DDE /* OpenSettingsButton.swift in Sources */,
376A33E62720CB35000C1D6B /* Account.swift in Sources */,
37484C1F26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
376578932685490700D4EA09 /* PlaylistsView.swift in Sources */,
@ -1542,6 +1574,7 @@
37F49BA826CB0FCE00304AC0 /* PlaylistFormView.swift in Sources */,
37D4B19926717E1500C925CA /* Video.swift in Sources */,
378E50FD26FE8B9F00F49626 /* Instance.swift in Sources */,
37D526E02720AC4400ED2F5E /* VideosAPI.swift in Sources */,
3705B184267B4E4900704544 /* TrendingCategory.swift in Sources */,
3761ABFF26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */,
37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */,
@ -2237,14 +2270,6 @@
minimumVersion = 5.0.0;
};
};
3743CA48270EF79400E4D32B /* XCRemoteSwiftPackageReference "SwiftUIKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/danielsaidi/SwiftUIKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.0.0;
};
};
3797757B268922D100DD52A8 /* XCRemoteSwiftPackageReference "siesta" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/bustoutsolutions/siesta";
@ -2293,16 +2318,6 @@
package = 372915E22687E33E00F5A35B /* XCRemoteSwiftPackageReference "Defaults" */;
productName = Defaults;
};
3743CA49270EF79400E4D32B /* SwiftUIKit */ = {
isa = XCSwiftPackageProductDependency;
package = 3743CA48270EF79400E4D32B /* XCRemoteSwiftPackageReference "SwiftUIKit" */;
productName = SwiftUIKit;
};
3743CA4B270EF7A500E4D32B /* SwiftUIKit */ = {
isa = XCSwiftPackageProductDependency;
package = 3743CA48270EF79400E4D32B /* XCRemoteSwiftPackageReference "SwiftUIKit" */;
productName = SwiftUIKit;
};
377FC7D4267A080300A6BBAF /* SwiftyJSON */ = {
isa = XCSwiftPackageProductDependency;
package = 37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */;

View File

@ -46,15 +46,6 @@
"version": "0.1.3"
}
},
{
"package": "SwiftUIKit",
"repositoryURL": "https://github.com/danielsaidi/SwiftUIKit.git",
"state": {
"branch": null,
"revision": "ad509355ba9bc87f8375a297c3df93acd42e6c01",
"version": "2.0.0"
}
},
{
"package": "SwiftyJSON",
"repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git",

8
Shared/AppDelegate.swift Normal file
View File

@ -0,0 +1,8 @@
//
// AppDelegate.swift
// Pearvidious
//
// Created by Arkadiusz Fal on 20/10/2021.
//
import Foundation

View File

@ -9,13 +9,13 @@ extension Defaults.Keys {
.init(app: .piped, id: pipedInstanceID, name: "Public", url: "https://pipedapi.kavin.rocks"),
.init(app: .invidious, id: invidiousInstanceID, name: "Private", url: "https://invidious.home.arekf.net")
])
static let accounts = Key<[Instance.Account]>("accounts", default: [
static let accounts = Key<[Account]>("accounts", default: [
.init(instanceID: invidiousInstanceID,
name: "arekf",
url: "https://invidious.home.arekf.net",
sid: "ki55SJbaQmm0bOxUWctGAQLYPQRgk-CXDPw5Dp4oBmI=")
])
static let lastAccountID = Key<Instance.Account.ID?>("lastAccountID")
static let lastAccountID = Key<Account.ID?>("lastAccountID")
static let lastInstanceID = Key<Instance.ID?>("lastInstanceID")
static let quality = Key<Stream.ResolutionSetting>("quality", default: .hd720pFirstThenBest)

View File

@ -0,0 +1,9 @@
//
// GestureTimer.swift
// SwiftUIKit
//
// Created by Daniel Saidi on 2021-02-17.
// Copyright © 2021 Daniel Saidi. All rights reserved.
//
import Foundation

View File

@ -0,0 +1,22 @@
import SwiftUI
extension View {
func onSwipeGesture(
up: @escaping () -> Void = {},
down: @escaping () -> Void = {}
) -> some View {
gesture(
DragGesture(minimumDistance: 10)
.onEnded { gesture in
let translation = gesture.translation
if abs(translation.height) > 100_000 {
return
}
let isUp = translation.height < 0
isUp ? up() : down()
}
)
}
}

View File

@ -22,11 +22,11 @@ struct AccountsMenuView: View {
.transaction { t in t.animation = .none }
}
private var allAccounts: [Instance.Account] {
private var allAccounts: [Account] {
accounts + instances.map(\.anonymousAccount)
}
private func accountButtonTitle(account: Instance.Account) -> String {
private func accountButtonTitle(account: Account) -> String {
instances.count > 1 ? "\(account.description)\(account.instance.description)" : account.description
}
}

View File

@ -32,18 +32,19 @@ struct Sidebar: View {
Label("Watch Now", systemImage: "play.circle")
.accessibility(label: Text("Watch Now"))
}
if accounts.signedIn {
if accounts.app.supportsSubscriptions && accounts.signedIn {
NavigationLink(destination: LazyView(SubscriptionsView()), tag: TabSelection.subscriptions, selection: $navigation.tabSelection) {
Label("Subscriptions", systemImage: "star.circle")
.accessibility(label: Text("Subscriptions"))
}
}
if accounts.app.supportsPopular {
NavigationLink(destination: LazyView(PopularView()), tag: TabSelection.popular, selection: $navigation.tabSelection) {
Label("Popular", systemImage: "chart.bar")
.accessibility(label: Text("Popular"))
}
}
NavigationLink(destination: LazyView(TrendingView()), tag: TabSelection.trending, selection: $navigation.tabSelection) {
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")

View File

@ -13,7 +13,7 @@ struct PlayerQueueView: View {
}
#if os(macOS)
.listStyle(.groupedWithInsets)
.listStyle(.inset)
#elseif os(iOS)
.listStyle(.insetGrouped)
#else

View File

@ -16,6 +16,7 @@ struct VideoDetails: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@ -86,7 +87,8 @@ struct VideoDetails: View {
}
}
.onAppear {
guard video != nil else {
guard video != nil, accounts.app.supportsSubscriptions else {
subscribed = false
return
}
@ -155,6 +157,7 @@ struct VideoDetails: View {
}
.foregroundColor(.secondary)
if accounts.app.supportsSubscriptions {
Spacer()
Section {
@ -189,7 +192,7 @@ struct VideoDetails: View {
.buttonStyle(.borderless)
.buttonBorderShape(.roundedRectangle)
}
Divider()
}
}
}
}
@ -264,7 +267,10 @@ struct VideoDetails: View {
Group {
if let video = player.currentItem?.video {
Group {
HStack {
publishedDateSection
Spacer()
}
Divider()
@ -274,8 +280,13 @@ struct VideoDetails: View {
Divider()
VStack(alignment: .leading, spacing: 10) {
Text(video.description)
if let description = video.description {
Text(description)
.font(.caption)
} else {
Text("No description")
.foregroundColor(.secondary)
}
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
HStack {

View File

@ -2,9 +2,6 @@ import AVKit
import Defaults
import Siesta
import SwiftUI
#if !os(tvOS)
import SwiftUIKit
#endif
struct VideoPlayerView: View {
static let defaultAspectRatio: Double = 1.77777778

View File

@ -170,7 +170,7 @@ struct PlaylistFormView: View {
let body = ["title": name, "privacy": visibility.rawValue]
resource.request(editing ? .patch : .post, json: body).onSuccess { response in
resource?.request(editing ? .patch : .post, json: body).onSuccess { response in
if let modifiedPlaylist: Playlist = response.typedContent() {
playlist = modifiedPlaylist
}
@ -181,7 +181,7 @@ struct PlaylistFormView: View {
}
}
var resource: Resource {
var resource: Resource? {
editing ? api.playlist(playlist.id) : api.playlists
}
@ -227,7 +227,7 @@ struct PlaylistFormView: View {
}
func deletePlaylistAndDismiss() {
api.playlist(playlist.id).request(.delete).onSuccess { _ in
api.playlist(playlist.id)?.request(.delete).onSuccess { _ in
playlist = nil
playlists.load(force: true)
dismiss()

View File

@ -3,7 +3,7 @@ import SwiftUI
struct AccountFormView: View {
let instance: Instance
var selectedAccount: Binding<Instance.Account?>?
var selectedAccount: Binding<Account?>?
@State private var name = ""
@State private var sid = ""
@ -134,7 +134,7 @@ struct AccountFormView: View {
AccountValidator(
app: .constant(instance.app),
url: instance.url,
account: Instance.Account(instanceID: instance.id, url: instance.url, sid: sid),
account: Account(instanceID: instance.id, url: instance.url, sid: sid),
id: $sid,
isValid: $isValid,
isValidated: $isValidated,

View File

@ -14,8 +14,8 @@ struct AccountsSettingsView: View {
}
var body: some View {
Group {
if instance.supportsAccounts {
VStack {
if instance.app.supportsAccounts {
accounts
} else {
Text("Accounts are not supported for the application of this instance")
@ -68,7 +68,7 @@ struct AccountsSettingsView: View {
#endif
}
private func removeAccount(_ account: Instance.Account) {
private func removeAccount(_ account: Account) {
AccountsModel.remove(account)
accountsChanged.toggle()
}

View File

@ -5,7 +5,7 @@ struct InstanceFormView: View {
@State private var name = ""
@State private var url = ""
@State private var app = Instance.App.invidious
@State private var app = VideosApp.invidious
@State private var isValid = false
@State private var isValidated = false
@ -75,7 +75,7 @@ struct InstanceFormView: View {
private var formFields: some View {
Group {
Picker("Application", selection: $app) {
ForEach(Instance.App.allCases, id: \.self) { app in
ForEach(VideosApp.allCases, id: \.self) { app in
Text(app.rawValue.capitalized).tag(app)
}
}

View File

@ -10,7 +10,7 @@ struct InstancesSettingsView: View {
@EnvironmentObject<PlaylistsModel> private var playlists
@State private var selectedInstanceID: Instance.ID?
@State private var selectedAccount: Instance.Account?
@State private var selectedAccount: Account?
@State private var presentingInstanceForm = false
@State private var savedFormInstanceID: Instance.ID?

View File

@ -18,10 +18,12 @@ struct TrendingView: View {
}
var resource: Resource {
let resource = accounts.invidious.trending(category: category, country: country)
resource.addObserver(store)
let newResource: Resource
return resource
newResource = accounts.api.trending(country: country, category: category)
newResource.addObserver(store)
return newResource
}
var body: some View {
@ -56,12 +58,15 @@ struct TrendingView: View {
.toolbar {
#if os(macOS)
ToolbarItemGroup {
if accounts.app.supportsTrendingCategories {
categoryButton
}
countryButton
}
#elseif os(iOS)
ToolbarItemGroup(placement: .bottomBar) {
Group {
if accounts.app.supportsTrendingCategories {
HStack {
Text("Category")
.foregroundColor(.secondary)
@ -71,6 +76,9 @@ struct TrendingView: View {
// force redraw of the view when it changes
.id(UUID())
}
} else {
Spacer()
}
HStack {
Text("Country")
@ -97,12 +105,14 @@ struct TrendingView: View {
var toolbar: some View {
HStack {
if accounts.app.supportsTrendingCategories {
HStack {
Text("Category")
.foregroundColor(.secondary)
categoryButton
}
}
#if os(iOS)
Spacer()

View File

@ -7,6 +7,7 @@ struct VideoBanner: View {
var body: some View {
HStack(alignment: .center, spacing: 12) {
smallThumbnail
VStack(alignment: .leading, spacing: 4) {
Text(video.title)
.truncationMode(.middle)

View File

@ -99,7 +99,7 @@ struct ChannelVideosView: View {
}
var resource: Resource {
let resource = accounts.invidious.channel(channel.id)
let resource = accounts.api.channel(channel.id)
resource.addObserver(store)
return resource
@ -107,6 +107,7 @@ struct ChannelVideosView: View {
var subscriptionToggleButton: some View {
Group {
if accounts.app.supportsSubscriptions && accounts.signedIn {
if subscriptions.isSubscribing(channel.id) {
Button("Unsubscribe") {
navigation.presentUnsubscribeAlert(channel)
@ -120,6 +121,7 @@ struct ChannelVideosView: View {
}
}
}
}
var navigationTitle: String {
store.item?.name ?? channel.name

View File

@ -6,16 +6,16 @@ struct PopularView: View {
@EnvironmentObject<AccountsModel> private var accounts
var resource: Resource {
accounts.invidious.popular
var resource: Resource? {
accounts.api.popular
}
var body: some View {
PlayerControlsView {
VideosCellsVertical(videos: store.collection)
.onAppear {
resource.addObserver(store)
resource.loadIfNeeded()
resource?.addObserver(store)
resource?.loadIfNeeded()
}
#if !os(tvOS)
.navigationTitle("Popular")

View File

@ -19,6 +19,7 @@ struct SearchView: View {
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var state
@ -37,7 +38,9 @@ struct SearchView: View {
} else {
#if os(tvOS)
ScrollView(.vertical, showsIndicators: false) {
if accounts.app.supportsSearchFilters {
filtersHorizontalStack
}
VideosCellsHorizontal(videos: state.store.collection)
}
@ -61,6 +64,7 @@ struct SearchView: View {
.toolbar {
#if !os(tvOS)
ToolbarItemGroup(placement: toolbarPlacement) {
if accounts.app.supportsSearchFilters {
Section {
#if os(macOS)
HStack {
@ -81,7 +85,7 @@ struct SearchView: View {
filtersMenu
}
}
#endif
}
.onAppear {

View File

@ -6,12 +6,8 @@ struct SubscriptionsView: View {
@EnvironmentObject<AccountsModel> private var accounts
var api: InvidiousAPI {
accounts.invidious
}
var feed: Resource {
api.feed
var feed: Resource? {
accounts.api.feed
}
var body: some View {
@ -32,9 +28,9 @@ struct SubscriptionsView: View {
}
fileprivate func loadResources(force: Bool = false) {
feed.addObserver(store)
feed?.addObserver(store)
if let request = force ? api.home.load() : api.home.loadIfNeeded() {
if let request = force ? accounts.api.home?.load() : accounts.api.home?.loadIfNeeded() {
request.onSuccess { _ in
loadFeed(force: force)
}
@ -44,6 +40,6 @@ struct SubscriptionsView: View {
}
fileprivate func loadFeed(force: Bool = false) {
_ = force ? feed.load() : feed.loadIfNeeded()
_ = force ? feed?.load() : feed?.loadIfNeeded()
}
}

View File

@ -8,6 +8,7 @@ struct VideoContextMenuView: View {
@Environment(\.inNavigationView) private var inNavigationView
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var playlists
@ -25,9 +26,12 @@ struct VideoContextMenuView: View {
Section {
openChannelButton
if accounts.app.supportsSubscriptions {
subscriptionButton
}
}
if accounts.app.supportsUserPlaylists {
Section {
if navigation.tabSelection != .playlists {
addToPlaylistButton
@ -39,6 +43,7 @@ struct VideoContextMenuView: View {
removeFromPlaylistButton(playlistID: id)
}
}
}
#if os(tvOS)
Button("Cancel", role: .cancel) {}

View File

@ -3,14 +3,14 @@ import Siesta
import SwiftUI
struct WatchNowSection: View {
let resource: Resource
let resource: Resource?
let label: String
@StateObject private var store = Store<[Video]>()
@EnvironmentObject<AccountsModel> private var accounts
init(resource: Resource, label: String) {
init(resource: Resource?, label: String) {
self.resource = resource
self.label = label
}
@ -18,11 +18,11 @@ struct WatchNowSection: View {
var body: some View {
WatchNowSectionBody(label: label, videos: store.collection)
.onAppear {
resource.addObserver(store)
resource.loadIfNeeded()
resource?.addObserver(store)
resource?.loadIfNeeded()
}
.onChange(of: accounts.current) { _ in
resource.load()
resource?.load()
}
}
}

View File

@ -5,22 +5,22 @@ import SwiftUI
struct WatchNowView: View {
@EnvironmentObject<AccountsModel> private var accounts
var api: InvidiousAPI! {
accounts.invidious
}
var body: some View {
PlayerControlsView {
ScrollView(.vertical, showsIndicators: false) {
if !accounts.current.isNil {
VStack(alignment: .leading, spacing: 0) {
if api.signedIn {
WatchNowSection(resource: api.feed, label: "Subscriptions")
if accounts.api.signedIn {
WatchNowSection(resource: accounts.api.feed, label: "Subscriptions")
}
if accounts.app.supportsPopular {
WatchNowSection(resource: accounts.api.popular, label: "Popular")
}
WatchNowSection(resource: accounts.api.trending(country: .pl, category: .default), label: "Trending")
if accounts.app.supportsTrendingCategories {
WatchNowSection(resource: accounts.api.trending(country: .pl, category: .movies), label: "Movies")
WatchNowSection(resource: accounts.api.trending(country: .pl, category: .music), label: "Music")
}
WatchNowSection(resource: api.popular, label: "Popular")
WatchNowSection(resource: api.trending(category: .default, country: .pl), label: "Trending")
WatchNowSection(resource: api.trending(category: .movies, country: .pl), label: "Movies")
WatchNowSection(resource: api.trending(category: .music, country: .pl), label: "Music")
// TODO: adding sections to view
// ===================

View File

@ -3,7 +3,7 @@ import SwiftUI
struct InstancesSettingsView: View {
@State private var selectedInstanceID: Instance.ID?
@State private var selectedAccount: Instance.Account?
@State private var selectedAccount: Account?
@State private var presentingAccountForm = false
@State private var presentingInstanceForm = false
@ -34,7 +34,7 @@ struct InstancesSettingsView: View {
.foregroundColor(.secondary)
}
if !selectedInstance.isNil, selectedInstance.supportsAccounts {
if !selectedInstance.isNil, selectedInstance.app.supportsAccounts {
Text("Accounts")
List(selection: $selectedAccount) {
if selectedInstanceAccounts.isEmpty {
@ -67,7 +67,7 @@ struct InstancesSettingsView: View {
.listStyle(.inset(alternatesRowBackgrounds: true))
}
if selectedInstance != nil, !selectedInstance.supportsAccounts {
if selectedInstance != nil, !selectedInstance.app.supportsAccounts {
Text("Accounts are not supported for the application of this instance")
.font(.caption)
.foregroundColor(.secondary)
@ -81,7 +81,7 @@ struct InstancesSettingsView: View {
selectedAccount = nil
presentingAccountForm = true
}
.disabled(!selectedInstance.supportsAccounts)
.disabled(!selectedInstance.app.supportsAccounts)
Spacer()
@ -134,7 +134,7 @@ struct InstancesSettingsView: View {
InstancesModel.find(selectedInstanceID)
}
private var selectedInstanceAccounts: [Instance.Account] {
private var selectedInstanceAccounts: [Account] {
guard selectedInstance != nil else {
return []
}

View File

@ -32,15 +32,15 @@ struct AccountSelectionView: View {
.id(UUID())
}
var allAccounts: [Instance.Account] {
var allAccounts: [Account] {
accounts + instances.map(\.anonymousAccount)
}
private var nextAccount: Instance.Account? {
private var nextAccount: Account? {
allAccounts.next(after: accountsModel.current)
}
func accountButtonTitle(account: Instance.Account! = nil) -> String {
func accountButtonTitle(account: Account! = nil) -> String {
guard account != nil else {
return "Not selected"
}

View File

@ -2,6 +2,7 @@ import Defaults
import SwiftUI
struct TVNavigationView: View {
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<RecentsModel> private var recents
@ -13,21 +14,27 @@ struct TVNavigationView: View {
.tabItem { Text("Watch Now") }
.tag(TabSelection.watchNow)
if accounts.app.supportsSubscriptions {
SubscriptionsView()
.tabItem { Text("Subscriptions") }
.tag(TabSelection.subscriptions)
}
if accounts.app.supportsPopular {
PopularView()
.tabItem { Text("Popular") }
.tag(TabSelection.popular)
}
TrendingView()
.tabItem { Text("Trending") }
.tag(TabSelection.trending)
if accounts.app.supportsUserPlaylists {
PlaylistsView()
.tabItem { Text("Playlists") }
.tag(TabSelection.playlists)
}
NowPlayingView()
.tabItem { Text("Now Playing") }