54 Commits

Author SHA1 Message Date
3aa819eccc ios: Fixes for add media view
- Adds paste button
- Fix autofocus behavior (using UIKit)
- Remove pointless sheet detents
2025-11-15 17:42:00 -08:00
718518c3f2 ios: tighten fonts / list styles 2025-11-15 17:13:11 -08:00
f3053c1db1 ios: fix backgrounding error 2025-11-15 16:15:53 -08:00
58a43a617e MediaPlayer: remove config line for reconnect rules 2025-11-15 15:45:34 -08:00
85fd112a1a web: update nix flake lock 2025-11-15 15:35:55 -08:00
8ac494049d dont mess with these 2025-11-02 11:40:14 -08:00
4e3bcb406a flake: try and remove restrictions to improve mpv reliability 2025-11-02 11:35:11 -08:00
ec1ee508b3 backend: better timeouts/stream reconnect options for mpv 2025-11-02 10:52:58 -08:00
4e8cd11d8f fix screenshots 2025-10-10 23:15:44 -07:00
ac4c22c2fb Add 'ios/' from commit '2220a0d4f2bb0f0ebca509581c21d6c90359bd14'
git-subtree-dir: ios
git-subtree-mainline: 52968df567
git-subtree-split: 2220a0d4f2
2025-10-10 23:13:50 -07:00
52968df567 move web components to web/ 2025-10-10 23:13:42 -07:00
2220a0d4f2 unconditional active scene phase change 2025-10-10 23:10:10 -07:00
6110f712bd Fix WebSocket reconnection after app backgrounding
- Add scenePhase monitoring to ContentView to detect app lifecycle changes
- Implement handleScenePhaseChange() to force WebSocket reconnection and full UI refresh when app returns to foreground from background
- Update error handling in watchWebsocket() to suppress UI errors for backgrounding (error code 53) while still triggering reconnection
- Simplify API.notifyError() to always report errors, letting UI layer decide what to display

This fixes the issue where WebSocket connections would permanently disconnect after extended backgrounding, as iOS terminates background network connections after ~30 seconds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 11:42:13 -07:00
3a5c285511 version 1.2 2025-10-05 18:23:16 -07:00
4021881f11 Resolve various connection issues 2025-10-05 18:19:51 -07:00
623b562e8d NowPlaying: appearance fixes for timestamp indicator 2025-06-27 01:13:45 -07:00
08619255c7 update typescript 2025-06-26 01:47:07 -07:00
472860f426 nix: update npm deps hash 2025-06-26 01:41:29 -07:00
6d0c52b96f Implements seek/time bar 2025-06-26 01:38:12 -07:00
5e9842f02d Adds feature flags, and the ability to do browser playback 2025-06-24 12:50:15 -07:00
480b30d909 Fix package-lock issues when building with Docker 2025-06-23 11:53:35 -07:00
839ec53c17 fix volume slider 2025-06-20 19:03:33 -07:00
6e5e587998 implements editing items in favorites 2025-06-20 18:50:06 -07:00
d87d6e038e Appearance tweaks 2025-06-20 18:22:31 -07:00
0d2eb229cf Resolves on-device Bonjour service discovery issue 2025-06-20 15:16:58 -07:00
82b5c886cb More granular websocket error handling 2025-06-20 14:55:55 -07:00
751261ffc4 Bonjour services to info.plist 2025-06-11 21:24:25 -07:00
0e7305baa4 implements youtube search 2025-06-11 21:16:59 -07:00
484d08d3d4 bleh... 2025-06-11 20:27:42 -07:00
663125aa0e flake: fix error in flake config for INVIDIOUS_BASE_URL 2025-06-11 20:17:20 -07:00
937a061cdd Implements add media page 2025-06-11 20:13:37 -07:00
601ffc4a75 Implements updated nowplaying view 2025-06-11 19:33:20 -07:00
bde29e7e98 better error handling and server switching 2025-06-11 18:41:39 -07:00
afe985661a add server: show progress bar when resolving 2025-06-11 17:42:26 -07:00
ce8ece23a5 implements favorites/playlist deletion 2025-06-11 15:08:17 -07:00
9aa55864f8 Tweaks to now playing view 2025-06-11 13:48:31 -07:00
a98bcd5b66 Started working on NowPlaying mini 2025-06-11 13:32:34 -07:00
ca829dde4c Implements server selection UI 2025-06-11 13:00:09 -07:00
51048678bb Unify playlist/favorites views 2025-06-11 12:12:53 -07:00
7e6d449c52 Selected server and better settings 2025-06-10 23:10:13 -07:00
0cdbecc031 finish implementing server configuration 2025-06-10 22:40:51 -07:00
f4f3ef543f Started working on multiple server configuration 2025-06-10 18:45:34 -07:00
c775fa0def Implements UI for adding servers in settings, moves to tab model on Phone 2025-06-10 14:16:47 -07:00
13b27a2a1a project reorg 2025-06-10 11:09:44 -07:00
63094f7e49 zeroconf: add hostname to service name 2025-06-07 11:39:56 -07:00
d34363c650 nix: update deps hash 2025-05-31 15:21:35 -07:00
1bde92b974 Adds support for zeroconf (Bonjour) 2025-05-30 20:12:53 -07:00
6c183aea03 adds CLAUDE.md 2025-05-30 17:17:18 -07:00
3775f2dc7c Better connection handling, favorites support 2025-05-30 17:06:52 -07:00
8807d6e621 better handling of connection errors 2025-05-30 16:45:09 -07:00
3552c9c476 Better socket handling, loadfile api update for mpv 0.38.0 2025-05-30 16:40:20 -07:00
45f1f521e2 implements settings 2025-05-02 21:27:46 -07:00
74c0227ec7 Implements a few more api endpoints 2025-03-03 21:08:47 -08:00
fb9a6fcb9b Initial commit 2025-03-03 20:21:30 -08:00
77 changed files with 5179 additions and 6598 deletions

