diff --git a/ios/CLAUDE.md b/ios/CLAUDE.md new file mode 100644 index 0000000..01ffb9d --- /dev/null +++ b/ios/CLAUDE.md @@ -0,0 +1,93 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +QueueCube is a SwiftUI-based jukebox client application for iOS and macOS (via Mac Catalyst). It provides a frontend for controlling a server-based jukebox system that supports playlist management, favorites, and playback controls. + +## Architecture + +### Core Components + +- **API.swift**: Central networking layer that handles all communication with the jukebox server. Includes REST API methods for playback control, playlist management, and WebSocket events for real-time updates. +- **ContentView.swift**: Main view controller containing the `MainViewModel` that coordinates between UI components and API calls. Handles WebSocket event processing and data flow. +- **Server.swift**: Represents individual jukebox servers with support for both manual configuration and Bonjour service discovery. +- **Settings.swift**: Manages multiple server configurations stored in UserDefaults with validation through `SettingsViewModel` that tests connectivity on URL changes. + +### Data Flow + +1. **Multiple Server Support**: Array of `Server` objects stored in UserDefaults with selected server tracking +2. **Settings**: Server configurations validated asynchronously via API calls with live connectivity testing +3. **Real-time Updates**: WebSocket connection provides live updates for playlist changes, playback state, and volume +4. **API Integration**: All server communication goes through the `API` struct using a fluent `RequestBuilder` pattern +5. **State Management**: Uses SwiftUI's `@Observable` pattern for reactive UI updates + +### Request Builder Pattern + +The API layer uses a fluent builder pattern for HTTP requests: +```swift +try await request() + .path("/nowplaying") + .json() +``` + +This provides type-safe, composable API calls with automatic error handling and connection state management. + +### Key Features + +- **Real-time sync**: WebSocket events automatically refresh UI when server state changes +- **Cross-platform**: Supports iOS, iPadOS, and macOS via Mac Catalyst +- **Settings validation**: Live server connectivity testing with visual feedback +- **Error handling**: Connection state management with user-friendly error displays + +## Development Commands + +### Building +```bash +# Build for iOS Simulator +xcodebuild -project QueueCube.xcodeproj -scheme QueueCube -destination 'platform=iOS Simulator,name=iPhone 15' build + +# Build for Mac Catalyst +xcodebuild -project QueueCube.xcodeproj -scheme QueueCube -destination 'platform=macOS,variant=Mac Catalyst' build +``` + +### Running +- Open `QueueCube.xcodeproj` in Xcode +- Select target device (iOS Simulator or Mac) +- Run with Cmd+R + +## API Endpoints Reference + +The server API includes these endpoints: +- `GET /nowplaying` - Current playback status +- `GET /playlist` - Current playlist items +- `GET /favorites` - User favorites +- `POST /play`, `/pause`, `/skip`, `/previous` - Playback controls +- `POST /playlist` - Add media URL to playlist +- `DELETE /playlist/{index}` - Remove playlist item +- `POST /volume` - Set volume level +- `WS /events` - WebSocket for real-time updates + +## UI Structure + +### View Hierarchy +``` +QueueCubeApp +└── ContentView (coordination layer) + └── MainView (tab management) + ├── PlaylistView (with embedded NowPlayingView) + ├── FavoritesView (favorites management) + └── SettingsView (server configuration) + ├── ServerListSettingsView + ├── AddServerView + └── GeneralSettingsView +``` + +### Key Views +- **ContentView**: Main coordinator that manages API instances and global state +- **MainView**: Tab-based navigation container with platform-specific adaptations +- **PlaylistView**: Scrollable list of queued media with reorder/delete actions, includes embedded NowPlayingView +- **NowPlayingView**: Playback controls and current track display +- **AddMediaBarView**: Input field for adding new media URLs to playlist +- **SettingsView**: Multi-server configuration with live validation and service discovery \ No newline at end of file diff --git a/ios/QueueCube.xcodeproj/project.pbxproj b/ios/QueueCube.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1fceea3 --- /dev/null +++ b/ios/QueueCube.xcodeproj/project.pbxproj @@ -0,0 +1,358 @@ +// !$*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 PBXFileSystemSynchronizedBuildFileExceptionSet section */ + CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + App/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 = ""; + }; +/* 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_EMIT_LOC_STRINGS = YES; + 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; + SWIFT_EMIT_LOC_STRINGS = YES; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + CD4E9BA32D7691C40066FC17 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = QueueCube/App/Entitlements.plist; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 5; + DEVELOPMENT_TEAM = DQQH5H6GBD; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = QueueCube/App/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 = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.2; + 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_ENTITLEMENTS = QueueCube/App/Entitlements.plist; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 5; + DEVELOPMENT_TEAM = DQQH5H6GBD; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = QueueCube/App/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 = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.2; + 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/ios/QueueCube.xcodeproj/xcshareddata/xcschemes/QueueCube.xcscheme b/ios/QueueCube.xcodeproj/xcshareddata/xcschemes/QueueCube.xcscheme new file mode 100644 index 0000000..ba5f128 --- /dev/null +++ b/ios/QueueCube.xcodeproj/xcshareddata/xcschemes/QueueCube.xcscheme @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/QueueCube/App/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/QueueCube/App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/QueueCube/App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/QueueCube/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/QueueCube/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..a0354a5 Binary files /dev/null and b/ios/QueueCube/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ios/QueueCube/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/QueueCube/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ce8e776 --- /dev/null +++ b/ios/QueueCube/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "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/ios/QueueCube/App/Assets.xcassets/Contents.json b/ios/QueueCube/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/QueueCube/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/QueueCube/App/Entitlements.plist b/ios/QueueCube/App/Entitlements.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/ios/QueueCube/App/Entitlements.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/ios/QueueCube/App/Info.plist b/ios/QueueCube/App/Info.plist new file mode 100644 index 0000000..5c034db --- /dev/null +++ b/ios/QueueCube/App/Info.plist @@ -0,0 +1,17 @@ + + + + + NSBonjourServices + + _queuecube._tcp. + + NSLocalNetworkUsageDescription + QueueCube needs access to your local network to discover nearby jukebox servers. + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/ios/QueueCube/App/QueueCubeApp.swift b/ios/QueueCube/App/QueueCubeApp.swift new file mode 100644 index 0000000..eb7ecfa --- /dev/null +++ b/ios/QueueCube/App/QueueCubeApp.swift @@ -0,0 +1,44 @@ +// +// QueueCubeApp.swift +// QueueCube +// +// Created by James Magahern on 3/3/25. +// + +import SwiftUI + +@main +struct QueueCubeApp: App { + @Environment(\.openWindow) private var openWindow + + var body: some Scene { + WindowGroup { + ContentView() + .onAppear { +#if targetEnvironment(macCatalyst) + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } + windowScene.titlebar?.titleVisibility = .hidden + 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/ios/QueueCube/Backend/API.swift b/ios/QueueCube/Backend/API.swift new file mode 100644 index 0000000..a4bdaf4 --- /dev/null +++ b/ios/QueueCube/Backend/API.swift @@ -0,0 +1,289 @@ +// +// 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? + + var displayTitle: String { + metadata?.title ?? title ?? filename ?? "item \(id)" + } + + // MARK: - Types + + struct Metadata: Codable + { + let title: String? + let description: String? + let siteName: String? + } +} + +struct SearchResultItem: Codable +{ + var type: String + var title: String + var author: String + var mediaUrl: String + var thumbnailUrl: String +} + +struct FetchResult: Codable +{ + let success: Bool + let results: T? + let error: String? +} + +struct NowPlayingInfo: Codable +{ + let playingItem: MediaItem? + let isPaused: Bool + let volume: Int +} + +actor API +{ + let baseURL: URL + + private var pingTask: Task<(), any Swift.Error>? = nil + + 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 fetchFavorites() async throws -> [MediaItem] { + try await request() + .path("/favorites") + .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 stop() async throws { + try await request() + .path("/stop") + .post() + } + + public func skip(_ to: Int? = nil) async throws { + let path = if let to { "/skip/\(to)" } else { "/skip" } + try await request() + .path(path) + .post() + } + + public func previous() async throws { + try await request() + .path("/previous") + .post() + } + + public func add(mediaURL: String) async throws { + try await request() + .path("/playlist") + .body([ "url" : mediaURL ]) + .post() + } + + public func replace(mediaURL: String) async throws { + try await request() + .path("/playlist/replace") + .body([ "url" : mediaURL ]) + .post() + } + + public func addFavorite(mediaURL: String) async throws { + try await request() + .path("/favorites") + .body([ "filename" : mediaURL ]) + .post() + } + + public func deleteFavorite(mediaURL: String) async throws { + try await request() + .pathString("/favorites/\(mediaURL.uriEncoded())") + .method(.delete) + .execute() + } + + public func renameFavorite(mediaURL: String, title: String) async throws { + try await request() + .pathString("/favorites/\(mediaURL.uriEncoded())/title") + .body([ "title": title ]) + .method(.put) + .execute() + } + + public func delete(index: Int) async throws { + try await request() + .path("/playlist/\(index)") + .method(.delete) + .execute() + } + + public func setVolume(_ value: Double) async throws { + try await request() + .path("/volume") + .body([ "volume" : Int(value * 100) ]) + .post() + } + + public func search(query: String) async throws -> FetchResult<[SearchResultItem]> { + try await request() + .pathString("/search?q=\(query.uriEncoded())") + .json() + } + + public func events() async throws -> AsyncStream { + let requestBuilder: () -> RequestBuilder = request + + return AsyncStream { continuation in + let websocketTask: URLSessionWebSocketTask = API.spawnWebsocketTask(requestBuilder: requestBuilder, with: continuation) + + Task { + var pingLoopEnabled = true + while pingLoopEnabled { + try await Task.sleep(for: .seconds(5)) + + websocketTask.sendPing { error in + if let error { + API.notifyError(error, continuation: continuation) + pingLoopEnabled = false + } else { + continuation.yield(.event(Event(type: .receivedWebsocketPong))) + } + } + } + } + } + } + + private static func spawnWebsocketTask( + requestBuilder: () -> RequestBuilder, + with continuation: AsyncStream.Continuation + ) -> URLSessionWebSocketTask + { + let url = requestBuilder() + .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(event)) + case .data(let data): + let event = try event(data) + continuation.yield(.event(event)) + default: + break + } + } + } catch { + notifyError(error, continuation: continuation) + } + } + + return websocketTask + } + + private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream.Continuation) { + print("Websocket Error: \(error)") + + let nsError = error as NSError + + // Always notify observers of WebSocket errors so reconnection can happen + // The UI layer can decide whether to show the error to the user + continuation.yield(.error(.websocketError(error))) + } + + private func request() -> RequestBuilder { + RequestBuilder(url: baseURL) + } + + // MARK: - Types + + enum Error: Swift.Error + { + case apiNotConfigured + case websocketError(Swift.Error) + } + + enum StreamEvent { + case event(Event) + case error(API.Error) + } + + 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" + + // Private UI events + case receivedWebsocketPong + case websocketReconnected + } + } +} + +extension String +{ + func uriEncoded() -> Self { + return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)! + } +} diff --git a/ios/QueueCube/Backend/Server.swift b/ios/QueueCube/Backend/Server.swift new file mode 100644 index 0000000..6260524 --- /dev/null +++ b/ios/QueueCube/Backend/Server.swift @@ -0,0 +1,57 @@ +// +// Server.swift +// QueueCube +// +// Created by James Magahern on 6/10/25. +// + +import Foundation + +struct Server: Identifiable, Codable, Equatable +{ + let serviceName: String? + let baseURL: URL + + var id: String { baseURL.absoluteString } + + var api: API { API(baseURL: baseURL) } + + 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 + } +} diff --git a/ios/QueueCube/Backend/Settings.swift b/ios/QueueCube/Backend/Settings.swift new file mode 100644 index 0000000..2e31a8b --- /dev/null +++ b/ios/QueueCube/Backend/Settings.swift @@ -0,0 +1,117 @@ +// +// Settings.swift +// QueueCube +// +// Created by James Magahern on 6/10/25. +// + +import Foundation + +struct Settings +{ + var selectedServer: Server? + + var configuredServers: [Server] { + willSet { + // Set selected server to whatever the first server is, if we're adding the first one. + if configuredServers.isEmpty && !newValue.isEmpty && selectedServer == nil { + selectedServer = newValue.first + } + + // If the selected server is being removed, set it to something else + if !newValue.contains(where: { $0 == selectedServer }) { + selectedServer = newValue.first // nil if empty + } + } + } + + var isConfigured: Bool { !configuredServers.isEmpty } + + static func fromDefaults() -> Settings { + let defaults = UserDefaults.standard + return Settings( + selectedServer: defaults[SelectedServerKey.self], + configuredServers: defaults[ConfiguredServersKey.self] + ) + } + + func save() { + let defaults = UserDefaults.standard + defaults[ConfiguredServersKey.self] = configuredServers + defaults[SelectedServerKey.self] = selectedServer + + postSettingsChanged() + } + + func postSettingsChanged() { + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } + + // MARK: - Modifiers + + func selectedServer(_ server: Server?) -> Self { + var copy = self + copy.selectedServer = server + return copy + } + + func configuredServers(_ servers: [Server]) -> Self { + var copy = self + copy.configuredServers = servers + return copy + } + + // MARK: - Types + + enum Keys: String + { + case selectedServer + case configuredServers + } + + fileprivate protocol Key + { + associatedtype Value: Codable + + static var defaultValue: Value { get } + static var key: String { get } + } + + private struct ConfiguredServersKey: Key { + static var defaultValue: [Server] { [] } + } + + private struct SelectedServerKey: Key { + static var defaultValue: Server? { nil } + } +} + +extension UserDefaults +{ + fileprivate subscript(_ type: T.Type) -> T.Value { + get { + guard let data = data(forKey: type.key) + else { return type.defaultValue } + + guard let value = try? PropertyListDecoder().decode(type.Value, from: data) + else { return type.defaultValue } + + return value + } + + set { + let data = try? PropertyListEncoder().encode(newValue) + set(data, forKey: type.key) + } + } +} + +extension Settings.Key +{ + static var key: String { Mirror(reflecting: Self.self).description } +} + +extension Notification.Name +{ + static let settingsChanged = Notification.Name("settingsChanged") +} diff --git a/ios/QueueCube/Backend/Utilities.swift b/ios/QueueCube/Backend/Utilities.swift new file mode 100644 index 0000000..f4d4d00 --- /dev/null +++ b/ios/QueueCube/Backend/Utilities.swift @@ -0,0 +1,101 @@ +// +// Utilities.swift +// QueueCube +// +// Created by James Magahern on 3/3/25. +// + +import Foundation +import SwiftUI + +extension Optional +{ + func try_unwrap() throws -> Wrapped { + guard let self else { throw UnwrapError() } + return self + } + + struct UnwrapError: Swift.Error {} +} + +struct RequestBuilder +{ + let url: URL + private var httpMethod: HTTPMethod = .get + private var body: Data? = nil + + 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 pathString(_ pathString: any StringProtocol) -> Self { + // xxx: should just fix DELETE /favorites/:filename: instead. + return RequestBuilder(url: URL(string: self.url.absoluteString + pathString)!) + } + + public func body(_ data: Codable) -> Self { + var copy = self + copy.body = try! JSONEncoder().encode(data) + return copy + } + + public func build() -> URLRequest { + var request = URLRequest(url: self.url) + request.httpMethod = self.httpMethod.rawValue + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = body + } + + 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 { + try await self.method(.post).execute() + } + + public func execute() async throws { + let urlRequest = self.build() + let (data, response) = try await URLSession.shared.data(for: urlRequest) + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode != 200 { + print("POST error \(httpResponse.statusCode): \(String(data: data, encoding: .utf8)!)") + } + } + } + + public func websocket() -> URL { + guard var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false) else { fatalError() } + components.scheme = components.scheme == "https" ? "wss" : "ws" + components.host = components.host!.replacing(/\%(.*)$/, with: "") + return components.url! + } + + enum HTTPMethod: String { + case get = "GET" + case put = "PUT" + case post = "POST" + case delete = "DELETE" + } +} + +extension Color +{ + static let label = Color(uiColor: .label) +} diff --git a/ios/QueueCube/Localizable/Localizable.xcstrings b/ios/QueueCube/Localizable/Localizable.xcstrings new file mode 100644 index 0000000..87be220 --- /dev/null +++ b/ios/QueueCube/Localizable/Localizable.xcstrings @@ -0,0 +1,385 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%@" : { + + }, + "ADD" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + } + } + }, + "ADD_ANY_URL" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add any URL…" + } + } + } + }, + "ADD_MEDIA" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Media" + } + } + } + }, + "ADD_SERVER" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Server" + } + } + } + }, + "ADD_TO_QUEUE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add to Queue" + } + } + } + }, + "CANCEL" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "CONFIGURATION" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration" + } + } + } + }, + "CONNECTION_ERROR" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connection Error" + } + } + } + }, + "COPY_TITLE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy Title" + } + } + } + }, + "COPY_URL" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy URL" + } + } + } + }, + "DISCOVERED" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discovered" + } + } + } + }, + "DONE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, + "EDIT" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit…" + } + } + } + }, + "EDIT_ITEM" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit Item" + } + } + } + }, + "ENTER_MANUALLY" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter Manually" + } + } + } + }, + "FAVORITE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorite" + } + } + } + }, + "FAVORITES" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorites" + } + } + } + }, + "FAVORITES_IS_EMPTY" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorites is empty" + } + } + } + }, + "FINDING_SERVERS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finding Servers…" + } + } + } + }, + "GENERAL" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" + } + } + } + }, + "NO_RESULTS_FOUND" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Results Found" + } + } + } + }, + "NO_SERVERS_CONFIGURED" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Servers Configured" + } + } + } + }, + "NOT_CONFIGURED" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Configured" + } + } + } + }, + "NOT_PLAYING" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Playing" + } + } + } + }, + "Nothing here yet." : { + + }, + "PLAYLIST" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playlist" + } + } + } + }, + "PLAYLIST_IS_EMPTY" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playlist is empty" + } + } + } + }, + "SEARCH_FOR_MEDIA" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search YouTube for Media…" + } + } + } + }, + "SEARCHING_" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Searching…" + } + } + } + }, + "SERVER_IS_ONLINE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server is online" + } + } + } + }, + "SERVER_URL" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server URL" + } + } + } + }, + "SERVERS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Servers" + } + } + } + }, + "SETTINGS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "SETTINGS_ELLIPSES" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings…" + } + } + } + }, + "TITLE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + } + } + }, + "UNABLE_TO_CONNECT" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to connect" + } + } + } + }, + "URL" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL" + } + } + } + }, + "VALIDATING" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Validating…" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/ios/QueueCube/Localizable/Strings.swift b/ios/QueueCube/Localizable/Strings.swift new file mode 100644 index 0000000..bc73ee7 --- /dev/null +++ b/ios/QueueCube/Localizable/Strings.swift @@ -0,0 +1,49 @@ +// +// 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") + static let connectionError = LocalizedStringKey("CONNECTION_ERROR") + static let playlist = LocalizedStringKey("PLAYLIST") + static let favorites = LocalizedStringKey("FAVORITES") + static let favorite = LocalizedStringKey("FAVORITE") + 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") + static let noServersConfigured = LocalizedStringKey("NO_SERVERS_CONFIGURED") + static let playlistEmpty = LocalizedStringKey("PLAYLIST_IS_EMPTY") + static let favoritesEmpty = LocalizedStringKey("FAVORITES_IS_EMPTY") + static let addMedia = LocalizedStringKey("ADD_MEDIA") + static let searchForMedia = LocalizedStringKey("SEARCH_FOR_MEDIA") + static let searching = LocalizedStringKey("SEARCHING_") + static let noResultsFound = LocalizedStringKey("NO_RESULTS_FOUND") + static let copyTitle = LocalizedStringKey("COPY_TITLE") + static let copyURL = LocalizedStringKey("COPY_URL") + static let edit = LocalizedStringKey("EDIT") + static let editItem = LocalizedStringKey("EDIT_ITEM") + static let addToQueue = LocalizedStringKey("ADD_TO_QUEUE") + static let notPlaying = LocalizedStringKey("NOT_PLAYING") + static let url = LocalizedStringKey("URL") + static let title = LocalizedStringKey("TITLE") +} diff --git a/ios/QueueCube/Views/AddMediaView.swift b/ios/QueueCube/Views/AddMediaView.swift new file mode 100644 index 0000000..2bc1f6a --- /dev/null +++ b/ios/QueueCube/Views/AddMediaView.swift @@ -0,0 +1,284 @@ +// +// AddMediaView.swift +// QueueCube +// +// Created by James Magahern on 6/11/25. +// + +import SwiftUI + +struct AddMediaView: View +{ + @Binding var model: ViewModel + @FocusState var fieldFocused: Bool + + var body: some View { + NavigationStack { + Form { + // Add URL + Section { + TextField(.addAnyURL, text: $model.fieldContents) + .autocapitalization(.none) + .autocorrectionDisabled() + .focused($fieldFocused) + } + + if model.supportsSearch { + Section { + NavigationLink { + SearchMediaView(model: $model) + } label: { + Image(systemName: "magnifyingglass") + Button(.searchForMedia, action: model.onSearch) + } + .tint(.label) + } + } + } + .task { fieldFocused = true } + .onAppear { model.activeDetent = ViewModel.Detent.collapsed.value } + .navigationTitle(.addMedia) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button(.add, action: model.addButtonTapped) + .disabled(model.fieldContents.isEmpty) + .bold() + } + + ToolbarItemGroup(placement: .topBarLeading) { + Button(.cancel, action: model.onCancel) + } + } + } + } + + // MARK: - Types + + enum Page: String, Identifiable + { + case addURL + case searchMedia + + var id: String { rawValue } + } + + @Observable + class ViewModel + { + var fieldContents: String = "" + var onAdd: (String) -> Void = { _ in } + var onCancel: () -> Void = { } + var onSearch: () -> Void = { } + var supportsSearch: Bool = true + + var activeDetent: PresentationDetent = Detent.collapsed.value + + enum Detent: CaseIterable + { + case collapsed + case expanded + + var value: PresentationDetent { + switch self { + case .collapsed: .height(320.0) + case .expanded: .large + } + } + } + + fileprivate func addButtonTapped() { + onAdd(fieldContents) + } + } +} + +struct SearchMediaView: View +{ + @Binding var model: AddMediaView.ViewModel + @State private var searchModel = SearchModel() + @State private var searchText = "" + @FocusState private var searchFieldFocused: Bool + + var body: some View { + VStack(spacing: 0) { + // Search field + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField(.searchForMedia, text: $searchText) + .focused($searchFieldFocused) + .onSubmit { + performSearch() + } + + if !searchText.isEmpty { + Button { + searchText = "" + searchModel.displayedResults = [] + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } + } + .padding() + .background(Color(.systemGray6)) + + if searchModel.isLoading { + VStack { + Spacer() + ProgressView(.searching) + .progressViewStyle(CircularProgressViewStyle()) + Spacer() + } + } else if searchModel.displayedResults.isEmpty && !searchText.isEmpty && searchModel.lastSearchedQuery == searchText { + VStack { + Spacer() + Text(.noResultsFound) + .foregroundColor(.secondary) + Spacer() + } + } else { + // Results list + List(searchModel.displayedResults, id: \.mediaUrl) { item in + SearchResultRow(item: item) { + model.onAdd(item.mediaUrl) + } + } + .listStyle(PlainListStyle()) + } + } + .navigationTitle(.searchForMedia) + .presentationBackground(.regularMaterial) + .onAppear { + model.activeDetent = AddMediaView.ViewModel.Detent.expanded.value + searchFieldFocused = true + } + } + + private func performSearch() { + guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + searchModel.performSearch(query: searchText) + } +} + +struct SearchResultRow: View +{ + let item: SearchResultItem + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + // Thumbnail + AsyncImage(url: URL(string: item.thumbnailUrl)) { phase in + switch phase { + case .empty: + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemGray5)) + .frame(width: 80, height: 60) + .overlay { + ProgressView() + .scaleEffect(0.8) + } + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 80, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 8)) + case .failure(_): + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemGray5)) + .frame(width: 80, height: 60) + .overlay { + Image(systemName: "photo") + .foregroundColor(.secondary) + } + @unknown default: + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemGray5)) + .frame(width: 80, height: 60) + } + } + + // Content + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.headline) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + .lineLimit(2) + + Text(item.author) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + + Text(item.type.capitalized) + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color(.systemGray6)) + .clipShape(Capsule()) + } + + Spacer() + + Image(systemName: "plus.circle.fill") + .foregroundColor(.accentColor) + .font(.title2) + } + .padding(.vertical, 8) + } + .buttonStyle(PlainButtonStyle()) + } +} + +extension SearchMediaView +{ + // MARK: - Types + + @Observable + class SearchModel + { + var displayedResults: [SearchResultItem] = [] + var isLoading: Bool = false + var lastSearchedQuery: String? = nil + + func performSearch(query: String) { + guard let api = Settings.fromDefaults().selectedServer?.api else { return } + + isLoading = true + lastSearchedQuery = query + + Task { + do { + let fetchResult = try await api.search(query: query) + if let results = fetchResult.results { + await MainActor.run { + self.displayedResults = results + .map { item in + // Convert relative thumbnail urls to absolute for loading by AsyncImage + var copy = item + copy.thumbnailUrl = api.baseURL.absoluteString + .replacingOccurrences(of: "/api", with: "") + item.thumbnailUrl // xxx: ugh... + return copy + } + + self.isLoading = false + } + } + } catch { + await MainActor.run { + self.displayedResults = [] + self.isLoading = false + } + } + } + } + } +} diff --git a/ios/QueueCube/Views/ContentPlaceholderView.swift b/ios/QueueCube/Views/ContentPlaceholderView.swift new file mode 100644 index 0000000..df33000 --- /dev/null +++ b/ios/QueueCube/Views/ContentPlaceholderView.swift @@ -0,0 +1,64 @@ +// +// ContentPlaceholderView.swift +// QueueCube +// +// Created by James Magahern on 6/10/25. +// + +import SwiftUI + +struct ContentPlaceholderView: View + where Label: View, Actions: View +{ + let label: Label + let actions: Actions + + init(@ViewBuilder label: () -> Label, @ViewBuilder actions: () -> Actions = { EmptyView() }) { + self.label = label() + self.actions = actions() + } + + var body: some View { + Spacer() + + ContentUnavailableView { + label + .imageScale(.large) + .tint(.secondary) + } actions: { actions } + + Spacer() + } +} + +func contentPlaceholderView( + title: LocalizedStringKey, + subtitle: (any StringProtocol)? = nil, + systemImage: String, @ViewBuilder actions: () -> Actions = { EmptyView() }) +-> ContentPlaceholderView +{ + ContentPlaceholderView(label: { + AnyView(erasing: VStack(spacing: 16.0) { + Image(systemName: systemImage) + .resizable() + .scaledToFit() + + .frame(width: 50.0, height: 50.0) + .foregroundStyle(.secondary) + .imageScale(.large) + + + Text(title) + .foregroundStyle(.tint) + .bold() + + if let subtitle { + Text(subtitle) + .foregroundStyle(.tint.opacity(0.5)) + } + + Spacer() + .frame(height: 14.0) + }) + }, actions: actions) +} diff --git a/ios/QueueCube/Views/ContentView.swift b/ios/QueueCube/Views/ContentView.swift new file mode 100644 index 0000000..8aa9769 --- /dev/null +++ b/ios/QueueCube/Views/ContentView.swift @@ -0,0 +1,199 @@ +// +// ContentView.swift +// QueueCube +// +// Created by James Magahern on 3/3/25. +// + +import SwiftUI + +struct ContentView: View +{ + @State var model = MainViewModel() + @State private var websocketRestartTrigger = 0 + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + MainView(model: $model) + .task(id: websocketRestartTrigger) { await watchWebsocket() } + .task { await refresh([.nowPlaying, .playlist, .favorites]) } + .task { await watchForSettingsChanges() } + .onChange(of: scenePhase) { oldPhase, newPhase in + handleScenePhaseChange(from: oldPhase, to: newPhase) + } + .sheet(isPresented: $model.isNowPlayingSheetPresented) { + NowPlayingView(model: model.nowPlayingViewModel) + .presentationBackground(.regularMaterial) + .presentationDetents([ .height(320.0) ]) + } + .sheet(isPresented: $model.isAddMediaSheetPresented) { + AddMediaView(model: $model.addMediaViewModel) + .presentationBackground(.regularMaterial) + .presentationDetents( + Set(AddMediaView.ViewModel.Detent.allCases.map { $0.value }), + selection: $model.addMediaViewModel.activeDetent + ) + } + .sheet(isPresented: $model.isEditSheetPresented) { + EditItemView(model: $model.editMediaViewModel) + .presentationBackground(.regularMaterial) + } + } + + // MARK: - Types + + struct RefreshType: OptionSet + { + let rawValue: Int + + static let nowPlaying = RefreshType(rawValue: 1 << 0) + static let playlist = RefreshType(rawValue: 1 << 1) + static let favorites = RefreshType(rawValue: 1 << 2) + } +} + +extension ContentView +{ + private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) { + // When app returns to active state from background, force reconnect and refresh + if newPhase == .active { + Task { + // Force WebSocket reconnection + websocketRestartTrigger += 1 + + // Give the WebSocket a moment to reconnect + try? await Task.sleep(for: .milliseconds(100)) + + // Full UI refresh + await refresh([.nowPlaying, .playlist, .favorites]) + } + } + } + + private func refresh(_ what: RefreshType) async { + await model.withModificationsViaAPI { api in + if what.contains(.nowPlaying) { + let nowPlaying = try await api.fetchNowPlayingInfo() + model.nowPlayingViewModel.title = nowPlaying.playingItem?.title + model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename + + model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused + model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0 + model.playlistModel.isPlaying = !nowPlaying.isPaused + model.favoritesModel.isPlaying = !nowPlaying.isPaused + } + + if what.contains(.playlist) { + let playlist = try await api.fetchPlaylist() + model.playlistModel.items = playlist.enumerated().map { (idx, mediaItem) in + MediaListItem( + id: String(mediaItem.id), + title: mediaItem.displayTitle, + filename: mediaItem.filename ?? "", + index: idx, + isCurrent: mediaItem.current ?? false + ) + } + } + + if what.contains(.favorites) { + let favorites = try await api.fetchFavorites() + let nowPlaying = try await api.fetchNowPlayingInfo() + model.favoritesModel.items = favorites.map { mediaItem in + MediaListItem( + id: String(mediaItem.id), + title: mediaItem.displayTitle, + filename: mediaItem.filename ?? "", + isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename + ) + } + } + } + } + + private func watchWebsocket() async { + guard let api = model.selectedServer?.api else { return } + + do { + for await streamEvent in try await api.events() { + switch streamEvent { + case .event(let event): + await clearConnectionErrorIfNecessary() + await handle(event: event) + case .error(let error): + // Check if this is a backgrounding error (connection abort) + let nsError = error as NSError + let isBackgroundingError = nsError.code == 53 + + // Only show connection error to user if it's not a backgrounding error + if !isBackgroundingError { + model.connectionError = error + } + + // Always attempt reconnection after a delay + Task { @MainActor in + try await Task.sleep(for: .seconds(1.0)) + websocketRestartTrigger += 1 + } + + break + } + } + } catch { + print("Events error: \(error)") + } + } + + private func handle(event: API.Event) async { + switch event.type { + case .volumeUpdate: fallthrough + case .nowPlayingUpdate: + await refresh(.nowPlaying) + + case .playlistUpdate: + await refresh(.playlist) + + case .favoritesUpdate: + await refresh(.favorites) + + case .websocketReconnected: fallthrough + case .metadataUpdate: fallthrough + case .mpdUpdate: + await refresh([.playlist, .nowPlaying, .favorites]) + + case .receivedWebsocketPong: + // This means we're online. + await clearConnectionErrorIfNecessary() + } + } + + private func clearConnectionErrorIfNecessary() async { + if model.connectionError != nil { + model.connectionError = nil + await refresh([.playlist, .nowPlaying, .favorites]) + } + } + + private func watchForSettingsChanges() async { + let settingsChangedNotifications = NotificationCenter.default.notifications(named: .settingsChanged) + .map({ _ in Optional.none }) + + for await _ in settingsChangedNotifications { + let newSelectedServer = Settings.fromDefaults().selectedServer + if newSelectedServer != model.selectedServer { + model.selectedServer = newSelectedServer + + // Reset view model to defaults + await model.reset() + + // Restart WebSocket connection for new server + websocketRestartTrigger += 1 + + await refresh([.playlist, .nowPlaying, .favorites]) + } + + // Always reset this + model.serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel() + } + } +} diff --git a/ios/QueueCube/Views/EditItemView.swift b/ios/QueueCube/Views/EditItemView.swift new file mode 100644 index 0000000..8dd1194 --- /dev/null +++ b/ios/QueueCube/Views/EditItemView.swift @@ -0,0 +1,60 @@ +// +// EditItemView.swift +// QueueCube +// +// Created by James Magahern on 6/20/25. +// + +import SwiftUI + +@Observable +class EditItemViewModel +{ + var mediaURL: String = "" + var title: String = "" + + var onDone: (EditItemViewModel) -> Void = { _ in } + var onCancel: (EditItemViewModel) -> Void = { _ in } +} + +struct EditItemView: View +{ + @Binding var model: EditItemViewModel + + var body: some View { + NavigationStack { + Form { + Section(.url) { + TextField(.url, text: $model.mediaURL) + .foregroundStyle(.secondary) + .disabled(true) // editing URL not yet supported by server + .contextMenu { + Button(.copyURL) { + UIPasteboard.general.string = model.mediaURL + } + } + } + + Section(.title) { + TextField(.title, text: $model.title) + } + } + .navigationTitle(.editItem) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + Button(.cancel, role: .cancel) { + model.onCancel(model) + } + } + + ToolbarItemGroup(placement: .topBarTrailing) { + Button(.done, role: .destructive) { + model.onDone(model) + } + .bold() + } + } + } + } +} diff --git a/ios/QueueCube/Views/MainView.swift b/ios/QueueCube/Views/MainView.swift new file mode 100644 index 0000000..2116278 --- /dev/null +++ b/ios/QueueCube/Views/MainView.swift @@ -0,0 +1,423 @@ +// +// MainView.swift +// QueueCube +// +// Created by James Magahern on 6/10/25. +// + +import SwiftUI + +@Observable +class MainViewModel +{ + var selectedServer: Server? = Settings.fromDefaults().selectedServer + + var connectionError: Error? = nil + var selectedTab: Tab = .playlist + + var isNowPlayingSheetPresented: Bool = false + var isAddMediaSheetPresented: Bool = false + var isEditSheetPresented: Bool = false + + var playlistModel = MediaListViewModel(mode: .playlist) + var favoritesModel = MediaListViewModel(mode: .favorites) + var nowPlayingViewModel = NowPlayingViewModel() + var addMediaViewModel = AddMediaView.ViewModel() + var serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel() + var editMediaViewModel = EditItemViewModel() + + private var refreshingFromAPIDepth: UInt8 = 0 + private var isRefreshingFromAPI: Bool { refreshingFromAPIDepth > 0 } + + enum Tab: String, CaseIterable + { + case playlist + case favorites + case settings + } + + init() { + observePlaylistChanges() + observeNowPlayingModel() + configureViewModelCallbacks() + } + + func onAddButtonTapped() { + isAddMediaSheetPresented = true + } + + func onNowPlayingMiniTapped() { + isNowPlayingSheetPresented = true + } + + func reset() async { + await withModificationsViaAPI { _ in + playlistModel = MediaListViewModel(mode: .playlist) + favoritesModel = MediaListViewModel(mode: .favorites) + nowPlayingViewModel = NowPlayingViewModel() + } + + configureViewModelCallbacks() + } + + func configureViewModelCallbacks() { + // Now Playing + nowPlayingViewModel.onPlayPause = apiCallback { model, api in + model.isPlaying ? try await api.pause() : try await api.play() + } + + nowPlayingViewModel.onStop = apiCallback { model, api in + try await api.stop() + } + + nowPlayingViewModel.onNext = apiCallback { _, api in + try await api.skip() + } + + nowPlayingViewModel.onPrev = apiCallback { _, api in + try await api.previous() + } + + nowPlayingViewModel.onSheetDismiss = { [weak self] _ in + self?.isNowPlayingSheetPresented = false + } + + // Playlist + playlistModel.onSeek = apiCallback { item, api in + if let index = item.index { + try await api.skip(index) + } + } + + playlistModel.onFavorite = apiCallback { item, api in + try await api.addFavorite(mediaURL: item.filename) + } + + // Favorites + favoritesModel.onPlay = apiCallback { item, api in + try await api.replace(mediaURL: item.filename) + try await api.play() + } + + favoritesModel.onEdit = { [weak self] item in + guard let self else { return } + editMediaViewModel.mediaURL = item.filename + editMediaViewModel.title = item.title + isEditSheetPresented = true + } + + favoritesModel.onQueue = apiCallback { item, api in + try await api.add(mediaURL: item.filename) + } + + // Edit + editMediaViewModel.onCancel = { [weak self] _ in + self?.isEditSheetPresented = false + } + + editMediaViewModel.onDone = apiCallback { [weak self] model, api in + self?.isEditSheetPresented = false + try await api.renameFavorite(mediaURL: model.mediaURL, title: model.title) + } + + // Add Media + addMediaViewModel.onAdd = apiCallback { [weak self] mediaURL, api in + guard let self else { return } + + let strippedURL = mediaURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !strippedURL.isEmpty { + addMediaViewModel.fieldContents = "" + isAddMediaSheetPresented = false + + switch selectedTab { + case .playlist: + try await api.add(mediaURL: strippedURL) + case .favorites: + try await api.addFavorite(mediaURL: strippedURL) + case .settings: + break + } + } + } + + addMediaViewModel.onCancel = { [weak self] in + self?.isAddMediaSheetPresented = false + } + } + + func observeNowPlayingModel() { + withObservationTracking { + _ = nowPlayingViewModel.volume + } onChange: { [weak self] in + guard let self else { return } + + let isRefreshing = isRefreshingFromAPI + Task { + if !isRefreshing { + await self.withModificationsViaAPI { api in + try await api.setVolume(self.nowPlayingViewModel.volume) + } + } + + await MainActor.run { self.observeNowPlayingModel() } + } + } + } + + func withModificationsViaAPI(_ modificationBlock: (API) async throws -> Void) async { + guard let api = selectedServer?.api else { return } + + refreshingFromAPIDepth += 1 + + do { + try await modificationBlock(api) + connectionError = nil + } catch { + print("Error refreshing content: \(error)") + connectionError = error + } + + refreshingFromAPIDepth -= 1 + } + + private func apiCallback(_ f: @escaping (T, API) async throws -> Void) -> (T) -> Void { + return { t in + Task { + await self.withModificationsViaAPI { try await f(t, $0) } + } + } + } + + private func observePlaylistChanges() { + withObservationTracking { + _ = playlistModel.items + _ = favoritesModel.items + } onChange: { [weak self] in + guard let self else { return } + + let isRefreshing = isRefreshingFromAPI + let oldPlaylist = playlistModel.items + let oldFavorites = favoritesModel.items + Task { @MainActor [weak self] in + guard let self else { return } + + if !isRefreshing { + // Notify server of removals + let playlistDiff = playlistModel.items.difference(from: oldPlaylist) { $0.id == $1.id } + await withModificationsViaAPI { api in + for removal in playlistDiff.removals { + switch removal { + case .remove(let offset, _, _): + try await api.delete(index: offset) + default: break + } + } + } + + let favoritesDiff = favoritesModel.items.difference(from: oldFavorites) { $0.id == $1.id } + await withModificationsViaAPI { api in + for removal in favoritesDiff.removals { + switch removal { + case .remove(_, let favorite, _): + try await api.deleteFavorite(mediaURL: favorite.filename) + default: break + } + } + } + } + + observePlaylistChanges() + } + } + } +} + +struct MainView: View +{ + @Binding var model: MainViewModel + @State var isSettingsVisible: Bool = false + + init(model: Binding) { + self._model = model + + // If no servers are configured, make Settings the default tab. + if !Settings.fromDefaults().isConfigured { + model.wrappedValue.selectedTab = .settings + } + } + + var body: some View { + TabView(selection: $model.selectedTab) { + Tab(.playlist, systemImage: "list.bullet", value: .playlist) { + NavigationStack { + MediaListView(model: $model.playlistModel) + .displayingServerSelectionToolbar(model: $model.serverSelectionViewModel) + .displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() } + .displayingError(model.connectionError) + .withAddButton { model.onAddButtonTapped() } + .navigationTitle(.playlist) + } + } + + Tab(.favorites, systemImage: "heart.fill", value: .favorites) { + NavigationStack { + MediaListView(model: $model.favoritesModel) + .displayingServerSelectionToolbar(model: $model.serverSelectionViewModel) + .displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() } + .displayingError(model.connectionError) + .withAddButton { model.onAddButtonTapped() } + .navigationTitle(.favorites) + } + } + + Tab(.settings, systemImage: "gear", value: .settings) { + SettingsView(onDone: {}) + } + } + .tabViewStyle(.sidebarAdaptable) + } +} + +struct NowPlayingMiniPlayerModifier: ViewModifier +{ + let onTap: () -> Void + + @Binding var model: NowPlayingViewModel + @State var nowPlayingHeight: CGFloat = 0.0 + + func body(content: Content) -> some View { + ZStack { + content + .safeAreaPadding(.bottom, nowPlayingHeight) + + VStack { + Spacer() + + NowPlayingMiniView(model: $model, onTap: onTap) + .padding() + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 800.0) + .onGeometryChange(for: CGSize.self) { $0.size } + action: { nowPlayingHeight = $0.height } + } + } + } +} + +struct ServerSelectionToolbarModifier: ViewModifier +{ + @Binding var model: ViewModel + + func body(content: Content) -> some View { + content + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + Menu { + Section { + ForEach(model.selectableServers) { server in + Button { + model.selectedServer = server + } label: { + Text(server.displayName) + if model.selectedServer == server { + Image(systemName: "checkmark") + } + } + } + } + + #if false + // TODO + Section { + Button(.addServer) { + + } + } + #endif + } label: { + Label(model.selectedServer?.displayName ?? "Servers", systemImage: "chevron.down") + .labelStyle(.titleAndIcon) + } + .buttonBorderShape(.capsule) + .buttonStyle(.bordered) + .menuStyle(.button) + } + } + } + + // MARK: - Types + + @Observable + class ViewModel + { + var selectableServers: [Server] = Settings.fromDefaults().configuredServers + var selectedServer: Server? = Settings.fromDefaults().selectedServer { + didSet { + Settings + .fromDefaults() + .selectedServer(selectedServer) + .save() + } + } + } +} + +struct AddButtonToolbarModifier: ViewModifier +{ + let onAdd: () -> Void + + func body(content: Content) -> some View { + content + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + onAdd() + } label: { + Image(systemName: "plus") + } + } + } + } +} + +struct ErrorDisplayModifier: ViewModifier +{ + let error: Error? + + func body(content: Content) -> some View { + content + .overlay { + if error != nil { + ZStack { + Rectangle() + .fill(.background) + + contentPlaceholderView( + title: .connectionError, + subtitle: error?.localizedDescription, + systemImage: "exclamationmark.triangle.fill" + ).tint(.label) + } + } + } + } +} + +extension View { + func displayingServerSelectionToolbar(model: Binding) -> some View { + modifier(ServerSelectionToolbarModifier(model: model)) + } + + func displayingNowPlayingMiniPlayer(model: Binding, onTap: @escaping () -> Void) -> some View { + modifier(NowPlayingMiniPlayerModifier(onTap: onTap, model: model)) + } + + func withAddButton(onAdd: @escaping () -> Void) -> some View { + modifier(AddButtonToolbarModifier(onAdd: onAdd)) + } + + func displayingError(_ error: Error?) -> some View { + modifier(ErrorDisplayModifier(error: error)) + } +} + diff --git a/ios/QueueCube/Views/NowPlayingMiniView.swift b/ios/QueueCube/Views/NowPlayingMiniView.swift new file mode 100644 index 0000000..bcb57c1 --- /dev/null +++ b/ios/QueueCube/Views/NowPlayingMiniView.swift @@ -0,0 +1,69 @@ +// +// NowPlayingMiniView.swift +// QueueCube +// +// Created by James Magahern on 6/11/25. +// + +import SwiftUI + +struct NowPlayingMiniView: View { + @Binding var model: NowPlayingViewModel + let onTap: () -> Void + + @GestureState private var tapGestureState = false + + private var nothingQueued: Bool { + guard let title = model.title, let subtitle = model.subtitle else { return true } + return title.isEmpty && subtitle.isEmpty + } + + var body: some View { + let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill" + let tapGesture = DragGesture(minimumDistance: 0) + .updating($tapGestureState) { _, state, _ in + state = true + } + .onEnded { _ in + onTap() + } + + HStack { + VStack(alignment: .leading) { + if let title = model.title, !title.isEmpty { + Text(title) + .font(.caption) + .lineLimit(1) + .bold() + } + + if let subtitle = model.subtitle, !subtitle.isEmpty { + Text(subtitle) + .lineLimit(1) + .font(.caption) + .foregroundStyle(.secondary) + } + + if nothingQueued { + Text(.notPlaying) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) } + .imageScale(.large) + .padding(12.0) + } + .padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0)) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(tapGestureState ? .ultraThinMaterial : .bar) + .stroke(.ultraThinMaterial, lineWidth: 1.0) + ) + .shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0) + .gesture(tapGesture) + } +} diff --git a/ios/QueueCube/Views/NowPlayingView.swift b/ios/QueueCube/Views/NowPlayingView.swift new file mode 100644 index 0000000..3de33fc --- /dev/null +++ b/ios/QueueCube/Views/NowPlayingView.swift @@ -0,0 +1,173 @@ +// +// NowPlayingView.swift +// QueueCube +// +// Created by James Magahern on 3/3/25. +// + +import SwiftUI + +@Observable +class NowPlayingViewModel +{ + var onPlayPause: (NowPlayingViewModel) -> Void = { _ in } + var onStop: (NowPlayingViewModel) -> Void = { _ in } + var onNext: (NowPlayingViewModel) -> Void = { _ in } + var onPrev: (NowPlayingViewModel) -> Void = { _ in } + var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in } + + var isPlaying: Bool = false + var title: String? = "" + var subtitle: String? = "" + var volume: Double = 0.5 + + fileprivate var isSettingVolume: Bool = false + fileprivate var settingVolume: Double = 0.0 { + didSet { volume = settingVolume } + } +} + +struct NowPlayingView: View +{ + @State var model: NowPlayingViewModel + private var nothingQueued: Bool { model.title == nil && model.subtitle == nil } + + var body: some View { + NavigationStack { + VStack { + Spacer() + .frame(height: 1.0) + + VStack { + if let title = model.title { + Text(title) + .font(.title2) + .lineLimit(1) + .bold() + } + + if let subtitle = model.subtitle { + Text(subtitle) + .font(.title3) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if nothingQueued { + Text(.notPlaying) + .font(.title2) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 24.0) + + VStack { + HStack { + ForEach(Buttons.allCases) { button in + Spacer() + + Button(action: button.action(model: model)) { + Image(systemName: button.imageName(isPlaying: model.isPlaying)) + .resizable() + .aspectRatio(1.0, contentMode: .fit) + .scaleEffect(button.scale, anchor: .center) + .tint(button.tintColor) + } + .disabled(nothingQueued) + + Spacer() + } + } + .imageScale(.large) + .frame(height: 34.0) + .tint(.label) + + Spacer() + + Slider( + value: model.isSettingVolume ? $model.settingVolume : $model.volume, + in: 0.0...1.0, + onEditingChanged: { editing in + if model.isSettingVolume != editing { + model.settingVolume = model.volume + model.isSettingVolume = editing + } + } + ) + .padding(.horizontal, 18.0) + .padding(.bottom, -12.0) // intrinsic sizing bug workaround? + } + .padding(.vertical, 44.0) + .padding(.horizontal, 12.0) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 14.0) + .fill(.ultraThinMaterial) + .stroke(Color.label.opacity(0.08)) + ) + } + + .padding(.horizontal, 15.0) + .padding(.bottom, 10.0) + + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + model.onSheetDismiss(model) + } label: { + Image(systemName: "xmark.circle.fill") + .tint(.secondary) + } + } + } + } + } + + // MARK: - Types + + private enum Buttons: Int, CaseIterable, Identifiable { + case backward + case stop + case playPause + case forward + + var id: Int { rawValue } + + var scale: Double { + switch self { + case .backward: 0.7 + case .forward: 0.7 + case .playPause: 1.0 + case .stop: 0.8 + } + } + + var tintColor: Color { + switch self { + case .backward: .label.mix(with: .gray, by: 0.5) + case .forward: .label.mix(with: .gray, by: 0.5) + case .playPause: .label + case .stop: .label + } + } + + func imageName(isPlaying: Bool) -> String { + switch self { + case .backward: "backward.fill" + case .stop: "stop.fill" + case .playPause: isPlaying ? "pause.fill" : "play.fill" + case .forward: "forward.fill" + } + } + + func action(model: NowPlayingViewModel) -> () -> Void { + switch self { + case .backward: { model.onPrev(model) } + case .stop: { model.onStop(model) } + case .playPause: { model.onPlayPause(model) } + case .forward: { model.onNext(model) } + } + } + } +} diff --git a/ios/QueueCube/Views/PlaylistView.swift b/ios/QueueCube/Views/PlaylistView.swift new file mode 100644 index 0000000..9f312df --- /dev/null +++ b/ios/QueueCube/Views/PlaylistView.swift @@ -0,0 +1,174 @@ +// +// PlaylistView.swift +// QueueCube +// +// Created by James Magahern on 3/3/25. +// + +import SwiftUI + +struct MediaListItem: Identifiable +{ + let _id: String + let title: String + let filename: String + let index: Int? + let isCurrent: Bool + + var id: String { + _id + filename // temporary: we get duplicate ids from the server sometimes... + } + + init(id: String, title: String, filename: String, index: Int? = nil, isCurrent: Bool = false) { + self._id = id + self.title = title + self.filename = filename + self.index = index + self.isCurrent = isCurrent + } +} + +enum MediaListMode { + case playlist + case favorites +} + +@Observable +class MediaListViewModel +{ + let mode: MediaListMode + var isPlaying: Bool = false + var items: [MediaListItem] = [] + + var onSeek: (MediaListItem) -> Void = { _ in } + var onPlay: (MediaListItem) -> Void = { _ in } + var onQueue: (MediaListItem) -> Void = { _ in } + var onEdit: (MediaListItem) -> Void = { _ in } + var onFavorite: (MediaListItem) -> Void = { _ in } + + init(mode: MediaListMode) { + self.mode = mode + } +} + +struct MediaListView: View +{ + @Binding var model: MediaListViewModel + + var body: some View { + VStack { + if model.items.isEmpty { + let title: LocalizedStringKey = switch model.mode { + case .playlist: .playlistEmpty + case .favorites: .favoritesEmpty + } + + contentPlaceholderView(title: title, systemImage: "list.bullet") + } else { + List($model.items, editActions: .delete) { item in + let item = item.wrappedValue + let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued + + Button { + switch model.mode { + case .playlist: + model.onSeek(item) + case .favorites: + model.onPlay(item) + } + } label: { + MediaItemCell( + title: item.title, + subtitle: item.filename, + state: state + ) + } + .listRowBackground((model.mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.10) : nil) + .contextMenu { + Button(.copyTitle) { + UIPasteboard.general.string = item.title + } + + Button(.copyURL) { + if let url = URL(string: item.filename) { + UIPasteboard.general.url = url + } else { + UIPasteboard.general.string = item.filename + } + } + + if model.mode == .favorites { + Button(.edit) { + model.onEdit(item) + } + } + } + .swipeActions(edge: .leading) { + if model.mode == .favorites { + Button { + model.onQueue(item) + } label: { + Image(systemName: "plus.square.on.square") + Text(.addToQueue) + } + .tint(.blue) + } else if model.mode == .playlist { + Button { + model.onFavorite(item) + } label: { + Image(systemName: "star") + Text(.favorite) + } + .tint(.yellow) + } + } + } + } + } + } +} + + +struct MediaItemCell: 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 { + Image(systemName: icon) + .tint(Color.primary) + .frame(width: 15.0) + .padding(.trailing, 10.0) + + VStack(alignment: .leading) { + Text(title) + .tint(.primary) + .lineLimit(1) + + Text(subtitle) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Spacer() + } + .padding([.top, .bottom], 4.0) + } + + // MARK: - Types + + enum State { + case queued + case playing + case paused + } +} + diff --git a/ios/QueueCube/Views/Settings View/AddServerView.swift b/ios/QueueCube/Views/Settings View/AddServerView.swift new file mode 100644 index 0000000..bc861cf --- /dev/null +++ b/ios/QueueCube/Views/Settings View/AddServerView.swift @@ -0,0 +1,280 @@ +// +// AddServerView.swift +// QueueCube +// +// Created by James Magahern on 6/10/25. +// + +import Network +import SwiftUI + +struct AddServerView: View +{ + let onAddServer: (Server) -> Void + @State var model = ViewModel() + + var body: some View { + Form { + // Manual Entry + Section(.manual) { + 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) + + 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() + } + } + } + } + + // Discovered + Section(.discovered) { + if model.discoveredServers.isEmpty { + HStack { + ProgressView() + .progressViewStyle(.circular) + Text(.findingServers) + } + } else { + List(model.discoveredServers) { (server: DiscoveredEndpoint) in + Button { + resolveEndpoint(server) + } label: { + HStack { + Image(systemName: "network") + Text("\(server.displayName)") + .bold() + + Spacer() + if model.resolvingServers.contains(server) { + ProgressView() + .progressViewStyle(.circular) + } + } + } + .tint(.primary) + } + } + } + } + + .task { + model.startDiscovery() + } + } + + private func resolveEndpoint(_ endpoint: DiscoveredEndpoint) { + Task { + model.resolvingServers.insert(endpoint) + + let server = try await endpoint.resolve() + onAddServer(server) + + model.resolvingServers.remove(endpoint) + } + } + + // MARK: - Types + + @Observable + class ViewModel + { + var serverURL: String = "" + var validationURL: String = "" + var validationState: ValidationState = .empty + + var discoveredServers: [DiscoveredEndpoint] = [] + var resolvingServers = Set() + + private let browser = NWBrowser(for: .bonjour(type: "_queuecube._tcp.", domain: "local."), 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() + observeForValidation() + } + } + } + + private func setNeedsValidation() { + self.validationURL = self.serverURL + self.validationTimer?.invalidate() + self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + self?.validateSettings() + } + } + + private func validateSettings() { + guard !validationURL.isEmpty else { + validationState = .empty + return + } + + self.validationState = .validating + + Task { + do { + let url = try URL(string: validationURL).try_unwrap() + let api = API(baseURL: url) + _ = try await api.fetchNowPlayingInfo() + + self.validationState = .valid + + if validationURL != serverURL { + self.serverURL = self.validationURL + } + } catch { + print("Validation failed: \(error)") + + if !validationURL.hasSuffix("/api") { + // Try adding /api and validating again. + self.validationURL = serverURL.appending("/api") + validateSettings() + } else { + self.validationState = .notValid + } + } + } + } + + // MARK: - Types + + enum ValidationState + { + case empty + case validating + case notValid + case valid + } + } +} + +struct DiscoveredEndpoint: Identifiable, Hashable +{ + 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) + } + + connection.cancel() + case .cancelled: + // expected + break + case .failed(let error): + continuation.resume(throwing: error) + connection.cancel() + default: + break + } + } + + connection.start(queue: .global(qos: .userInitiated)) + } + } + + // MARK: - Types + + enum Error: Swift.Error + { + case cancelledConnection + case endpointIncorrect + case urlError + } +} diff --git a/ios/QueueCube/Views/Settings View/GeneralSettingsView.swift b/ios/QueueCube/Views/Settings View/GeneralSettingsView.swift new file mode 100644 index 0000000..7f37230 --- /dev/null +++ b/ios/QueueCube/Views/Settings View/GeneralSettingsView.swift @@ -0,0 +1,16 @@ +// +// GeneralSettingsView.swift +// QueueCube +// +// Created by James Magahern on 6/10/25. +// + +import SwiftUI + +struct GeneralSettingsView: View +{ + var body: some View { + Text("Nothing here yet.") + } +} + diff --git a/ios/QueueCube/Views/Settings View/ServerListSettingsView.swift b/ios/QueueCube/Views/Settings View/ServerListSettingsView.swift new file mode 100644 index 0000000..867679c --- /dev/null +++ b/ios/QueueCube/Views/Settings View/ServerListSettingsView.swift @@ -0,0 +1,120 @@ +// +// ServerListSettingsView.swift +// QueueCube +// +// Created by James Magahern on 6/10/25. +// + +import SwiftUI + +struct ServerListSettingsView: View +{ + @State var model = ViewModel() + + var body: some View { + VStack { + if model.configuredServers.isEmpty { + contentPlaceholderView(title: .noServersConfigured, systemImage: "server.rack") { + Button { + model.isAddServerPresented = true + } label: { + Text(.addServer) + } + } + } else { + Form { + List($model.configuredServers, editActions: [.delete]) { server in + serverListItem(server.wrappedValue) + .tag(server.id) + } + } + } + } + + .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 + 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 + var selectedItems: [Server.ID] = [] + + init() { + self.configuredServers = Settings + .fromDefaults() + .configuredServers + + observeForChanges() + } + + func observeForChanges() { + withObservationTracking { + _ = configuredServers + } onChange: { + Task { @MainActor [weak self] in + guard let self else { return } + saveToSettings() + observeForChanges() + } + } + } + + func onAddServer(server: Server) { + isAddServerPresented = false + configuredServers = configuredServers + [ server ] + saveToSettings() + } + + func saveToSettings() { + Settings + .fromDefaults() + .configuredServers(configuredServers) + .save() + } + } +} diff --git a/ios/QueueCube/Views/Settings View/SettingsView.swift b/ios/QueueCube/Views/Settings View/SettingsView.swift new file mode 100644 index 0000000..d6e173f --- /dev/null +++ b/ios/QueueCube/Views/Settings View/SettingsView.swift @@ -0,0 +1,61 @@ +// +// SettingsView.swift +// QueueCube +// +// Created by James Magahern on 5/2/25. +// + +import SwiftUI + +struct SettingsView: View +{ + let onDone: () -> Void + @State private var navigationPath: [SettingsPage] + + init(onDone: @escaping () -> Void) { + self.onDone = onDone + self.navigationPath = if !Settings.fromDefaults().isConfigured { + // Show server settings if not configured. + [ .servers ] + } else { + [] + } + } + + var body: some View { + NavigationStack(path: $navigationPath) { + List { + NavigationLink(value: SettingsPage.general) { + Image(systemName: "gear") + Text(.general) + } + + NavigationLink(value: SettingsPage.servers) { + Image(systemName: "server.rack") + Text(.servers) + } + } + .navigationDestination(for: SettingsPage.self, destination: { page in + Group { + switch page { + case .general: GeneralSettingsView() + case .servers: ServerListSettingsView() + } + } + .navigationBarTitleDisplayMode(.inline) + }) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(.settings) + } + } + + // MARK: - Types + + enum SettingsPage: String, Identifiable + { + var id: String { rawValue } + + case general + case servers + } +}