From fb9a6fcb9b55b0525b3c8469c4927f5277352974 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 3 Mar 2025 20:21:30 -0800 Subject: [PATCH] Initial commit --- QueueCube.xcodeproj/project.pbxproj | 335 ++++++++++++++++++ .../xcshareddata/xcschemes/QueueCube.xcscheme | 82 +++++ QueueCube/API.swift | 126 +++++++ QueueCube/AddMediaBarView.swift | 38 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ QueueCube/Assets.xcassets/Contents.json | 6 + QueueCube/ContentView.swift | 122 +++++++ QueueCube/NowPlayingView.swift | 73 ++++ QueueCube/PlaylistView.swift | 90 +++++ QueueCube/QueueCubeApp.swift | 26 ++ QueueCube/Utilities.swift | 56 +++ 12 files changed, 1000 insertions(+) create mode 100644 QueueCube.xcodeproj/project.pbxproj create mode 100644 QueueCube.xcodeproj/xcshareddata/xcschemes/QueueCube.xcscheme create mode 100644 QueueCube/API.swift create mode 100644 QueueCube/AddMediaBarView.swift create mode 100644 QueueCube/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 QueueCube/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 QueueCube/Assets.xcassets/Contents.json create mode 100644 QueueCube/ContentView.swift create mode 100644 QueueCube/NowPlayingView.swift create mode 100644 QueueCube/PlaylistView.swift create mode 100644 QueueCube/QueueCubeApp.swift create mode 100644 QueueCube/Utilities.swift diff --git a/QueueCube.xcodeproj/project.pbxproj b/QueueCube.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f9a5720 --- /dev/null +++ b/QueueCube.xcodeproj/project.pbxproj @@ -0,0 +1,335 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + CD4E9B972D7691C20066FC17 /* QueueCube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QueueCube.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + CD4E9B992D7691C20066FC17 /* QueueCube */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = QueueCube; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + CD4E9B942D7691C20066FC17 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + CD4E9B8E2D7691C20066FC17 = { + isa = PBXGroup; + children = ( + CD4E9B992D7691C20066FC17 /* QueueCube */, + CD4E9B982D7691C20066FC17 /* Products */, + ); + sourceTree = ""; + }; + CD4E9B982D7691C20066FC17 /* Products */ = { + isa = PBXGroup; + children = ( + CD4E9B972D7691C20066FC17 /* QueueCube.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + CD4E9B962D7691C20066FC17 /* QueueCube */ = { + isa = PBXNativeTarget; + buildConfigurationList = CD4E9BA22D7691C40066FC17 /* Build configuration list for PBXNativeTarget "QueueCube" */; + buildPhases = ( + CD4E9B932D7691C20066FC17 /* Sources */, + CD4E9B942D7691C20066FC17 /* Frameworks */, + CD4E9B952D7691C20066FC17 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + CD4E9B992D7691C20066FC17 /* QueueCube */, + ); + name = QueueCube; + packageProductDependencies = ( + ); + productName = QueueCube; + productReference = CD4E9B972D7691C20066FC17 /* QueueCube.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + CD4E9B8F2D7691C20066FC17 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1700; + LastUpgradeCheck = 1700; + TargetAttributes = { + CD4E9B962D7691C20066FC17 = { + CreatedOnToolsVersion = 17.0; + }; + }; + }; + buildConfigurationList = CD4E9B922D7691C20066FC17 /* Build configuration list for PBXProject "QueueCube" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = CD4E9B8E2D7691C20066FC17; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = CD4E9B982D7691C20066FC17 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + CD4E9B962D7691C20066FC17 /* QueueCube */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + CD4E9B952D7691C20066FC17 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + CD4E9B932D7691C20066FC17 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + CD4E9BA02D7691C40066FC17 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 19.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + CD4E9BA12D7691C40066FC17 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 19.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + CD4E9BA32D7691C40066FC17 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + 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; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,6"; + }; + name = Debug; + }; + CD4E9BA42D7691C40066FC17 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + 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; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,6"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + CD4E9B922D7691C20066FC17 /* Build configuration list for PBXProject "QueueCube" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CD4E9BA02D7691C40066FC17 /* Debug */, + CD4E9BA12D7691C40066FC17 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CD4E9BA22D7691C40066FC17 /* Build configuration list for PBXNativeTarget "QueueCube" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CD4E9BA32D7691C40066FC17 /* Debug */, + CD4E9BA42D7691C40066FC17 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = CD4E9B8F2D7691C20066FC17 /* Project object */; +} diff --git a/QueueCube.xcodeproj/xcshareddata/xcschemes/QueueCube.xcscheme b/QueueCube.xcodeproj/xcshareddata/xcschemes/QueueCube.xcscheme new file mode 100644 index 0000000..edecdfc --- /dev/null +++ b/QueueCube.xcodeproj/xcshareddata/xcschemes/QueueCube.xcscheme @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/QueueCube/API.swift b/QueueCube/API.swift new file mode 100644 index 0000000..abdd1bb --- /dev/null +++ b/QueueCube/API.swift @@ -0,0 +1,126 @@ +// +// API.swift +// QueueCube +// +// Created by James Magahern on 3/3/25. +// + +import Foundation + +struct MediaItem: Codable +{ + let filename: String + let title: String? + let id: Int + + let current: Bool? + let playing: Bool? + let metadata: Metadata? + + // MARK: - Types + + struct Metadata: Codable + { + let title: String? + let description: String? + let siteName: String? + } +} + +struct NowPlayingInfo: Codable +{ + let playingItem: MediaItem? + let isPaused: Bool + let volume: Int +} + +struct API +{ + let baseURL: URL + + init(baseURL: URL) { + self.baseURL = baseURL + } + + public func fetchNowPlayingInfo() async throws -> NowPlayingInfo { + try await request() + .path("/nowplaying") + .json() + } + + public func fetchPlaylist() async throws -> [MediaItem] { + try await request() + .path("/playlist") + .json() + } + + public func play() async throws { + try await request() + .path("/play") + .post() + } + + public func pause() async throws { + try await request() + .path("/pause") + .post() + } + + public func events() async throws -> AsyncStream { + return AsyncStream { continuation in + let url = request() + .path("/events") + .websocket() + + let websocketTask = URLSession.shared.webSocketTask(with: url) + websocketTask.resume() + + Task { + do { + let event = { (data: Data) in + try JSONDecoder().decode(Event.self, from: data) + } + + while websocketTask.state == .running { + switch try await websocketTask.receive() { + case .string(let string): + let event = try event(string.data(using: .utf8)!) + continuation.yield(event) + case .data(let data): + let event = try event(data) + continuation.yield(event) + default: + break + } + } + } catch { + print("Websocket Error: \(error)") + } + } + } + } + + private func request() -> RequestBuilder { + RequestBuilder(url: self.baseURL) + } + + // MARK: - Types + + struct Event: Decodable + { + let type: EventType + + enum CodingKeys: String, CodingKey { + case type = "event" + } + + enum EventType: String, Decodable { + case playlistUpdate = "playlist_update" + case nowPlayingUpdate = "now_playing_update" + case volumeUpdate = "volume_update" + case favoritesUpdate = "favorites_update" + case metadataUpdate = "metadata_update" + case mpdUpdate = "mpd_update" + } + } +} diff --git a/QueueCube/AddMediaBarView.swift b/QueueCube/AddMediaBarView.swift new file mode 100644 index 0000000..9b1ddcd --- /dev/null +++ b/QueueCube/AddMediaBarView.swift @@ -0,0 +1,38 @@ +// +// AddMediaBarView.swift +// QueueCube +// +// Created by James Magahern on 3/3/25. +// + +import SwiftUI + +@Observable +class AddMediaBarViewModel +{ + var fieldContents: String = "" + + let onAdd: (String) -> Void = { _ in } + let onSearch: () -> Void = {} +} + +struct AddMediaBarView: View +{ + @State var model: AddMediaBarViewModel + + var body: some View { + VStack { + HStack { + Button(action: model.onSearch) { Image(systemName: "magnifyingglass") } + + TextField("Add any URL…", text: $model.fieldContents) + .textFieldStyle(.roundedBorder) + + Button(action: { model.onAdd(model.fieldContents) }) { Text("Add") } + .keyboardShortcut(.defaultAction) + } + .padding() + } + .background(Color.black.opacity(0.4)) + } +} diff --git a/QueueCube/Assets.xcassets/AccentColor.colorset/Contents.json b/QueueCube/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/QueueCube/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QueueCube/Assets.xcassets/AppIcon.appiconset/Contents.json b/QueueCube/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/QueueCube/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QueueCube/Assets.xcassets/Contents.json b/QueueCube/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/QueueCube/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QueueCube/ContentView.swift b/QueueCube/ContentView.swift new file mode 100644 index 0000000..1ab8b1e --- /dev/null +++ b/QueueCube/ContentView.swift @@ -0,0 +1,122 @@ +// +// ContentView.swift +// QueueCube +// +// Created by James Magahern on 3/3/25. +// + +import SwiftUI + +struct ContentView: View +{ + @State var model: MainViewModel + + init() { + self.model = MainViewModel() + + let api = self.model.api + self.model.nowPlayingViewModel.onPlayPause = { model in + Task { model.isPlaying ? try await api.pause() : try await api.play() } + } + } + + var body: some View { + MainView(model: model) + .task { await watchWebsocket() } + .task { await refresh([.nowPlaying, .playlist]) } + } + + // MARK: - Types + + struct RefreshType: OptionSet + { + let rawValue: Int + + static let nowPlaying = RefreshType(rawValue: 1 << 0) + static let playlist = RefreshType(rawValue: 1 << 1) + } +} + +extension ContentView +{ + private func refresh(_ what: RefreshType) async { + do { + if what.contains(.nowPlaying) { + let nowPlaying = try await model.api.fetchNowPlayingInfo() + model.nowPlayingViewModel.title = nowPlaying.playingItem?.title ?? "??" + model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename ?? "??" + model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused + model.playlistModel.isPlaying = !nowPlaying.isPaused + } + + if what.contains(.playlist) { + let playlist = try await model.api.fetchPlaylist() + model.playlistModel.items = playlist.map { mediaItem in + PlaylistItem( + id: String(mediaItem.id), + title: mediaItem.title ?? mediaItem.filename, + filename: mediaItem.filename, + isCurrent: mediaItem.current ?? false + ) + } + } + } catch { + print("Error refreshing content: \(error)") + } + } + + private func watchWebsocket() async { + do { + for await event in try await model.api.events() { + await handle(event: event) + } + } catch { + print("Events error: \(error)") + } + } + + private func handle(event: API.Event) async { + switch event.type { + case .volumeUpdate: fallthrough + case .nowPlayingUpdate: + await refresh(.nowPlaying) + + case .metadataUpdate: fallthrough + case .mpdUpdate: + await refresh([.playlist, .nowPlaying]) + + default: + break + } + } +} + +@Observable +class MainViewModel +{ + var api = API(baseURL: URL(string: ProcessInfo.processInfo.environment["API_SERVER"]!)!) + + var playlistModel = PlaylistViewModel() + var nowPlayingViewModel = NowPlayingViewModel() + var addMediaViewModel = AddMediaBarViewModel() +} + +struct MainView: View +{ + @State var model: MainViewModel + + var body: some View { + VStack { + VStack { + NowPlayingView(model: model.nowPlayingViewModel) + PlaylistView(model: model.playlistModel) + } + .frame(minHeight: 0.0) + .layoutPriority(1.0) + .padding() + + AddMediaBarView(model: model.addMediaViewModel) + .layoutPriority(2.0) + } + } +} diff --git a/QueueCube/NowPlayingView.swift b/QueueCube/NowPlayingView.swift new file mode 100644 index 0000000..fc8321f --- /dev/null +++ b/QueueCube/NowPlayingView.swift @@ -0,0 +1,73 @@ +// +// NowPlayingView.swift +// QueueCube +// +// Created by James Magahern on 3/3/25. +// + +import SwiftUI + +@Observable +class NowPlayingViewModel +{ + var onPlayPause: (NowPlayingViewModel) -> Void = { _ in } + var onNext: (NowPlayingViewModel) -> Void = { _ in } + var onPrev: (NowPlayingViewModel) -> Void = { _ in } + + var isPlaying: Bool = false + var title: String = "Loading…" + var subtitle: String = "" + var volume: Double = 0.5 +} + +struct NowPlayingView: View +{ + @State var model: NowPlayingViewModel + + var body: some View { + content() + .background(background()) + } + + @ViewBuilder + private func content() -> some View { + HStack { + VStack(alignment: .leading) { + Text(model.title) + .font(.title3) + .lineLimit(1) + + Text(model.subtitle) + .foregroundColor(.secondary) + .font(.subheadline) + .lineLimit(1) + } + .padding() + + Spacer() + + controls() + .padding() + } + } + + @ViewBuilder + private func controls() -> some View { + let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill" + + HStack { + Slider(value: $model.volume, in: 0.0...1.0) + .frame(maxWidth: 100.0) + + Button(action: { model.onPrev(model) } ) { Image(systemName: "arrow.left.to.line.compact") } + Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) } + Button(action: { model.onNext(model) }) { Image(systemName: "arrow.right.to.line.compact") } + } + } + + @ViewBuilder + private func background() -> some View { + RoundedRectangle(cornerRadius: 8.0) + .fill(Color(white: 0.0, opacity: 0.4)) + } +} diff --git a/QueueCube/PlaylistView.swift b/QueueCube/PlaylistView.swift new file mode 100644 index 0000000..0d5bda1 --- /dev/null +++ b/QueueCube/PlaylistView.swift @@ -0,0 +1,90 @@ +// +// PlaylistView.swift +// QueueCube +// +// Created by James Magahern on 3/3/25. +// + +import SwiftUI + +struct PlaylistItem: Identifiable +{ + let id: String + let title: String + let filename: String + let isCurrent: Bool +} + +@Observable +class PlaylistViewModel +{ + var isPlaying: Bool = false + var items: [PlaylistItem] = [] +} + +struct PlaylistView: View +{ + var model: PlaylistViewModel + + var body: some View { + List(model.items) { item in + PlaylistItemCell( + title: item.title, + subtitle: item.filename, + state: item.isCurrent ? (model.isPlaying ? PlaylistItemCell.State.playing : PlaylistItemCell.State.paused) + : .queued + ) + } + } +} + +struct PlaylistItemCell: View +{ + let title: String + let subtitle: String + let state: State + + var body: some View { + let icon: String = switch state { + case .queued: "play.fill" + case .playing: "speaker.wave.3.fill" + case .paused: "speaker.fill" + } + + HStack { + Button(action: {}) { Image(systemName: icon) } + .buttonStyle(BorderlessButtonStyle()) + .tint(Color.primary) + .frame(width: 15.0) + + VStack(alignment: .leading) { + Text(title) + .font(.body.bold()) + .lineLimit(1) + + Text(subtitle) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Spacer() + + HStack { + Button(action: {}) { + Image(systemName: "xmark") + .tint(.red) + } + } + } + .listRowBackground(state != .queued ? Color.white.opacity(0.15) : nil) + .padding([.top, .bottom], 8.0) + } + + // MARK: - Types + + enum State { + case queued + case playing + case paused + } +} diff --git a/QueueCube/QueueCubeApp.swift b/QueueCube/QueueCubeApp.swift new file mode 100644 index 0000000..508fcb8 --- /dev/null +++ b/QueueCube/QueueCubeApp.swift @@ -0,0 +1,26 @@ +// +// QueueCubeApp.swift +// QueueCube +// +// Created by James Magahern on 3/3/25. +// + +import SwiftUI + +@main +struct QueueCubeApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .frame(minWidth: 400.0, minHeight: 600.0) + .onAppear { +#if targetEnvironment(macCatalyst) + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } + windowScene.titlebar?.titleVisibility = .hidden + windowScene.titlebar?.separatorStyle = .none +#endif + } + } + + } +} diff --git a/QueueCube/Utilities.swift b/QueueCube/Utilities.swift new file mode 100644 index 0000000..3d961ca --- /dev/null +++ b/QueueCube/Utilities.swift @@ -0,0 +1,56 @@ +// +// Utilities.swift +// QueueCube +// +// Created by James Magahern on 3/3/25. +// + +import Foundation + +struct RequestBuilder +{ + let url: URL + private var httpMethod: HTTPMethod = .get + + init(url: URL) { + self.url = url + } + + public func method(_ method: HTTPMethod) -> Self { + var copy = self + copy.httpMethod = method + return copy + } + + public func path(_ path: any StringProtocol) -> Self { + return RequestBuilder(url: self.url.appending(path: path)) + } + + public func build() -> URLRequest { + var request = URLRequest(url: self.url) + request.httpMethod = self.httpMethod.rawValue + return request + } + + public func json() async throws -> T { + let urlRequest = self.build() + let (data, _) = try await URLSession.shared.data(for: urlRequest) + return try JSONDecoder().decode(T.self, from: data) + } + + public func post() async throws { + let urlRequest = self.method(.post).build() + (_, _) = try await URLSession.shared.data(for: urlRequest) + } + + public func websocket() -> URL { + guard var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false) else { fatalError() } + components.scheme = components.scheme == "https" ? "wss" : "ws" + return components.url! + } + + enum HTTPMethod: String { + case get = "GET" + case post = "POST" + } +}