Add support for Piped accounts and subscriptions

This commit is contained in:
Arkadiusz Fal 2021-11-15 00:06:01 +01:00
parent a70d4f3b38
commit 0e3effd512
23 changed files with 253 additions and 81 deletions

View File

@ -14,4 +14,13 @@ extension Double {
return formatter.string(from: self) return formatter.string(from: self)
} }
func formattedAsRelativeTime() -> String? {
let date = Date(timeIntervalSince1970: self)
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: Date())
}
} }

View File

@ -8,7 +8,9 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
let instanceID: String let instanceID: String
var name: String? var name: String?
let url: String let url: String
let sid: String let username: String
let password: String?
var token: String?
let anonymous: Bool let anonymous: Bool
init( init(
@ -16,7 +18,9 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
instanceID: String? = nil, instanceID: String? = nil,
name: String? = nil, name: String? = nil,
url: String? = nil, url: String? = nil,
sid: String? = nil, username: String? = nil,
password: String? = nil,
token: String? = nil,
anonymous: Bool = false anonymous: Bool = false
) { ) {
self.anonymous = anonymous self.anonymous = anonymous
@ -25,27 +29,29 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
self.instanceID = instanceID ?? UUID().uuidString self.instanceID = instanceID ?? UUID().uuidString
self.name = name self.name = name
self.url = url ?? "" self.url = url ?? ""
self.sid = sid ?? "" self.username = username ?? ""
self.token = token
self.password = password ?? ""
} }
var instance: Instance! { var instance: Instance! {
Defaults[.instances].first { $0.id == instanceID } Defaults[.instances].first { $0.id == instanceID }
} }
var anonymizedSID: String { var shortUsername: String {
guard sid.count > 3 else { guard username.count > 10 else {
return "" return username
} }
let index = sid.index(sid.startIndex, offsetBy: 4) let index = username.index(username.startIndex, offsetBy: 11)
return String(sid[..<index]) return String(username[..<index])
} }
var description: String { var description: String {
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name! (name != nil && name!.isEmpty) ? shortUsername : name!
} }
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(sid) hasher.combine(username)
} }
} }

View File

@ -5,7 +5,7 @@ import SwiftUI
final class AccountValidator: Service { final class AccountValidator: Service {
let app: Binding<VideosApp> let app: Binding<VideosApp>
let url: String let url: String
let account: Account? let account: Account!
var formObjectID: Binding<String> var formObjectID: Binding<String>
var isValid: Binding<Bool> var isValid: Binding<Bool>
@ -46,7 +46,11 @@ final class AccountValidator: Service {
return return
} }
$0.headers["Cookie"] = self.cookieHeader $0.headers["Cookie"] = self.invidiousCookieHeader
}
configure("/login", requestMethods: [.post]) {
$0.headers["Content-Type"] = "application/json"
} }
} }
@ -84,20 +88,27 @@ final class AccountValidator: Service {
} }
} }
func validateInvidiousAccount() { func validateAccount() {
reset() reset()
feed accountRequest
.load() .onSuccess { response in
.onSuccess { _ in guard self.account!.username == self.formObjectID.wrappedValue else {
guard self.account!.sid == self.formObjectID.wrappedValue else {
return return
} }
self.isValid.wrappedValue = true switch self.app.wrappedValue {
case .invidious:
self.isValid.wrappedValue = true
case .piped:
let error = response.json.dictionaryValue["error"]?.string
let token = response.json.dictionaryValue["token"]?.string
self.isValid.wrappedValue = error?.isEmpty ?? !(token?.isEmpty ?? true)
self.error!.wrappedValue = error
}
} }
.onFailure { _ in .onFailure { _ in
guard self.account!.sid == self.formObjectID.wrappedValue else { guard self.account!.username == self.formObjectID.wrappedValue else {
return return
} }
@ -109,6 +120,15 @@ final class AccountValidator: Service {
} }
} }
var accountRequest: Request {
switch app.wrappedValue {
case .invidious:
return feed.load()
case .piped:
return login.request(.post, json: ["username": account.username, "password": account.password])
}
}
func reset() { func reset() {
isValid.wrappedValue = false isValid.wrappedValue = false
isValidated.wrappedValue = false isValidated.wrappedValue = false
@ -116,8 +136,12 @@ final class AccountValidator: Service {
error?.wrappedValue = nil error?.wrappedValue = nil
} }
var cookieHeader: String { var invidiousCookieHeader: String {
"SID=\(account!.sid)" "SID=\(account.username)"
}
var login: Resource {
resource("/login")
} }
var feed: Resource { var feed: Resource {

View File

@ -15,7 +15,8 @@ struct AccountsBridge: Defaults.Bridge {
"instanceID": value.instanceID, "instanceID": value.instanceID,
"name": value.name ?? "", "name": value.name ?? "",
"apiURL": value.url, "apiURL": value.url,
"sid": value.sid "username": value.username,
"password": value.password ?? ""
] ]
} }
@ -25,13 +26,14 @@ struct AccountsBridge: Defaults.Bridge {
let id = object["id"], let id = object["id"],
let instanceID = object["instanceID"], let instanceID = object["instanceID"],
let url = object["apiURL"], let url = object["apiURL"],
let sid = object["sid"] let username = object["username"]
else { else {
return nil return nil
} }
let name = object["name"] ?? "" let name = object["name"] ?? ""
let password = object["password"]
return Account(id: id, instanceID: instanceID, name: name, url: url, sid: sid) return Account(id: id, instanceID: instanceID, name: name, url: url, username: username, password: password)
} }
} }

