From c775fa0def06b9a9d78b74499e6f9aef60458f4b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Tue, 10 Jun 2025 14:16:47 -0700 Subject: [PATCH] Implements UI for adding servers in settings, moves to tab model on Phone --- QueueCube.xcodeproj/project.pbxproj | 2 + QueueCube/App/Entitlements.plist | 8 + QueueCube/Backend/Server.swift | 55 +++ QueueCube/Backend/Settings.swift | 6 + QueueCube/Localizable/Localizable.xcstrings | 64 ++- QueueCube/Localizable/Strings.swift | 6 + QueueCube/Views/ContentView.swift | 78 ++-- QueueCube/Views/SettingsView.swift | 439 +++++++++++++++----- 8 files changed, 514 insertions(+), 144 deletions(-) create mode 100644 QueueCube/App/Entitlements.plist create mode 100644 QueueCube/Backend/Server.swift diff --git a/QueueCube.xcodeproj/project.pbxproj b/QueueCube.xcodeproj/project.pbxproj index 68ddcd4..264f5d6 100644 --- a/QueueCube.xcodeproj/project.pbxproj +++ b/QueueCube.xcodeproj/project.pbxproj @@ -264,6 +264,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = QueueCube/App/Entitlements.plist; CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; @@ -299,6 +300,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = QueueCube/App/Entitlements.plist; CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; diff --git a/QueueCube/App/Entitlements.plist b/QueueCube/App/Entitlements.plist new file mode 100644 index 0000000..ec706aa --- /dev/null +++ b/QueueCube/App/Entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.developer.networking.multicast + + + diff --git a/QueueCube/Backend/Server.swift b/QueueCube/Backend/Server.swift new file mode 100644 index 0000000..74bb288 --- /dev/null +++ b/QueueCube/Backend/Server.swift @@ -0,0 +1,55 @@ +// +// Server.swift +// QueueCube +// +// Created by James Magahern on 6/10/25. +// + +import Foundation + +struct Server: Identifiable +{ + let serviceName: String? + let baseURL: URL + + var id: String { baseURL.absoluteString } + + var displayName: String { + if let serviceName { + return serviceName.queueCubeServiceName + } + + let components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + return components.host ?? baseURL.absoluteString + } + + init?(serviceName: String?, host: String, port: UInt16) { + self.serviceName = serviceName + + // Assumes this is the local service discovery path, which is http + // Bounjour gives us the interface sometimes, which we can handle, but need to percent encode. + let host = host.replacingOccurrences(of: "%", with: "%25") + guard let url = URL(string: "http://\(host):\(port)/api") else { + return nil + } + + self.baseURL = url + } + + init(baseURL: URL) { + self.serviceName = nil + self.baseURL = baseURL + } +} + +extension String +{ + var queueCubeServiceName: String { + let regex = /.* \((.*)\)/ + if let match = try? regex.firstMatch(in: self) { + return String(match.output.1) + } + + return self + } +} diff --git a/QueueCube/Backend/Settings.swift b/QueueCube/Backend/Settings.swift index ee4de0b..f46c620 100644 --- a/QueueCube/Backend/Settings.swift +++ b/QueueCube/Backend/Settings.swift @@ -27,6 +27,12 @@ struct Settings { case serverURL } + + struct Server: Codable + { + let address: String + let port: UInt32 + } } extension Notification.Name diff --git a/QueueCube/Localizable/Localizable.xcstrings b/QueueCube/Localizable/Localizable.xcstrings index fb1bc16..1df70cd 100644 --- a/QueueCube/Localizable/Localizable.xcstrings +++ b/QueueCube/Localizable/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "%@" : { + + }, "ADD" : { "localizations" : { "en" : { @@ -21,6 +24,26 @@ } } }, + "ADD_SERVER" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Server" + } + } + } + }, + "CANCEL" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, "CONFIGURATION" : { "localizations" : { "en" : { @@ -41,6 +64,16 @@ } } }, + "DISCOVERED" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discovered" + } + } + } + }, "DONE" : { "localizations" : { "en" : { @@ -51,6 +84,16 @@ } } }, + "ENTER_MANUALLY" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter Manually" + } + } + } + }, "FAVORITES" : { "localizations" : { "en" : { @@ -61,8 +104,15 @@ } } }, - "General" : { - + "FINDING_SERVERS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finding Servers…" + } + } + } }, "GENERAL" : { "extractionState" : "manual", @@ -116,6 +166,16 @@ } } }, + "SERVERS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Servers" + } + } + } + }, "SETTINGS" : { "localizations" : { "en" : { diff --git a/QueueCube/Localizable/Strings.swift b/QueueCube/Localizable/Strings.swift index 1ccacae..ba419a9 100644 --- a/QueueCube/Localizable/Strings.swift +++ b/QueueCube/Localizable/Strings.swift @@ -24,4 +24,10 @@ extension LocalizedStringKey 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") } diff --git a/QueueCube/Views/ContentView.swift b/QueueCube/Views/ContentView.swift index 7cd7510..326f2f0 100644 --- a/QueueCube/Views/ContentView.swift +++ b/QueueCube/Views/ContentView.swift @@ -48,6 +48,8 @@ struct ContentView: View try await api.add(mediaURL: strippedURL) case .favorites: try await api.addFavorite(mediaURL: strippedURL) + case .settings: + break } } } @@ -191,6 +193,7 @@ class MainViewModel enum MainTab: String, CaseIterable { case playlist case favorites + case settings } struct MainView: View @@ -214,44 +217,45 @@ struct MainView: View var body: some View { let showConfigurationDialog = model.api == nil - VStack { - VStack { - NowPlayingView(model: model.nowPlayingViewModel) - .padding() - .disabled(showConfigurationDialog || model.connectionError != nil) - - 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) { - Tab(.playlist, systemImage: "list.bullet", value: .playlist) { - PlaylistView(model: model.playlistModel) - } - - Tab(.favorites, systemImage: "heart.fill", value: .favorites) { - FavoritesView(model: model.favoritesModel) - } - } - .frame(maxWidth: 640.0) - } + TabView(selection: $model.selectedTab) { + Tab(.playlist, systemImage: "list.bullet", value: .playlist) { + PlaylistView(model: model.playlistModel) } - .frame(minHeight: 0.0) - .layoutPriority(1.0) + 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) @@ -260,6 +264,8 @@ struct MainView: View SettingsView(onDone: { isSettingsVisible = false }) } + #endif + } } diff --git a/QueueCube/Views/SettingsView.swift b/QueueCube/Views/SettingsView.swift index 6ea6fbb..afc1600 100644 --- a/QueueCube/Views/SettingsView.swift +++ b/QueueCube/Views/SettingsView.swift @@ -5,133 +5,360 @@ // Created by James Magahern on 5/2/25. // +import Combine +import Network import SwiftUI -@Observable -class SettingsViewModel -{ - var serverURL: String = "" - var validationState: ValidationState = .empty - - private var validationTimer: Timer? = nil - - init() { - validateSettings() - observeForValidation() - } - - static func fromDefaults() -> SettingsViewModel { - let settings = Settings.fromDefaults() - let model = SettingsViewModel() - model.serverURL = settings.serverURL ?? "" - - return model - } - - private func observeForValidation() { - withObservationTracking { - _ = serverURL - } onChange: { - Task { @MainActor [weak self] in - guard let self else { return } - setNeedsValidation() - saveSettings() - - observeForValidation() - } - } - } - - private func setNeedsValidation() { - self.validationTimer?.invalidate() - self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in - self?.validateSettings() - } - } - - private func validateSettings() { - guard !serverURL.isEmpty else { - validationState = .empty - return - } - - self.validationState = .validating - - Task { - do { - let url = try URL(string: serverURL).try_unwrap() - let api = API(baseURL: url) - _ = try await api.fetchNowPlayingInfo() - - self.validationState = .valid - } catch { - print("Validation failed: \(error)") - self.validationState = .notValid - } - } - } - - private func saveSettings() { - Settings(serverURL: self.serverURL) - .save() - } - - // MARK: - Types - - enum ValidationState - { - case empty - case validating - case notValid - case valid - } -} - struct SettingsView: View { let onDone: () -> Void - @State var model = SettingsViewModel.fromDefaults() var body: some View { - TabView { - Tab("General", systemImage: "gear") { - generalTab() + 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 generalTab() -> some View { + 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 + @State var model = ViewModel() + + var body: some View { Form { - Section(.configuration) { + // Manual Entry + Section(.manual) { TextField(.serverURL, text: $model.serverURL) .autocapitalization(.none) .autocorrectionDisabled() .keyboardType(.URL) + + switch model.validationState { + case .empty: + EmptyView() + case .validating: + HStack { + ProgressView() + .progressViewStyle(.circular) + Text(.validating) + } + case .notValid: + HStack { + Image(systemName: "x.circle.fill") + Text(.unableToConnect) + } + .foregroundStyle(.red) + case .valid: + HStack { + Image(systemName: "checkmark.circle.fill") + Text(.serverIsOnline) + } + .foregroundStyle(.green) + + Button { + // Force unwrap, since we validated it at this point. + let server = Server(baseURL: URL(string: model.serverURL)!) + onAddServer(server) + } label: { + HStack { + Spacer() + Text(.addServer) + Spacer() + } + } + } } - switch model.validationState { - case .empty: - EmptyView() - case .validating: - HStack { - ProgressView() - .progressViewStyle(.circular) - Text(.validating) + // Discovered + Section(.discovered) { + if model.discoveredServers.isEmpty { + HStack { + ProgressView() + .progressViewStyle(.circular) + Text(.findingServers) + } + } else { + List(model.discoveredServers) { (server: DiscoveredEndpoint) in + Button { + resolveEndpoint(server) + } label: { + HStack { + Image(systemName: "network") + Text("\(server.displayName)") + .bold() + } + } + .tint(.primary) + } } - case .notValid: - HStack { - Image(systemName: "x.circle.fill") - Text(.unableToConnect) - } - .foregroundStyle(.red) - case .valid: - HStack { - Image(systemName: "checkmark.circle.fill") - Text(.serverIsOnline) - } - .foregroundStyle(.green) } } + + .task { + model.startDiscovery() + } + } + + private func resolveEndpoint(_ endpoint: DiscoveredEndpoint) { + Task { + let server = try await endpoint.resolve() + onAddServer(server) + } + } + + // MARK: - Types + + @Observable + class ViewModel + { + var serverURL: String = "" + var validationState: ValidationState = .empty + + var discoveredServers: [DiscoveredEndpoint] = [] + private let browser = NWBrowser(for: .bonjour(type: "_queuecube._tcp.", domain: nil), using: .tcp) + + private var validationTimer: Timer? = nil + + init() { + observeForValidation() + } + + public func startDiscovery() { + browser.browseResultsChangedHandler = { [weak self] results, changes in + guard let self else { return } + self.discoveredServers = results.map { DiscoveredEndpoint(result: $0) } + } + + browser.stateUpdateHandler = { state in + if case .failed(let error) = state { + print("Discovery error: \(error)") + } + } + + browser.start(queue: .global(qos: .userInitiated)) + } + + private func observeForValidation() { + withObservationTracking { + _ = serverURL + } onChange: { + Task { @MainActor [weak self] in + guard let self else { return } + setNeedsValidation() + saveSettings() + + observeForValidation() + } + } + } + + private func setNeedsValidation() { + self.validationTimer?.invalidate() + self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + self?.validateSettings() + } + } + + private func validateSettings() { + guard !serverURL.isEmpty else { + validationState = .empty + return + } + + self.validationState = .validating + + Task { + do { + let url = try URL(string: serverURL).try_unwrap() + let api = API(baseURL: url) + _ = try await api.fetchNowPlayingInfo() + + self.validationState = .valid + } catch { + print("Validation failed: \(error)") + self.validationState = .notValid + } + } + } + + private func saveSettings() { + Settings(serverURL: self.serverURL) + .save() + } + + // MARK: - Types + + enum ValidationState + { + case empty + case validating + case notValid + case valid + } + } +} + +struct DiscoveredEndpoint: Identifiable +{ + let endpoint: NWEndpoint + let serviceName: String + + var displayName: String { + serviceName.queueCubeServiceName + } + + var id: String { serviceName } + + init(result: NWBrowser.Result) { + self.endpoint = result.endpoint + + switch result.endpoint { + case .service(name: let name, type: _, domain: _, interface: _): + self.serviceName = name + default: + self.serviceName = "(Unknown)" + break + } + } + + func resolve() async throws -> Server { + return try await withCheckedThrowingContinuation { continuation in + let connection = NWConnection(to: endpoint, using: .tcp) + connection.stateUpdateHandler = { state in + switch state { + case .preparing: break + case .ready: + // xxx: is this really the right way to do this? Maybe we should not try to turn this into a URL. + if case .hostPort(host: let host, port: let port) = connection.currentPath?.remoteEndpoint { + let address = switch host { + case .name(let string, _): string + case .ipv4(let iPv4Address): iPv4Address.debugDescription + case .ipv6(let iPv6Address): iPv6Address.debugDescription + default: "unknown" + } + + if let server = Server(serviceName: serviceName, host: address, port: port.rawValue) { + continuation.resume(returning: server) + } else { + continuation.resume(throwing: Self.Error.urlError) + } + } else { + continuation.resume(throwing: Self.Error.endpointIncorrect) + } + case .cancelled: + continuation.resume(throwing: Self.Error.cancelledConnection) + case .failed(let error): + continuation.resume(throwing: error) + default: + break + } + } + + connection.start(queue: .global(qos: .userInitiated)) + } + } + + // MARK: - Types + + enum Error: Swift.Error + { + case cancelledConnection + case endpointIncorrect + case urlError } }