Implements UI for adding servers in settings, moves to tab model on Phone
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
8
QueueCube/App/Entitlements.plist
Normal file
8
QueueCube/App/Entitlements.plist
Normal 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>
|
||||||
55
QueueCube/Backend/Server.swift
Normal file
55
QueueCube/Backend/Server.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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" : {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,43 +217,44 @@ 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)
|
|
||||||
|
Tab(.favorites, systemImage: "heart.fill", value: .favorites) {
|
||||||
if showConfigurationDialog {
|
FavoritesView(model: model.favoritesModel)
|
||||||
ContentPlaceholderView {
|
}
|
||||||
Image(systemName: "server.rack")
|
|
||||||
Text(.notConfigured)
|
Tab(.settings, systemImage: "gear", value: .settings) {
|
||||||
} actions: {
|
SettingsView(onDone: {})
|
||||||
Button {
|
}
|
||||||
isSettingsVisible = true
|
}
|
||||||
} label: {
|
|
||||||
Text(.settings)
|
|
||||||
}
|
#if false
|
||||||
}
|
VStack {
|
||||||
} else if model.connectionError != nil {
|
if showConfigurationDialog {
|
||||||
ContentPlaceholderView {
|
ContentPlaceholderView {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "server.rack")
|
||||||
Text(.connectionError)
|
Text(.notConfigured)
|
||||||
}
|
} actions: {
|
||||||
} else {
|
Button {
|
||||||
TabView(selection: $model.selectedTab) {
|
isSettingsVisible = true
|
||||||
Tab(.playlist, systemImage: "list.bullet", value: .playlist) {
|
} label: {
|
||||||
PlaylistView(model: model.playlistModel)
|
Text(.settings)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Tab(.favorites, systemImage: "heart.fill", value: .favorites) {
|
} else if model.connectionError != nil {
|
||||||
FavoritesView(model: model.favoritesModel)
|
ContentPlaceholderView {
|
||||||
}
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
}
|
Text(.connectionError)
|
||||||
.frame(maxWidth: 640.0)
|
}
|
||||||
}
|
} else {
|
||||||
|
TabView(selection: $model.selectedTab) {
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 640.0)
|
||||||
}
|
}
|
||||||
.frame(minHeight: 0.0)
|
|
||||||
.layoutPriority(1.0)
|
|
||||||
|
|
||||||
AddMediaBarView(model: model.addMediaViewModel)
|
AddMediaBarView(model: model.addMediaViewModel)
|
||||||
.layoutPriority(2.0)
|
.layoutPriority(2.0)
|
||||||
@@ -260,6 +264,8 @@ struct MainView: View
|
|||||||
SettingsView(onDone: { isSettingsVisible = false })
|
SettingsView(onDone: { isSettingsVisible = false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user