View File

@ -35,7 +35,7 @@ final class AccountsModel: ObservableObject {
} }
var signedIn: Bool { var signedIn: Bool {
!isEmpty && !current.anonymous !isEmpty && !current.anonymous && api.signedIn
} }
init() { init() {
@ -74,8 +74,14 @@ final class AccountsModel: ObservableObject {
Defaults[.accounts].first { $0.id == id } Defaults[.accounts].first { $0.id == id }
} }
static func add(instance: Instance, name: String, sid: String) -> Account { static func add(instance: Instance, name: String, username: String, password: String? = nil) -> Account {
let account = Account(instanceID: instance.id, name: name, url: instance.apiURL, sid: sid) let account = Account(
instanceID: instance.id,
name: name,
url: instance.apiURL,
username: username,
password: password
)
Defaults[.accounts].append(account) Defaults[.accounts].append(account)
return account return account

View File

@ -70,7 +70,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
func configure() { func configure() {
configure { configure {
if !self.account.sid.isEmpty { if !self.account.username.isEmpty {
$0.headers["Cookie"] = self.cookieHeader $0.headers["Cookie"] = self.cookieHeader
} }
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"]) $0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
@ -160,7 +160,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
} }
private var cookieHeader: String { private var cookieHeader: String {
"SID=\(account.sid)" "SID=\(account.username)"
} }
var popular: Resource? { var popular: Resource? {
@ -185,8 +185,18 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")) resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
} }
func channelSubscription(_ id: String) -> Resource? { func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")).child(id) resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
.child(channelID)
.request(.post)
.onCompletion { _ in onCompletion() }
}
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
.child(channelID)
.request(.delete)
.onCompletion { _ in onCompletion() }
} }
func channel(_ id: String) -> Resource { func channel(_ id: String) -> Resource {
@ -202,7 +212,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
} }
var playlists: Resource? { var playlists: Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/playlists")) if account.isNil || account.anonymous {
return nil
}
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
} }
func playlist(_ id: String) -> Resource? { func playlist(_ id: String) -> Resource? {

View File

@ -4,6 +4,8 @@ import Siesta
import SwiftyJSON import SwiftyJSON
final class PipedAPI: Service, ObservableObject, VideosAPI { final class PipedAPI: Service, ObservableObject, VideosAPI {
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
@Published var account: Account! @Published var account: Account!
var anonymousAccount: Account { var anonymousAccount: Account {
@ -27,10 +29,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
} }
func configure() { func configure() {
invalidateConfiguration()
configure { configure {
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"]) $0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
} }
configure(whenURLMatches: { url in self.needsAuthorization(url) }) {
$0.headers["Authorization"] = self.account.token
}
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
PipedAPI.extractChannel(from: content.json) PipedAPI.extractChannel(from: content.json)
} }
@ -54,6 +62,38 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
content.json.arrayValue.map(String.init) content.json.arrayValue.map(String.init)
} }
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.map { PipedAPI.extractChannel(from: $0)! }
}
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map { PipedAPI.extractVideo(from: $0)! }
}
if account.token.isNil {
updateToken()
}
}
func needsAuthorization(_ url: URL) -> Bool {
PipedAPI.authorizedEndpoints.contains { url.absoluteString.contains($0) }
}
@discardableResult func updateToken() -> Request {
account.token = nil
return login.request(
.post,
json: ["username": account.username, "password": account.password]
)
.onSuccess { response in
self.account.token = response.json.dictionaryValue["token"]?.string ?? ""
self.configure()
}
}
var login: Resource {
resource(baseURL: account.url, path: "login")
} }
func channel(_ id: String) -> Resource { func channel(_ id: String) -> Resource {
@ -88,15 +128,34 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.instance.apiURL, path: "streams/\(id)") resource(baseURL: account.instance.apiURL, path: "streams/\(id)")
} }
var signedIn: Bool { false } var signedIn: Bool {
!account.anonymous && !(account.token?.isEmpty ?? true)
}
var subscriptions: Resource? {
resource(baseURL: account.instance.apiURL, path: "subscriptions")
}
var feed: Resource? {
resource(baseURL: account.instance.apiURL, path: "feed")
.withParam("authToken", account.token)
}
var subscriptions: Resource? { nil }
var feed: Resource? { nil }
var home: Resource? { nil } var home: Resource? { nil }
var popular: Resource? { nil } var popular: Resource? { nil }
var playlists: Resource? { nil } var playlists: Resource? { nil }
func channelSubscription(_: String) -> Resource? { nil } func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.instance.apiURL, path: "subscribe")
.request(.post, json: ["channelId": channelID])
.onCompletion { _ in onCompletion() }
}
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.instance.apiURL, path: "unsubscribe")
.request(.post, json: ["channelId": channelID])
.onCompletion { _ in onCompletion() }
}
func playlist(_: String) -> Resource? { nil } func playlist(_: String) -> Resource? { nil }
func playlistVideo(_: String, _: String) -> Resource? { nil } func playlistVideo(_: String, _: String) -> Resource? { nil }
@ -211,13 +270,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
} }
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
return Video( return Video(
videoID: PipedAPI.extractID(from: content), videoID: PipedAPI.extractID(from: content),
title: details["title"]!.stringValue, title: details["title"]!.stringValue,
author: author, author: author,
length: details["duration"]!.doubleValue, length: details["duration"]!.doubleValue,
published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue, published: published,
views: details["views"]!.intValue, views: details["views"]!.intValue,
description: PipedAPI.extractDescription(from: content), description: PipedAPI.extractDescription(from: content),
channel: Channel(id: channelId, name: author), channel: Channel(id: channelId, name: author),

View File

@ -20,7 +20,8 @@ protocol VideosAPI {
var popular: Resource? { get } var popular: Resource? { get }
var playlists: Resource? { get } var playlists: Resource? { get }
func channelSubscription(_ id: String) -> Resource? func subscribe(_ channelID: String, onCompletion: @escaping () -> Void)
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void)
func playlist(_ id: String) -> Resource? func playlist(_ id: String) -> Resource?
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?

View File

@ -8,7 +8,11 @@ enum VideosApp: String, CaseIterable {
} }
var supportsAccounts: Bool { var supportsAccounts: Bool {
self == .invidious true
}
var accountsUsePassword: Bool {
self == .piped
} }
var supportsPopular: Bool { var supportsPopular: Bool {

View File

@ -28,6 +28,11 @@ final class PlaylistsModel: ObservableObject {
} }
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) { func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
guard !resource.isNil else {
playlists = []
return
}
let request = force ? resource?.load() : resource?.loadIfNeeded() let request = force ? resource?.load() : resource?.loadIfNeeded()
guard !request.isNil else { guard !request.isNil else {

View File

@ -19,11 +19,15 @@ final class SubscriptionsModel: ObservableObject {
} }
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) { func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performRequest(channelID, method: .post, onSuccess: onSuccess) accounts.api.subscribe(channelID) {
self.scheduleLoad(onSuccess: onSuccess)
}
} }
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) { func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performRequest(channelID, method: .delete, onSuccess: onSuccess) accounts.api.unsubscribe(channelID) {
self.scheduleLoad(onSuccess: onSuccess)
}
} }
func isSubscribing(_ channelID: String) -> Bool { func isSubscribing(_ channelID: String) -> Bool {
@ -31,6 +35,9 @@ final class SubscriptionsModel: ObservableObject {
} }
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) { func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
guard accounts.app.supportsSubscriptions else {
return
}
let request = force ? resource?.load() : resource?.loadIfNeeded() let request = force ? resource?.load() : resource?.loadIfNeeded()
request? request?
@ -45,8 +52,8 @@ final class SubscriptionsModel: ObservableObject {
} }
} }
fileprivate func performRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) { private func scheduleLoad(onSuccess: @escaping () -> Void) {
accounts.api.channelSubscription(channelID)?.request(method).onCompletion { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.load(force: true, onSuccess: onSuccess) self.load(force: true, onSuccess: onSuccess)
} }
} }

