From 45f1f521e24ca2f99c5d69bf4eca08fc1d08d622 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 2 May 2025 21:27:46 -0700 Subject: [PATCH] implements settings --- QueueCube.xcodeproj/project.pbxproj | 25 +++- .../xcshareddata/xcschemes/QueueCube.xcscheme | 3 +- QueueCube/API.swift | 16 +- QueueCube/AddMediaBarView.swift | 4 +- QueueCube/ContentView.swift | 115 ++++++++++----- QueueCube/Info.plist | 11 ++ QueueCube/Localizable/Localizable.xcstrings | 132 +++++++++++++++++ QueueCube/Localizable/Strings.swift | 24 +++ QueueCube/NowPlayingView.swift | 2 +- QueueCube/QueueCubeApp.swift | 19 +++ QueueCube/SettingsView.swift | 137 ++++++++++++++++++ QueueCube/Utilities.swift | 37 +++++ 12 files changed, 481 insertions(+), 44 deletions(-) create mode 100644 QueueCube/Info.plist create mode 100644 QueueCube/Localizable/Localizable.xcstrings create mode 100644 QueueCube/Localizable/Strings.swift create mode 100644 QueueCube/SettingsView.swift diff --git a/QueueCube.xcodeproj/project.pbxproj b/QueueCube.xcodeproj/project.pbxproj index f9a5720..bb48242 100644 --- a/QueueCube.xcodeproj/project.pbxproj +++ b/QueueCube.xcodeproj/project.pbxproj @@ -10,9 +10,22 @@ CD4E9B972D7691C20066FC17 /* QueueCube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QueueCube.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = CD4E9B962D7691C20066FC17 /* QueueCube */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ CD4E9B992D7691C20066FC17 /* QueueCube */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */, + ); path = QueueCube; sourceTree = ""; }; @@ -184,6 +197,7 @@ ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -240,6 +254,7 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; VALIDATE_PRODUCT = YES; }; name = Release; @@ -250,16 +265,19 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = DQQH5H6GBD; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = QueueCube/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -282,16 +300,19 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = DQQH5H6GBD; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = QueueCube/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/QueueCube.xcodeproj/xcshareddata/xcschemes/QueueCube.xcscheme b/QueueCube.xcodeproj/xcshareddata/xcschemes/QueueCube.xcscheme index edecdfc..ba5f128 100644 --- a/QueueCube.xcodeproj/xcshareddata/xcschemes/QueueCube.xcscheme +++ b/QueueCube.xcodeproj/xcshareddata/xcschemes/QueueCube.xcscheme @@ -39,8 +39,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" - allowLocationSimulation = "YES" - internalIOSLaunchStyle = "2"> + allowLocationSimulation = "YES"> 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 } @@ -135,11 +144,16 @@ struct API } private func request() -> RequestBuilder { - RequestBuilder(url: self.baseURL) + RequestBuilder(url: baseURL) } // MARK: - Types + enum Error: Swift.Error + { + case apiNotConfigured + } + struct Event: Decodable { let type: EventType diff --git a/QueueCube/AddMediaBarView.swift b/QueueCube/AddMediaBarView.swift index 5e57d1a..8ef535d 100644 --- a/QueueCube/AddMediaBarView.swift +++ b/QueueCube/AddMediaBarView.swift @@ -25,10 +25,10 @@ struct AddMediaBarView: View HStack { Button(action: model.onSearch) { Image(systemName: "magnifyingglass") } - TextField("Add any URL…", text: $model.fieldContents) + TextField(.addAnyURL, text: $model.fieldContents) .textFieldStyle(.roundedBorder) - Button(action: { model.onAdd(model.fieldContents) }) { Text("Add") } + Button(action: { model.onAdd(model.fieldContents) }) { Text(.add) } .keyboardShortcut(.defaultAction) } .padding() diff --git a/QueueCube/ContentView.swift b/QueueCube/ContentView.swift index c70f59b..4cc0b7c 100644 --- a/QueueCube/ContentView.swift +++ b/QueueCube/ContentView.swift @@ -14,36 +14,36 @@ struct ContentView: View init() { self.model = MainViewModel() - let api = self.model.api - - let nowPlayingModel = self.model.nowPlayingViewModel - nowPlayingModel.onPlayPause = { model in - Task { model.isPlaying ? try await api.pause() : try await api.play() } - } - nowPlayingModel.onNext = { _ in - Task { try await api.skip() } - } - nowPlayingModel.onPrev = { _ in - Task { try await api.previous() } - } - nowPlayingModel.onVolumeChange = { model in - Task { try await api.setVolume(model.volume) } - } - - let playlistModel = model.playlistModel - playlistModel.onSeek = { item in - Task { try await api.skip(item.index) } - } - playlistModel.onDelete = { item in - Task { try await api.delete(index: item.index) } - } - - let addMediaModel = model.addMediaViewModel - addMediaModel.onAdd = { mediaURL in - let strippedURL = mediaURL.trimmingCharacters(in: .whitespacesAndNewlines) - if !strippedURL.isEmpty { - addMediaModel.fieldContents = "" - Task { try await api.add(mediaURL: strippedURL) } + if let api = model.api { + let nowPlayingModel = self.model.nowPlayingViewModel + nowPlayingModel.onPlayPause = { model in + Task { model.isPlaying ? try await api.pause() : try await api.play() } + } + nowPlayingModel.onNext = { _ in + Task { try await api.skip() } + } + nowPlayingModel.onPrev = { _ in + Task { try await api.previous() } + } + nowPlayingModel.onVolumeChange = { model in + Task { try await api.setVolume(model.volume) } + } + + let playlistModel = model.playlistModel + playlistModel.onSeek = { item in + Task { try await api.skip(item.index) } + } + playlistModel.onDelete = { item in + Task { try await api.delete(index: item.index) } + } + + let addMediaModel = model.addMediaViewModel + addMediaModel.onAdd = { mediaURL in + let strippedURL = mediaURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !strippedURL.isEmpty { + addMediaModel.fieldContents = "" + Task { try await api.add(mediaURL: strippedURL) } + } } } } @@ -68,9 +68,11 @@ struct ContentView: View extension ContentView { private func refresh(_ what: RefreshType) async { + guard let api = model.api else { return } + do { if what.contains(.nowPlaying) { - let nowPlaying = try await model.api.fetchNowPlayingInfo() + let nowPlaying = try await api.fetchNowPlayingInfo() model.nowPlayingViewModel.title = nowPlaying.playingItem?.title ?? "??" model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename ?? "??" model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused @@ -79,7 +81,7 @@ extension ContentView } if what.contains(.playlist) { - let playlist = try await model.api.fetchPlaylist() + let playlist = try await api.fetchPlaylist() model.playlistModel.items = playlist.enumerated().map { (idx, mediaItem) in PlaylistItem( index: idx, @@ -96,8 +98,10 @@ extension ContentView } private func watchWebsocket() async { + guard let api = model.api else { return } + do { - for await event in try await model.api.events() { + for await event in try await api.events() { print("Got event: \(event.type)") await handle(event: event) } @@ -128,7 +132,7 @@ extension ContentView @Observable class MainViewModel { - var api = API(baseURL: URL(string: ProcessInfo.processInfo.environment["API_SERVER"]!)!) + var api = API.fromSettings() var playlistModel = PlaylistViewModel() var nowPlayingViewModel = NowPlayingViewModel() @@ -138,21 +142,60 @@ class MainViewModel 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 + VStack { VStack { NowPlayingView(model: model.nowPlayingViewModel) .padding() + .disabled(showConfigurationDialog) - PlaylistView(model: model.playlistModel) - .frame(maxWidth: 640.0) + if showConfigurationDialog { + Spacer() + + ContentUnavailableView { + Image(systemName: "server.rack") + Text(.notConfigured) + } actions: { + Button { + isSettingsVisible = true + } label: { + Text(.settings) + } + } + + Spacer() + } else { + PlaylistView(model: model.playlistModel) + .frame(maxWidth: 640.0) + } } .frame(minHeight: 0.0) .layoutPriority(1.0) AddMediaBarView(model: model.addMediaViewModel) .layoutPriority(2.0) + .disabled(showConfigurationDialog) } + .sheet(isPresented: $isSettingsVisible) { + SettingsView(onDone: { isSettingsVisible = false }) + } + } } diff --git a/QueueCube/Info.plist b/QueueCube/Info.plist new file mode 100644 index 0000000..6a6654d --- /dev/null +++ b/QueueCube/Info.plist @@ -0,0 +1,11 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/QueueCube/Localizable/Localizable.xcstrings b/QueueCube/Localizable/Localizable.xcstrings new file mode 100644 index 0000000..6719077 --- /dev/null +++ b/QueueCube/Localizable/Localizable.xcstrings @@ -0,0 +1,132 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "ADD" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + } + } + }, + "ADD_ANY_URL" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add any URL…" + } + } + } + }, + "CONFIGURATION" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration" + } + } + } + }, + "DONE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, + "General" : { + + }, + "GENERAL" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" + } + } + } + }, + "NOT_CONFIGURED" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Configured" + } + } + } + }, + "SERVER_IS_ONLINE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server is online" + } + } + } + }, + "SERVER_URL" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server URL" + } + } + } + }, + "SETTINGS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "SETTINGS_ELLIPSES" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings…" + } + } + } + }, + "UNABLE_TO_CONNECT" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to connect" + } + } + } + }, + "VALIDATING" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Validating…" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/QueueCube/Localizable/Strings.swift b/QueueCube/Localizable/Strings.swift new file mode 100644 index 0000000..ce9c4f4 --- /dev/null +++ b/QueueCube/Localizable/Strings.swift @@ -0,0 +1,24 @@ +// +// Strings.swift +// QueueCube +// +// Created by James Magahern on 5/2/25. +// + +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") +} diff --git a/QueueCube/NowPlayingView.swift b/QueueCube/NowPlayingView.swift index 4fa8712..65dde23 100644 --- a/QueueCube/NowPlayingView.swift +++ b/QueueCube/NowPlayingView.swift @@ -16,7 +16,7 @@ class NowPlayingViewModel var onVolumeChange: (NowPlayingViewModel) -> Void = { _ in } var isPlaying: Bool = false - var title: String = "Loading…" + var title: String = "" var subtitle: String = "" var volume: Double = 0.5 } diff --git a/QueueCube/QueueCubeApp.swift b/QueueCube/QueueCubeApp.swift index 508fcb8..d2430ac 100644 --- a/QueueCube/QueueCubeApp.swift +++ b/QueueCube/QueueCubeApp.swift @@ -9,6 +9,8 @@ import SwiftUI @main struct QueueCubeApp: App { + @Environment(\.openWindow) private var openWindow + var body: some Scene { WindowGroup { ContentView() @@ -20,7 +22,24 @@ struct QueueCubeApp: App { windowScene.titlebar?.separatorStyle = .none #endif } + }.commands { + CommandGroup(replacing: .appSettings) { + Button(.settings_) { + openWindow(id: .settingsWindowID) + } + .keyboardShortcut(",", modifiers: .command) + } } + .defaultSize(width: 640.0, height: 800.0) + WindowGroup(id: .settingsWindowID) { + SettingsView(onDone: {}) + } + .defaultSize(width: 480.0, height: 400.0) } } + +fileprivate extension String +{ + static let settingsWindowID = "settings" +} diff --git a/QueueCube/SettingsView.swift b/QueueCube/SettingsView.swift new file mode 100644 index 0000000..6ea6fbb --- /dev/null +++ b/QueueCube/SettingsView.swift @@ -0,0 +1,137 @@ +// +// SettingsView.swift +// QueueCube +// +// Created by James Magahern on 5/2/25. +// + +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() + } + } + } + + @ViewBuilder + func generalTab() -> some View { + Form { + Section(.configuration) { + 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) + } + } + } +} diff --git a/QueueCube/Utilities.swift b/QueueCube/Utilities.swift index 8cf95a9..154e7f9 100644 --- a/QueueCube/Utilities.swift +++ b/QueueCube/Utilities.swift @@ -7,6 +7,43 @@ import Foundation +struct Settings +{ + var serverURL: String? + + static func fromDefaults() -> Settings { + let serverURL = UserDefaults.standard.string(forKey: Keys.serverURL.rawValue) + return Settings(serverURL: serverURL) + } + + func save() { + UserDefaults.standard.set(serverURL, forKey: Keys.serverURL.rawValue) + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } + + // MARK: - Types + + enum Keys: String + { + case serverURL + } +} + +extension Notification.Name +{ + static let settingsChanged = Notification.Name("settingsChanged") +} + +extension Optional +{ + func try_unwrap() throws -> Wrapped { + guard let self else { throw UnwrapError() } + return self + } + + struct UnwrapError: Swift.Error {} +} + struct RequestBuilder { let url: URL