2121
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,99 +0,0 @@
import React, { HTMLAttributes } from 'react';
import classNames from 'classnames';
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa';
interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
songName: string;
fileName: string;
isPlaying: boolean;
isIdle: boolean;
volume: number;
onPlayPause: () => void;
onStop: () => void;
onSkip: () => void;
onPrevious: () => void;
onScreenShare: () => void;
isScreenSharingSupported: boolean;
isScreenSharing: boolean;
// Sent when the volume setting actually changes value
onVolumeSettingChange: (volume: number) => void;
// Sent when the volume is about to start changing
onVolumeWillChange: (volume: number) => void;
// Sent when the volume has changed
onVolumeDidChange: (volume: number) => void;
}
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
const titleArea = props.isScreenSharing ? (
<div className="flex flex-row items-center gap-2 text-white text-center justify-center">
<FaDesktop size={24} />
<div className="text-lg font-bold truncate">Screen Sharing</div>
</div>
) : (
<div className={classNames(props.isIdle ? 'opacity-50' : 'opacity-100')}>
<div className="text-lg font-bold truncate">{props.songName}</div>
<div className="text-sm truncate">{props.fileName}</div>
</div>
);
return (
<div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
<div className="flex flex-col w-full gap-2">
<div className="flex flex-col w-full h-full bg-black/50 rounded-lg p-5 gap-4">
<div className="flex-grow min-w-0 w-full text-white text-left">
{titleArea}
</div>
<div className="flex flex-row items-center gap-4 w-full">
<div className="flex items-center gap-2 text-white w-full max-w-[250px]">
<FaVolumeUp size={20} />
<input
type="range"
min="0"
max="100"
value={props.volume}
onMouseDown={() => props.onVolumeWillChange(props.volume)}
onMouseUp={() => props.onVolumeDidChange(props.volume)}
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
className="fancy-slider h-2 w-full"
/>
</div>
<div className="flex-grow"></div>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
<FaStepBackward size={24} />
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
{(props.isPlaying && !props.isIdle) ? <FaPause size={24} /> : <FaPlay size={24} />}
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onStop}>
<FaStop size={24} className={props.isIdle ? 'opacity-25' : 'opacity-100'} />
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
<FaStepForward size={24} />
</button>
{props.isScreenSharingSupported && (
<button
className={classNames("text-white hover:text-violet-300 transition-colors rounded-full p-2", props.isScreenSharing ? ' bg-violet-800' : '')}
onClick={props.onScreenShare}
title="Share your screen"
>
<FaDesktop size={24} />
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default NowPlaying;

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,359 @@
// !$*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/Entitlements.plist,
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,296 @@
//
// 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 ?? displayFilename ?? "item \(id)"
}
private var displayFilename: String? {
guard let filename else { return nil }
if let url = URL(string: filename) {
return url.lastPathComponent
}
return filename
}
// 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)")
// 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,269 @@
//
// AddMediaView.swift
// QueueCube
//
// Created by James Magahern on 6/11/25.
//
import SwiftUI
struct AddMediaView: View
{
@Binding var model: ViewModel
var body: some View {
NavigationStack {
Form {
// Add URL
Section {
HStack {
AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents)
.autocapitalization(.none)
.autocorrectionDisabled()
PasteButton(payloadType: String.self) { payload in
guard let contents = payload.first else { return }
model.fieldContents = contents
}
.labelStyle(.iconOnly)
}
}
if model.supportsSearch {
Section {
NavigationLink {
SearchMediaView(model: $model)
} label: {
Image(systemName: "magnifyingglass")
Button(.searchForMedia, action: model.onSearch)
}
.tint(.label)
}
}
}
.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
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)
AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch)
.focused($searchFieldFocused)
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 {
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,69 @@
//
// AutofocusingTextField.swift
// QueueCube
//
// Created by James Magahern on 11/15/25.
//
import SwiftUI
import UIKit
/// Stupid: it appears to be impossible to make it so SwiftUI's `.focused(_:)` modifier takes place during the
/// presentation of a sheet, so this needs to exist just to make sure it's made first responder as soon as it moves to
/// the view hierarchy.
struct AutofocusingTextField: UIViewRepresentable
{
let placeholder: String
@Binding var text: String
var onSubmit: () -> Void = {}
init(_ placeholder: String, text: Binding<String>, onSubmit: @escaping () -> Void = {}) {
self.placeholder = placeholder
self._text = text
self.onSubmit = onSubmit
}
func makeUIView(context: Context) -> UITextField {
let tf = FirstResponderTextField()
tf.placeholder = placeholder
tf.delegate = context.coordinator
tf.returnKeyType = .done
tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
return tf
}
func updateUIView(_ uiView: UITextField, context: Context) {
if uiView.text != text {
uiView.text = text
}
context.coordinator.parent = self // keep latest onSubmit/text binding
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
final class Coordinator: NSObject, UITextFieldDelegate {
var parent: AutofocusingTextField
init(parent: AutofocusingTextField) {
self.parent = parent
}
func textFieldDidChangeSelection(_ textField: UITextField) {
parent.text = textField.text ?? ""
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
parent.onSubmit()
return true
}
}
}
final class FirstResponderTextField: UITextField {
override func didMoveToSuperview() {
super.didMoveToSuperview()
becomeFirstResponder()
}
}

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,201 @@
//
// 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)
}
.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):
// Ignore if we're in the bg
guard scenePhase == .active else { break }
// Check if this is a backgrounding error (connection abort)
var isBackgroundingError = false
if case let .websocketError(wsError) = error {
let nsError = wsError as NSError
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,414 @@
//
// 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")
}
}
}
}
} label: {
Label(model.selectedServer?.displayName ?? "Servers", systemImage: "chevron.down")
.labelStyle(.titleOnly)
}
.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(.title3)
.lineLimit(1)
.bold()
}
if let subtitle = model.subtitle {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
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,177 @@
//
// 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)
.bold()
.font(.subheadline)
.tint(.primary)
.lineLimit(1)
Text(subtitle)
.font(.caption)
.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
}
}