View File

@ -20,8 +20,8 @@ Video player with support for [Invidious](https://github.com/iv-org/invidious) a
### Availability ### Availability
| Feature | Invidious | Piped | | Feature | Invidious | Piped |
| - | - | - | | - | - | - |
| User Accounts | ✅ | 🔴 | | User Accounts | ✅ | |
| Subscriptions | ✅ | 🔴 | | Subscriptions | ✅ | |
| Popular | ✅ | 🔴 | | Popular | ✅ | 🔴 |
| User Playlists | ✅ | 🔴 | | User Playlists | ✅ | 🔴 |
| Trending | ✅ | ✅ | | Trending | ✅ | ✅ |

View File

@ -52,7 +52,7 @@ struct FavoriteItemView: View {
.opacity(dragging?.id == item.id ? 0.5 : 1) .opacity(dragging?.id == item.id ? 0.5 : 1)
.onAppear { .onAppear {
resource?.addObserver(store) resource?.addObserver(store)
resource?.loadIfNeeded() resource?.load()
} }
#if !os(tvOS) #if !os(tvOS)
.onDrag { .onDrag {

View File

@ -15,7 +15,7 @@ struct AccountsMenuView: View {
} }
} }
} label: { } label: {
Label(model.current?.name ?? "Select Account", systemImage: "person.crop.circle") Label(model.current?.description ?? "Select Account", systemImage: "person.crop.circle")
.labelStyle(.titleAndIcon) .labelStyle(.titleAndIcon)
} }
.disabled(instances.isEmpty) .disabled(instances.isEmpty)

