diff --git a/Extensions/Double+Format.swift b/Extensions/Double+Format.swift index 488823d8..903fd5c1 100644 --- a/Extensions/Double+Format.swift +++ b/Extensions/Double+Format.swift @@ -14,4 +14,13 @@ extension Double { 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()) + } } diff --git a/Model/Accounts/Account.swift b/Model/Accounts/Account.swift index 43f7d636..30d93fdf 100644 --- a/Model/Accounts/Account.swift +++ b/Model/Accounts/Account.swift @@ -8,7 +8,9 @@ struct Account: Defaults.Serializable, Hashable, Identifiable { let instanceID: String var name: String? let url: String - let sid: String + let username: String + let password: String? + var token: String? let anonymous: Bool init( @@ -16,7 +18,9 @@ struct Account: Defaults.Serializable, Hashable, Identifiable { instanceID: String? = nil, name: String? = nil, url: String? = nil, - sid: String? = nil, + username: String? = nil, + password: String? = nil, + token: String? = nil, anonymous: Bool = false ) { self.anonymous = anonymous @@ -25,27 +29,29 @@ struct Account: Defaults.Serializable, Hashable, Identifiable { self.instanceID = instanceID ?? UUID().uuidString self.name = name self.url = url ?? "" - self.sid = sid ?? "" + self.username = username ?? "" + self.token = token + self.password = password ?? "" } var instance: Instance! { Defaults[.instances].first { $0.id == instanceID } } - var anonymizedSID: String { - guard sid.count > 3 else { - return "" + var shortUsername: String { + guard username.count > 10 else { + return username } - let index = sid.index(sid.startIndex, offsetBy: 4) - return String(sid[.. let url: String - let account: Account? + let account: Account! var formObjectID: Binding var isValid: Binding @@ -46,7 +46,11 @@ final class AccountValidator: Service { 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() - feed - .load() - .onSuccess { _ in - guard self.account!.sid == self.formObjectID.wrappedValue else { + accountRequest + .onSuccess { response in + guard self.account!.username == self.formObjectID.wrappedValue else { 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 - guard self.account!.sid == self.formObjectID.wrappedValue else { + guard self.account!.username == self.formObjectID.wrappedValue else { 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() { isValid.wrappedValue = false isValidated.wrappedValue = false @@ -116,8 +136,12 @@ final class AccountValidator: Service { error?.wrappedValue = nil } - var cookieHeader: String { - "SID=\(account!.sid)" + var invidiousCookieHeader: String { + "SID=\(account.username)" + } + + var login: Resource { + resource("/login") } var feed: Resource { diff --git a/Model/Accounts/AccountsBridge.swift b/Model/Accounts/AccountsBridge.swift index 8bf87745..9cfa5bfb 100644 --- a/Model/Accounts/AccountsBridge.swift +++ b/Model/Accounts/AccountsBridge.swift @@ -15,7 +15,8 @@ struct AccountsBridge: Defaults.Bridge { "instanceID": value.instanceID, "name": value.name ?? "", "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 instanceID = object["instanceID"], let url = object["apiURL"], - let sid = object["sid"] + let username = object["username"] else { return nil } 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) } } diff --git a/Model/Accounts/AccountsModel.swift b/Model/Accounts/AccountsModel.swift index 31575b30..928d7f32 100644 --- a/Model/Accounts/AccountsModel.swift +++ b/Model/Accounts/AccountsModel.swift @@ -35,7 +35,7 @@ final class AccountsModel: ObservableObject { } var signedIn: Bool { - !isEmpty && !current.anonymous + !isEmpty && !current.anonymous && api.signedIn } init() { @@ -74,8 +74,14 @@ final class AccountsModel: ObservableObject { Defaults[.accounts].first { $0.id == id } } - static func add(instance: Instance, name: String, sid: String) -> Account { - let account = Account(instanceID: instance.id, name: name, url: instance.apiURL, sid: sid) + static func add(instance: Instance, name: String, username: String, password: String? = nil) -> Account { + let account = Account( + instanceID: instance.id, + name: name, + url: instance.apiURL, + username: username, + password: password + ) Defaults[.accounts].append(account) return account diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index 04220eed..805c57ac 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -70,7 +70,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { func configure() { configure { - if !self.account.sid.isEmpty { + if !self.account.username.isEmpty { $0.headers["Cookie"] = self.cookieHeader } $0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"]) @@ -160,7 +160,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } private var cookieHeader: String { - "SID=\(account.sid)" + "SID=\(account.username)" } var popular: Resource? { @@ -185,8 +185,18 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")) } - func channelSubscription(_ id: String) -> Resource? { - resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")).child(id) + func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) { + 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 { @@ -202,7 +212,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } 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? { diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index 01829573..73d7db36 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -4,6 +4,8 @@ import Siesta import SwiftyJSON final class PipedAPI: Service, ObservableObject, VideosAPI { + static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"] + @Published var account: Account! var anonymousAccount: Account { @@ -27,10 +29,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } func configure() { + invalidateConfiguration() + configure { $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) -> Channel? in PipedAPI.extractChannel(from: content.json) } @@ -54,6 +62,38 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { configureTransformer(pathPattern("suggestions")) { (content: Entity) -> [String] in content.json.arrayValue.map(String.init) } + + configureTransformer(pathPattern("subscriptions")) { (content: Entity) -> [Channel] in + content.json.arrayValue.map { PipedAPI.extractChannel(from: $0)! } + } + + configureTransformer(pathPattern("feed")) { (content: Entity) -> [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 { @@ -88,15 +128,34 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { 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 popular: 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 playlistVideo(_: String, _: String) -> Resource? { nil } @@ -211,13 +270,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue + let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ?? + (details["uploaded"]!.double! / 1000).formattedAsRelativeTime()! return Video( videoID: PipedAPI.extractID(from: content), title: details["title"]!.stringValue, author: author, length: details["duration"]!.doubleValue, - published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue, + published: published, views: details["views"]!.intValue, description: PipedAPI.extractDescription(from: content), channel: Channel(id: channelId, name: author), diff --git a/Model/Applications/VideosAPI.swift b/Model/Applications/VideosAPI.swift index cd3499bb..c103aec4 100644 --- a/Model/Applications/VideosAPI.swift +++ b/Model/Applications/VideosAPI.swift @@ -20,7 +20,8 @@ protocol VideosAPI { var popular: 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 playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? diff --git a/Model/Applications/VideosApp.swift b/Model/Applications/VideosApp.swift index 726c793d..43135642 100644 --- a/Model/Applications/VideosApp.swift +++ b/Model/Applications/VideosApp.swift @@ -8,7 +8,11 @@ enum VideosApp: String, CaseIterable { } var supportsAccounts: Bool { - self == .invidious + true + } + + var accountsUsePassword: Bool { + self == .piped } var supportsPopular: Bool { diff --git a/Model/PlaylistsModel.swift b/Model/PlaylistsModel.swift index 763012cc..84c0bfd6 100644 --- a/Model/PlaylistsModel.swift +++ b/Model/PlaylistsModel.swift @@ -28,6 +28,11 @@ final class PlaylistsModel: ObservableObject { } func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) { + guard !resource.isNil else { + playlists = [] + return + } + let request = force ? resource?.load() : resource?.loadIfNeeded() guard !request.isNil else { diff --git a/Model/SubscriptionsModel.swift b/Model/SubscriptionsModel.swift index cbee8a56..435d067e 100644 --- a/Model/SubscriptionsModel.swift +++ b/Model/SubscriptionsModel.swift @@ -19,11 +19,15 @@ final class SubscriptionsModel: ObservableObject { } 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 = {}) { - performRequest(channelID, method: .delete, onSuccess: onSuccess) + accounts.api.unsubscribe(channelID) { + self.scheduleLoad(onSuccess: onSuccess) + } } func isSubscribing(_ channelID: String) -> Bool { @@ -31,6 +35,9 @@ final class SubscriptionsModel: ObservableObject { } func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) { + guard accounts.app.supportsSubscriptions else { + return + } let request = force ? resource?.load() : resource?.loadIfNeeded() request? @@ -45,8 +52,8 @@ final class SubscriptionsModel: ObservableObject { } } - fileprivate func performRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) { - accounts.api.channelSubscription(channelID)?.request(method).onCompletion { _ in + private func scheduleLoad(onSuccess: @escaping () -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.load(force: true, onSuccess: onSuccess) } } diff --git a/README.md b/README.md index 3f7bb905..955b84f6 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ Video player with support for [Invidious](https://github.com/iv-org/invidious) a ### Availability | Feature | Invidious | Piped | | - | - | - | -| User Accounts | ✅ | 🔴 | -| Subscriptions | ✅ | 🔴 | +| User Accounts | ✅ | ✅ | +| Subscriptions | ✅ | ✅ | | Popular | ✅ | 🔴 | | User Playlists | ✅ | 🔴 | | Trending | ✅ | ✅ | diff --git a/Shared/Favorites/FavoriteItemView.swift b/Shared/Favorites/FavoriteItemView.swift index 33f2232b..ffe49f49 100644 --- a/Shared/Favorites/FavoriteItemView.swift +++ b/Shared/Favorites/FavoriteItemView.swift @@ -52,7 +52,7 @@ struct FavoriteItemView: View { .opacity(dragging?.id == item.id ? 0.5 : 1) .onAppear { resource?.addObserver(store) - resource?.loadIfNeeded() + resource?.load() } #if !os(tvOS) .onDrag { diff --git a/Shared/Navigation/AccountsMenuView.swift b/Shared/Navigation/AccountsMenuView.swift index 575a6668..9afdf5c5 100644 --- a/Shared/Navigation/AccountsMenuView.swift +++ b/Shared/Navigation/AccountsMenuView.swift @@ -15,7 +15,7 @@ struct AccountsMenuView: View { } } } label: { - Label(model.current?.name ?? "Select Account", systemImage: "person.crop.circle") + Label(model.current?.description ?? "Select Account", systemImage: "person.crop.circle") .labelStyle(.titleAndIcon) } .disabled(instances.isEmpty) diff --git a/Shared/Navigation/AppSidebarPlaylists.swift b/Shared/Navigation/AppSidebarPlaylists.swift index 890b9ddf..7d92ce8e 100644 --- a/Shared/Navigation/AppSidebarPlaylists.swift +++ b/Shared/Navigation/AppSidebarPlaylists.swift @@ -30,9 +30,6 @@ struct AppSidebarPlaylists: View { newPlaylistButton .padding(.top, 8) } - .onAppear { - playlists.load() - } } var newPlaylistButton: some View { diff --git a/Shared/Navigation/AppSidebarSubscriptions.swift b/Shared/Navigation/AppSidebarSubscriptions.swift index 337c8662..ddbb5c4d 100644 --- a/Shared/Navigation/AppSidebarSubscriptions.swift +++ b/Shared/Navigation/AppSidebarSubscriptions.swift @@ -22,8 +22,5 @@ struct AppSidebarSubscriptions: View { .id("channel\(channel.id)") } } - .onAppear { - subscriptions.load() - } } } diff --git a/Shared/Navigation/Sidebar.swift b/Shared/Navigation/Sidebar.swift index 4e2421d7..add58854 100644 --- a/Shared/Navigation/Sidebar.swift +++ b/Shared/Navigation/Sidebar.swift @@ -3,6 +3,8 @@ import SwiftUI struct Sidebar: View { @EnvironmentObject private var accounts @EnvironmentObject private var navigation + @EnvironmentObject private var playlists + @EnvironmentObject private var subscriptions var body: some View { ScrollViewReader { scrollView in @@ -13,12 +15,25 @@ struct Sidebar: View { AppSidebarRecents() .id("recentlyOpened") - if accounts.signedIn { - AppSidebarSubscriptions() - AppSidebarPlaylists() + if accounts.api.signedIn { + if accounts.app.supportsSubscriptions { + 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 scrollScrollViewToItem(scrollView: scrollView, for: navigation.tabSelection) } diff --git a/Shared/Settings/AccountForm.swift b/Shared/Settings/AccountForm.swift index 11865bf3..0815a3fe 100644 --- a/Shared/Settings/AccountForm.swift +++ b/Shared/Settings/AccountForm.swift @@ -6,11 +6,13 @@ struct AccountForm: View { var selectedAccount: Binding? @State private var name = "" - @State private var sid = "" + @State private var username = "" + @State private var password = "" @State private var isValid = false @State private var isValidated = false @State private var isValidating = false + @State private var validationError: String? @State private var validationDebounce = Debounce() @FocusState private var focused: Bool @@ -67,21 +69,42 @@ struct AccountForm: View { #endif } .onAppear(perform: initializeForm) - .onChange(of: sid) { _ in validate() } + .onChange(of: username) { _ in validate() } + .onChange(of: password) { _ in validate() } } var formFields: some View { Group { - TextField("Name", text: $name, prompt: Text("Account Name (optional)")) - .focused($focused) + if !instance.app.accountsUsePassword { + 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 { HStack { - AccountValidationStatus(isValid: $isValid, isValidated: $isValidated, isValidating: $isValidating, error: .constant(nil)) + AccountValidationStatus( + isValid: $isValid, + isValidated: $isValidated, + isValidating: $isValidating, + error: $validationError + ) Spacer() @@ -106,7 +129,9 @@ struct AccountForm: View { isValid = false validationDebounce.invalidate() - guard !sid.isEmpty else { + let passwordIsValid = instance.app.accountsUsePassword ? !password.isEmpty : true + + guard !username.isEmpty, passwordIsValid else { validator.reset() return } @@ -114,7 +139,7 @@ struct AccountForm: View { isValidating = true validationDebounce.debouncing(1) { - validator.validateInvidiousAccount() + validator.validateAccount() } } @@ -123,7 +148,7 @@ struct AccountForm: View { 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 dismiss() @@ -133,11 +158,12 @@ struct AccountForm: View { AccountValidator( app: .constant(instance.app), url: instance.apiURL, - account: Account(instanceID: instance.id, url: instance.apiURL, sid: sid), - id: $sid, + account: Account(instanceID: instance.id, url: instance.apiURL, username: username, password: password), + id: $username, isValid: $isValid, isValidated: $isValidated, - isValidating: $isValidating + isValidating: $isValidating, + error: $validationError ) } } diff --git a/Shared/Settings/AccountValidationStatus.swift b/Shared/Settings/AccountValidationStatus.swift index a0c86091..001baa64 100644 --- a/Shared/Settings/AccountValidationStatus.swift +++ b/Shared/Settings/AccountValidationStatus.swift @@ -17,8 +17,8 @@ struct AccountValidationStatus: View { VStack(alignment: .leading) { Text(isValid ? "Connected successfully" : "Connection failed") - if !isValid && !error.isNil { - Text(error!) + if let error = error, !isValid { + Text(error) .font(.caption2) .foregroundColor(.secondary) .truncationMode(.tail) diff --git a/Shared/Settings/SettingsView.swift b/Shared/Settings/SettingsView.swift index 4091f1c4..d73bbd45 100644 --- a/Shared/Settings/SettingsView.swift +++ b/Shared/Settings/SettingsView.swift @@ -52,7 +52,7 @@ struct SettingsView: View { .tag(Tabs.services) } .padding(20) - .frame(width: 400, height: 310) + .frame(width: 400, height: 380) #else NavigationView { List { diff --git a/Shared/Views/SubscriptionsView.swift b/Shared/Views/SubscriptionsView.swift index f58efb64..454d28ab 100644 --- a/Shared/Views/SubscriptionsView.swift +++ b/Shared/Views/SubscriptionsView.swift @@ -39,8 +39,13 @@ struct SubscriptionsView: View { fileprivate func loadResources(force: Bool = false) { feed?.addObserver(store) - if let request = force ? accounts.api.home?.load() : accounts.api.home?.loadIfNeeded() { - request.onSuccess { _ in + if accounts.app == .invidious { + // 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) } } else { diff --git a/macOS/Settings/InstancesSettings.swift b/macOS/Settings/InstancesSettings.swift index f3d014e4..c5cb3bb3 100644 --- a/macOS/Settings/InstancesSettings.swift +++ b/macOS/Settings/InstancesSettings.swift @@ -86,8 +86,6 @@ struct InstancesSettings: View { Text("If provided, you can copy links from videos, channels and playlist") .font(.caption) .foregroundColor(.secondary) - - Spacer() } if selectedInstance != nil, !selectedInstance.app.supportsAccounts { diff --git a/tvOS/EditFavorites.swift b/tvOS/EditFavorites.swift index fab5d14d..7e5ad57e 100644 --- a/tvOS/EditFavorites.swift +++ b/tvOS/EditFavorites.swift @@ -44,12 +44,7 @@ struct EditFavorites: View { ForEach(model.addableItems()) { item in HStack { - HStack { - Text(label(item)) - Spacer() - Text("only with Invidious") - .foregroundColor(.secondary) - } + Text(label(item)) Spacer()