implements settings

This commit is contained in:
2025-05-02 21:27:46 -07:00
parent 74c0227ec7
commit 45f1f521e2
12 changed files with 481 additions and 44 deletions

View File

@@ -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 = "<group>";
};
@@ -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",

View File

@@ -39,8 +39,7 @@
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
internalIOSLaunchStyle = "2">
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference

View File

@@ -38,6 +38,15 @@ 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
}
@@ -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

View File

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

View File

@@ -14,36 +14,36 @@ struct ContentView: View
init() {
self.model = MainViewModel()
let api = self.model.api
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 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 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) }
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 })
}
}
}

11
QueueCube/Info.plist Normal file
View File

@@ -0,0 +1,11 @@
<?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>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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