Implements UI for adding servers in settings, moves to tab model on Phone

This commit is contained in:
2025-06-10 14:16:47 -07:00
parent 13b27a2a1a
commit c775fa0def
8 changed files with 514 additions and 144 deletions

View File

@@ -264,6 +264,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = QueueCube/App/Entitlements.plist;
CODE_SIGN_IDENTITY = "-"; CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@@ -299,6 +300,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = QueueCube/App/Entitlements.plist;
CODE_SIGN_IDENTITY = "-"; CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.multicast</key>
<true/>
</dict>
</plist>

View File

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

View File

@@ -27,6 +27,12 @@ struct Settings
{ {
case serverURL case serverURL
} }
struct Server: Codable
{
let address: String
let port: UInt32
}
} }
extension Notification.Name extension Notification.Name

View File

@@ -1,6 +1,9 @@
{ {
"sourceLanguage" : "en", "sourceLanguage" : "en",
"strings" : { "strings" : {
"%@" : {
},
"ADD" : { "ADD" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -21,6 +24,26 @@
} }
} }
}, },
"ADD_SERVER" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add Server"
}
}
}
},
"CANCEL" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cancel"
}
}
}
},
"CONFIGURATION" : { "CONFIGURATION" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -41,6 +64,16 @@
} }
} }
}, },
"DISCOVERED" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Discovered"
}
}
}
},
"DONE" : { "DONE" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -51,6 +84,16 @@
} }
} }
}, },
"ENTER_MANUALLY" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Enter Manually"
}
}
}
},
"FAVORITES" : { "FAVORITES" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -61,8 +104,15 @@
} }
} }
}, },
"General" : { "FINDING_SERVERS" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Finding Servers…"
}
}
}
}, },
"GENERAL" : { "GENERAL" : {
"extractionState" : "manual", "extractionState" : "manual",
@@ -116,6 +166,16 @@
} }
} }
}, },
"SERVERS" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Servers"
}
}
}
},
"SETTINGS" : { "SETTINGS" : {
"localizations" : { "localizations" : {
"en" : { "en" : {

View File

@@ -24,4 +24,10 @@ extension LocalizedStringKey
static let connectionError = LocalizedStringKey("CONNECTION_ERROR") static let connectionError = LocalizedStringKey("CONNECTION_ERROR")
static let playlist = LocalizedStringKey("PLAYLIST") static let playlist = LocalizedStringKey("PLAYLIST")
static let favorites = LocalizedStringKey("FAVORITES") 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")
} }

View File

@@ -48,6 +48,8 @@ struct ContentView: View
try await api.add(mediaURL: strippedURL) try await api.add(mediaURL: strippedURL)
case .favorites: case .favorites:
try await api.addFavorite(mediaURL: strippedURL) try await api.addFavorite(mediaURL: strippedURL)
case .settings:
break
} }
} }
} }
@@ -191,6 +193,7 @@ class MainViewModel
enum MainTab: String, CaseIterable { enum MainTab: String, CaseIterable {
case playlist case playlist
case favorites case favorites
case settings
} }
struct MainView: View struct MainView: View
@@ -214,44 +217,45 @@ struct MainView: View
var body: some View { var body: some View {
let showConfigurationDialog = model.api == nil let showConfigurationDialog = model.api == nil
VStack { TabView(selection: $model.selectedTab) {
VStack { Tab(.playlist, systemImage: "list.bullet", value: .playlist) {
NowPlayingView(model: model.nowPlayingViewModel) PlaylistView(model: model.playlistModel)
.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)
}
} }
.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) AddMediaBarView(model: model.addMediaViewModel)
.layoutPriority(2.0) .layoutPriority(2.0)
.disabled(showConfigurationDialog) .disabled(showConfigurationDialog)
@@ -260,6 +264,8 @@ struct MainView: View
SettingsView(onDone: { isSettingsVisible = false }) SettingsView(onDone: { isSettingsVisible = false })
} }
#endif
} }
} }

View File

@@ -5,133 +5,360 @@
// Created by James Magahern on 5/2/25. // Created by James Magahern on 5/2/25.
// //
import Combine
import Network
import SwiftUI 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 struct SettingsView: View
{ {
let onDone: () -> Void let onDone: () -> Void
@State var model = SettingsViewModel.fromDefaults()
var body: some View { var body: some View {
TabView { NavigationStack {
Tab("General", systemImage: "gear") { List {
generalTab() 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 @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 { Form {
Section(.configuration) { // Manual Entry
Section(.manual) {
TextField(.serverURL, text: $model.serverURL) TextField(.serverURL, text: $model.serverURL)
.autocapitalization(.none) .autocapitalization(.none)
.autocorrectionDisabled() .autocorrectionDisabled()
.keyboardType(.URL) .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 { // Discovered
case .empty: Section(.discovered) {
EmptyView() if model.discoveredServers.isEmpty {
case .validating: HStack {
HStack { ProgressView()
ProgressView() .progressViewStyle(.circular)
.progressViewStyle(.circular) Text(.findingServers)
Text(.validating) }
} 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
} }
} }