View File

@ -30,9 +30,6 @@ struct AppSidebarPlaylists: View {
newPlaylistButton newPlaylistButton
.padding(.top, 8) .padding(.top, 8)
} }
.onAppear {
playlists.load()
}
} }
var newPlaylistButton: some View { var newPlaylistButton: some View {

View File

@ -22,8 +22,5 @@ struct AppSidebarSubscriptions: View {
.id("channel\(channel.id)") .id("channel\(channel.id)")
} }
} }
.onAppear {
subscriptions.load()
}
} }
} }

View File

@ -3,6 +3,8 @@ import SwiftUI
struct Sidebar: View { struct Sidebar: View {
@EnvironmentObject<AccountsModel> private var accounts @EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation @EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<SubscriptionsModel> private var subscriptions
var body: some View { var body: some View {
ScrollViewReader { scrollView in ScrollViewReader { scrollView in
@ -13,12 +15,25 @@ struct Sidebar: View {
AppSidebarRecents() AppSidebarRecents()
.id("recentlyOpened") .id("recentlyOpened")
if accounts.signedIn { if accounts.api.signedIn {
AppSidebarSubscriptions() if accounts.app.supportsSubscriptions {
AppSidebarPlaylists() AppSidebarSubscriptions()
}
if accounts.app.supportsUserPlaylists {
AppSidebarPlaylists()
}
} }
} }
} }
.onAppear {
subscriptions.load()
playlists.load()
}
.onChange(of: accounts.signedIn) { _ in
subscriptions.load(force: true)
playlists.load(force: true)
}
.onChange(of: navigation.sidebarSectionChanged) { _ in .onChange(of: navigation.sidebarSectionChanged) { _ in
scrollScrollViewToItem(scrollView: scrollView, for: navigation.tabSelection) scrollScrollViewToItem(scrollView: scrollView, for: navigation.tabSelection)
} }

View File

@ -6,11 +6,13 @@ struct AccountForm: View {
var selectedAccount: Binding<Account?>? var selectedAccount: Binding<Account?>?
@State private var name = "" @State private var name = ""
@State private var sid = "" @State private var username = ""
@State private var password = ""
@State private var isValid = false @State private var isValid = false
@State private var isValidated = false @State private var isValidated = false
@State private var isValidating = false @State private var isValidating = false
@State private var validationError: String?
@State private var validationDebounce = Debounce() @State private var validationDebounce = Debounce()
@FocusState private var focused: Bool @FocusState private var focused: Bool
@ -67,21 +69,42 @@ struct AccountForm: View {
#endif #endif
} }
.onAppear(perform: initializeForm) .onAppear(perform: initializeForm)
.onChange(of: sid) { _ in validate() } .onChange(of: username) { _ in validate() }
.onChange(of: password) { _ in validate() }
} }
var formFields: some View { var formFields: some View {
Group { Group {
TextField("Name", text: $name, prompt: Text("Account Name (optional)")) if !instance.app.accountsUsePassword {
.focused($focused) TextField("Name", text: $name, prompt: Text("Account Name (optional)"))
.focused($focused)
}
TextField("SID", text: $sid, prompt: Text("Invidious SID Cookie")) TextField("Username", text: $username, prompt: usernamePrompt)
if instance.app.accountsUsePassword {
SecureField("Password", text: $password, prompt: Text("Password"))
}
}
}
var usernamePrompt: Text {
switch instance.app {
case .invidious:
return Text("SID Cookie")
default:
return Text("Username")
} }
} }
var footer: some View { var footer: some View {
HStack { HStack {
AccountValidationStatus(isValid: $isValid, isValidated: $isValidated, isValidating: $isValidating, error: .constant(nil)) AccountValidationStatus(
isValid: $isValid,
isValidated: $isValidated,
isValidating: $isValidating,
error: $validationError
)
Spacer() Spacer()
@ -106,7 +129,9 @@ struct AccountForm: View {
isValid = false isValid = false
validationDebounce.invalidate() validationDebounce.invalidate()
guard !sid.isEmpty else { let passwordIsValid = instance.app.accountsUsePassword ? !password.isEmpty : true
guard !username.isEmpty, passwordIsValid else {
validator.reset() validator.reset()
return return
} }
@ -114,7 +139,7 @@ struct AccountForm: View {
isValidating = true isValidating = true
validationDebounce.debouncing(1) { validationDebounce.debouncing(1) {
validator.validateInvidiousAccount() validator.validateAccount()
} }
} }
@ -123,7 +148,7 @@ struct AccountForm: View {
return return
} }
let account = AccountsModel.add(instance: instance, name: name, sid: sid) let account = AccountsModel.add(instance: instance, name: name, username: username, password: password)
selectedAccount?.wrappedValue = account selectedAccount?.wrappedValue = account
dismiss() dismiss()
@ -133,11 +158,12 @@ struct AccountForm: View {
AccountValidator( AccountValidator(
app: .constant(instance.app), app: .constant(instance.app),
url: instance.apiURL, url: instance.apiURL,
account: Account(instanceID: instance.id, url: instance.apiURL, sid: sid), account: Account(instanceID: instance.id, url: instance.apiURL, username: username, password: password),
id: $sid, id: $username,
isValid: $isValid, isValid: $isValid,
isValidated: $isValidated, isValidated: $isValidated,
isValidating: $isValidating isValidating: $isValidating,
error: $validationError
) )
} }
} }

View File

@ -17,8 +17,8 @@ struct AccountValidationStatus: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(isValid ? "Connected successfully" : "Connection failed") Text(isValid ? "Connected successfully" : "Connection failed")
if !isValid && !error.isNil { if let error = error, !isValid {
Text(error!) Text(error)
.font(.caption2) .font(.caption2)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.truncationMode(.tail) .truncationMode(.tail)

View File

@ -52,7 +52,7 @@ struct SettingsView: View {
.tag(Tabs.services) .tag(Tabs.services)
} }
.padding(20) .padding(20)
.frame(width: 400, height: 310) .frame(width: 400, height: 380)
#else #else
NavigationView { NavigationView {
List { List {

View File

@ -39,8 +39,13 @@ struct SubscriptionsView: View {
fileprivate func loadResources(force: Bool = false) { fileprivate func loadResources(force: Bool = false) {
feed?.addObserver(store) feed?.addObserver(store)
if let request = force ? accounts.api.home?.load() : accounts.api.home?.loadIfNeeded() { if accounts.app == .invidious {
request.onSuccess { _ in // Invidious for some reason won't refresh feed until homepage is loaded
if let request = force ? accounts.api.home?.load() : accounts.api.home?.loadIfNeeded() {
request.onSuccess { _ in
loadFeed(force: force)
}
} else {
loadFeed(force: force) loadFeed(force: force)
} }
} else { } else {

View File

@ -86,8 +86,6 @@ struct InstancesSettings: View {
Text("If provided, you can copy links from videos, channels and playlist") Text("If provided, you can copy links from videos, channels and playlist")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Spacer()
} }
if selectedInstance != nil, !selectedInstance.app.supportsAccounts { if selectedInstance != nil, !selectedInstance.app.supportsAccounts {

View File

@ -44,12 +44,7 @@ struct EditFavorites: View {
ForEach(model.addableItems()) { item in ForEach(model.addableItems()) { item in
HStack { HStack {
HStack { Text(label(item))
Text(label(item))
Spacer()
Text("only with Invidious")
.foregroundColor(.secondary)
}
Spacer() Spacer()