Started working on multiple server configuration

This commit is contained in:
2025-06-10 18:45:34 -07:00
parent c775fa0def
commit f4f3ef543f
12 changed files with 397 additions and 271 deletions

View File

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

View File

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

View File

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

View File

@@ -125,6 +125,16 @@
}
}
},
"NO_SERVERS_CONFIGURED" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No Servers Configured"
}
}
}
},
"NOT_CONFIGURED" : {
"localizations" : {
"en" : {

View File

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

View File

@@ -0,0 +1,57 @@
//
// ContentPlaceholderView.swift
// QueueCube
//
// Created by James Magahern on 6/10/25.
//
import SwiftUI
struct ContentPlaceholderView<Label, Actions>: 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<Actions>(
title: LocalizedStringKey,
systemImage: String, @ViewBuilder actions: () -> Actions = { EmptyView() })
-> ContentPlaceholderView<AnyView, Actions>
{
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)
}

View File

@@ -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<Label, Actions>: 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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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