implements settings
This commit is contained in:
@@ -10,9 +10,22 @@
|
|||||||
CD4E9B972D7691C20066FC17 /* QueueCube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QueueCube.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
CD4E9B972D7691C20066FC17 /* QueueCube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QueueCube.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* 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 */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
CD4E9B992D7691C20066FC17 /* QueueCube */ = {
|
CD4E9B992D7691C20066FC17 /* QueueCube */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */,
|
||||||
|
);
|
||||||
path = QueueCube;
|
path = QueueCube;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -184,6 +197,7 @@
|
|||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -240,6 +254,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -250,16 +265,19 @@
|
|||||||
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_IDENTITY = "-";
|
CODE_SIGN_IDENTITY = "-";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = QueueCube/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -282,16 +300,19 @@
|
|||||||
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_IDENTITY = "-";
|
CODE_SIGN_IDENTITY = "-";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = QueueCube/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -39,8 +39,7 @@
|
|||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
allowLocationSimulation = "YES"
|
allowLocationSimulation = "YES">
|
||||||
internalIOSLaunchStyle = "2">
|
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ struct API
|
|||||||
{
|
{
|
||||||
let baseURL: URL
|
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) {
|
init(baseURL: URL) {
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
}
|
}
|
||||||
@@ -135,11 +144,16 @@ struct API
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func request() -> RequestBuilder {
|
private func request() -> RequestBuilder {
|
||||||
RequestBuilder(url: self.baseURL)
|
RequestBuilder(url: baseURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Types
|
// MARK: - Types
|
||||||
|
|
||||||
|
enum Error: Swift.Error
|
||||||
|
{
|
||||||
|
case apiNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
struct Event: Decodable
|
struct Event: Decodable
|
||||||
{
|
{
|
||||||
let type: EventType
|
let type: EventType
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ struct AddMediaBarView: View
|
|||||||
HStack {
|
HStack {
|
||||||
Button(action: model.onSearch) { Image(systemName: "magnifyingglass") }
|
Button(action: model.onSearch) { Image(systemName: "magnifyingglass") }
|
||||||
|
|
||||||
TextField("Add any URL…", text: $model.fieldContents)
|
TextField(.addAnyURL, text: $model.fieldContents)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
Button(action: { model.onAdd(model.fieldContents) }) { Text("Add") }
|
Button(action: { model.onAdd(model.fieldContents) }) { Text(.add) }
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|||||||
@@ -14,36 +14,36 @@ struct ContentView: View
|
|||||||
init() {
|
init() {
|
||||||
self.model = MainViewModel()
|
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
|
let playlistModel = model.playlistModel
|
||||||
nowPlayingModel.onPlayPause = { model in
|
playlistModel.onSeek = { item in
|
||||||
Task { model.isPlaying ? try await api.pause() : try await api.play() }
|
Task { try await api.skip(item.index) }
|
||||||
}
|
}
|
||||||
nowPlayingModel.onNext = { _ in
|
playlistModel.onDelete = { item in
|
||||||
Task { try await api.skip() }
|
Task { try await api.delete(index: item.index) }
|
||||||
}
|
}
|
||||||
nowPlayingModel.onPrev = { _ in
|
|
||||||
Task { try await api.previous() }
|
|
||||||
}
|
|
||||||
nowPlayingModel.onVolumeChange = { model in
|
|
||||||
Task { try await api.setVolume(model.volume) }
|
|
||||||
}
|
|
||||||
|
|
||||||
let playlistModel = model.playlistModel
|
let addMediaModel = model.addMediaViewModel
|
||||||
playlistModel.onSeek = { item in
|
addMediaModel.onAdd = { mediaURL in
|
||||||
Task { try await api.skip(item.index) }
|
let strippedURL = mediaURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
if !strippedURL.isEmpty {
|
||||||
playlistModel.onDelete = { item in
|
addMediaModel.fieldContents = ""
|
||||||
Task { try await api.delete(index: item.index) }
|
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
|
extension ContentView
|
||||||
{
|
{
|
||||||
private func refresh(_ what: RefreshType) async {
|
private func refresh(_ what: RefreshType) async {
|
||||||
|
guard let api = model.api else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
if what.contains(.nowPlaying) {
|
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.title = nowPlaying.playingItem?.title ?? "??"
|
||||||
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename ?? "??"
|
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename ?? "??"
|
||||||
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
|
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
|
||||||
@@ -79,7 +81,7 @@ extension ContentView
|
|||||||
}
|
}
|
||||||
|
|
||||||
if what.contains(.playlist) {
|
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
|
model.playlistModel.items = playlist.enumerated().map { (idx, mediaItem) in
|
||||||
PlaylistItem(
|
PlaylistItem(
|
||||||
index: idx,
|
index: idx,
|
||||||
@@ -96,8 +98,10 @@ extension ContentView
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func watchWebsocket() async {
|
private func watchWebsocket() async {
|
||||||
|
guard let api = model.api else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
for await event in try await model.api.events() {
|
for await event in try await api.events() {
|
||||||
print("Got event: \(event.type)")
|
print("Got event: \(event.type)")
|
||||||
await handle(event: event)
|
await handle(event: event)
|
||||||
}
|
}
|
||||||
@@ -128,7 +132,7 @@ extension ContentView
|
|||||||
@Observable
|
@Observable
|
||||||
class MainViewModel
|
class MainViewModel
|
||||||
{
|
{
|
||||||
var api = API(baseURL: URL(string: ProcessInfo.processInfo.environment["API_SERVER"]!)!)
|
var api = API.fromSettings()
|
||||||
|
|
||||||
var playlistModel = PlaylistViewModel()
|
var playlistModel = PlaylistViewModel()
|
||||||
var nowPlayingViewModel = NowPlayingViewModel()
|
var nowPlayingViewModel = NowPlayingViewModel()
|
||||||
@@ -138,21 +142,60 @@ class MainViewModel
|
|||||||
struct MainView: View
|
struct MainView: View
|
||||||
{
|
{
|
||||||
@State var model: MainViewModel
|
@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 {
|
var body: some View {
|
||||||
|
let showConfigurationDialog = model.api == nil
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
VStack {
|
VStack {
|
||||||
NowPlayingView(model: model.nowPlayingViewModel)
|
NowPlayingView(model: model.nowPlayingViewModel)
|
||||||
.padding()
|
.padding()
|
||||||
|
.disabled(showConfigurationDialog)
|
||||||
|
|
||||||
PlaylistView(model: model.playlistModel)
|
if showConfigurationDialog {
|
||||||
.frame(maxWidth: 640.0)
|
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)
|
.frame(minHeight: 0.0)
|
||||||
.layoutPriority(1.0)
|
.layoutPriority(1.0)
|
||||||
|
|
||||||
AddMediaBarView(model: model.addMediaViewModel)
|
AddMediaBarView(model: model.addMediaViewModel)
|
||||||
.layoutPriority(2.0)
|
.layoutPriority(2.0)
|
||||||
|
.disabled(showConfigurationDialog)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $isSettingsVisible) {
|
||||||
|
SettingsView(onDone: { isSettingsVisible = false })
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
QueueCube/Info.plist
Normal file
11
QueueCube/Info.plist
Normal 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>
|
||||||
132
QueueCube/Localizable/Localizable.xcstrings
Normal file
132
QueueCube/Localizable/Localizable.xcstrings
Normal 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"
|
||||||
|
}
|
||||||
24
QueueCube/Localizable/Strings.swift
Normal file
24
QueueCube/Localizable/Strings.swift
Normal 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")
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ class NowPlayingViewModel
|
|||||||
var onVolumeChange: (NowPlayingViewModel) -> Void = { _ in }
|
var onVolumeChange: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
|
|
||||||
var isPlaying: Bool = false
|
var isPlaying: Bool = false
|
||||||
var title: String = "Loading…"
|
var title: String = ""
|
||||||
var subtitle: String = ""
|
var subtitle: String = ""
|
||||||
var volume: Double = 0.5
|
var volume: Double = 0.5
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct QueueCubeApp: App {
|
struct QueueCubeApp: App {
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
@@ -20,7 +22,24 @@ struct QueueCubeApp: App {
|
|||||||
windowScene.titlebar?.separatorStyle = .none
|
windowScene.titlebar?.separatorStyle = .none
|
||||||
#endif
|
#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"
|
||||||
|
}
|
||||||
|
|||||||
137
QueueCube/SettingsView.swift
Normal file
137
QueueCube/SettingsView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,43 @@
|
|||||||
|
|
||||||
import Foundation
|
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
|
struct RequestBuilder
|
||||||
{
|
{
|
||||||
let url: URL
|
let url: URL
|
||||||
|
|||||||
Reference in New Issue
Block a user