Locations manifest, reorganized instances settings

This commit is contained in:
Arkadiusz Fal
2022-07-01 23:28:32 +02:00
parent 6f62f14adf
commit 4fcf57d755
28 changed files with 686 additions and 214 deletions

View File

@@ -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<String>("instancesManifest", default: "")
static let countryOfPublicInstances = Key<String?>("countryOfPublicInstances")
static let instances = Key<[Instance]>("instances", default: [])
static let accounts = Key<[Account]>("accounts", default: [])
static let lastAccountID = Key<Account.ID?>("lastAccountID")
static let lastInstanceID = Key<Instance.ID?>("lastInstanceID")
@@ -59,7 +53,7 @@ extension Defaults.Keys {
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
static let showKeywords = Key<Bool>("showKeywords", default: false)
static let showHistoryInPlayer = Key<Bool>("showHistoryInPlayer", default: false)
static let commentsInstanceID = Key<Instance.ID?>("commentsInstance", default: kavinPipedInstanceID)
static let commentsInstanceID = Key<Instance.ID?>("commentsInstance", default: nil)
#if !os(tvOS)
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
#endif

View File

@@ -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)"
}
}

View File

@@ -19,6 +19,7 @@ struct ContentView: View {
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@EnvironmentObject<SettingsModel> private var settings
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@EnvironmentObject<ThumbnailsModel> 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)

View File

@@ -66,9 +66,7 @@ final class AppleAVPlayerViewController: UIViewController {
#if os(tvOS)
var infoViewControllers = [UIHostingController<AnyView>]()
if CommentsModel.enabled {
infoViewControllers.append(infoViewController([.comments], title: "Comments"))
}
infoViewControllers.append(infoViewController([.comments], title: "Comments"))
var queueSections = [NowPlayingView.ViewSection.playingNext]
if Defaults[.showHistoryInPlayer] {

View File

@@ -118,6 +118,7 @@ struct AccountForm: View {
var footer: some View {
HStack {
AccountValidationStatus(
app: .constant(instance.app),
isValid: $isValid,
isValidated: $isValidated,
isValidating: $isValidating,

View File

@@ -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)

View File

@@ -11,6 +11,10 @@ struct AccountsNavigationLink: View {
.buttonStyle(.plain)
.contextMenu {
removeInstanceButton(instance)
#if os(tvOS)
Button("Cancel", role: .cancel) {}
#endif
}
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<SettingsModel> 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())
}
}

View File

@@ -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 {

View File

@@ -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<AccountsModel> private var accounts
@State private var presentingInstanceForm = false
@State private var savedFormInstanceID: Instance.ID?
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<SettingsModel> 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 {

View File

@@ -1,65 +1,93 @@
import Defaults
import Siesta
import SwiftUI
struct WelcomeScreen: View {
@Environment(\.presentationMode) private var presentationMode
@EnvironmentObject<AccountsModel> 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
}
}

View File

@@ -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