Add 'ios/' from commit '2220a0d4f2bb0f0ebca509581c21d6c90359bd14'

git-subtree-dir: ios
git-subtree-mainline: 52968df567
git-subtree-split: 2220a0d4f2
This commit is contained in:
2025-10-10 23:13:50 -07:00
28 changed files with 3572 additions and 0 deletions

93
ios/CLAUDE.md Normal file
View File

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

View File

@@ -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 = "<group>";
};
/* 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 = "<group>";
};
CD4E9B982D7691C20066FC17 /* Products */ = {
isa = PBXGroup;
children = (
CD4E9B972D7691C20066FC17 /* QueueCube.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD4E9B962D7691C20066FC17"
BuildableName = "QueueCube.app"
BlueprintName = "QueueCube"
ReferencedContainer = "container:QueueCube.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD4E9B962D7691C20066FC17"
BuildableName = "QueueCube.app"
BlueprintName = "QueueCube"
ReferencedContainer = "container:QueueCube.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD4E9B962D7691C20066FC17"
BuildableName = "QueueCube.app"
BlueprintName = "QueueCube"
ReferencedContainer = "container:QueueCube.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
<InstallAction
buildConfiguration = "Release">
</InstallAction>
</Scheme>

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

View File

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

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSBonjourServices</key>
<array>
<string>_queuecube._tcp.</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>QueueCube needs access to your local network to discover nearby jukebox servers.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

View File

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

View File

@@ -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<T: Codable>: 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<StreamEvent> {
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<StreamEvent>.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<StreamEvent>.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)!
}
}

View File

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

View File

@@ -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<T: Settings.Key>(_ 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")
}

View File

@@ -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<T: Decodable>() 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)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
//
// ContentPlaceholderView.swift
// QueueCube
//
// Created by James Magahern on 6/10/25.
//
import SwiftUI
struct ContentPlaceholderView<Label, Actions>: 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<Actions>(
title: LocalizedStringKey,
subtitle: (any StringProtocol)? = nil,
systemImage: String, @ViewBuilder actions: () -> Actions = { EmptyView() })
-> ContentPlaceholderView<AnyView, Actions>
{
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)
}

View File

@@ -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 ?? "<null>",
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 ?? "<null>",
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()
}
}
}

View File

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

View File

@@ -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<T>(_ 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<MainViewModel>) {
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<ServerSelectionToolbarModifier.ViewModel>) -> some View {
modifier(ServerSelectionToolbarModifier(model: model))
}
func displayingNowPlayingMiniPlayer(model: Binding<NowPlayingViewModel>, 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))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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