View File

View File

@@ -18,18 +18,25 @@ RUN npm run build --workspaces
FROM --platform=$TARGETPLATFORM debian:testing-20250203
RUN apt-get update && apt-get install -y \
mpv npm yt-dlp pulseaudio pulseaudio-utils \
mpv npm yt-dlp pulseaudio pulseaudio-utils ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install only production dependencies
COPY backend/package*.json ./
COPY package-lock.json ./
RUN rm -rf node_modules/ # need to do a clean build
RUN npm ci --production
# Copy built files
COPY --from=builder /app/backend/build ./build
COPY --from=builder /app/frontend/dist ./dist/frontend
# Copy entrypoint script
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh
EXPOSE 3000
CMD ["node", "build/server.js"]
CMD ["./entrypoint.sh"]

View File

@@ -21,6 +21,7 @@
},
"dependencies": {
"@types/node-fetch": "^2.6.12",
"bonjour-service": "^1.3.0",
"classnames": "^2.5.1",
"express": "^4.21.2",
"express-ws": "^5.0.2",

View File

@@ -48,7 +48,7 @@ export interface ThumbnailResponse {
}
const USE_INVIDIOUS = process.env.USE_INVIDIOUS || true;
const INVIDIOUS_BASE_URL = process.env.INVIDIOUS_BASE_URL || 'http://invidious.nor';
const INVIDIOUS_BASE_URL = process.env.INVIDIOUS_BASE_URL || process.env.INVIDIOUS_URL || 'http://invidious.nor';
const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
export const getInvidiousSearchURL = (query: string): string =>

View File

@@ -22,6 +22,8 @@ import { WebSocket } from "ws";
import { getLinkPreview } from "link-preview-js";
import { PlaylistItem, LinkMetadata } from './types';
import { FavoritesStore } from "./FavoritesStore";
import { Bonjour } from "bonjour-service";
import os from 'os';
interface PendingCommand {
resolve: (value: any) => void;
@@ -37,9 +39,16 @@ enum UserEvent {
MPDUpdate = "mpd_update",
}
export interface Features {
video: boolean;
screenshare: boolean;
browserPlayback: boolean;
}
export class MediaPlayer {
private playerProcess: ChildProcess;
private socket: Socket;
private playerProcess: ChildProcess | null = null;
private socket: Promise<Socket>;
private eventSubscribers: WebSocket[] = [];
private favoritesStore: FavoritesStore;
@@ -47,11 +56,61 @@ export class MediaPlayer {
private requestId: number = 1;
private dataBuffer: string = '';
private metadata: Map<string, LinkMetadata> = new Map();
private bonjourInstance: Bonjour | null = null;
constructor() {
this.socket = this.tryRespawnPlayerProcess();
this.favoritesStore = new FavoritesStore();
this.favoritesStore.onFavoritesChanged = (favorites) => {
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
};
this.getFeatures().then(features => {
console.log("Features: ", features);
});
}
public startZeroconfService(port: number) {
if (this.bonjourInstance) {
console.log("Zeroconf service already running");
return;
}
this.bonjourInstance = new Bonjour();
const service = this.bonjourInstance.publish({
name: `QueueCube Media Server (${os.hostname()})`,
type: 'queuecube',
port: port,
txt: {
version: '1.0.0',
features: 'playlist,favorites,screenshare'
}
});
service.on('up', () => {
console.log(`Zeroconf service advertised: ${service.name} on port ${port}`);
});
service.on('error', (err: Error) => {
console.error('Zeroconf service error:', err);
});
}
public stopZeroconfService() {
if (this.bonjourInstance) {
this.bonjourInstance.destroy();
this.bonjourInstance = null;
console.log("Zeroconf service stopped");
}
}
private tryRespawnPlayerProcess(): Promise<Socket> {
const socketFilename = Math.random().toString(36).substring(2, 10);
const socketPath = `/tmp/mpv-${socketFilename}`;
const enableVideo = process.env.ENABLE_VIDEO || false;
const logfilePath = `/tmp/mpv-logfile.txt`;
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
this.playerProcess = spawn("mpv", [
@@ -59,22 +118,31 @@ export class MediaPlayer {
"--fullscreen",
"--no-terminal",
"--idle=yes",
"--input-ipc-server=" + socketPath
"--input-ipc-server=" + socketPath,
"--log-file=" + logfilePath,
"--msg-level=all=v"
]);
this.socket = new Socket();
let socketReady!: (s: Socket) => void;
let socketPromise = new Promise<Socket>(resolve => {
socketReady = resolve;
});
this.playerProcess.on("spawn", () => {
console.log(`Player process spawned, opening socket @ ${socketPath}`);
setTimeout(() => {
this.connectToSocket(socketPath);
let socket = this.connectToSocket(socketPath);
socketReady(socket);
}, 500);
});
this.favoritesStore = new FavoritesStore();
this.favoritesStore.onFavoritesChanged = (favorites) => {
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
};
this.playerProcess.on("error", (error) => {
console.error("Player process error:", error);
console.log("Continuing without mpv player...");
});
return socketPromise;
}
public async getPlaylist(): Promise<PlaylistItem[]> {
@@ -92,8 +160,12 @@ export class MediaPlayer {
public async getNowPlaying(): Promise<PlaylistItem> {
const playlist = await this.getPlaylist();
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
const fetchMediaTitle = async (): Promise<string> => {
return (await this.writeCommand("get_property", ["media-title"])).data;
const fetchMediaTitle = async (): Promise<string | null> => {
try {
return (await this.writeCommand("get_property", ["media-title"])).data;
} catch (err) {
return null;
}
};
if (currentlyPlayingSong !== undefined) {
@@ -101,14 +173,14 @@ export class MediaPlayer {
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
return {
...currentlyPlayingSong,
title: await fetchMediaTitle()
title: await fetchMediaTitle() || currentlyPlayingSong.filename
};
}
return currentlyPlayingSong;
}
const mediaTitle = await fetchMediaTitle();
const mediaTitle = await fetchMediaTitle() || "";
return {
id: 0,
filename: mediaTitle,
@@ -116,11 +188,11 @@ export class MediaPlayer {
};
}
public async getCurrentFile(): Promise<string> {
public async getCurrentFile(): Promise<string | null> {
return this.writeCommand("get_property", ["stream-open-filename"])
.then((response) => {
return response.data;
});
}, (reject) => { return null; });
}
public async getPauseState(): Promise<boolean> {
@@ -137,6 +209,27 @@ export class MediaPlayer {
});
}
public async getTimePosition(): Promise<number | null> {
return this.writeCommand("get_property", ["time-pos"])
.then((response) => {
return response.data;
}, (rejected) => { return null; });
}
public async getDuration(): Promise<number | null> {
return this.writeCommand("get_property", ["duration"])
.then((response) => {
return response.data;
}, (rejected) => { return null; });
}
public async getSeekable(): Promise<boolean | null> {
return this.writeCommand("get_property", ["seekable"])
.then((response) => {
return response.data;
}, (rejected) => { return null; });
}
public async getIdle(): Promise<boolean> {
return this.writeCommand("get_property", ["idle"])
.then((response) => {
@@ -218,6 +311,10 @@ export class MediaPlayer {
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
}
public async seek(time: number) {
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("seek", [time, "absolute"]));
}
public subscribe(ws: WebSocket) {
this.eventSubscribers.push(ws);
}
@@ -246,8 +343,16 @@ export class MediaPlayer {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
}
public async getFeatures(): Promise<Features> {
return {
video: !!process.env.ENABLE_VIDEO,
screenshare: !!process.env.ENABLE_SCREENSHARE,
browserPlayback: !!process.env.ENABLE_BROWSER_PLAYBACK
};
}
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, options.join(',')]));
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
if (fetchMetadata) {
this.fetchMetadataAndNotify(url).catch(error => {
@@ -262,10 +367,16 @@ export class MediaPlayer {
// Notify all subscribers
this.handleEvent(event, {});
return result;
});
}, (reject) => {
console.log("Error modifying playlist: " + reject);
return reject;
});
}
private async writeCommand(command: string, args: any[]): Promise<any> {
// Wait for socket to become available.
let socket = await this.socket;
return new Promise((resolve, reject) => {
const id = this.requestId++;
@@ -274,8 +385,13 @@ export class MediaPlayer {
request_id: id
});
this.pendingCommands.set(id, { resolve, reject });
this.socket.write(commandObject + '\n');
try {
this.pendingCommands.set(id, { resolve, reject });
socket.write(commandObject + '\n');
} catch (e: any) {
console.error(`Error writing to socket: ${e}. Trying to respawn.`)
this.tryRespawnPlayerProcess();
}
// Add timeout to prevent hanging promises
setTimeout(() => {
@@ -313,9 +429,12 @@ export class MediaPlayer {
}
}
private connectToSocket(path: string) {
this.socket.connect(path);
this.socket.on("data", data => this.receiveData(data.toString()));
private connectToSocket(path: string): Socket {
let socket = new Socket();
socket.connect(path);
socket.on("data", data => this.receiveData(data.toString()));
return socket;
}
private handleEvent(event: string, data: any) {
@@ -342,7 +461,12 @@ export class MediaPlayer {
if (response.request_id) {
const pending = this.pendingCommands.get(response.request_id);
if (pending) {
pending.resolve(response);
if (response.error == "success") {
pending.resolve(response);
} else {
pending.reject(response.error);
}
this.pendingCommands.delete(response.request_id);
}
} else if (response.event) {

View File

@@ -43,6 +43,7 @@ const withErrorHandling = (func: (req: any, res: any) => Promise<any>) => {
try {
await func(req, res);
} catch (error: any) {
console.log(`Error (${func.name}): ${error}`);
res.status(500).send(JSON.stringify({ success: false, error: error.message }));
}
};
@@ -108,6 +109,9 @@ apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
const pauseState = await mediaPlayer.getPauseState();
const volume = await mediaPlayer.getVolume();
const idle = await mediaPlayer.getIdle();
const timePosition = await mediaPlayer.getTimePosition();
const duration = await mediaPlayer.getDuration();
const seekable = await mediaPlayer.getSeekable();
res.send(JSON.stringify({
success: true,
@@ -115,7 +119,10 @@ apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
isPaused: pauseState,
volume: volume,
isIdle: idle,
currentFile: currentFile
currentFile: currentFile,
timePosition: timePosition,
duration: duration,
seekable: seekable
}));
}));
@@ -125,6 +132,12 @@ apiRouter.post("/volume", withErrorHandling(async (req, res) => {
res.send(JSON.stringify({ success: true }));
}));
apiRouter.post("/player/seek", withErrorHandling(async (req, res) => {
const { time } = req.body as { time: number };
await mediaPlayer.seek(time);
res.send(JSON.stringify({ success: true }));
}));
apiRouter.ws("/events", (ws, req) => {
console.log("Events client connected");
mediaPlayer.subscribe(ws);
@@ -281,6 +294,11 @@ apiRouter.put("/favorites/:filename/title", withErrorHandling(async (req, res) =
res.send(JSON.stringify({ success: true }));
}));
apiRouter.get("/features", withErrorHandling(async (req, res) => {
const features = await mediaPlayer.getFeatures();
res.send(JSON.stringify(features));
}));
// Serve static files for React app (after building)
app.use(express.static(path.join(__dirname, "../dist/frontend")));
@@ -295,12 +313,18 @@ app.get("*", (req, res) => {
const port = process.env.PORT || 3000;
const server = app.listen(port, () => {
console.log(`Server is running on port ${port}`);
// Start zeroconf service advertisement
mediaPlayer.startZeroconfService(Number(port));
});
// Add graceful shutdown handling
const shutdown = async () => {
console.log('Received shutdown signal. Closing server...');
// Stop zeroconf service
mediaPlayer.stopZeroconfService();
server.close(() => {
console.log('Server closed');
process.exit(0);

46
web/entrypoint.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
# Check if browser playback is enabled
if [ "$ENABLE_BROWSER_PLAYBACK" = "1" ]; then
echo "Browser playback enabled - setting up audio streaming..."
echo "Starting PulseAudio..."
pulseaudio --start --log-target=syslog --system=false
# Wait a moment for PulseAudio to initialize
sleep 2
# Create virtual sink
echo "Creating virtual audio sink..."
pactl load-module module-null-sink sink_name=virtual_output sink_properties=device.description="Virtual_Audio_Output"
# Make it the default sink
pactl set-default-sink virtual_output
# Create stream directory if it doesn't exist
mkdir -p ./dist/frontend/stream
# Start FFmpeg streaming in background
echo "Starting audio stream..."
FFMPEG_OPTS="-loglevel error -f pulse \
-i virtual_output.monitor \
-c:a aac -b:a 128k \
-f hls \
-hls_time 1 \
-hls_list_size 3 \
-hls_flags delete_segments+append_list \
-hls_segment_type mpegts \
-hls_segment_filename ./dist/frontend/stream/segment_%03d.ts \
./dist/frontend/stream/audio.m3u8"
echo "FFmpeg options: $FFMPEG_OPTS"
ffmpeg $FFMPEG_OPTS &
else
echo "Browser playback disabled - skipping audio streaming setup"
fi
# Start the Node.js server
echo "Starting Node.js server..."
exec node build/server.js

View File

@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1740828860,
"narHash": "sha256-cjbHI+zUzK5CPsQZqMhE3npTyYFt9tJ3+ohcfaOF/WM=",
"lastModified": 1762977756,
"narHash": "sha256-4PqRErxfe+2toFJFgcRKZ0UI9NSIOJa+7RXVtBhy4KE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "303bd8071377433a2d8f76e684ec773d70c5b642",
"rev": "c5ae371f1a6a7fd27823bc500d9390b38c05fa55",
"type": "github"
},
"original": {

View File

@@ -78,15 +78,39 @@
Restart = "on-failure";
RestartSec = 5;
# Allow access to X11 for mpv
Environment = [ "DISPLAY=:0" ];
# Remove all resource limits for mpv to function properly
LimitNOFILE = "infinity"; # No limit on file descriptors
LimitMEMLOCK = "infinity"; # No limit on locked memory (for real-time audio)
LimitNPROC = "infinity"; # No limit on number of processes
LimitAS = "infinity"; # No limit on address space
LimitRSS = "infinity"; # No limit on resident set size
LimitCORE = "infinity"; # Allow core dumps for debugging
LimitDATA = "infinity"; # No limit on data segment
LimitSTACK = "infinity"; # No limit on stack size
LimitCPU = "infinity"; # No limit on CPU time
LimitRTPRIO = "99"; # Allow real-time priority
LimitRTTIME = "infinity"; # No limit on real-time scheduling
# Nice level for better performance
Nice = "-10";
# Allow access to necessary devices and features
PrivateDevices = false;
ProtectHome = false;
ProtectSystem = false;
NoNewPrivileges = false;
# Environment for X11 and runtime directories
Environment = [
"DISPLAY=:0"
];
};
environment = {
PORT = toString cfg.port;
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
INVIDIOUS_URL = cfg.invidious.url;
INVIDIOUS_BASE_URL = cfg.invidious.url;
STORE_PATH = cfg.store_path;
};
};
@@ -155,7 +179,7 @@
'';
# Let buildNpmPackage handle npm package hash
npmDepsHash = "sha256-BqjJ4CxTPc14Od88sAm/ASwsLszkvcHHeNoZupotlFw=";
npmDepsHash = "sha256-kwbWqNqji0EcBeRuc/sqQUuGQkE+P8puLTfpAyRRzgY=";
meta = with pkgs.lib; {
description = "NodeJS application with media playback capabilities";

View File

@@ -24,7 +24,7 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"typescript": "~5.7.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.22.0",
"vite": "^6.1.0"
}

View File

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -5,6 +5,15 @@ export interface NowPlayingResponse {
volume: number;
isIdle: boolean;
currentFile: string;
timePosition?: number;
duration?: number;
seekable?: boolean;
}
export interface Features {
video: boolean;
screenshare: boolean;
browserPlayback: boolean;
}
export interface Metadata {
@@ -57,6 +66,11 @@ export enum ServerEvent {
}
export const API = {
async getFeatures(): Promise<Features> {
const response = await fetch('/api/features');
return response.json();
},
async getPlaylist(): Promise<PlaylistItem[]> {
const response = await fetch('/api/playlist');
return response.json();
@@ -127,6 +141,16 @@ export const API = {
});
},
async seek(time: number): Promise<void> {
await fetch('/api/player/seek', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ time }),
});
},
async search(query: string): Promise<SearchResponse> {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
return response.json();

View File

@@ -4,11 +4,12 @@ import NowPlaying from './NowPlaying';
import AddSongPanel from './AddSongPanel';
import RenameFavoriteModal from './RenameFavoriteModal';
import { TabView, Tab } from './TabView';
import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
import { API, Features, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
import useWebSocket from 'react-use-websocket';
import classNames from 'classnames';
import { useScreenShare } from '../hooks/useScreenShare';
import AudioPlayer from './AudioPlayer';
enum Tabs {
Playlist = "playlist",
@@ -86,6 +87,9 @@ const App: React.FC = () => {
const [isIdle, setIsIdle] = useState(false);
const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null);
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
const [timePosition, setTimePosition] = useState<number | undefined>(undefined);
const [duration, setDuration] = useState<number | undefined>(undefined);
const [seekable, setSeekable] = useState<boolean | undefined>(undefined);
const [volume, setVolume] = useState(100);
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
@@ -93,6 +97,8 @@ const App: React.FC = () => {
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
const [audioEnabled, setAudioEnabled] = useState(false);
const [features, setFeatures] = useState<Features | null>(null);
const {
isScreenSharing,
@@ -124,6 +130,12 @@ const App: React.FC = () => {
setIsPlaying(!nowPlaying.isPaused);
setVolume(nowPlaying.volume);
setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true);
setTimePosition(nowPlaying.timePosition);
setDuration(nowPlaying.duration);
setSeekable(nowPlaying.seekable);
const features = await API.getFeatures();
setFeatures(features);
}, [volumeSettingIsLocked]);
const handleAddURL = async (url: string) => {
@@ -170,6 +182,11 @@ const App: React.FC = () => {
fetchNowPlaying();
};
const handleSeek = async (time: number) => {
await API.seek(time);
fetchNowPlaying();
};
const handleVolumeSettingChange = async (volume: number) => {
setVolume(volume);
await API.setVolume(volume);
@@ -198,7 +215,6 @@ const App: React.FC = () => {
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`;
console.log('Connecting to WebSocket at', wsUrl);
useWebSocket(wsUrl, {
onOpen: () => {
console.log('WebSocket connected');
@@ -241,6 +257,15 @@ const App: React.FC = () => {
fetchFavorites();
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
useEffect(() => {
const interval = setInterval(() => {
if (isPlaying) {
fetchNowPlaying();
}
}, 1000);
return () => clearInterval(interval);
}, [isPlaying, fetchNowPlaying]);
const AuxButton: React.FC<{ children: ReactNode, className: string, title: string, onClick: () => void }> = (props) => (
<button
className={
@@ -313,16 +338,24 @@ const App: React.FC = () => {
return (
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
<div className="bg-violet-900 w-full md:max-w-2xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col">
{features?.browserPlayback && (
<AudioPlayer isPlaying={isPlaying} enabled={audioEnabled} />
)}
<NowPlaying
className="flex flex-row md:rounded-t-2xl"
songName={nowPlayingSong || "(Not Playing)"}
fileName={nowPlayingFileName || ""}
isPlaying={isPlaying}
isIdle={isIdle}
timePosition={timePosition}
duration={duration}
seekable={seekable}
onPlayPause={togglePlayPause}
onStop={handleStop}
onSkip={handleSkip}
onPrevious={handlePrevious}
onSeek={handleSeek}
onScreenShare={toggleScreenShare}
isScreenSharing={isScreenSharing}
volume={volume}
@@ -330,6 +363,9 @@ const App: React.FC = () => {
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
isScreenSharingSupported={isScreenSharingSupported}
features={features}
audioEnabled={audioEnabled}
onAudioEnabledChange={setAudioEnabled}
/>
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>

View File

@@ -0,0 +1,32 @@
import React, { useEffect, useRef } from 'react';
interface AudioPlayerProps {
isPlaying: boolean;
enabled: boolean;
}
const AudioPlayer: React.FC<AudioPlayerProps> = ({ isPlaying, enabled }) => {
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
if (enabled && isPlaying) {
console.log("Playing audio");
audioRef.current?.play().catch((error) => {
console.error("Audio playback error:", error);
});
} else {
console.log("Pausing audio");
audioRef.current?.pause();
}
}, [isPlaying, enabled]);
return (
<audio
ref={audioRef}
src="/stream/audio.m3u8"
preload="metadata"
/>
);
};
export default AudioPlayer;

View File

@@ -0,0 +1,176 @@
import React, { HTMLAttributes, useState, useRef } from 'react';
import classNames from 'classnames';
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa';
import { Features } from '../api/player';
interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
songName: string;
fileName: string;
isPlaying: boolean;
isIdle: boolean;
volume: number;
timePosition?: number;
duration?: number;
seekable?: boolean;
onPlayPause: () => void;
onStop: () => void;
onSkip: () => void;
onPrevious: () => void;
onSeek: (time: number) => void;
onScreenShare: () => void;
isScreenSharingSupported: boolean;
isScreenSharing: boolean;
// Sent when the volume setting actually changes value
onVolumeSettingChange: (volume: number) => void;
// Sent when the volume is about to start changing
onVolumeWillChange: (volume: number) => void;
// Sent when the volume has changed
onVolumeDidChange: (volume: number) => void;
features: Features | null;
audioEnabled: boolean;
onAudioEnabledChange: (enabled: boolean) => void;
}
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
const [isSeeking, setIsSeeking] = useState(false);
const progressBarRef = useRef<HTMLDivElement>(null);
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (progressBarRef.current) {
const rect = progressBarRef.current.getBoundingClientRect();
const newSeekPosition = (e.clientX - rect.left) / rect.width;
if (props.duration) {
props.onSeek(newSeekPosition * props.duration);
}
}
};
const titleArea = props.isScreenSharing ? (
<div className="flex flex-row items-center gap-2 text-white text-center justify-center">
<FaDesktop size={24} />
<div className="text-lg font-bold truncate">Screen Sharing</div>
</div>
) : (
<div className={classNames(props.isIdle ? 'opacity-50' : 'opacity-100', "flex flex-row items-center justify-between gap-2 w-full")}>
<div className="truncate">
<div className="text-lg font-bold truncate">{props.songName}</div>
<div className="text-sm truncate">{props.fileName}</div>
</div>
<div className="text-sm opacity-50 shrink-0">
{props.timePosition && props.duration ?
(props.seekable ? `${formatTime(props.timePosition)} / ${formatTime(props.duration)}`
: `${formatTime(props.timePosition)}` )
: ''}
</div>
</div>
);
return (
<div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
<div className="flex flex-col w-full gap-2">
<div className="flex flex-col w-full h-full bg-black/50 rounded-lg gap-4 overflow-hidden">
<div className="p-5">
<div className="flex-grow min-w-0 w-full text-white text-left">
{titleArea}
</div>
<div className="flex flex-row items-center gap-4 w-full pt-4">
<div className="flex items-center gap-2 text-white w-full max-w-[250px]">
<FaVolumeUp size={20} />
<input
type="range"
min="0"
max="100"
value={props.volume}
onMouseDown={() => props.onVolumeWillChange(props.volume)}
onMouseUp={() => props.onVolumeDidChange(props.volume)}
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
className="fancy-slider h-2 w-full"
/>
</div>
<div className="flex-grow"></div>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
<FaStepBackward size={24} />
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
{(props.isPlaying && !props.isIdle) ? <FaPause size={24} /> : <FaPlay size={24} />}
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onStop}>
<FaStop size={24} className={props.isIdle ? 'opacity-25' : 'opacity-100'} />
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
<FaStepForward size={24} />
</button>
{(props.isScreenSharingSupported && props.features?.screenshare) && (
<button
className={classNames("text-white hover:text-violet-300 transition-colors rounded-full p-2", props.isScreenSharing ? ' bg-violet-800' : '')}
onClick={props.onScreenShare}
title="Share your screen"
>
<FaDesktop size={24} />
</button>
)}
</div>
</div>
{props.seekable !== false && (
<div
ref={progressBarRef}
className="w-full h-2 bg-gray-600 cursor-pointer -mt-3"
onMouseDown={(e) => {
setIsSeeking(true);
handleSeek(e);
}}
onMouseMove={(e) => {
if (isSeeking) {
handleSeek(e);
}
}}
onMouseUp={() => setIsSeeking(false)}
onMouseLeave={() => setIsSeeking(false)}
>
<div
className="h-full bg-violet-500"
style={{ width: `${(props.timePosition && props.duration ? (props.timePosition / props.duration) * 100 : 0)}%` }}
/>
</div>
)}
{props.features?.browserPlayback && (
<div>
<label className="flex items-center gap-2 text-white text-sm cursor-pointer">
<input
type="checkbox"
checked={props.audioEnabled}
onChange={(e) => props.onAudioEnabledChange(e.target.checked)}
className="w-4 h-4 text-violet-600 bg-gray-100 border-gray-300 rounded focus:ring-violet-500 focus:ring-2"
/>
Enable audio playback in browser
</label>
</div>
)}
</div>
</div>
</div>
);
};
export default NowPlaying;

File diff suppressed because it is too large Load Diff