From f4f3ef543fe958c0f3c003bb1a2a8a3cf2149e06 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Tue, 10 Jun 2025 18:45:34 -0700 Subject: [PATCH] Started working on multiple server configuration --- QueueCube/Backend/API.swift | 9 -- QueueCube/Backend/Server.swift | 4 +- QueueCube/Backend/Settings.swift | 24 +++- QueueCube/Localizable/Localizable.xcstrings | 10 ++ QueueCube/Localizable/Strings.swift | 43 +++--- QueueCube/Views/ContentPlaceholderView.swift | 57 ++++++++ QueueCube/Views/ContentView.swift | 122 +----------------- QueueCube/Views/MainView.swift | 109 ++++++++++++++++ .../AddServerView.swift} | 119 +---------------- .../Settings View/GeneralSettingsView.swift | 16 +++ .../ServerListSettingsView.swift | 94 ++++++++++++++ .../Views/Settings View/SettingsView.swift | 61 +++++++++ 12 files changed, 397 insertions(+), 271 deletions(-) create mode 100644 QueueCube/Views/ContentPlaceholderView.swift create mode 100644 QueueCube/Views/MainView.swift rename QueueCube/Views/{SettingsView.swift => Settings View/AddServerView.swift} (73%) create mode 100644 QueueCube/Views/Settings View/GeneralSettingsView.swift create mode 100644 QueueCube/Views/Settings View/ServerListSettingsView.swift create mode 100644 QueueCube/Views/Settings View/SettingsView.swift diff --git a/QueueCube/Backend/API.swift b/QueueCube/Backend/API.swift index 79cdcac..fe370c3 100644 --- a/QueueCube/Backend/API.swift +++ b/QueueCube/Backend/API.swift @@ -38,15 +38,6 @@ struct API { let baseURL: URL - static func fromSettings() -> Self? { - let settings = Settings.fromDefaults() - - guard let baseURL = settings.serverURL.flatMap({ URL(string: $0) }) - else { return nil } - - return API(baseURL: baseURL) - } - init(baseURL: URL) { self.baseURL = baseURL } diff --git a/QueueCube/Backend/Server.swift b/QueueCube/Backend/Server.swift index 74bb288..acefade 100644 --- a/QueueCube/Backend/Server.swift +++ b/QueueCube/Backend/Server.swift @@ -7,13 +7,15 @@ import Foundation -struct Server: Identifiable +struct Server: Identifiable, Codable { let serviceName: String? let baseURL: URL var id: String { baseURL.absoluteString } + var api: API { API(baseURL: baseURL) } + var displayName: String { if let serviceName { return serviceName.queueCubeServiceName diff --git a/QueueCube/Backend/Settings.swift b/QueueCube/Backend/Settings.swift index f46c620..2d3a767 100644 --- a/QueueCube/Backend/Settings.swift +++ b/QueueCube/Backend/Settings.swift @@ -9,15 +9,29 @@ import Foundation struct Settings { - var serverURL: String? + var configuredServers: [Server] + + var isConfigured: Bool { + !configuredServers.isEmpty + } static func fromDefaults() -> Settings { - let serverURL = UserDefaults.standard.string(forKey: Keys.serverURL.rawValue) - return Settings(serverURL: serverURL) + let configuredServers: [Server] = { + guard let configuredServersData = UserDefaults.standard.data(forKey: Keys.configuredServers.rawValue) + else { return [] } + + guard let configuredServers = try? PropertyListDecoder().decode([Server].self, from: configuredServersData) + else { return [] } + + return configuredServers + }() + + return Settings(configuredServers: configuredServers) } func save() { - UserDefaults.standard.set(serverURL, forKey: Keys.serverURL.rawValue) + let configuredServersData = try! PropertyListEncoder().encode(configuredServers) + UserDefaults.standard.set(configuredServersData, forKey: Keys.configuredServers.rawValue) NotificationCenter.default.post(name: .settingsChanged, object: nil) } @@ -25,7 +39,7 @@ struct Settings enum Keys: String { - case serverURL + case configuredServers } struct Server: Codable diff --git a/QueueCube/Localizable/Localizable.xcstrings b/QueueCube/Localizable/Localizable.xcstrings index 1df70cd..5a26ac5 100644 --- a/QueueCube/Localizable/Localizable.xcstrings +++ b/QueueCube/Localizable/Localizable.xcstrings @@ -125,6 +125,16 @@ } } }, + "NO_SERVERS_CONFIGURED" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Servers Configured" + } + } + } + }, "NOT_CONFIGURED" : { "localizations" : { "en" : { diff --git a/QueueCube/Localizable/Strings.swift b/QueueCube/Localizable/Strings.swift index ba419a9..6bdb9a5 100644 --- a/QueueCube/Localizable/Strings.swift +++ b/QueueCube/Localizable/Strings.swift @@ -9,25 +9,26 @@ import SwiftUI extension LocalizedStringKey { - static let serverURL = LocalizedStringKey("SERVER_URL") - static let settings = LocalizedStringKey("SETTINGS") - static let settings_ = LocalizedStringKey("SETTINGS_ELLIPSES") - static let done = LocalizedStringKey("DONE") - static let notConfigured = LocalizedStringKey("NOT_CONFIGURED") - static let add = LocalizedStringKey("ADD") - static let addAnyURL = LocalizedStringKey("ADD_ANY_URL") - static let serverIsOnline = LocalizedStringKey("SERVER_IS_ONLINE") - static let unableToConnect = LocalizedStringKey("UNABLE_TO_CONNECT") - static let configuration = LocalizedStringKey("CONFIGURATION") - static let validating = LocalizedStringKey("VALIDATING") - static let general = LocalizedStringKey("GENERAL") - static let connectionError = LocalizedStringKey("CONNECTION_ERROR") - static let playlist = LocalizedStringKey("PLAYLIST") - static let favorites = LocalizedStringKey("FAVORITES") - static let servers = LocalizedStringKey("SERVERS") - static let addServer = LocalizedStringKey("ADD_SERVER") - static let cancel = LocalizedStringKey("CANCEL") - static let manual = LocalizedStringKey("ENTER_MANUALLY") - static let discovered = LocalizedStringKey("DISCOVERED") - static let findingServers = LocalizedStringKey("FINDING_SERVERS") + static let serverURL = LocalizedStringKey("SERVER_URL") + static let settings = LocalizedStringKey("SETTINGS") + static let settings_ = LocalizedStringKey("SETTINGS_ELLIPSES") + static let done = LocalizedStringKey("DONE") + static let notConfigured = LocalizedStringKey("NOT_CONFIGURED") + static let add = LocalizedStringKey("ADD") + static let addAnyURL = LocalizedStringKey("ADD_ANY_URL") + static let serverIsOnline = LocalizedStringKey("SERVER_IS_ONLINE") + static let unableToConnect = LocalizedStringKey("UNABLE_TO_CONNECT") + static let configuration = LocalizedStringKey("CONFIGURATION") + static let validating = LocalizedStringKey("VALIDATING") + static let general = LocalizedStringKey("GENERAL") + static let connectionError = LocalizedStringKey("CONNECTION_ERROR") + static let playlist = LocalizedStringKey("PLAYLIST") + static let favorites = LocalizedStringKey("FAVORITES") + static let servers = LocalizedStringKey("SERVERS") + static let addServer = LocalizedStringKey("ADD_SERVER") + static let cancel = LocalizedStringKey("CANCEL") + static let manual = LocalizedStringKey("ENTER_MANUALLY") + static let discovered = LocalizedStringKey("DISCOVERED") + static let findingServers = LocalizedStringKey("FINDING_SERVERS") + static let noServersConfigured = LocalizedStringKey("NO_SERVERS_CONFIGURED") } diff --git a/QueueCube/Views/ContentPlaceholderView.swift b/QueueCube/Views/ContentPlaceholderView.swift new file mode 100644 index 0000000..de3da0c --- /dev/null +++ b/QueueCube/Views/ContentPlaceholderView.swift @@ -0,0 +1,57 @@ +// +// ContentPlaceholderView.swift +// QueueCube +// +// Created by James Magahern on 6/10/25. +// + +import SwiftUI + +struct ContentPlaceholderView: View + where Label: View, Actions: View +{ + let label: Label + let actions: Actions + + init(@ViewBuilder label: () -> Label, @ViewBuilder actions: () -> Actions = { EmptyView() }) { + self.label = label() + self.actions = actions() + } + + var body: some View { + Spacer() + + ContentUnavailableView { + label + .imageScale(.large) + .tint(.secondary) + } actions: { actions } + + Spacer() + } +} + +func contentPlaceholderView( + title: LocalizedStringKey, + systemImage: String, @ViewBuilder actions: () -> Actions = { EmptyView() }) +-> ContentPlaceholderView +{ + ContentPlaceholderView(label: { + AnyView(erasing: VStack(spacing: 16.0) { + Image(systemName: systemImage) + .resizable() + .scaledToFit() + + .frame(width: 50.0, height: 50.0) + .foregroundStyle(.secondary) + .imageScale(.large) + + + Text(title) + .bold() + + Spacer() + .frame(height: 14.0) + }) + }, actions: actions) +} diff --git a/QueueCube/Views/ContentView.swift b/QueueCube/Views/ContentView.swift index 326f2f0..660d606 100644 --- a/QueueCube/Views/ContentView.swift +++ b/QueueCube/Views/ContentView.swift @@ -14,7 +14,7 @@ struct ContentView: View init() { self.model = MainViewModel() - if let api = model.api { + if let api = model.selectedServer?.api { let nowPlayingModel = self.model.nowPlayingViewModel nowPlayingModel.onPlayPause = { model in Task { model.isPlaying ? try await api.pause() : try await api.play() } @@ -83,7 +83,7 @@ struct ContentView: View extension ContentView { private func refresh(_ what: RefreshType) async { - guard let api = model.api else { return } + guard let api = model.selectedServer?.api else { return } do { if what.contains(.nowPlaying) { @@ -133,7 +133,7 @@ extension ContentView } private func watchWebsocket() async { - guard let api = model.api else { return } + guard let api = model.selectedServer?.api else { return } do { for await streamEvent in try await api.events() { @@ -175,119 +175,3 @@ extension ContentView } } } - -@Observable -class MainViewModel -{ - var api = API.fromSettings() - - var connectionError: Error? = nil - var selectedTab: MainTab = .playlist - - var playlistModel = PlaylistViewModel() - var favoritesModel = FavoritesViewModel() - var nowPlayingViewModel = NowPlayingViewModel() - var addMediaViewModel = AddMediaBarViewModel() -} - -enum MainTab: String, CaseIterable { - case playlist - case favorites - case settings -} - -struct MainView: View -{ - @State var model: MainViewModel - @State var isSettingsVisible: Bool = false - - init(model: MainViewModel) { - self.model = model - - Task { - let settingsChangedNotifications = NotificationCenter.default.notifications(named: .settingsChanged) - .map({ _ in Optional.none }) - - for await _ in settingsChangedNotifications { - model.api = API.fromSettings() - } - } - } - - var body: some View { - let showConfigurationDialog = model.api == nil - - TabView(selection: $model.selectedTab) { - Tab(.playlist, systemImage: "list.bullet", value: .playlist) { - PlaylistView(model: model.playlistModel) - } - - Tab(.favorites, systemImage: "heart.fill", value: .favorites) { - FavoritesView(model: model.favoritesModel) - } - - Tab(.settings, systemImage: "gear", value: .settings) { - SettingsView(onDone: {}) - } - } - - - #if false - VStack { - if showConfigurationDialog { - ContentPlaceholderView { - Image(systemName: "server.rack") - Text(.notConfigured) - } actions: { - Button { - isSettingsVisible = true - } label: { - Text(.settings) - } - } - } else if model.connectionError != nil { - ContentPlaceholderView { - Image(systemName: "exclamationmark.triangle.fill") - Text(.connectionError) - } - } else { - TabView(selection: $model.selectedTab) { - } - .frame(maxWidth: 640.0) - } - - AddMediaBarView(model: model.addMediaViewModel) - .layoutPriority(2.0) - .disabled(showConfigurationDialog) - } - .sheet(isPresented: $isSettingsVisible) { - SettingsView(onDone: { isSettingsVisible = false }) - } - - #endif - - } -} - -struct ContentPlaceholderView: View - where Label: View, Actions: View -{ - let label: Label - let actions: Actions - - init(@ViewBuilder label: () -> Label, @ViewBuilder actions: () -> Actions = { EmptyView() }) { - self.label = label() - self.actions = actions() - } - - var body: some View { - Spacer() - - ContentUnavailableView { - label - .imageScale(.large) - } actions: { actions } - - Spacer() - } -} diff --git a/QueueCube/Views/MainView.swift b/QueueCube/Views/MainView.swift new file mode 100644 index 0000000..d8147e8 --- /dev/null +++ b/QueueCube/Views/MainView.swift @@ -0,0 +1,109 @@ +// +// MainView.swift +// QueueCube +// +// Created by James Magahern on 6/10/25. +// + +import SwiftUI + +@Observable +class MainViewModel +{ + var selectedServer: Server? = nil + + var connectionError: Error? = nil + var selectedTab: Tab = .playlist + + var playlistModel = PlaylistViewModel() + var favoritesModel = FavoritesViewModel() + var nowPlayingViewModel = NowPlayingViewModel() + var addMediaViewModel = AddMediaBarViewModel() + + enum Tab: String, CaseIterable + { + case playlist + case favorites + case settings + } +} + +struct MainView: View +{ + @State var model: MainViewModel + @State var isSettingsVisible: Bool = false + + init(model: MainViewModel) { + self.model = model + + // If no servers are configured, make Settings the default tab. + if !Settings.fromDefaults().isConfigured { + model.selectedTab = .settings + } + + Task { + let settingsChangedNotifications = NotificationCenter.default.notifications(named: .settingsChanged) + .map({ _ in Optional.none }) + + for await _ in settingsChangedNotifications { + // TODO + // model.api = API.fromSettings() + } + } + } + + var body: some View { + let showConfigurationDialog = model.selectedServer == nil + + TabView(selection: $model.selectedTab) { + Tab(.playlist, systemImage: "list.bullet", value: .playlist) { + PlaylistView(model: model.playlistModel) + } + + Tab(.favorites, systemImage: "heart.fill", value: .favorites) { + FavoritesView(model: model.favoritesModel) + } + + Tab(.settings, systemImage: "gear", value: .settings) { + SettingsView(onDone: {}) + } + } + + + #if false + VStack { + if showConfigurationDialog { + ContentPlaceholderView { + Image(systemName: "server.rack") + Text(.notConfigured) + } actions: { + Button { + isSettingsVisible = true + } label: { + Text(.settings) + } + } + } else if model.connectionError != nil { + ContentPlaceholderView { + Image(systemName: "exclamationmark.triangle.fill") + Text(.connectionError) + } + } else { + TabView(selection: $model.selectedTab) { + } + .frame(maxWidth: 640.0) + } + + AddMediaBarView(model: model.addMediaViewModel) + .layoutPriority(2.0) + .disabled(showConfigurationDialog) + } + .sheet(isPresented: $isSettingsVisible) { + SettingsView(onDone: { isSettingsVisible = false }) + } + + #endif + + } +} + diff --git a/QueueCube/Views/SettingsView.swift b/QueueCube/Views/Settings View/AddServerView.swift similarity index 73% rename from QueueCube/Views/SettingsView.swift rename to QueueCube/Views/Settings View/AddServerView.swift index afc1600..ecbc2f6 100644 --- a/QueueCube/Views/SettingsView.swift +++ b/QueueCube/Views/Settings View/AddServerView.swift @@ -1,119 +1,13 @@ // -// SettingsView.swift +// AddServerView.swift // QueueCube // -// Created by James Magahern on 5/2/25. +// Created by James Magahern on 6/10/25. // -import Combine import Network import SwiftUI -struct SettingsView: View -{ - let onDone: () -> Void - - var body: some View { - NavigationStack { - List { - NavigationLink(destination: GeneralSettingsView()) { - Image(systemName: "gear") - Text(.general) - } - - NavigationLink(destination: ServerListSettingsView()) { - Image(systemName: "server.rack") - Text(.servers) - } - } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle(.settings) - } - } -} - -struct GeneralSettingsView: View -{ - var body: some View { - EmptyView() - } -} - -struct ServerListSettingsView: View -{ - @State var model = ViewModel() - - var body: some View { - Form { - List(model.configuredServers) { server in - serverListItem(server) - } - } - - .navigationTitle(.servers) - - .toolbar { - Button { - model.isAddServerPresented = true - } label: { - Image(systemName: "plus") - } - - } - - .sheet(isPresented: $model.isAddServerPresented) { - NavigationView { - AddServerView(onAddServer: { model.onAddServer(server: $0) }) - .navigationTitle(.addServer) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItemGroup(placement: .cancellationAction) { - Button(.cancel) { model.isAddServerPresented = false } - } - } - - } - } - } - - @ViewBuilder - func serverListItem(_ server: Server) -> some View { - HStack { - Image(systemName: "hifispeaker.fill") - - VStack(alignment: .leading) { - Text(server.displayName) - .lineLimit(1) - .bold() - - Text(server.baseURL.absoluteString) - .foregroundStyle(.secondary) - .font(.caption) - } - - Spacer() - } - } - - // MARK: - Types - - @Observable - class ViewModel - { - var configuredServers: [Server] - var isAddServerPresented = false - - init() { - self.configuredServers = [] - } - - func onAddServer(server: Server) { - isAddServerPresented = false - configuredServers.append(server) - } - } -} - struct AddServerView: View { let onAddServer: (Server) -> Void @@ -240,8 +134,6 @@ struct AddServerView: View Task { @MainActor [weak self] in guard let self else { return } setNeedsValidation() - saveSettings() - observeForValidation() } } @@ -275,12 +167,7 @@ struct AddServerView: View } } } - - private func saveSettings() { - Settings(serverURL: self.serverURL) - .save() - } - + // MARK: - Types enum ValidationState diff --git a/QueueCube/Views/Settings View/GeneralSettingsView.swift b/QueueCube/Views/Settings View/GeneralSettingsView.swift new file mode 100644 index 0000000..9851f44 --- /dev/null +++ b/QueueCube/Views/Settings View/GeneralSettingsView.swift @@ -0,0 +1,16 @@ +// +// GeneralSettingsView.swift +// QueueCube +// +// Created by James Magahern on 6/10/25. +// + +import SwiftUI + +struct GeneralSettingsView: View +{ + var body: some View { + EmptyView() + } +} + diff --git a/QueueCube/Views/Settings View/ServerListSettingsView.swift b/QueueCube/Views/Settings View/ServerListSettingsView.swift new file mode 100644 index 0000000..99c2da5 --- /dev/null +++ b/QueueCube/Views/Settings View/ServerListSettingsView.swift @@ -0,0 +1,94 @@ +// +// ServerListSettingsView.swift +// QueueCube +// +// Created by James Magahern on 6/10/25. +// + +import SwiftUI + +struct ServerListSettingsView: View +{ + @State var model = ViewModel() + + var body: some View { + VStack { + if model.configuredServers.isEmpty { + contentPlaceholderView(title: .noServersConfigured, systemImage: "server.rack") { + Button { + model.isAddServerPresented = true + } label: { + Text(.addServer) + } + } + } else { + Form { + List(model.configuredServers) { server in + serverListItem(server) + } + } + } + } + + .navigationTitle(.servers) + + .toolbar { + Button { + model.isAddServerPresented = true + } label: { + Image(systemName: "plus") + } + } + + .sheet(isPresented: $model.isAddServerPresented) { + NavigationView { + AddServerView(onAddServer: { model.onAddServer(server: $0) }) + .navigationTitle(.addServer) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .cancellationAction) { + Button(.cancel) { model.isAddServerPresented = false } + } + } + + } + } + } + + @ViewBuilder + func serverListItem(_ server: Server) -> some View { + HStack { + Image(systemName: "hifispeaker.fill") + + VStack(alignment: .leading) { + Text(server.displayName) + .lineLimit(1) + .bold() + + Text(server.baseURL.absoluteString) + .foregroundStyle(.secondary) + .font(.caption) + } + + Spacer() + } + } + + // MARK: - Types + + @Observable + class ViewModel + { + var configuredServers: [Server] + var isAddServerPresented = false + + init() { + self.configuredServers = [] + } + + func onAddServer(server: Server) { + isAddServerPresented = false + configuredServers.append(server) + } + } +} diff --git a/QueueCube/Views/Settings View/SettingsView.swift b/QueueCube/Views/Settings View/SettingsView.swift new file mode 100644 index 0000000..d6e173f --- /dev/null +++ b/QueueCube/Views/Settings View/SettingsView.swift @@ -0,0 +1,61 @@ +// +// SettingsView.swift +// QueueCube +// +// Created by James Magahern on 5/2/25. +// + +import SwiftUI + +struct SettingsView: View +{ + let onDone: () -> Void + @State private var navigationPath: [SettingsPage] + + init(onDone: @escaping () -> Void) { + self.onDone = onDone + self.navigationPath = if !Settings.fromDefaults().isConfigured { + // Show server settings if not configured. + [ .servers ] + } else { + [] + } + } + + var body: some View { + NavigationStack(path: $navigationPath) { + List { + NavigationLink(value: SettingsPage.general) { + Image(systemName: "gear") + Text(.general) + } + + NavigationLink(value: SettingsPage.servers) { + Image(systemName: "server.rack") + Text(.servers) + } + } + .navigationDestination(for: SettingsPage.self, destination: { page in + Group { + switch page { + case .general: GeneralSettingsView() + case .servers: ServerListSettingsView() + } + } + .navigationBarTitleDisplayMode(.inline) + }) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(.settings) + } + } + + // MARK: - Types + + enum SettingsPage: String, Identifiable + { + var id: String { rawValue } + + case general + case servers + } +}