diff --git a/Fixtures/Thumbnail+Fixtures.swift b/Fixtures/Thumbnail+Fixtures.swift index 24335e3a..d3916d7c 100644 --- a/Fixtures/Thumbnail+Fixtures.swift +++ b/Fixtures/Thumbnail+Fixtures.swift @@ -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")! } } diff --git a/Model/Account.swift b/Model/Account.swift new file mode 100644 index 00000000..d4ca6ed1 --- /dev/null +++ b/Model/Account.swift @@ -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[.. + let app: Binding let url: String - let account: Instance.Account? + let account: Account? var formObjectID: Binding var isValid: Binding @@ -14,9 +14,9 @@ final class AccountValidator: Service { var error: Binding? init( - app: Binding, + app: Binding, url: String, - account: Instance.Account? = nil, + account: Account? = nil, id: Binding, isValid: Binding, isValidated: Binding, diff --git a/Model/AccountsModel.swift b/Model/AccountsModel.swift index af7972e3..2296783e 100644 --- a/Model/AccountsModel.swift +++ b/Model/AccountsModel.swift @@ -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) } diff --git a/Model/Instance.swift b/Model/Instance.swift index c7e8f2b9..ac93e529 100644 --- a/Model/Instance.swift +++ b/Model/Instance.swift @@ -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[.. 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) } diff --git a/Model/InstancesModel.swift b/Model/InstancesModel.swift index 3d0541db..f4fff116 100644 --- a/Model/InstancesModel.swift +++ b/Model/InstancesModel.swift @@ -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 } } diff --git a/Model/InvidiousAPI.swift b/Model/InvidiousAPI.swift index 5695eb7f..d514b84d 100644 --- a/Model/InvidiousAPI.swift +++ b/Model/InvidiousAPI.swift @@ -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 { diff --git a/Model/PipedAPI.swift b/Model/PipedAPI.swift index 2f3cdd71..530eb358 100644 --- a/Model/PipedAPI.swift +++ b/Model/PipedAPI.swift @@ -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) -> [Stream] in - self.extractStreams(content) + configureTransformer(pathPattern("channel/*")) { (content: Entity) -> Channel? in + self.extractChannel(content.json) + } + + configureTransformer(pathPattern("streams/*")) { (content: Entity) -> Video? in + self.extractVideo(content.json) + } + + configureTransformer(pathPattern("trending")) { (content: Entity) -> [Video] in + self.extractVideos(content.json) + } + + configureTransformer(pathPattern("search")) { (content: Entity) -> [Video] in + self.extractVideos(content.json.dictionaryValue["items"]!) + } + + configureTransformer(pathPattern("suggestions")) { (content: Entity) -> [String] in + content.json.arrayValue.map(String.init) } } - private func extractStreams(_ content: Entity) -> [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: "
|
|
", + 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] { + 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] { + 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)") - } } diff --git a/Model/PlayerModel.swift b/Model/PlayerModel.swift index 2f27e36e..d7232dfe 100644 --- a/Model/PlayerModel.swift +++ b/Model/PlayerModel.swift @@ -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), diff --git a/Model/PlayerQueue.swift b/Model/PlayerQueue.swift index d83e0373..f30b6211 100644 --- a/Model/PlayerQueue.swift +++ b/Model/PlayerQueue.swift @@ -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) } diff --git a/Model/PlayerStreams.swift b/Model/PlayerStreams.swift index 549ea472..74ec67e1 100644 --- a/Model/PlayerStreams.swift +++ b/Model/PlayerStreams.swift @@ -23,57 +23,28 @@ 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 - self.completeIfAllInstancesLoaded( - instance: instance, - streams: self.availableStreams, - instancesWithLoadedStreams: &instancesWithLoadedStreams, - completionHandler: completionHandler - ) - } + fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video) { _ in + self.completeIfAllInstancesLoaded( + instance: instance, + streams: self.availableStreams, + instancesWithLoadedStreams: &instancesWithLoadedStreams, + completionHandler: completionHandler + ) } } } - 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) diff --git a/Model/PlaylistsModel.swift b/Model/PlaylistsModel.swift index 6bd622f5..eb994312 100644 --- a/Model/PlaylistsModel.swift +++ b/Model/PlaylistsModel.swift @@ -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? { diff --git a/Model/SearchModel.swift b/Model/SearchModel.swift index 0fcdc164..b2ede26d 100644 --- a/Model/SearchModel.swift +++ b/Model/SearchModel.swift @@ -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() diff --git a/Model/SubscriptionsModel.swift b/Model/SubscriptionsModel.swift index bae985aa..cbee8a56 100644 --- a/Model/SubscriptionsModel.swift +++ b/Model/SubscriptionsModel.swift @@ -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) } } diff --git a/Model/Thumbnail.swift b/Model/Thumbnail.swift index 46a06358..ed0dc39e 100644 --- a/Model/Thumbnail.swift +++ b/Model/Thumbnail.swift @@ -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 diff --git a/Model/Video.swift b/Model/Video.swift index 9452ca84..64baca6f 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -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] { diff --git a/Model/VideosAPI.swift b/Model/VideosAPI.swift new file mode 100644 index 00000000..f26bd5ce --- /dev/null +++ b/Model/VideosAPI.swift @@ -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? +} diff --git a/Model/VideosApp.swift b/Model/VideosApp.swift new file mode 100644 index 00000000..0fc8432c --- /dev/null +++ b/Model/VideosApp.swift @@ -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 + } +} diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index 94b7364d..c675af13 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -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 = ""; }; 376578902685490700D4EA09 /* PlaylistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsView.swift; sourceTree = ""; }; 37666BA927023AF000F869E5 /* AccountSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSelectionView.swift; sourceTree = ""; }; + 376A33DF2720CAD6000C1D6B /* VideosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosApp.swift; sourceTree = ""; }; + 376A33E32720CB35000C1D6B /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = ""; }; 376B2E0626F920D600B1D64D /* SignInRequiredView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInRequiredView.swift; sourceTree = ""; }; 376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Instance+Fixtures.swift"; sourceTree = ""; }; + 37725DF327204139006D4D4B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 37732FEF2703A26300F04329 /* ValidationStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidationStatusView.swift; sourceTree = ""; }; 37732FF32703D32400F04329 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = ""; }; 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedContentAccessors.swift; sourceTree = ""; }; @@ -465,6 +477,8 @@ 37D4B18B26717B3800C925CA /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = ""; }; 37D4B19626717E1500C925CA /* Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Video.swift; sourceTree = ""; }; 37D4B1AE26729DEB00C925CA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 37D526DD2720AC4400ED2F5E /* VideosAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosAPI.swift; sourceTree = ""; }; + 37D526E22720B4BE00ED2F5E /* View+SwipeGesture.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+SwipeGesture.swift"; sourceTree = ""; }; 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStreams.swift; sourceTree = ""; }; 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsView.swift; sourceTree = ""; }; 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsModel.swift; sourceTree = ""; }; @@ -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 = ""; }; + 37D526E12720B49200ED2F5E /* Gestures */ = { + isa = PBXGroup; + children = ( + 37D526E22720B4BE00ED2F5E /* View+SwipeGesture.swift */, + ); + path = Gestures; + sourceTree = ""; + }; 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" */; diff --git a/Pearvidious.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Pearvidious.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 80793e68..e4e6775c 100644 --- a/Pearvidious.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Pearvidious.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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", diff --git a/Shared/AppDelegate.swift b/Shared/AppDelegate.swift new file mode 100644 index 00000000..8360d0c3 --- /dev/null +++ b/Shared/AppDelegate.swift @@ -0,0 +1,8 @@ +// +// AppDelegate.swift +// Pearvidious +// +// Created by Arkadiusz Fal on 20/10/2021. +// + +import Foundation diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 917c58b2..3eec9922 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -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("lastAccountID") + static let lastAccountID = Key("lastAccountID") static let lastInstanceID = Key("lastInstanceID") static let quality = Key("quality", default: .hd720pFirstThenBest) diff --git a/Shared/Gestures/GestureTimer.swift b/Shared/Gestures/GestureTimer.swift new file mode 100644 index 00000000..27f9b91d --- /dev/null +++ b/Shared/Gestures/GestureTimer.swift @@ -0,0 +1,9 @@ +// +// GestureTimer.swift +// SwiftUIKit +// +// Created by Daniel Saidi on 2021-02-17. +// Copyright © 2021 Daniel Saidi. All rights reserved. +// + +import Foundation diff --git a/Shared/Gestures/View+SwipeGesture.swift b/Shared/Gestures/View+SwipeGesture.swift new file mode 100644 index 00000000..642b11d2 --- /dev/null +++ b/Shared/Gestures/View+SwipeGesture.swift @@ -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() + } + ) + } +} diff --git a/Shared/Navigation/AccountsMenuView.swift b/Shared/Navigation/AccountsMenuView.swift index 8d2ffbca..575a6668 100644 --- a/Shared/Navigation/AccountsMenuView.swift +++ b/Shared/Navigation/AccountsMenuView.swift @@ -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 } } diff --git a/Shared/Navigation/Sidebar.swift b/Shared/Navigation/Sidebar.swift index 7a78e3bf..3d0b8ed2 100644 --- a/Shared/Navigation/Sidebar.swift +++ b/Shared/Navigation/Sidebar.swift @@ -32,17 +32,18 @@ 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")) } } - NavigationLink(destination: LazyView(PopularView()), tag: TabSelection.popular, selection: $navigation.tabSelection) { - Label("Popular", systemImage: "chart.bar") - .accessibility(label: Text("Popular")) + 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) { diff --git a/Shared/Player/PlayerQueueView.swift b/Shared/Player/PlayerQueueView.swift index e287fde7..caadc18f 100644 --- a/Shared/Player/PlayerQueueView.swift +++ b/Shared/Player/PlayerQueueView.swift @@ -13,7 +13,7 @@ struct PlayerQueueView: View { } #if os(macOS) - .listStyle(.groupedWithInsets) + .listStyle(.inset) #elseif os(iOS) .listStyle(.insetGrouped) #else diff --git a/Shared/Player/VideoDetails.swift b/Shared/Player/VideoDetails.swift index f36dab64..6f1166a5 100644 --- a/Shared/Player/VideoDetails.swift +++ b/Shared/Player/VideoDetails.swift @@ -16,6 +16,7 @@ struct VideoDetails: View { @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var accounts @EnvironmentObject private var player @EnvironmentObject 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,41 +157,42 @@ struct VideoDetails: View { } .foregroundColor(.secondary) - Spacer() + if accounts.app.supportsSubscriptions { + Spacer() - Section { - if subscribed { - Button("Unsubscribe") { - confirmationShown = true - } - #if os(iOS) - .tint(.gray) - #endif - .confirmationDialog("Are you you want to unsubscribe from \(video!.channel.name)?", isPresented: $confirmationShown) { + Section { + if subscribed { Button("Unsubscribe") { - subscriptions.unsubscribe(video!.channel.id) + confirmationShown = true + } + #if os(iOS) + .tint(.gray) + #endif + .confirmationDialog("Are you you want to unsubscribe from \(video!.channel.name)?", isPresented: $confirmationShown) { + Button("Unsubscribe") { + subscriptions.unsubscribe(video!.channel.id) + + withAnimation { + subscribed.toggle() + } + } + } + } else { + Button("Subscribe") { + subscriptions.subscribe(video!.channel.id) withAnimation { subscribed.toggle() } } + .tint(.blue) } - } else { - Button("Subscribe") { - subscriptions.subscribe(video!.channel.id) - - withAnimation { - subscribed.toggle() - } - } - .tint(.blue) } + .font(.system(size: 13)) + .buttonStyle(.borderless) + .buttonBorderShape(.roundedRectangle) } - .font(.system(size: 13)) - .buttonStyle(.borderless) - .buttonBorderShape(.roundedRectangle) } - Divider() } } } @@ -264,7 +267,10 @@ struct VideoDetails: View { Group { if let video = player.currentItem?.video { Group { - publishedDateSection + HStack { + publishedDateSection + Spacer() + } Divider() @@ -274,8 +280,13 @@ struct VideoDetails: View { Divider() VStack(alignment: .leading, spacing: 10) { - Text(video.description) - .font(.caption) + if let description = video.description { + Text(description) + .font(.caption) + } else { + Text("No description") + .foregroundColor(.secondary) + } ScrollView(.horizontal, showsIndicators: showScrollIndicators) { HStack { diff --git a/Shared/Player/VideoPlayerView.swift b/Shared/Player/VideoPlayerView.swift index 6b3c8677..d0d0281f 100644 --- a/Shared/Player/VideoPlayerView.swift +++ b/Shared/Player/VideoPlayerView.swift @@ -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 diff --git a/Shared/Playlists/PlaylistFormView.swift b/Shared/Playlists/PlaylistFormView.swift index 9360c80b..c0935def 100644 --- a/Shared/Playlists/PlaylistFormView.swift +++ b/Shared/Playlists/PlaylistFormView.swift @@ -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() diff --git a/Shared/Settings/AccountFormView.swift b/Shared/Settings/AccountFormView.swift index c98b433a..01a39c9c 100644 --- a/Shared/Settings/AccountFormView.swift +++ b/Shared/Settings/AccountFormView.swift @@ -3,7 +3,7 @@ import SwiftUI struct AccountFormView: View { let instance: Instance - var selectedAccount: Binding? + var selectedAccount: Binding? @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, diff --git a/Shared/Settings/AccountsSettingsView.swift b/Shared/Settings/AccountsSettingsView.swift index b34b0061..97a8e86d 100644 --- a/Shared/Settings/AccountsSettingsView.swift +++ b/Shared/Settings/AccountsSettingsView.swift @@ -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() } diff --git a/Shared/Settings/InstanceFormView.swift b/Shared/Settings/InstanceFormView.swift index 6ddc4ae0..253269b9 100644 --- a/Shared/Settings/InstanceFormView.swift +++ b/Shared/Settings/InstanceFormView.swift @@ -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) } } diff --git a/Shared/Settings/InstancesSettingsView.swift b/Shared/Settings/InstancesSettingsView.swift index b334512e..31f587db 100644 --- a/Shared/Settings/InstancesSettingsView.swift +++ b/Shared/Settings/InstancesSettingsView.swift @@ -10,7 +10,7 @@ struct InstancesSettingsView: View { @EnvironmentObject 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? diff --git a/Shared/Trending/TrendingView.swift b/Shared/Trending/TrendingView.swift index 405047ae..662569d2 100644 --- a/Shared/Trending/TrendingView.swift +++ b/Shared/Trending/TrendingView.swift @@ -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,20 +58,26 @@ struct TrendingView: View { .toolbar { #if os(macOS) ToolbarItemGroup { - categoryButton + if accounts.app.supportsTrendingCategories { + categoryButton + } countryButton } #elseif os(iOS) ToolbarItemGroup(placement: .bottomBar) { Group { - HStack { - Text("Category") - .foregroundColor(.secondary) + if accounts.app.supportsTrendingCategories { + HStack { + Text("Category") + .foregroundColor(.secondary) - categoryButton - // only way to disable Menu animation is to - // force redraw of the view when it changes - .id(UUID()) + categoryButton + // only way to disable Menu animation is to + // force redraw of the view when it changes + .id(UUID()) + } + } else { + Spacer() } HStack { @@ -97,11 +105,13 @@ struct TrendingView: View { var toolbar: some View { HStack { - HStack { - Text("Category") - .foregroundColor(.secondary) + if accounts.app.supportsTrendingCategories { + HStack { + Text("Category") + .foregroundColor(.secondary) - categoryButton + categoryButton + } } #if os(iOS) diff --git a/Shared/Videos/VideoBanner.swift b/Shared/Videos/VideoBanner.swift index 4986894d..b7e6a520 100644 --- a/Shared/Videos/VideoBanner.swift +++ b/Shared/Videos/VideoBanner.swift @@ -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) diff --git a/Shared/Views/ChannelVideosView.swift b/Shared/Views/ChannelVideosView.swift index 14f2ca29..7004d87c 100644 --- a/Shared/Views/ChannelVideosView.swift +++ b/Shared/Views/ChannelVideosView.swift @@ -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,14 +107,16 @@ struct ChannelVideosView: View { var subscriptionToggleButton: some View { Group { - if subscriptions.isSubscribing(channel.id) { - Button("Unsubscribe") { - navigation.presentUnsubscribeAlert(channel) - } - } else { - Button("Subscribe") { - subscriptions.subscribe(channel.id) { - navigation.sidebarSectionChanged.toggle() + if accounts.app.supportsSubscriptions && accounts.signedIn { + if subscriptions.isSubscribing(channel.id) { + Button("Unsubscribe") { + navigation.presentUnsubscribeAlert(channel) + } + } else { + Button("Subscribe") { + subscriptions.subscribe(channel.id) { + navigation.sidebarSectionChanged.toggle() + } } } } diff --git a/Shared/Views/PopularView.swift b/Shared/Views/PopularView.swift index d3a34f60..dfae5110 100644 --- a/Shared/Views/PopularView.swift +++ b/Shared/Views/PopularView.swift @@ -6,16 +6,16 @@ struct PopularView: View { @EnvironmentObject 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") diff --git a/Shared/Views/SearchView.swift b/Shared/Views/SearchView.swift index 3a2f2619..7d05ecc0 100644 --- a/Shared/Views/SearchView.swift +++ b/Shared/Views/SearchView.swift @@ -19,6 +19,7 @@ struct SearchView: View { @Environment(\.navigationStyle) private var navigationStyle + @EnvironmentObject private var accounts @EnvironmentObject private var recents @EnvironmentObject private var state @@ -37,7 +38,9 @@ struct SearchView: View { } else { #if os(tvOS) ScrollView(.vertical, showsIndicators: false) { - filtersHorizontalStack + if accounts.app.supportsSearchFilters { + filtersHorizontalStack + } VideosCellsHorizontal(videos: state.store.collection) } @@ -61,27 +64,28 @@ struct SearchView: View { .toolbar { #if !os(tvOS) ToolbarItemGroup(placement: toolbarPlacement) { - Section { - #if os(macOS) - HStack { - Text("Sort:") - .foregroundColor(.secondary) + if accounts.app.supportsSearchFilters { + Section { + #if os(macOS) + HStack { + Text("Sort:") + .foregroundColor(.secondary) - searchSortOrderPicker - } - #else - Menu("Sort: \(searchSortOrder.name)") { - searchSortOrderPicker - } - #endif + searchSortOrderPicker + } + #else + Menu("Sort: \(searchSortOrder.name)") { + searchSortOrderPicker + } + #endif + } + .transaction { t in t.animation = .none } + + Spacer() + + filtersMenu } - .transaction { t in t.animation = .none } - - Spacer() - - filtersMenu } - #endif } .onAppear { diff --git a/Shared/Views/SubscriptionsView.swift b/Shared/Views/SubscriptionsView.swift index c961d42f..52a0c219 100644 --- a/Shared/Views/SubscriptionsView.swift +++ b/Shared/Views/SubscriptionsView.swift @@ -6,12 +6,8 @@ struct SubscriptionsView: View { @EnvironmentObject 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() } } diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index bd5f3541..55a9dc4d 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -8,6 +8,7 @@ struct VideoContextMenuView: View { @Environment(\.inNavigationView) private var inNavigationView + @EnvironmentObject private var accounts @EnvironmentObject private var navigation @EnvironmentObject private var player @EnvironmentObject private var playlists @@ -25,18 +26,22 @@ struct VideoContextMenuView: View { Section { openChannelButton - subscriptionButton + if accounts.app.supportsSubscriptions { + subscriptionButton + } } - Section { - if navigation.tabSelection != .playlists { - addToPlaylistButton - } else if let playlist = playlists.currentPlaylist { - removeFromPlaylistButton(playlistID: playlist.id) - } + if accounts.app.supportsUserPlaylists { + Section { + if navigation.tabSelection != .playlists { + addToPlaylistButton + } else if let playlist = playlists.currentPlaylist { + removeFromPlaylistButton(playlistID: playlist.id) + } - if case let .playlist(id) = navigation.tabSelection { - removeFromPlaylistButton(playlistID: id) + if case let .playlist(id) = navigation.tabSelection { + removeFromPlaylistButton(playlistID: id) + } } } diff --git a/Shared/Watch Now/WatchNowSection.swift b/Shared/Watch Now/WatchNowSection.swift index b993a9cb..c2286221 100644 --- a/Shared/Watch Now/WatchNowSection.swift +++ b/Shared/Watch Now/WatchNowSection.swift @@ -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 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() } } } diff --git a/Shared/Watch Now/WatchNowView.swift b/Shared/Watch Now/WatchNowView.swift index 49648d85..9c093e52 100644 --- a/Shared/Watch Now/WatchNowView.swift +++ b/Shared/Watch Now/WatchNowView.swift @@ -5,22 +5,22 @@ import SwiftUI struct WatchNowView: View { @EnvironmentObject 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 // =================== diff --git a/macOS/Settings/InstancesSettingsView.swift b/macOS/Settings/InstancesSettingsView.swift index 2e944bb6..7de89800 100644 --- a/macOS/Settings/InstancesSettingsView.swift +++ b/macOS/Settings/InstancesSettingsView.swift @@ -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 [] } diff --git a/tvOS/AccountSelectionView.swift b/tvOS/AccountSelectionView.swift index 4b828bf6..f2393f73 100644 --- a/tvOS/AccountSelectionView.swift +++ b/tvOS/AccountSelectionView.swift @@ -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" } diff --git a/tvOS/TVNavigationView.swift b/tvOS/TVNavigationView.swift index 5caa0058..10b9c073 100644 --- a/tvOS/TVNavigationView.swift +++ b/tvOS/TVNavigationView.swift @@ -2,6 +2,7 @@ import Defaults import SwiftUI struct TVNavigationView: View { + @EnvironmentObject private var accounts @EnvironmentObject private var player @EnvironmentObject private var navigation @EnvironmentObject private var recents @@ -13,21 +14,27 @@ struct TVNavigationView: View { .tabItem { Text("Watch Now") } .tag(TabSelection.watchNow) - SubscriptionsView() - .tabItem { Text("Subscriptions") } - .tag(TabSelection.subscriptions) + if accounts.app.supportsSubscriptions { + SubscriptionsView() + .tabItem { Text("Subscriptions") } + .tag(TabSelection.subscriptions) + } - PopularView() - .tabItem { Text("Popular") } - .tag(TabSelection.popular) + if accounts.app.supportsPopular { + PopularView() + .tabItem { Text("Popular") } + .tag(TabSelection.popular) + } TrendingView() .tabItem { Text("Trending") } .tag(TabSelection.trending) - PlaylistsView() - .tabItem { Text("Playlists") } - .tag(TabSelection.playlists) + if accounts.app.supportsUserPlaylists { + PlaylistsView() + .tabItem { Text("Playlists") } + .tag(TabSelection.playlists) + } NowPlayingView() .tabItem { Text("Now Playing") }