diff --git a/Extensions/Sequence+Unique.swift b/Extensions/Sequence+Unique.swift new file mode 100644 index 00000000..1fe6bd6b --- /dev/null +++ b/Extensions/Sequence+Unique.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Sequence where Iterator.Element: Hashable { + func unique() -> [Iterator.Element] { + var seen: Set = [] + return filter { seen.insert($0).inserted } + } +} diff --git a/Fixtures/View+Fixtures.swift b/Fixtures/View+Fixtures.swift index 247c4e13..03bc4b74 100644 --- a/Fixtures/View+Fixtures.swift +++ b/Fixtures/View+Fixtures.swift @@ -7,6 +7,7 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier { .environmentObject(AccountsModel()) .environmentObject(comments) .environmentObject(InstancesModel()) + .environmentObject(InstancesManifest()) .environmentObject(invidious) .environmentObject(NavigationModel()) .environmentObject(NetworkStateModel()) diff --git a/Model/Accounts/Account.swift b/Model/Accounts/Account.swift index 30d93fdf..47ff8fd9 100644 --- a/Model/Accounts/Account.swift +++ b/Model/Accounts/Account.swift @@ -5,37 +5,50 @@ struct Account: Defaults.Serializable, Hashable, Identifiable { static var bridge = AccountsBridge() let id: String - let instanceID: String + let app: VideosApp + let instanceID: String? var name: String? let url: String let username: String let password: String? var token: String? let anonymous: Bool + let country: String? + let region: String? init( id: String? = nil, + app: VideosApp? = nil, instanceID: String? = nil, name: String? = nil, url: String? = nil, username: String? = nil, password: String? = nil, token: String? = nil, - anonymous: Bool = false + anonymous: Bool = false, + country: String? = nil, + region: String? = nil ) { self.anonymous = anonymous - self.id = id ?? (anonymous ? "anonymous-\(instanceID!)" : UUID().uuidString) - self.instanceID = instanceID ?? UUID().uuidString + self.id = id ?? (anonymous ? "anonymous-\(instanceID ?? url ?? UUID().uuidString)" : UUID().uuidString) + self.app = app ?? .invidious + self.instanceID = instanceID self.name = name self.url = url ?? "" self.username = username ?? "" self.token = token self.password = password ?? "" + self.country = country + self.region = region } var instance: Instance! { - Defaults[.instances].first { $0.id == instanceID } + Defaults[.instances].first { $0.id == instanceID } ?? Instance(app: app, name: url, apiURL: url) + } + + var isPublic: Bool { + instanceID.isNil } var shortUsername: String { diff --git a/Model/Accounts/AccountValidator.swift b/Model/Accounts/AccountValidator.swift index 4983a57a..309595c4 100644 --- a/Model/Accounts/AccountValidator.swift +++ b/Model/Accounts/AccountValidator.swift @@ -3,7 +3,7 @@ import Siesta import SwiftUI final class AccountValidator: Service { - let app: Binding + let app: Binding let url: String let account: Account! @@ -13,8 +13,10 @@ final class AccountValidator: Service { var isValidating: Binding var error: Binding? + private var appsToValidateInstance = VideosApp.allCases + init( - app: Binding, + app: Binding, url: String, account: Account? = nil, id: Binding, @@ -54,82 +56,128 @@ final class AccountValidator: Service { } } + func instanceValidationResource(_ app: VideosApp) -> Resource { + switch app { + case .invidious: + return resource("/api/v1/videos/dQw4w9WgXcQ") + + case .piped: + return resource("/streams/dQw4w9WgXcQ") + } + } + func validateInstance() { reset() - neverGonnaGiveYouUp + guard let app = appsToValidateInstance.popLast() else { return } + tryValidatingUsing(app) + } + + func tryValidatingUsing(_ app: VideosApp) { + instanceValidationResource(app) .load() .onSuccess { response in guard self.url == self.formObjectID.wrappedValue else { return } + guard !response.json.isEmpty else { + guard let app = self.appsToValidateInstance.popLast() else { + self.isValid.wrappedValue = false + self.isValidated.wrappedValue = true + self.isValidating.wrappedValue = false + return + } + + self.tryValidatingUsing(app) + return + } + let json = response.json.dictionaryValue - let author = self.app.wrappedValue == .invidious ? json["author"] : json["uploader"] + let author = app == .invidious ? json["author"] : json["uploader"] if author == "Rick Astley" { + self.app.wrappedValue = app self.isValid.wrappedValue = true self.error?.wrappedValue = nil } else { self.isValid.wrappedValue = false } + self.isValidated.wrappedValue = true + self.isValidating.wrappedValue = false } .onFailure { error in guard self.url == self.formObjectID.wrappedValue else { return } - self.isValid.wrappedValue = false - self.error?.wrappedValue = error.userMessage - } - .onCompletion { _ in - self.isValidated.wrappedValue = true - self.isValidating.wrappedValue = false + if self.appsToValidateInstance.isEmpty { + self.isValidating.wrappedValue = false + self.isValidated.wrappedValue = true + self.isValid.wrappedValue = false + self.error?.wrappedValue = error.userMessage + } else { + guard let app = self.appsToValidateInstance.popLast() else { return } + self.tryValidatingUsing(app) + } } } func validateAccount() { reset() - accountRequest - .onSuccess { response in - guard self.account!.username == self.formObjectID.wrappedValue else { - return - } + guard let request = accountRequest else { + isValid.wrappedValue = false + isValidated.wrappedValue = true + isValidating.wrappedValue = false - 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!.username == self.formObjectID.wrappedValue else { - return - } + return + } - self.isValid.wrappedValue = false + request.onSuccess { response in + guard self.account!.username == self.formObjectID.wrappedValue else { + return } - .onCompletion { _ in - self.isValidated.wrappedValue = true - self.isValidating.wrappedValue = false + + 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 + default: + return } + } + .onFailure { _ in + guard self.account!.username == self.formObjectID.wrappedValue else { + return + } + + self.isValid.wrappedValue = false + } + .onCompletion { _ in + self.isValidated.wrappedValue = true + self.isValidating.wrappedValue = false + } } - var accountRequest: Request { + var accountRequest: Request? { switch app.wrappedValue { case .invidious: return feed.load() case .piped: return login.request(.post, json: ["username": account.username, "password": account.password]) + default: + return nil } } func reset() { + appsToValidateInstance = VideosApp.allCases + app.wrappedValue = nil isValid.wrappedValue = false isValidated.wrappedValue = false isValidating.wrappedValue = false diff --git a/Model/Accounts/AccountsBridge.swift b/Model/Accounts/AccountsBridge.swift index 9cfa5bfb..22bfb597 100644 --- a/Model/Accounts/AccountsBridge.swift +++ b/Model/Accounts/AccountsBridge.swift @@ -12,7 +12,7 @@ struct AccountsBridge: Defaults.Bridge { return [ "id": value.id, - "instanceID": value.instanceID, + "instanceID": value.instanceID ?? "", "name": value.name ?? "", "apiURL": value.url, "username": value.username, diff --git a/Model/Accounts/AccountsModel.swift b/Model/Accounts/AccountsModel.swift index 2f067641..f9c92c62 100644 --- a/Model/Accounts/AccountsModel.swift +++ b/Model/Accounts/AccountsModel.swift @@ -8,6 +8,8 @@ final class AccountsModel: ObservableObject { @Published private var invidious = InvidiousAPI() @Published private var piped = PipedAPI() + @Published var publicAccount: Account? + private var cancellables = [AnyCancellable]() var all: [Account] { @@ -70,7 +72,7 @@ final class AccountsModel: ObservableObject { piped.setAccount(account) } - Defaults[.lastAccountID] = account.anonymous ? nil : account.id + Defaults[.lastAccountID] = account.anonymous ? (account.isPublic ? "public" : nil) : account.id Defaults[.lastInstanceID] = account.instanceID } diff --git a/Model/InstancesManifest.swift b/Model/InstancesManifest.swift new file mode 100644 index 00000000..1c47dbf7 --- /dev/null +++ b/Model/InstancesManifest.swift @@ -0,0 +1,104 @@ +import Defaults +import Foundation +import Siesta +import SwiftyJSON + +final class InstancesManifest: Service, ObservableObject { + static let builtinManifestUrl = "https://r.yattee.stream/manifest.json" + static let shared = InstancesManifest() + + @Published var instances = [ManifestedInstance]() + + init() { + super.init() + + configure { + $0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"]) + } + + configureTransformer( + manifestURL, + requestMethods: [.get] + ) { (content: Entity + ) -> [ManifestedInstance] in + guard let instances = content.json.dictionaryValue["instances"] else { return [] } + + return instances.arrayValue.compactMap(self.extractInstance) + } + } + + func setPublicAccount(_ country: String?, accounts: AccountsModel, asCurrent: Bool = true) { + guard let country = country else { + accounts.publicAccount = nil + if asCurrent { + accounts.setCurrent(nil) + } + return + } + + instancesList.load().onSuccess { response in + if let instances: [ManifestedInstance] = response.typedContent() { + guard let instance = instances.filter { $0.country == country }.randomElement() else { return } + let account = instance.anonymousAccount + accounts.publicAccount = account + if asCurrent { + accounts.setCurrent(account) + } + } + } + } + + func changePublicAccount(_ accounts: AccountsModel, settings: SettingsModel) { + instancesList.load().onSuccess { response in + if let instances: [ManifestedInstance] = response.typedContent() { + let countryInstances = instances.filter { $0.country == Defaults[.countryOfPublicInstances] } + let region = countryInstances.first?.region ?? "Europe" + var regionInstances = instances.filter { $0.region == region } + + if let publicAccountUrl = accounts.publicAccount?.url { + regionInstances = regionInstances.filter { $0.url.absoluteString != publicAccountUrl } + } + + guard let instance = regionInstances.randomElement() else { + settings.presentAlert(title: "Could not change location", message: "No locations available at the moment") + return + } + + let account = instance.anonymousAccount + accounts.publicAccount = account + accounts.setCurrent(account) + } + } + } + + func extractInstance(from json: JSON) -> ManifestedInstance? { + guard let app = json["app"].string, + let videosApp = VideosApp(rawValue: app.lowercased()), + let region = json["region"].string, + let country = json["country"].string, + let flag = json["flag"].string, + let url = json["url"].url else { return nil } + + return ManifestedInstance( + app: videosApp, + country: country, + region: region, + flag: flag, + url: url + ) + } + + var manifestURL: String { + var url = Defaults[.instancesManifest] + + if url.isEmpty { + url = Self.builtinManifestUrl + } + + return url + } + + var instancesList: Resource { + resource(absoluteURL: manifestURL) + } +} diff --git a/Model/ManifestedInstance.swift b/Model/ManifestedInstance.swift new file mode 100644 index 00000000..545e4685 --- /dev/null +++ b/Model/ManifestedInstance.swift @@ -0,0 +1,30 @@ +import Foundation + +struct ManifestedInstance: Identifiable, Hashable { + let id = UUID().uuidString + let app: VideosApp + let country: String + let region: String + let flag: String + let url: URL + + var instance: Instance { + .init(app: app, name: "Public - \(country)", apiURL: url.absoluteString) + } + + var location: String { + "\(flag) \(country)" + } + + var anonymousAccount: Account { + .init( + id: UUID().uuidString, + app: app, + name: location, + url: url.absoluteString, + anonymous: true, + country: country, + region: region + ) + } +} diff --git a/Model/NavigationModel.swift b/Model/NavigationModel.swift index cb36f296..e073d560 100644 --- a/Model/NavigationModel.swift +++ b/Model/NavigationModel.swift @@ -221,8 +221,9 @@ final class NavigationModel: ObservableObject { #endif } - func presentAlert(title: String, message: String) { - alert = Alert(title: Text(title), message: Text(message)) + func presentAlert(title: String, message: String? = nil) { + let message = message.isNil ? nil : Text(message!) + alert = Alert(title: Text(title), message: message) presentingAlert = true } diff --git a/Model/SettingsModel.swift b/Model/SettingsModel.swift new file mode 100644 index 00000000..47bf3a24 --- /dev/null +++ b/Model/SettingsModel.swift @@ -0,0 +1,18 @@ +import Foundation +import SwiftUI + +class SettingsModel: ObservableObject { + @Published var presentingAlert = false + @Published var alert = Alert(title: Text("Error")) + + func presentAlert(title: String, message: String? = nil) { + let message = message.isNil ? nil : Text(message!) + alert = Alert(title: Text(title), message: message) + presentingAlert = true + } + + func presentAlert(_ alert: Alert) { + self.alert = alert + presentingAlert = true + } +} diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 0df704dc..1bdf77ce 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -18,16 +18,10 @@ extension Defaults.Keys { static let defaultForPlayerDetailsPageButtonLabelStyle = UIDevice.current.userInterfaceIdiom == .phone ? PlayerDetailsPageButtonLabelStyle.iconOnly : .iconAndText #endif - static let kavinPipedInstanceID = "kavin-piped" - static let instances = Key<[Instance]>("instances", default: [ - .init( - app: .piped, - id: kavinPipedInstanceID, - name: "Kavin", - apiURL: "https://pipedapi.kavin.rocks", - frontendURL: "https://piped.kavin.rocks" - ) - ]) + static let instancesManifest = Key("instancesManifest", default: "") + static let countryOfPublicInstances = Key("countryOfPublicInstances") + + static let instances = Key<[Instance]>("instances", default: []) static let accounts = Key<[Account]>("accounts", default: []) static let lastAccountID = Key("lastAccountID") static let lastInstanceID = Key("lastInstanceID") @@ -59,7 +53,7 @@ extension Defaults.Keys { static let playerInstanceID = Key("playerInstance") static let showKeywords = Key("showKeywords", default: false) static let showHistoryInPlayer = Key("showHistoryInPlayer", default: false) - static let commentsInstanceID = Key("commentsInstance", default: kavinPipedInstanceID) + static let commentsInstanceID = Key("commentsInstance", default: nil) #if !os(tvOS) static let commentsPlacement = Key("commentsPlacement", default: .separate) #endif diff --git a/Shared/Navigation/AccountsMenuView.swift b/Shared/Navigation/AccountsMenuView.swift index e5baaf60..4f534f81 100644 --- a/Shared/Navigation/AccountsMenuView.swift +++ b/Shared/Navigation/AccountsMenuView.swift @@ -8,45 +8,50 @@ struct AccountsMenuView: View { @Default(.instances) private var instances @Default(.accountPickerDisplaysUsername) private var accountPickerDisplaysUsername - var body: some View { - Menu { - ForEach(allAccounts, id: \.id) { account in - Button { - model.setCurrent(account) - } label: { - HStack { - Text(accountButtonTitle(account: account)) + @ViewBuilder var body: some View { + if !instances.isEmpty { + Menu { + ForEach(allAccounts, id: \.id) { account in + Button { + model.setCurrent(account) + } label: { + HStack { + Text(accountButtonTitle(account: account)) - Spacer() + Spacer() - if model.current == account { - Image(systemName: "checkmark") + if model.current == account { + Image(systemName: "checkmark") + } } } } - } - } label: { - HStack { - Image(systemName: "person.crop.circle") - if accountPickerDisplaysUsername { - label - .labelStyle(.titleOnly) + } label: { + HStack { + if !accountPickerDisplaysUsername || !(model.current?.isPublic ?? true) { + Image(systemName: "globe") + } + + if accountPickerDisplaysUsername { + label + .labelStyle(.titleOnly) + } } } + .disabled(allAccounts.isEmpty) + .transaction { t in t.animation = .none } } - .disabled(instances.isEmpty) - .transaction { t in t.animation = .none } } private var label: some View { - Label(model.current?.description ?? "Select Account", systemImage: "person.crop.circle") + Label(model.current?.description ?? "Select Account", systemImage: "globe") } private var allAccounts: [Account] { - accounts + instances.map(\.anonymousAccount) + accounts + instances.map(\.anonymousAccount) + [model.publicAccount].compactMap { $0 } } private func accountButtonTitle(account: Account) -> String { - instances.count > 1 ? "\(account.description) — \(account.instance.description)" : account.description + account.isPublic ? account.description : "\(account.description) — \(account.instance.shortDescription)" } } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 5e96e13a..8636e309 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -19,6 +19,7 @@ struct ContentView: View { @EnvironmentObject private var playlists @EnvironmentObject private var recents @EnvironmentObject private var search + @EnvironmentObject private var settings @EnvironmentObject private var subscriptions @EnvironmentObject private var thumbnailsModel @@ -42,6 +43,7 @@ struct ContentView: View { AppSidebarNavigation() #elseif os(tvOS) TVNavigationView() + .environmentObject(settings) #endif } .onChange(of: accounts.signedIn) { _ in @@ -105,10 +107,12 @@ struct ContentView: View { } ) .background( - EmptyView().sheet(isPresented: $navigation.presentingSettings, onDismiss: openWelcomeScreenIfAccountEmpty) { + EmptyView().sheet(isPresented: $navigation.presentingSettings) { SettingsView() .environmentObject(accounts) .environmentObject(instances) + .environmentObject(settings) + .environmentObject(navigation) .environmentObject(player) } ) @@ -126,14 +130,6 @@ struct ContentView: View { #endif } - func openWelcomeScreenIfAccountEmpty() { - guard Defaults[.instances].isEmpty else { - return - } - - navigation.presentingWelcomeScreen = true - } - var videoPlayer: some View { VideoPlayerView() .environmentObject(accounts) diff --git a/Shared/Player/AppleAVPlayerViewController.swift b/Shared/Player/AppleAVPlayerViewController.swift index 880af902..e29a9eb0 100644 --- a/Shared/Player/AppleAVPlayerViewController.swift +++ b/Shared/Player/AppleAVPlayerViewController.swift @@ -66,9 +66,7 @@ final class AppleAVPlayerViewController: UIViewController { #if os(tvOS) var infoViewControllers = [UIHostingController]() - if CommentsModel.enabled { - infoViewControllers.append(infoViewController([.comments], title: "Comments")) - } + infoViewControllers.append(infoViewController([.comments], title: "Comments")) var queueSections = [NowPlayingView.ViewSection.playingNext] if Defaults[.showHistoryInPlayer] { diff --git a/Shared/Settings/AccountForm.swift b/Shared/Settings/AccountForm.swift index 9e1a0158..47df0120 100644 --- a/Shared/Settings/AccountForm.swift +++ b/Shared/Settings/AccountForm.swift @@ -118,6 +118,7 @@ struct AccountForm: View { var footer: some View { HStack { AccountValidationStatus( + app: .constant(instance.app), isValid: $isValid, isValidated: $isValidated, isValidating: $isValidating, diff --git a/Shared/Settings/AccountValidationStatus.swift b/Shared/Settings/AccountValidationStatus.swift index 001baa64..7928686d 100644 --- a/Shared/Settings/AccountValidationStatus.swift +++ b/Shared/Settings/AccountValidationStatus.swift @@ -2,6 +2,7 @@ import Foundation import SwiftUI struct AccountValidationStatus: View { + @Binding var app: VideosApp? @Binding var isValid: Bool @Binding var isValidated: Bool @Binding var isValidating: Bool @@ -16,7 +17,7 @@ struct AccountValidationStatus: View { .opacity(isValidating ? 1 : (isValidated ? 1 : 0)) VStack(alignment: .leading) { - Text(isValid ? "Connected successfully" : "Connection failed") + Text(isValid ? "Connected successfully (\(app?.name ?? "Unknown"))" : "Connection failed") if let error = error, !isValid { Text(error) .font(.caption2) diff --git a/Shared/Settings/AccountsNavigationLink.swift b/Shared/Settings/AccountsNavigationLink.swift index d7f70824..492f33d1 100644 --- a/Shared/Settings/AccountsNavigationLink.swift +++ b/Shared/Settings/AccountsNavigationLink.swift @@ -11,6 +11,10 @@ struct AccountsNavigationLink: View { .buttonStyle(.plain) .contextMenu { removeInstanceButton(instance) + + #if os(tvOS) + Button("Cancel", role: .cancel) {} + #endif } } diff --git a/Shared/Settings/AdvancedSettings.swift b/Shared/Settings/AdvancedSettings.swift new file mode 100644 index 00000000..ce5a08cb --- /dev/null +++ b/Shared/Settings/AdvancedSettings.swift @@ -0,0 +1,57 @@ +import Defaults +import SwiftUI + +struct AdvancedSettings: View { + @Default(.instancesManifest) private var instancesManifest + @Default(.showMPVPlaybackStats) private var showMPVPlaybackStats + + var body: some View { + VStack(alignment: .leading) { + #if os(macOS) + advancedSettings + Spacer() + #else + List { + advancedSettings + } + #if os(iOS) + .listStyle(.insetGrouped) + #endif + #endif + } + #if os(tvOS) + .frame(maxWidth: 1000) + #endif + .navigationTitle("Advanced") + } + + @ViewBuilder var advancedSettings: some View { + Section(header: manifestHeader, footer: manifestFooter) { + TextField("URL", text: $instancesManifest) + } + .padding(.bottom, 4) + + Section(header: SettingsHeader(text: "Debugging")) { + showMPVPlaybackStatsToggle + } + } + + var manifestHeader: some View { + SettingsHeader(text: "Public Manifest") + } + + var manifestFooter: some View { + Text("You can create your own locations manifest and set its URL here to replace the built-in one") + .foregroundColor(.secondary) + } + + var showMPVPlaybackStatsToggle: some View { + Toggle("Show MPV playback statistics", isOn: $showMPVPlaybackStats) + } +} + +struct AdvancedSettings_Previews: PreviewProvider { + static var previews: some View { + AdvancedSettings() + } +} diff --git a/Shared/Settings/InstanceForm.swift b/Shared/Settings/InstanceForm.swift index c7723e71..05f2540d 100644 --- a/Shared/Settings/InstanceForm.swift +++ b/Shared/Settings/InstanceForm.swift @@ -5,8 +5,8 @@ struct InstanceForm: View { @State private var name = "" @State private var url = "" - @State private var app = VideosApp.invidious + @State private var app: VideosApp? @State private var isValid = false @State private var isValidated = false @State private var isValidating = false @@ -27,7 +27,6 @@ struct InstanceForm: View { } .frame(maxWidth: 1000) } - .onChange(of: app) { _ in validate() } .onChange(of: url) { _ in validate() } #if os(iOS) .padding(.vertical) @@ -35,13 +34,13 @@ struct InstanceForm: View { .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .background(Color.background(scheme: colorScheme)) #else - .frame(width: 400, height: 190) + .frame(width: 400, height: 150) #endif } private var header: some View { HStack(alignment: .center) { - Text("Add Instance") + Text("Add Location") .font(.title2.bold()) Spacer() @@ -71,17 +70,9 @@ struct InstanceForm: View { private var formFields: some View { Group { - Picker("Application", selection: $app) { - ForEach(VideosApp.allCases, id: \.self) { app in - Text(app.rawValue.capitalized).tag(app) - } - } - .pickerStyle(.segmented) - .labelsHidden() - TextField("Name", text: $name) - TextField("API URL", text: $url) + TextField("URL", text: $url) #if !os(macOS) .autocapitalization(.none) @@ -92,7 +83,13 @@ struct InstanceForm: View { private var footer: some View { HStack(alignment: .center) { - AccountValidationStatus(isValid: $isValid, isValidated: $isValidated, isValidating: $isValidating, error: $validationError) + AccountValidationStatus( + app: $app, + isValid: $isValid, + isValidated: $isValidated, + isValidating: $isValidating, + error: $validationError + ) Spacer() @@ -137,7 +134,7 @@ struct InstanceForm: View { } func submitForm() { - guard isValid else { + guard isValid, let app = app else { return } diff --git a/Shared/Settings/LocationsSettings.swift b/Shared/Settings/LocationsSettings.swift new file mode 100644 index 00000000..3eaa73d6 --- /dev/null +++ b/Shared/Settings/LocationsSettings.swift @@ -0,0 +1,119 @@ +import Defaults +import SwiftUI + +struct LocationsSettings: View { + @State private var countries = [String]() + @State private var presentingInstanceForm = false + @State private var savedFormInstanceID: Instance.ID? + + @EnvironmentObject private var accounts + @EnvironmentObject private var navigation + @EnvironmentObject private var model + + @Default(.countryOfPublicInstances) private var countryOfPublicInstances + @Default(.instances) private var instances + + var body: some View { + VStack(alignment: .leading) { + #if os(macOS) + settings + Spacer() + #else + List { + settings + } + #if os(iOS) + .listStyle(.insetGrouped) + #endif + #endif + } + .onAppear(perform: loadCountries) + .onChange(of: countryOfPublicInstances) { newCountry in + InstancesManifest.shared.setPublicAccount(newCountry, accounts: accounts, asCurrent: accounts.current?.isPublic ?? true) + } + .sheet(isPresented: $presentingInstanceForm) { + InstanceForm(savedInstanceID: $savedFormInstanceID) + } + #if os(tvOS) + .frame(maxWidth: 1000) + #endif + .navigationTitle("Locations") + } + + @ViewBuilder var settings: some View { + Section(header: SettingsHeader(text: "Public Locations"), footer: countryFooter) { + Picker("Country", selection: $countryOfPublicInstances) { + Text("Don't use public locations").tag(String?.none) + ForEach(countries, id: \.self) { country in + Text(country).tag(Optional(country)) + } + } + #if os(tvOS) + .pickerStyle(.inline) + #endif + .disabled(countries.isEmpty) + + Button { + InstancesManifest.shared.changePublicAccount(accounts, settings: model) + } label: { + if let account = accounts.current, account.isPublic { + Text("Switch to other public location") + } else { + Text("Switch to public locations") + } + } + .disabled(countryOfPublicInstances.isNil) + } + + Section(header: SettingsHeader(text: "Custom Locations")) { + #if os(macOS) + InstancesSettings() + #else + ForEach(instances) { instance in + AccountsNavigationLink(instance: instance) + } + addInstanceButton + #endif + } + } + + @ViewBuilder var countryFooter: some View { + if let account = accounts.current { + let locationType = account.isPublic ? (account.country ?? "Unknown") : "Custom" + let description = account.isPublic ? account.url : account.instance?.description ?? "unknown" + + Text("Current: \(locationType)\n\(description)") + .foregroundColor(.secondary) + #if os(macOS) + .padding(.bottom, 10) + #endif + } + } + + func loadCountries() { + InstancesManifest.shared.instancesList.load() + .onSuccess { response in + if let instances: [ManifestedInstance] = response.typedContent() { + self.countries = instances.map(\.country).unique().sorted() + } + }.onFailure { _ in + model.presentAlert(title: "Could not load locations manifest") + } + } + + private var addInstanceButton: some View { + Button { + presentingInstanceForm = true + } label: { + Label("Add Location...", systemImage: "plus") + } + } +} + +struct LocationsSettings_Previews: PreviewProvider { + static var previews: some View { + LocationsSettings() + .environmentObject(AccountsModel()) + .environmentObject(NavigationModel()) + } +} diff --git a/Shared/Settings/PlayerSettings.swift b/Shared/Settings/PlayerSettings.swift index 60f7d0ef..ea4601b7 100644 --- a/Shared/Settings/PlayerSettings.swift +++ b/Shared/Settings/PlayerSettings.swift @@ -26,8 +26,6 @@ struct PlayerSettings: View { @Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike - @Default(.showMPVPlaybackStats) private var showMPVPlaybackStats - #if os(iOS) private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom @@ -103,10 +101,6 @@ struct PlayerSettings: View { lockOrientationInFullScreenToggle } #endif - - Section(header: SettingsHeader(text: "Debugging")) { - showMPVPlaybackStatsToggle - } } } @@ -233,10 +227,6 @@ struct PlayerSettings: View { Toggle("Close PiP and open player when application enters foreground", isOn: $closePiPAndOpenPlayerOnEnteringForeground) } #endif - - private var showMPVPlaybackStatsToggle: some View { - Toggle("Show MPV playback statistics", isOn: $showMPVPlaybackStats) - } } struct PlaybackSettings_Previews: PreviewProvider { diff --git a/Shared/Settings/SettingsView.swift b/Shared/Settings/SettingsView.swift index 98d626bd..3eba63d2 100644 --- a/Shared/Settings/SettingsView.swift +++ b/Shared/Settings/SettingsView.swift @@ -5,10 +5,10 @@ import SwiftUI struct SettingsView: View { #if os(macOS) private enum Tabs: Hashable { - case instances, browsing, player, history, sponsorBlock, help + case browsing, player, history, sponsorBlock, locations, advanced, help } - @State private var selection = Tabs.instances + @State private var selection = Tabs.browsing #endif @Environment(\.colorScheme) private var colorScheme @@ -18,24 +18,20 @@ struct SettingsView: View { #endif @EnvironmentObject private var accounts - - @State private var presentingInstanceForm = false - @State private var savedFormInstanceID: Instance.ID? + @EnvironmentObject private var navigation + @EnvironmentObject private var model @Default(.instances) private var instances var body: some View { + settings + .environmentObject(model) + .alert(isPresented: $model.presentingAlert) { model.alert } + } + + var settings: some View { #if os(macOS) TabView(selection: $selection) { - Form { - InstancesSettings() - .environmentObject(accounts) - } - .tabItem { - Label("Instances", systemImage: "server.rack") - } - .tag(Tabs.instances) - Form { BrowsingSettings() } @@ -68,6 +64,22 @@ struct SettingsView: View { } .tag(Tabs.sponsorBlock) + Form { + LocationsSettings() + } + .tabItem { + Label("Locations", systemImage: "globe") + } + .tag(Tabs.locations) + + Group { + AdvancedSettings() + } + .tabItem { + Label("Advanced", systemImage: "wrench.and.screwdriver") + } + .tag(Tabs.advanced) + Form { Help() } @@ -88,9 +100,7 @@ struct SettingsView: View { } #endif } - .sheet(isPresented: $presentingInstanceForm) { - InstanceForm(savedInstanceID: $savedFormInstanceID) - } + #endif } @@ -99,16 +109,6 @@ struct SettingsView: View { List { #if os(tvOS) AccountSelectionView() - #endif - - Section(header: Text("Instances")) { - ForEach(instances) { instance in - AccountsNavigationLink(instance: instance) - } - addInstanceButton - } - - #if os(tvOS) Divider() #endif @@ -144,6 +144,18 @@ struct SettingsView: View { } label: { Label("SponsorBlock", systemImage: "dollarsign.circle") } + + NavigationLink { + LocationsSettings() + } label: { + Label("Locations", systemImage: "globe") + } + + NavigationLink { + AdvancedSettings() + } label: { + Label("Advanced", systemImage: "wrench.and.screwdriver") + } } Section(footer: versionString) { @@ -175,8 +187,6 @@ struct SettingsView: View { #if os(macOS) private var windowHeight: Double { switch selection { - case .instances: - return 390 case .browsing: return 390 case .player: @@ -185,6 +195,10 @@ struct SettingsView: View { return 480 case .sponsorBlock: return 660 + case .locations: + return 480 + case .advanced: + return 300 case .help: return 570 } @@ -197,14 +211,6 @@ struct SettingsView: View { .foregroundColor(.secondary) #endif } - - private var addInstanceButton: some View { - Button { - presentingInstanceForm = true - } label: { - Label("Add Instance...", systemImage: "plus") - } - } } struct SettingsView_Previews: PreviewProvider { diff --git a/Shared/Views/WelcomeScreen.swift b/Shared/Views/WelcomeScreen.swift index c392251a..f38e2f78 100644 --- a/Shared/Views/WelcomeScreen.swift +++ b/Shared/Views/WelcomeScreen.swift @@ -1,65 +1,93 @@ import Defaults +import Siesta import SwiftUI struct WelcomeScreen: View { @Environment(\.presentationMode) private var presentationMode @EnvironmentObject private var accounts - - @Default(.accounts) private var allAccounts + @State private var store = [ManifestedInstance]() var body: some View { - let welcomeScreen = VStack { + VStack(alignment: .leading) { Spacer() - Text("Welcome") + Text("Welcome to Yattee") + .frame(maxWidth: .infinity) .font(.largeTitle) .padding(.bottom, 10) - if allAccounts.isEmpty { - Text("To start, configure your Instances in Settings") - .foregroundColor(.secondary) - } else { - Text("To start, pick one of your accounts:") - .foregroundColor(.secondary) - #if os(tvOS) - AccountSelectionView(showHeader: false) + Text("Select location closest to you to get the best performance") + .font(.subheadline) + ScrollView { + ForEach(store.map(\.country).sorted(), id: \.self) { country in Button { + Defaults[.countryOfPublicInstances] = country + InstancesManifest.shared.setPublicAccount(country, accounts: accounts) + presentationMode.wrappedValue.dismiss() } label: { - Text("Start") - } - .opacity(accounts.current.isNil ? 0 : 1) - .disabled(accounts.current.isNil) - - #else - AccountsMenuView() - .onChange(of: accounts.current) { _ in - presentationMode.wrappedValue.dismiss() + HStack(spacing: 10) { + if let flag = flag(country) { + Text(flag) + } + Text(country) + #if !os(tvOS) + .foregroundColor(.white) + #endif + .frame(maxWidth: .infinity, alignment: .leading) } - #if os(macOS) - .frame(maxWidth: 280) + #if os(tvOS) + .padding(8) + #else + .padding(4) + .background(RoundedRectangle(cornerRadius: 4).foregroundColor(Color.accentColor)) + .padding(.bottom, 2) + #endif + } + .buttonStyle(.plain) + #if os(tvOS) + .padding(.horizontal, 10) #endif - #endif + } + .padding(.horizontal, 30) } - Spacer() + Text("This information will not be collected and it will be saved only on your device. You can change it later in settings.") + .font(.caption) + .foregroundColor(.secondary) - OpenSettingsButton() + #if !os(tvOS) + Spacer() - Spacer() + OpenSettingsButton() + .frame(maxWidth: .infinity) + + Spacer() + #endif } + .onAppear { + resource.load().onSuccess { response in + if let instances: [ManifestedInstance] = response.typedContent() { + store = instances + } + } + } + .padding() #if os(macOS) - .frame(minWidth: 400, minHeight: 400) + .frame(minWidth: 400, minHeight: 400) + #elseif os(tvOS) + .frame(maxWidth: 1000) #endif + } - if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) { - welcomeScreen - .interactiveDismissDisabled() - } else { - welcomeScreen - } + func flag(_ country: String) -> String? { + store.first { $0.country == country }?.flag + } + + var resource: Resource { + InstancesManifest.shared.instancesList } } diff --git a/Shared/YatteeApp.swift b/Shared/YatteeApp.swift index 4a598c6f..8e282e19 100644 --- a/Shared/YatteeApp.swift +++ b/Shared/YatteeApp.swift @@ -40,6 +40,7 @@ struct YatteeApp: App { @StateObject private var playlists = PlaylistsModel() @StateObject private var recents = RecentsModel() @StateObject private var search = SearchModel() + @StateObject private var settings = SettingsModel() @StateObject private var subscriptions = SubscriptionsModel() @StateObject private var thumbnails = ThumbnailsModel() @@ -60,6 +61,7 @@ struct YatteeApp: App { .environmentObject(playerTime) .environmentObject(playlists) .environmentObject(recents) + .environmentObject(settings) .environmentObject(subscriptions) .environmentObject(thumbnails) .environmentObject(menu) @@ -144,8 +146,10 @@ struct YatteeApp: App { .environment(\.managedObjectContext, persistenceController.container.viewContext) .environmentObject(accounts) .environmentObject(instances) + .environmentObject(navigation) .environmentObject(player) .environmentObject(playerControls) + .environmentObject(settings) } #endif } @@ -170,17 +174,23 @@ struct YatteeApp: App { } #endif - if let account = accounts.lastUsed ?? - instances.lastUsed?.anonymousAccount ?? - InstancesModel.all.first?.anonymousAccount + if Defaults[.lastAccountID] != "public", + let account = accounts.lastUsed ?? + instances.lastUsed?.anonymousAccount ?? + InstancesModel.all.first?.anonymousAccount { accounts.setCurrent(account) } - if accounts.current.isNil { + let countryOfPublicInstances = Defaults[.countryOfPublicInstances] + if accounts.current.isNil, countryOfPublicInstances.isNil { navigation.presentingWelcomeScreen = true } + if !countryOfPublicInstances.isNil { + InstancesManifest.shared.setPublicAccount(countryOfPublicInstances!, accounts: accounts, asCurrent: accounts.current.isNil) + } + playlists.accounts = accounts search.accounts = accounts subscriptions.accounts = accounts diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 3d3875c0..8ddde2ff 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -398,7 +398,6 @@ 37737786276F9858000521C1 /* Windows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37737785276F9858000521C1 /* Windows.swift */; }; 3774123327387CB000423605 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; }; 3774123427387CC100423605 /* InvidiousAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37977582268922F600DD52A8 /* InvidiousAPI.swift */; }; - 3774123527387CC700423605 /* PipedAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155A271B0D4D0049C794 /* PipedAPI.swift */; }; 3774124927387D2300423605 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF28F26740715007FC770 /* Channel.swift */; }; 3774124A27387D2300423605 /* ContentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FB28402721B22200A57617 /* ContentItem.swift */; }; 3774124B27387D2300423605 /* ThumbnailsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C0698127260B2100F7F6CB /* ThumbnailsModel.swift */; }; @@ -442,6 +441,18 @@ 377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */; }; 377A20AA2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */; }; 377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */; }; + 377ABC40286E4AD5009C986F /* InstancesManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377ABC3F286E4AD5009C986F /* InstancesManifest.swift */; }; + 377ABC41286E4AD5009C986F /* InstancesManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377ABC3F286E4AD5009C986F /* InstancesManifest.swift */; }; + 377ABC42286E4AD5009C986F /* InstancesManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377ABC3F286E4AD5009C986F /* InstancesManifest.swift */; }; + 377ABC44286E4B74009C986F /* ManifestedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377ABC43286E4B74009C986F /* ManifestedInstance.swift */; }; + 377ABC45286E4B74009C986F /* ManifestedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377ABC43286E4B74009C986F /* ManifestedInstance.swift */; }; + 377ABC46286E4B74009C986F /* ManifestedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377ABC43286E4B74009C986F /* ManifestedInstance.swift */; }; + 377ABC48286E5887009C986F /* Sequence+Unique.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377ABC47286E5887009C986F /* Sequence+Unique.swift */; }; + 377ABC49286E5887009C986F /* Sequence+Unique.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377ABC47286E5887009C986F /* Sequence+Unique.swift */; }; + 377ABC4A286E5887009C986F /* Sequence+Unique.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377ABC47286E5887009C986F /* Sequence+Unique.swift */; }; + 377ABC4C286E6A78009C986F /* LocationsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377ABC4B286E6A78009C986F /* LocationsSettings.swift */; }; + 377ABC4D286E6A78009C986F /* LocationsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377ABC4B286E6A78009C986F /* LocationsSettings.swift */; }; + 377ABC4E286E6A78009C986F /* LocationsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377ABC4B286E6A78009C986F /* LocationsSettings.swift */; }; 377FC7D5267A080300A6BBAF /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 377FC7D4267A080300A6BBAF /* SwiftyJSON */; }; 377FC7DB267A080300A6BBAF /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 377FC7DA267A080300A6BBAF /* Logging */; }; 377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27D26737323007FC770 /* PopularView.swift */; }; @@ -726,6 +737,12 @@ 37EF9A77275BEB8E0043B585 /* CommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EF9A75275BEB8E0043B585 /* CommentView.swift */; }; 37EF9A78275BEB8E0043B585 /* CommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EF9A75275BEB8E0043B585 /* CommentView.swift */; }; 37EF9A79275BEB8E0043B585 /* CommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EF9A75275BEB8E0043B585 /* CommentView.swift */; }; + 37F0F4EA286F397E00C06C2E /* SettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F0F4E9286F397E00C06C2E /* SettingsModel.swift */; }; + 37F0F4EB286F397E00C06C2E /* SettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F0F4E9286F397E00C06C2E /* SettingsModel.swift */; }; + 37F0F4EC286F397E00C06C2E /* SettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F0F4E9286F397E00C06C2E /* SettingsModel.swift */; }; + 37F0F4EE286F734400C06C2E /* AdvancedSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F0F4ED286F734400C06C2E /* AdvancedSettings.swift */; }; + 37F0F4EF286F734400C06C2E /* AdvancedSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F0F4ED286F734400C06C2E /* AdvancedSettings.swift */; }; + 37F0F4F0286F734400C06C2E /* AdvancedSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F0F4ED286F734400C06C2E /* AdvancedSettings.swift */; }; 37F13B62285E43C000B137E4 /* ControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F13B61285E43C000B137E4 /* ControlsOverlay.swift */; }; 37F13B63285E43C000B137E4 /* ControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F13B61285E43C000B137E4 /* ControlsOverlay.swift */; }; 37F13B64285E43C000B137E4 /* ControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F13B61285E43C000B137E4 /* ControlsOverlay.swift */; }; @@ -1032,6 +1049,10 @@ 37732FF32703D32400F04329 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = ""; }; 37737785276F9858000521C1 /* Windows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windows.swift; sourceTree = ""; }; 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedContentAccessors.swift; sourceTree = ""; }; + 377ABC3F286E4AD5009C986F /* InstancesManifest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstancesManifest.swift; sourceTree = ""; }; + 377ABC43286E4B74009C986F /* ManifestedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManifestedInstance.swift; sourceTree = ""; }; + 377ABC47286E5887009C986F /* Sequence+Unique.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Unique.swift"; sourceTree = ""; }; + 377ABC4B286E6A78009C986F /* LocationsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsSettings.swift; sourceTree = ""; }; 3782B94E27553A6700990149 /* SearchSuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSuggestions.swift; sourceTree = ""; }; 3782B9512755667600990149 /* String+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Format.swift"; sourceTree = ""; }; 3782B95C2755858100990149 /* NSTextField+FocusRingType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTextField+FocusRingType.swift"; sourceTree = ""; }; @@ -1162,6 +1183,8 @@ 37EBD8C927AF26C200F1C24B /* MPVBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPVBackend.swift; sourceTree = ""; }; 37EF5C212739D37B00B03725 /* MenuModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuModel.swift; sourceTree = ""; }; 37EF9A75275BEB8E0043B585 /* CommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentView.swift; sourceTree = ""; }; + 37F0F4E9286F397E00C06C2E /* SettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModel.swift; sourceTree = ""; }; + 37F0F4ED286F734400C06C2E /* AdvancedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettings.swift; sourceTree = ""; }; 37F13B61285E43C000B137E4 /* ControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlsOverlay.swift; sourceTree = ""; }; 37F49BA226CAA59B00304AC0 /* Playlist+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playlist+Fixtures.swift"; sourceTree = ""; }; 37F4AD1A28612B23004D0F66 /* OpeningStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpeningStream.swift; sourceTree = ""; }; @@ -1620,11 +1643,13 @@ 37484C2826FC83FF00287258 /* AccountForm.swift */, 37E084AB2753D95F00039B7D /* AccountsNavigationLink.swift */, 37732FEF2703A26300F04329 /* AccountValidationStatus.swift */, + 37F0F4ED286F734400C06C2E /* AdvancedSettings.swift */, 376BE50A27349108009AD608 /* BrowsingSettings.swift */, 37579D5C27864F5F00FD0B98 /* Help.swift */, 37BC50A72778A84700510953 /* HistorySettings.swift */, 37484C2426FC83E000287258 /* InstanceForm.swift */, 37484C2C26FC844700287258 /* InstanceSettings.swift */, + 377ABC4B286E6A78009C986F /* LocationsSettings.swift */, 37484C1826FC837400287258 /* PlayerSettings.swift */, 374C053427242D9F009BDDBE /* SponsorBlockSettings.swift */, 376BE50627347B57009AD608 /* SettingsHeader.swift */, @@ -1814,6 +1839,7 @@ 37BA794E26DC3E0E002A0235 /* Int+Format.swift */, 370B79C8286279810045DB77 /* NSObject+Swizzle.swift */, 3782B95C2755858100990149 /* NSTextField+FocusRingType.swift */, + 377ABC47286E5887009C986F /* Sequence+Unique.swift */, 3782B9512755667600990149 /* String+Format.swift */, 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */, 370B79CB286279BA0045DB77 /* UIViewController+HideHomeIndicator.swift */, @@ -1949,6 +1975,8 @@ 37599F2F272B42810087F250 /* FavoriteItem.swift */, 37599F33272B44000087F250 /* FavoritesModel.swift */, 37BC50AB2778BCBA00510953 /* HistoryModel.swift */, + 377ABC3F286E4AD5009C986F /* InstancesManifest.swift */, + 377ABC43286E4B74009C986F /* ManifestedInstance.swift */, 37EF5C212739D37B00B03725 /* MenuModel.swift */, 371F2F19269B43D300E4A7AB /* NavigationModel.swift */, 3756C2A92861151C00E4B059 /* NetworkStateModel.swift */, @@ -1957,6 +1985,7 @@ 37BA794226DBA973002A0235 /* PlaylistsModel.swift */, 37C194C626F6A9C8005D3B96 /* RecentsModel.swift */, 37EAD86E267B9ED100D9E01B /* Segment.swift */, + 37F0F4E9286F397E00C06C2E /* SettingsModel.swift */, 37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */, 3797758A2689345500DD52A8 /* Store.swift */, 37CEE4C02677B697005A1EFE /* Stream.swift */, @@ -2628,6 +2657,7 @@ 376CD21626FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */, 37BA793B26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */, 37A5DBC8285E371400CA4DD1 /* ControlBackgroundModifier.swift in Sources */, + 377ABC40286E4AD5009C986F /* InstancesManifest.swift in Sources */, 37BD07B52698AA4D003EBB87 /* ContentView.swift in Sources */, 37130A5B277657090033018A /* Yattee.xcdatamodeld in Sources */, 37152EEA26EFEB95004FB96D /* LazyView.swift in Sources */, @@ -2660,6 +2690,7 @@ 37BE0BD326A1D4780092E2DB /* AppleAVPlayerView.swift in Sources */, 37A9965E26D6F9B9006E3224 /* FavoritesView.swift in Sources */, 37CEE4C12677B697005A1EFE /* Stream.swift in Sources */, + 37F0F4EA286F397E00C06C2E /* SettingsModel.swift in Sources */, 378AE943274EF00A006A4EE1 /* Color+Background.swift in Sources */, 37F4AE7226828F0900BD60EA /* VerticalCells.swift in Sources */, 376578852685429C00D4EA09 /* CaseIterable+Next.swift in Sources */, @@ -2727,6 +2758,7 @@ 37DD9DA32785BBC900539416 /* NoCommentsView.swift in Sources */, 377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */, + 377ABC48286E5887009C986F /* Sequence+Unique.swift in Sources */, 37520699285E8DD300CA655F /* Chapter.swift in Sources */, 37319F0527103F94004ECCD0 /* PlayerQueue.swift in Sources */, 376B2E0726F920D600B1D64D /* SignInRequiredView.swift in Sources */, @@ -2761,7 +2793,9 @@ 37FEF11327EFD8580033912F /* PlaceholderCell.swift in Sources */, 37B2631A2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */, 3748186E26A769D60084E870 /* DetailBadge.swift in Sources */, + 377ABC4C286E6A78009C986F /* LocationsSettings.swift in Sources */, 376BE50B27349108009AD608 /* BrowsingSettings.swift in Sources */, + 37F0F4EE286F734400C06C2E /* AdvancedSettings.swift in Sources */, 37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */, 37EF5C222739D37B00B03725 /* MenuModel.swift in Sources */, 37599F30272B42810087F250 /* FavoriteItem.swift in Sources */, @@ -2777,6 +2811,7 @@ 37484C2926FC83FF00287258 /* AccountForm.swift in Sources */, 37E70927271CDDAE00D34DDE /* OpenSettingsButton.swift in Sources */, 371F2F1A269B43D300E4A7AB /* NavigationModel.swift in Sources */, + 377ABC44286E4B74009C986F /* ManifestedInstance.swift in Sources */, 37EBD8C627AF26B300F1C24B /* AVPlayerBackend.swift in Sources */, 375E45F527B1976B00BA7902 /* MPVOGLView.swift in Sources */, 37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, @@ -2809,6 +2844,7 @@ files = ( 3727B74B27872B880021C15E /* VisualEffectBlur-macOS.swift in Sources */, 374710062755291C00CE0F87 /* SearchField.swift in Sources */, + 37F0F4EB286F397E00C06C2E /* SettingsModel.swift in Sources */, 378AE93F274EDFB5006A4EE1 /* Tint+Backport.swift in Sources */, 37C194C826F6A9C8005D3B96 /* RecentsModel.swift in Sources */, 37737786276F9858000521C1 /* Windows.swift in Sources */, @@ -2828,6 +2864,7 @@ 37C3A24E272360470087A57A /* ChannelPlaylist+Fixtures.swift in Sources */, 37CEE4C22677B697005A1EFE /* Stream.swift in Sources */, 3751BA8427E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift in Sources */, + 377ABC4D286E6A78009C986F /* LocationsSettings.swift in Sources */, 3782B95027553A6700990149 /* SearchSuggestions.swift in Sources */, 371B7E6B2759791900D21217 /* CommentsModel.swift in Sources */, 371F2F1B269B43D300E4A7AB /* NavigationModel.swift in Sources */, @@ -2863,6 +2900,7 @@ 37599F35272B44000087F250 /* FavoritesModel.swift in Sources */, 376527BC285F60F700102284 /* PlayerTimeModel.swift in Sources */, 37F64FE526FE70A60081B69E /* RedrawOnModifier.swift in Sources */, + 377ABC45286E4B74009C986F /* ManifestedInstance.swift in Sources */, 3795593727B08538007FF8F4 /* StreamControl.swift in Sources */, 372CFD16285F2E2A00B0B54B /* ControlsBar.swift in Sources */, 37FFC441272734C3009FFD26 /* Throttle.swift in Sources */, @@ -2925,12 +2963,14 @@ 37A9965B26D6F8CA006E3224 /* HorizontalCells.swift in Sources */, 37732FF52703D32400F04329 /* Sidebar.swift in Sources */, 379775942689365600DD52A8 /* Array+Next.swift in Sources */, + 377ABC49286E5887009C986F /* Sequence+Unique.swift in Sources */, 3748186726A7627F0084E870 /* Video+Fixtures.swift in Sources */, 3784B23E2728B85300B09468 /* ShareButton.swift in Sources */, 37BE0BDA26A214630092E2DB /* AppleAVPlayerViewController.swift in Sources */, 37FEF11427EFD8580033912F /* PlaceholderCell.swift in Sources */, 37E64DD226D597EB00C71877 /* SubscriptionsModel.swift in Sources */, 374108D1272B11B2006C5CC8 /* PictureInPictureDelegate.swift in Sources */, + 37F0F4EF286F734400C06C2E /* AdvancedSettings.swift in Sources */, 37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 37319F0627103F94004ECCD0 /* PlayerQueue.swift in Sources */, 37B767DC2677C3CA0098BAA8 /* PlayerModel.swift in Sources */, @@ -2984,6 +3024,7 @@ 373CFADC269663F1003CB2C6 /* Thumbnail.swift in Sources */, 37C0697B2725C09E00F7F6CB /* PlayerQueueItemBridge.swift in Sources */, 37C3A24A27235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */, + 377ABC41286E4AD5009C986F /* InstancesManifest.swift in Sources */, 373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, 3763495226DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */, 371B7E672759786B00D21217 /* Comment+Fixtures.swift in Sources */, @@ -3043,7 +3084,6 @@ 3774126627387D6D00423605 /* Array+Next.swift in Sources */, 3774126727387D6D00423605 /* View+Borders.swift in Sources */, 3774123327387CB000423605 /* Defaults.swift in Sources */, - 3774123527387CC700423605 /* PipedAPI.swift in Sources */, 3774124E27387D2300423605 /* Playlist.swift in Sources */, 3766AFD2273DA97D00686348 /* Int+FormatTests.swift in Sources */, 3774124F27387D2300423605 /* SubscriptionsModel.swift in Sources */, @@ -3079,6 +3119,7 @@ 371B7E632759706A00D21217 /* CommentsView.swift in Sources */, 37A9965C26D6F8CA006E3224 /* HorizontalCells.swift in Sources */, 3782B95727557E6E00990149 /* SearchSuggestions.swift in Sources */, + 37F0F4EC286F397E00C06C2E /* SettingsModel.swift in Sources */, 37BD07C92698FBDB003EBB87 /* ContentView.swift in Sources */, 37BC50AA2778A84700510953 /* HistorySettings.swift in Sources */, 37F13B64285E43C000B137E4 /* ControlsOverlay.swift in Sources */, @@ -3134,6 +3175,7 @@ 37A9966026D6F9B9006E3224 /* FavoritesView.swift in Sources */, 37001565271B1F250049C794 /* AccountsModel.swift in Sources */, 3751B4B427836902000B7DF4 /* SearchPage.swift in Sources */, + 377ABC46286E4B74009C986F /* ManifestedInstance.swift in Sources */, 374C0541272472C0009BDDBE /* PlayerSponsorBlock.swift in Sources */, 37130A61277657300033018A /* PersistenceController.swift in Sources */, 37E70929271CDDAE00D34DDE /* OpenSettingsButton.swift in Sources */, @@ -3142,6 +3184,7 @@ 37599F3A272B4D740087F250 /* FavoriteButton.swift in Sources */, 37EF5C242739D37B00B03725 /* MenuModel.swift in Sources */, 37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, + 377ABC4E286E6A78009C986F /* LocationsSettings.swift in Sources */, 3756C2A82861131100E4B059 /* NetworkState.swift in Sources */, 376578932685490700D4EA09 /* PlaylistsView.swift in Sources */, 37CC3F47270CE30600608308 /* PlayerQueueItem.swift in Sources */, @@ -3168,6 +3211,7 @@ 37484C2B26FC83FF00287258 /* AccountForm.swift in Sources */, 37FB2860272225E800A57617 /* ContentItemView.swift in Sources */, 374C053727242D9F009BDDBE /* SponsorBlockSettings.swift in Sources */, + 377ABC42286E4AD5009C986F /* InstancesManifest.swift in Sources */, 37C069802725C8D400F7F6CB /* CMTime+DefaultTimescale.swift in Sources */, 37EBD8CC27AF26C200F1C24B /* MPVBackend.swift in Sources */, 3711404126B206A6005B3555 /* SearchModel.swift in Sources */, @@ -3192,8 +3236,10 @@ 37EF9A78275BEB8E0043B585 /* CommentView.swift in Sources */, 37484C2726FC83E000287258 /* InstanceForm.swift in Sources */, 37F49BA826CB0FCE00304AC0 /* PlaylistFormView.swift in Sources */, + 37F0F4F0286F734400C06C2E /* AdvancedSettings.swift in Sources */, 373197DA2732060100EF734F /* RelatedView.swift in Sources */, 37DD9DA52785BBC900539416 /* NoCommentsView.swift in Sources */, + 377ABC4A286E5887009C986F /* Sequence+Unique.swift in Sources */, 37D4B19926717E1500C925CA /* Video.swift in Sources */, 378E50FD26FE8B9F00F49626 /* Instance.swift in Sources */, 37169AA82729E2CC0011DE61 /* AccountsBridge.swift in Sources */, diff --git a/macOS/InstancesSettings.swift b/macOS/InstancesSettings.swift index c773fb7f..6317c6fb 100644 --- a/macOS/InstancesSettings.swift +++ b/macOS/InstancesSettings.swift @@ -16,13 +16,12 @@ struct InstancesSettings: View { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var accounts + @EnvironmentObject private var settings @Default(.instances) private var instances var body: some View { - Group { - SettingsHeader(text: "Instance") - + VStack(alignment: .leading, spacing: 10) { if !instances.isEmpty { Picker("Instance", selection: $selectedInstanceID) { ForEach(instances) { instance in @@ -31,7 +30,7 @@ struct InstancesSettings: View { } .labelsHidden() } else { - Text("You have no instances configured") + Text("You have no custom locations configured") .font(.caption) .foregroundColor(.secondary) @@ -43,7 +42,7 @@ struct InstancesSettings: View { let list = List(selection: $selectedAccount) { if selectedInstanceAccounts.isEmpty { - Text("You have no accounts for this instance") + Text("You have no accounts for this location") .foregroundColor(.secondary) } ForEach(selectedInstanceAccounts) { account in @@ -116,13 +115,11 @@ struct InstancesSettings: View { Spacer() - Button("Remove Instance") { + Button("Remove Location") { presentingInstanceRemovalConfirmation = true - } - .alert(isPresented: $presentingInstanceRemovalConfirmation) { - Alert( + settings.presentAlert(Alert( title: Text( - "Are you sure you want to remove \(selectedInstance!.longDescription) instance?" + "Are you sure you want to remove \(selectedInstance!.longDescription) location?" ), message: Text("This cannot be undone"), primaryButton: .destructive(Text("Remove")) { @@ -134,13 +131,13 @@ struct InstancesSettings: View { selectedInstanceID = instances.last?.id }, secondaryButton: .cancel() - ) + )) } .foregroundColor(.red) } } - Button("Add Instance...") { + Button("Add Location...") { presentingInstanceForm = true } } diff --git a/tvOS/AccountSelectionView.swift b/tvOS/AccountSelectionView.swift index 966d2730..3e50c6a6 100644 --- a/tvOS/AccountSelectionView.swift +++ b/tvOS/AccountSelectionView.swift @@ -11,13 +11,13 @@ struct AccountSelectionView: View { @Default(.instances) private var instances var body: some View { - Section(header: Text(showHeader ? "Current Account" : "")) { - Button(accountButtonTitle(account: accountsModel.current, long: true)) { + Section(header: Text(showHeader ? "Current Location" : "")) { + Button(accountButtonTitle(account: accountsModel.current)) { if let account = nextAccount { accountsModel.setCurrent(account) } } - .disabled(instances.isEmpty) + .disabled(instances.isEmpty && Defaults[.countryOfPublicInstances].isNil) .contextMenu { ForEach(allAccounts) { account in Button(accountButtonTitle(account: account)) { @@ -32,20 +32,18 @@ struct AccountSelectionView: View { } var allAccounts: [Account] { - accounts + instances.map(\.anonymousAccount) + accounts + instances.map(\.anonymousAccount) + [accountsModel.publicAccount].compactMap { $0 } } private var nextAccount: Account? { allAccounts.next(after: accountsModel.current) } - func accountButtonTitle(account: Account! = nil, long: Bool = false) -> String { + func accountButtonTitle(account: Account! = nil) -> String { guard account != nil else { return "Not selected" } - let instanceDescription = long ? account.instance.longDescription : account.instance.description - - return instances.count > 1 ? "\(account.description) — \(instanceDescription)" : account.description + return account.isPublic ? account.description : "\(account.description) — \(account.instance.shortDescription)" } } diff --git a/tvOS/TVNavigationView.swift b/tvOS/TVNavigationView.swift index adf1b42b..8d72d57d 100644 --- a/tvOS/TVNavigationView.swift +++ b/tvOS/TVNavigationView.swift @@ -6,6 +6,7 @@ struct TVNavigationView: View { @EnvironmentObject private var navigation @EnvironmentObject private var player @EnvironmentObject private var recents + @EnvironmentObject private var settings @Default(.visibleSections) private var visibleSections @@ -36,7 +37,7 @@ struct TVNavigationView: View { .tag(TabSelection.trending) } - if visibleSections.contains(.playlists), accounts.app.supportsUserPlaylists { + if visibleSections.contains(.playlists), accounts.app.supportsUserPlaylists, accounts.signedIn { PlaylistsView() .tabItem { Text("Playlists") } .tag(TabSelection.playlists) @@ -56,7 +57,6 @@ struct TVNavigationView: View { .tag(TabSelection.settings) } } - .fullScreenCover(isPresented: $navigation.presentingSettings) { SettingsView() } .fullScreenCover(isPresented: $navigation.presentingAddToPlaylist) { if let video = navigation.videoToAddToPlaylist { AddToPlaylistView(video: video)