Compare commits
45 Commits
3.0
...
ac4c22c2fb
| Author | SHA1 | Date | |
|---|---|---|---|
| ac4c22c2fb | |||
| 52968df567 | |||
| 2220a0d4f2 | |||
| 6110f712bd | |||
| 3a5c285511 | |||
| 4021881f11 | |||
| 623b562e8d | |||
| 08619255c7 | |||
| 472860f426 | |||
| 6d0c52b96f | |||
| 5e9842f02d | |||
| 480b30d909 | |||
| 839ec53c17 | |||
| 6e5e587998 | |||
| d87d6e038e | |||
| 0d2eb229cf | |||
| 82b5c886cb | |||
| 751261ffc4 | |||
| 0e7305baa4 | |||
| 484d08d3d4 | |||
| 663125aa0e | |||
| 937a061cdd | |||
| 601ffc4a75 | |||
| bde29e7e98 | |||
| afe985661a | |||
| ce8ece23a5 | |||
| 9aa55864f8 | |||
| a98bcd5b66 | |||
| ca829dde4c | |||
| 51048678bb | |||
| 7e6d449c52 | |||
| 0cdbecc031 | |||
| f4f3ef543f | |||
| c775fa0def | |||
| 13b27a2a1a | |||
| 63094f7e49 | |||
| d34363c650 | |||
| 1bde92b974 | |||
| 6c183aea03 | |||
| 3775f2dc7c | |||
| 8807d6e621 | |||
| 3552c9c476 | |||
| 45f1f521e2 | |||
| 74c0227ec7 | |||
| fb9a6fcb9b |
2121
backend/package-lock.json
generated
2121
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
3673
frontend/package-lock.json
generated
3673
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
93
ios/CLAUDE.md
Normal 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
|
||||||
358
ios/QueueCube.xcodeproj/project.pbxproj
Normal file
358
ios/QueueCube.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
CD4E9B972D7691C20066FC17 /* QueueCube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QueueCube.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
App/Info.plist,
|
||||||
|
);
|
||||||
|
target = CD4E9B962D7691C20066FC17 /* QueueCube */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
CD4E9B992D7691C20066FC17 /* QueueCube */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */,
|
||||||
|
);
|
||||||
|
path = QueueCube;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
CD4E9B942D7691C20066FC17 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
CD4E9B8E2D7691C20066FC17 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CD4E9B992D7691C20066FC17 /* QueueCube */,
|
||||||
|
CD4E9B982D7691C20066FC17 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CD4E9B982D7691C20066FC17 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CD4E9B972D7691C20066FC17 /* QueueCube.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
CD4E9B962D7691C20066FC17 /* QueueCube */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = CD4E9BA22D7691C40066FC17 /* Build configuration list for PBXNativeTarget "QueueCube" */;
|
||||||
|
buildPhases = (
|
||||||
|
CD4E9B932D7691C20066FC17 /* Sources */,
|
||||||
|
CD4E9B942D7691C20066FC17 /* Frameworks */,
|
||||||
|
CD4E9B952D7691C20066FC17 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
CD4E9B992D7691C20066FC17 /* QueueCube */,
|
||||||
|
);
|
||||||
|
name = QueueCube;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = QueueCube;
|
||||||
|
productReference = CD4E9B972D7691C20066FC17 /* QueueCube.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
CD4E9B8F2D7691C20066FC17 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 1700;
|
||||||
|
LastUpgradeCheck = 1700;
|
||||||
|
TargetAttributes = {
|
||||||
|
CD4E9B962D7691C20066FC17 = {
|
||||||
|
CreatedOnToolsVersion = 17.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = CD4E9B922D7691C20066FC17 /* Build configuration list for PBXProject "QueueCube" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = CD4E9B8E2D7691C20066FC17;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = CD4E9B982D7691C20066FC17 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
CD4E9B962D7691C20066FC17 /* QueueCube */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
CD4E9B952D7691C20066FC17 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
CD4E9B932D7691C20066FC17 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
CD4E9BA02D7691C40066FC17 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 19.0;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
CD4E9BA12D7691C40066FC17 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 19.0;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
CD4E9BA32D7691C40066FC17 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = QueueCube/App/Entitlements.plist;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = QueueCube/App/Info.plist;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.2;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
CD4E9BA42D7691C40066FC17 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = QueueCube/App/Entitlements.plist;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = QueueCube/App/Info.plist;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.2;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
CD4E9B922D7691C20066FC17 /* Build configuration list for PBXProject "QueueCube" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
CD4E9BA02D7691C40066FC17 /* Debug */,
|
||||||
|
CD4E9BA12D7691C40066FC17 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
CD4E9BA22D7691C40066FC17 /* Build configuration list for PBXNativeTarget "QueueCube" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
CD4E9BA32D7691C40066FC17 /* Debug */,
|
||||||
|
CD4E9BA42D7691C40066FC17 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = CD4E9B8F2D7691C20066FC17 /* Project object */;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/QueueCube/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
ios/QueueCube/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 574 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
ios/QueueCube/App/Assets.xcassets/Contents.json
Normal file
6
ios/QueueCube/App/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
5
ios/QueueCube/App/Entitlements.plist
Normal file
5
ios/QueueCube/App/Entitlements.plist
Normal 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>
|
||||||
17
ios/QueueCube/App/Info.plist
Normal file
17
ios/QueueCube/App/Info.plist
Normal 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>
|
||||||
44
ios/QueueCube/App/QueueCubeApp.swift
Normal file
44
ios/QueueCube/App/QueueCubeApp.swift
Normal 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"
|
||||||
|
}
|
||||||
289
ios/QueueCube/Backend/API.swift
Normal file
289
ios/QueueCube/Backend/API.swift
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
//
|
||||||
|
// API.swift
|
||||||
|
// QueueCube
|
||||||
|
//
|
||||||
|
// Created by James Magahern on 3/3/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct MediaItem: Codable
|
||||||
|
{
|
||||||
|
let filename: String?
|
||||||
|
let title: String?
|
||||||
|
let id: Int
|
||||||
|
|
||||||
|
let current: Bool?
|
||||||
|
let playing: Bool?
|
||||||
|
let metadata: Metadata?
|
||||||
|
|
||||||
|
var displayTitle: String {
|
||||||
|
metadata?.title ?? title ?? filename ?? "item \(id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
struct Metadata: Codable
|
||||||
|
{
|
||||||
|
let title: String?
|
||||||
|
let description: String?
|
||||||
|
let siteName: String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchResultItem: Codable
|
||||||
|
{
|
||||||
|
var type: String
|
||||||
|
var title: String
|
||||||
|
var author: String
|
||||||
|
var mediaUrl: String
|
||||||
|
var thumbnailUrl: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FetchResult<T: Codable>: Codable
|
||||||
|
{
|
||||||
|
let success: Bool
|
||||||
|
let results: T?
|
||||||
|
let error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NowPlayingInfo: Codable
|
||||||
|
{
|
||||||
|
let playingItem: MediaItem?
|
||||||
|
let isPaused: Bool
|
||||||
|
let volume: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
actor API
|
||||||
|
{
|
||||||
|
let baseURL: URL
|
||||||
|
|
||||||
|
private var pingTask: Task<(), any Swift.Error>? = nil
|
||||||
|
|
||||||
|
init(baseURL: URL) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fetchNowPlayingInfo() async throws -> NowPlayingInfo {
|
||||||
|
try await request()
|
||||||
|
.path("/nowplaying")
|
||||||
|
.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fetchPlaylist() async throws -> [MediaItem] {
|
||||||
|
try await request()
|
||||||
|
.path("/playlist")
|
||||||
|
.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fetchFavorites() async throws -> [MediaItem] {
|
||||||
|
try await request()
|
||||||
|
.path("/favorites")
|
||||||
|
.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func play() async throws {
|
||||||
|
try await request()
|
||||||
|
.path("/play")
|
||||||
|
.post()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pause() async throws {
|
||||||
|
try await request()
|
||||||
|
.path("/pause")
|
||||||
|
.post()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() async throws {
|
||||||
|
try await request()
|
||||||
|
.path("/stop")
|
||||||
|
.post()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func skip(_ to: Int? = nil) async throws {
|
||||||
|
let path = if let to { "/skip/\(to)" } else { "/skip" }
|
||||||
|
try await request()
|
||||||
|
.path(path)
|
||||||
|
.post()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func previous() async throws {
|
||||||
|
try await request()
|
||||||
|
.path("/previous")
|
||||||
|
.post()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func add(mediaURL: String) async throws {
|
||||||
|
try await request()
|
||||||
|
.path("/playlist")
|
||||||
|
.body([ "url" : mediaURL ])
|
||||||
|
.post()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func replace(mediaURL: String) async throws {
|
||||||
|
try await request()
|
||||||
|
.path("/playlist/replace")
|
||||||
|
.body([ "url" : mediaURL ])
|
||||||
|
.post()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func addFavorite(mediaURL: String) async throws {
|
||||||
|
try await request()
|
||||||
|
.path("/favorites")
|
||||||
|
.body([ "filename" : mediaURL ])
|
||||||
|
.post()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteFavorite(mediaURL: String) async throws {
|
||||||
|
try await request()
|
||||||
|
.pathString("/favorites/\(mediaURL.uriEncoded())")
|
||||||
|
.method(.delete)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func renameFavorite(mediaURL: String, title: String) async throws {
|
||||||
|
try await request()
|
||||||
|
.pathString("/favorites/\(mediaURL.uriEncoded())/title")
|
||||||
|
.body([ "title": title ])
|
||||||
|
.method(.put)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func delete(index: Int) async throws {
|
||||||
|
try await request()
|
||||||
|
.path("/playlist/\(index)")
|
||||||
|
.method(.delete)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setVolume(_ value: Double) async throws {
|
||||||
|
try await request()
|
||||||
|
.path("/volume")
|
||||||
|
.body([ "volume" : Int(value * 100) ])
|
||||||
|
.post()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func search(query: String) async throws -> FetchResult<[SearchResultItem]> {
|
||||||
|
try await request()
|
||||||
|
.pathString("/search?q=\(query.uriEncoded())")
|
||||||
|
.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func events() async throws -> AsyncStream<StreamEvent> {
|
||||||
|
let requestBuilder: () -> RequestBuilder = request
|
||||||
|
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
let websocketTask: URLSessionWebSocketTask = API.spawnWebsocketTask(requestBuilder: requestBuilder, with: continuation)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
var pingLoopEnabled = true
|
||||||
|
while pingLoopEnabled {
|
||||||
|
try await Task.sleep(for: .seconds(5))
|
||||||
|
|
||||||
|
websocketTask.sendPing { error in
|
||||||
|
if let error {
|
||||||
|
API.notifyError(error, continuation: continuation)
|
||||||
|
pingLoopEnabled = false
|
||||||
|
} else {
|
||||||
|
continuation.yield(.event(Event(type: .receivedWebsocketPong)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func spawnWebsocketTask(
|
||||||
|
requestBuilder: () -> RequestBuilder,
|
||||||
|
with continuation: AsyncStream<StreamEvent>.Continuation
|
||||||
|
) -> URLSessionWebSocketTask
|
||||||
|
{
|
||||||
|
let url = requestBuilder()
|
||||||
|
.path("/events")
|
||||||
|
.websocket()
|
||||||
|
|
||||||
|
let websocketTask = URLSession.shared.webSocketTask(with: url)
|
||||||
|
websocketTask.resume()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let event = { (data: Data) in
|
||||||
|
try JSONDecoder().decode(Event.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
while websocketTask.state == .running {
|
||||||
|
switch try await websocketTask.receive() {
|
||||||
|
case .string(let string):
|
||||||
|
let event = try event(string.data(using: .utf8)!)
|
||||||
|
continuation.yield(.event(event))
|
||||||
|
case .data(let data):
|
||||||
|
let event = try event(data)
|
||||||
|
continuation.yield(.event(event))
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notifyError(error, continuation: continuation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return websocketTask
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream<StreamEvent>.Continuation) {
|
||||||
|
print("Websocket Error: \(error)")
|
||||||
|
|
||||||
|
let nsError = error as NSError
|
||||||
|
|
||||||
|
// Always notify observers of WebSocket errors so reconnection can happen
|
||||||
|
// The UI layer can decide whether to show the error to the user
|
||||||
|
continuation.yield(.error(.websocketError(error)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request() -> RequestBuilder {
|
||||||
|
RequestBuilder(url: baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
enum Error: Swift.Error
|
||||||
|
{
|
||||||
|
case apiNotConfigured
|
||||||
|
case websocketError(Swift.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StreamEvent {
|
||||||
|
case event(Event)
|
||||||
|
case error(API.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Event: Decodable
|
||||||
|
{
|
||||||
|
let type: EventType
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case type = "event"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EventType: String, Decodable {
|
||||||
|
case playlistUpdate = "playlist_update"
|
||||||
|
case nowPlayingUpdate = "now_playing_update"
|
||||||
|
case volumeUpdate = "volume_update"
|
||||||
|
case favoritesUpdate = "favorites_update"
|
||||||
|
case metadataUpdate = "metadata_update"
|
||||||
|
case mpdUpdate = "mpd_update"
|
||||||
|
|
||||||
|
// Private UI events
|
||||||
|
case receivedWebsocketPong
|
||||||
|
case websocketReconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String
|
||||||
|
{
|
||||||
|
func uriEncoded() -> Self {
|
||||||
|
return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
|
||||||
|
}
|
||||||
|
}
|
||||||
57
ios/QueueCube/Backend/Server.swift
Normal file
57
ios/QueueCube/Backend/Server.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
117
ios/QueueCube/Backend/Settings.swift
Normal file
117
ios/QueueCube/Backend/Settings.swift
Normal 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")
|
||||||
|
}
|
||||||
101
ios/QueueCube/Backend/Utilities.swift
Normal file
101
ios/QueueCube/Backend/Utilities.swift
Normal 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)
|
||||||
|
}
|
||||||
385
ios/QueueCube/Localizable/Localizable.xcstrings
Normal file
385
ios/QueueCube/Localizable/Localizable.xcstrings
Normal 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"
|
||||||
|
}
|
||||||
49
ios/QueueCube/Localizable/Strings.swift
Normal file
49
ios/QueueCube/Localizable/Strings.swift
Normal 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")
|
||||||
|
}
|
||||||
284
ios/QueueCube/Views/AddMediaView.swift
Normal file
284
ios/QueueCube/Views/AddMediaView.swift
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
//
|
||||||
|
// AddMediaView.swift
|
||||||
|
// QueueCube
|
||||||
|
//
|
||||||
|
// Created by James Magahern on 6/11/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddMediaView: View
|
||||||
|
{
|
||||||
|
@Binding var model: ViewModel
|
||||||
|
@FocusState var fieldFocused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Add URL
|
||||||
|
Section {
|
||||||
|
TextField(.addAnyURL, text: $model.fieldContents)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($fieldFocused)
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.supportsSearch {
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
SearchMediaView(model: $model)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
Button(.searchForMedia, action: model.onSearch)
|
||||||
|
}
|
||||||
|
.tint(.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { fieldFocused = true }
|
||||||
|
.onAppear { model.activeDetent = ViewModel.Detent.collapsed.value }
|
||||||
|
.navigationTitle(.addMedia)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
|
Button(.add, action: model.addButtonTapped)
|
||||||
|
.disabled(model.fieldContents.isEmpty)
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItemGroup(placement: .topBarLeading) {
|
||||||
|
Button(.cancel, action: model.onCancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
enum Page: String, Identifiable
|
||||||
|
{
|
||||||
|
case addURL
|
||||||
|
case searchMedia
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class ViewModel
|
||||||
|
{
|
||||||
|
var fieldContents: String = ""
|
||||||
|
var onAdd: (String) -> Void = { _ in }
|
||||||
|
var onCancel: () -> Void = { }
|
||||||
|
var onSearch: () -> Void = { }
|
||||||
|
var supportsSearch: Bool = true
|
||||||
|
|
||||||
|
var activeDetent: PresentationDetent = Detent.collapsed.value
|
||||||
|
|
||||||
|
enum Detent: CaseIterable
|
||||||
|
{
|
||||||
|
case collapsed
|
||||||
|
case expanded
|
||||||
|
|
||||||
|
var value: PresentationDetent {
|
||||||
|
switch self {
|
||||||
|
case .collapsed: .height(320.0)
|
||||||
|
case .expanded: .large
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func addButtonTapped() {
|
||||||
|
onAdd(fieldContents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchMediaView: View
|
||||||
|
{
|
||||||
|
@Binding var model: AddMediaView.ViewModel
|
||||||
|
@State private var searchModel = SearchModel()
|
||||||
|
@State private var searchText = ""
|
||||||
|
@FocusState private var searchFieldFocused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Search field
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
TextField(.searchForMedia, text: $searchText)
|
||||||
|
.focused($searchFieldFocused)
|
||||||
|
.onSubmit {
|
||||||
|
performSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
Button {
|
||||||
|
searchText = ""
|
||||||
|
searchModel.displayedResults = []
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
|
||||||
|
if searchModel.isLoading {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView(.searching)
|
||||||
|
.progressViewStyle(CircularProgressViewStyle())
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
} else if searchModel.displayedResults.isEmpty && !searchText.isEmpty && searchModel.lastSearchedQuery == searchText {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
Text(.noResultsFound)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Results list
|
||||||
|
List(searchModel.displayedResults, id: \.mediaUrl) { item in
|
||||||
|
SearchResultRow(item: item) {
|
||||||
|
model.onAdd(item.mediaUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(PlainListStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(.searchForMedia)
|
||||||
|
.presentationBackground(.regularMaterial)
|
||||||
|
.onAppear {
|
||||||
|
model.activeDetent = AddMediaView.ViewModel.Detent.expanded.value
|
||||||
|
searchFieldFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performSearch() {
|
||||||
|
guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||||
|
searchModel.performSearch(query: searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchResultRow: View
|
||||||
|
{
|
||||||
|
let item: SearchResultItem
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Thumbnail
|
||||||
|
AsyncImage(url: URL(string: item.thumbnailUrl)) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .empty:
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
.frame(width: 80, height: 60)
|
||||||
|
.overlay {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
}
|
||||||
|
case .success(let image):
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 80, height: 60)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
case .failure(_):
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
.frame(width: 80, height: 60)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
@unknown default:
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
.frame(width: 80, height: 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(item.title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Text(item.author)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(item.type.capitalized)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SearchMediaView
|
||||||
|
{
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class SearchModel
|
||||||
|
{
|
||||||
|
var displayedResults: [SearchResultItem] = []
|
||||||
|
var isLoading: Bool = false
|
||||||
|
var lastSearchedQuery: String? = nil
|
||||||
|
|
||||||
|
func performSearch(query: String) {
|
||||||
|
guard let api = Settings.fromDefaults().selectedServer?.api else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
lastSearchedQuery = query
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let fetchResult = try await api.search(query: query)
|
||||||
|
if let results = fetchResult.results {
|
||||||
|
await MainActor.run {
|
||||||
|
self.displayedResults = results
|
||||||
|
.map { item in
|
||||||
|
// Convert relative thumbnail urls to absolute for loading by AsyncImage
|
||||||
|
var copy = item
|
||||||
|
copy.thumbnailUrl = api.baseURL.absoluteString
|
||||||
|
.replacingOccurrences(of: "/api", with: "") + item.thumbnailUrl // xxx: ugh...
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.displayedResults = []
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
ios/QueueCube/Views/ContentPlaceholderView.swift
Normal file
64
ios/QueueCube/Views/ContentPlaceholderView.swift
Normal 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)
|
||||||
|
}
|
||||||
199
ios/QueueCube/Views/ContentView.swift
Normal file
199
ios/QueueCube/Views/ContentView.swift
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
//
|
||||||
|
// ContentView.swift
|
||||||
|
// QueueCube
|
||||||
|
//
|
||||||
|
// Created by James Magahern on 3/3/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View
|
||||||
|
{
|
||||||
|
@State var model = MainViewModel()
|
||||||
|
@State private var websocketRestartTrigger = 0
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MainView(model: $model)
|
||||||
|
.task(id: websocketRestartTrigger) { await watchWebsocket() }
|
||||||
|
.task { await refresh([.nowPlaying, .playlist, .favorites]) }
|
||||||
|
.task { await watchForSettingsChanges() }
|
||||||
|
.onChange(of: scenePhase) { oldPhase, newPhase in
|
||||||
|
handleScenePhaseChange(from: oldPhase, to: newPhase)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
|
||||||
|
NowPlayingView(model: model.nowPlayingViewModel)
|
||||||
|
.presentationBackground(.regularMaterial)
|
||||||
|
.presentationDetents([ .height(320.0) ])
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $model.isAddMediaSheetPresented) {
|
||||||
|
AddMediaView(model: $model.addMediaViewModel)
|
||||||
|
.presentationBackground(.regularMaterial)
|
||||||
|
.presentationDetents(
|
||||||
|
Set(AddMediaView.ViewModel.Detent.allCases.map { $0.value }),
|
||||||
|
selection: $model.addMediaViewModel.activeDetent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $model.isEditSheetPresented) {
|
||||||
|
EditItemView(model: $model.editMediaViewModel)
|
||||||
|
.presentationBackground(.regularMaterial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
struct RefreshType: OptionSet
|
||||||
|
{
|
||||||
|
let rawValue: Int
|
||||||
|
|
||||||
|
static let nowPlaying = RefreshType(rawValue: 1 << 0)
|
||||||
|
static let playlist = RefreshType(rawValue: 1 << 1)
|
||||||
|
static let favorites = RefreshType(rawValue: 1 << 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ContentView
|
||||||
|
{
|
||||||
|
private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) {
|
||||||
|
// When app returns to active state from background, force reconnect and refresh
|
||||||
|
if newPhase == .active {
|
||||||
|
Task {
|
||||||
|
// Force WebSocket reconnection
|
||||||
|
websocketRestartTrigger += 1
|
||||||
|
|
||||||
|
// Give the WebSocket a moment to reconnect
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
// Full UI refresh
|
||||||
|
await refresh([.nowPlaying, .playlist, .favorites])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh(_ what: RefreshType) async {
|
||||||
|
await model.withModificationsViaAPI { api in
|
||||||
|
if what.contains(.nowPlaying) {
|
||||||
|
let nowPlaying = try await api.fetchNowPlayingInfo()
|
||||||
|
model.nowPlayingViewModel.title = nowPlaying.playingItem?.title
|
||||||
|
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename
|
||||||
|
|
||||||
|
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
|
||||||
|
model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0
|
||||||
|
model.playlistModel.isPlaying = !nowPlaying.isPaused
|
||||||
|
model.favoritesModel.isPlaying = !nowPlaying.isPaused
|
||||||
|
}
|
||||||
|
|
||||||
|
if what.contains(.playlist) {
|
||||||
|
let playlist = try await api.fetchPlaylist()
|
||||||
|
model.playlistModel.items = playlist.enumerated().map { (idx, mediaItem) in
|
||||||
|
MediaListItem(
|
||||||
|
id: String(mediaItem.id),
|
||||||
|
title: mediaItem.displayTitle,
|
||||||
|
filename: mediaItem.filename ?? "<null>",
|
||||||
|
index: idx,
|
||||||
|
isCurrent: mediaItem.current ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if what.contains(.favorites) {
|
||||||
|
let favorites = try await api.fetchFavorites()
|
||||||
|
let nowPlaying = try await api.fetchNowPlayingInfo()
|
||||||
|
model.favoritesModel.items = favorites.map { mediaItem in
|
||||||
|
MediaListItem(
|
||||||
|
id: String(mediaItem.id),
|
||||||
|
title: mediaItem.displayTitle,
|
||||||
|
filename: mediaItem.filename ?? "<null>",
|
||||||
|
isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func watchWebsocket() async {
|
||||||
|
guard let api = model.selectedServer?.api else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
for await streamEvent in try await api.events() {
|
||||||
|
switch streamEvent {
|
||||||
|
case .event(let event):
|
||||||
|
await clearConnectionErrorIfNecessary()
|
||||||
|
await handle(event: event)
|
||||||
|
case .error(let error):
|
||||||
|
// Check if this is a backgrounding error (connection abort)
|
||||||
|
let nsError = error as NSError
|
||||||
|
let isBackgroundingError = nsError.code == 53
|
||||||
|
|
||||||
|
// Only show connection error to user if it's not a backgrounding error
|
||||||
|
if !isBackgroundingError {
|
||||||
|
model.connectionError = error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always attempt reconnection after a delay
|
||||||
|
Task { @MainActor in
|
||||||
|
try await Task.sleep(for: .seconds(1.0))
|
||||||
|
websocketRestartTrigger += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Events error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(event: API.Event) async {
|
||||||
|
switch event.type {
|
||||||
|
case .volumeUpdate: fallthrough
|
||||||
|
case .nowPlayingUpdate:
|
||||||
|
await refresh(.nowPlaying)
|
||||||
|
|
||||||
|
case .playlistUpdate:
|
||||||
|
await refresh(.playlist)
|
||||||
|
|
||||||
|
case .favoritesUpdate:
|
||||||
|
await refresh(.favorites)
|
||||||
|
|
||||||
|
case .websocketReconnected: fallthrough
|
||||||
|
case .metadataUpdate: fallthrough
|
||||||
|
case .mpdUpdate:
|
||||||
|
await refresh([.playlist, .nowPlaying, .favorites])
|
||||||
|
|
||||||
|
case .receivedWebsocketPong:
|
||||||
|
// This means we're online.
|
||||||
|
await clearConnectionErrorIfNecessary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearConnectionErrorIfNecessary() async {
|
||||||
|
if model.connectionError != nil {
|
||||||
|
model.connectionError = nil
|
||||||
|
await refresh([.playlist, .nowPlaying, .favorites])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func watchForSettingsChanges() async {
|
||||||
|
let settingsChangedNotifications = NotificationCenter.default.notifications(named: .settingsChanged)
|
||||||
|
.map({ _ in Optional.none })
|
||||||
|
|
||||||
|
for await _ in settingsChangedNotifications {
|
||||||
|
let newSelectedServer = Settings.fromDefaults().selectedServer
|
||||||
|
if newSelectedServer != model.selectedServer {
|
||||||
|
model.selectedServer = newSelectedServer
|
||||||
|
|
||||||
|
// Reset view model to defaults
|
||||||
|
await model.reset()
|
||||||
|
|
||||||
|
// Restart WebSocket connection for new server
|
||||||
|
websocketRestartTrigger += 1
|
||||||
|
|
||||||
|
await refresh([.playlist, .nowPlaying, .favorites])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always reset this
|
||||||
|
model.serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
ios/QueueCube/Views/EditItemView.swift
Normal file
60
ios/QueueCube/Views/EditItemView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
423
ios/QueueCube/Views/MainView.swift
Normal file
423
ios/QueueCube/Views/MainView.swift
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
//
|
||||||
|
// MainView.swift
|
||||||
|
// QueueCube
|
||||||
|
//
|
||||||
|
// Created by James Magahern on 6/10/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class MainViewModel
|
||||||
|
{
|
||||||
|
var selectedServer: Server? = Settings.fromDefaults().selectedServer
|
||||||
|
|
||||||
|
var connectionError: Error? = nil
|
||||||
|
var selectedTab: Tab = .playlist
|
||||||
|
|
||||||
|
var isNowPlayingSheetPresented: Bool = false
|
||||||
|
var isAddMediaSheetPresented: Bool = false
|
||||||
|
var isEditSheetPresented: Bool = false
|
||||||
|
|
||||||
|
var playlistModel = MediaListViewModel(mode: .playlist)
|
||||||
|
var favoritesModel = MediaListViewModel(mode: .favorites)
|
||||||
|
var nowPlayingViewModel = NowPlayingViewModel()
|
||||||
|
var addMediaViewModel = AddMediaView.ViewModel()
|
||||||
|
var serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel()
|
||||||
|
var editMediaViewModel = EditItemViewModel()
|
||||||
|
|
||||||
|
private var refreshingFromAPIDepth: UInt8 = 0
|
||||||
|
private var isRefreshingFromAPI: Bool { refreshingFromAPIDepth > 0 }
|
||||||
|
|
||||||
|
enum Tab: String, CaseIterable
|
||||||
|
{
|
||||||
|
case playlist
|
||||||
|
case favorites
|
||||||
|
case settings
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
observePlaylistChanges()
|
||||||
|
observeNowPlayingModel()
|
||||||
|
configureViewModelCallbacks()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onAddButtonTapped() {
|
||||||
|
isAddMediaSheetPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func onNowPlayingMiniTapped() {
|
||||||
|
isNowPlayingSheetPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() async {
|
||||||
|
await withModificationsViaAPI { _ in
|
||||||
|
playlistModel = MediaListViewModel(mode: .playlist)
|
||||||
|
favoritesModel = MediaListViewModel(mode: .favorites)
|
||||||
|
nowPlayingViewModel = NowPlayingViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
configureViewModelCallbacks()
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureViewModelCallbacks() {
|
||||||
|
// Now Playing
|
||||||
|
nowPlayingViewModel.onPlayPause = apiCallback { model, api in
|
||||||
|
model.isPlaying ? try await api.pause() : try await api.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
nowPlayingViewModel.onStop = apiCallback { model, api in
|
||||||
|
try await api.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
nowPlayingViewModel.onNext = apiCallback { _, api in
|
||||||
|
try await api.skip()
|
||||||
|
}
|
||||||
|
|
||||||
|
nowPlayingViewModel.onPrev = apiCallback { _, api in
|
||||||
|
try await api.previous()
|
||||||
|
}
|
||||||
|
|
||||||
|
nowPlayingViewModel.onSheetDismiss = { [weak self] _ in
|
||||||
|
self?.isNowPlayingSheetPresented = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist
|
||||||
|
playlistModel.onSeek = apiCallback { item, api in
|
||||||
|
if let index = item.index {
|
||||||
|
try await api.skip(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistModel.onFavorite = apiCallback { item, api in
|
||||||
|
try await api.addFavorite(mediaURL: item.filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites
|
||||||
|
favoritesModel.onPlay = apiCallback { item, api in
|
||||||
|
try await api.replace(mediaURL: item.filename)
|
||||||
|
try await api.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
favoritesModel.onEdit = { [weak self] item in
|
||||||
|
guard let self else { return }
|
||||||
|
editMediaViewModel.mediaURL = item.filename
|
||||||
|
editMediaViewModel.title = item.title
|
||||||
|
isEditSheetPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
favoritesModel.onQueue = apiCallback { item, api in
|
||||||
|
try await api.add(mediaURL: item.filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
editMediaViewModel.onCancel = { [weak self] _ in
|
||||||
|
self?.isEditSheetPresented = false
|
||||||
|
}
|
||||||
|
|
||||||
|
editMediaViewModel.onDone = apiCallback { [weak self] model, api in
|
||||||
|
self?.isEditSheetPresented = false
|
||||||
|
try await api.renameFavorite(mediaURL: model.mediaURL, title: model.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Media
|
||||||
|
addMediaViewModel.onAdd = apiCallback { [weak self] mediaURL, api in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
let strippedURL = mediaURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !strippedURL.isEmpty {
|
||||||
|
addMediaViewModel.fieldContents = ""
|
||||||
|
isAddMediaSheetPresented = false
|
||||||
|
|
||||||
|
switch selectedTab {
|
||||||
|
case .playlist:
|
||||||
|
try await api.add(mediaURL: strippedURL)
|
||||||
|
case .favorites:
|
||||||
|
try await api.addFavorite(mediaURL: strippedURL)
|
||||||
|
case .settings:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMediaViewModel.onCancel = { [weak self] in
|
||||||
|
self?.isAddMediaSheetPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeNowPlayingModel() {
|
||||||
|
withObservationTracking {
|
||||||
|
_ = nowPlayingViewModel.volume
|
||||||
|
} onChange: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
let isRefreshing = isRefreshingFromAPI
|
||||||
|
Task {
|
||||||
|
if !isRefreshing {
|
||||||
|
await self.withModificationsViaAPI { api in
|
||||||
|
try await api.setVolume(self.nowPlayingViewModel.volume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run { self.observeNowPlayingModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withModificationsViaAPI(_ modificationBlock: (API) async throws -> Void) async {
|
||||||
|
guard let api = selectedServer?.api else { return }
|
||||||
|
|
||||||
|
refreshingFromAPIDepth += 1
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await modificationBlock(api)
|
||||||
|
connectionError = nil
|
||||||
|
} catch {
|
||||||
|
print("Error refreshing content: \(error)")
|
||||||
|
connectionError = error
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshingFromAPIDepth -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apiCallback<T>(_ f: @escaping (T, API) async throws -> Void) -> (T) -> Void {
|
||||||
|
return { t in
|
||||||
|
Task {
|
||||||
|
await self.withModificationsViaAPI { try await f(t, $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observePlaylistChanges() {
|
||||||
|
withObservationTracking {
|
||||||
|
_ = playlistModel.items
|
||||||
|
_ = favoritesModel.items
|
||||||
|
} onChange: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
let isRefreshing = isRefreshingFromAPI
|
||||||
|
let oldPlaylist = playlistModel.items
|
||||||
|
let oldFavorites = favoritesModel.items
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
if !isRefreshing {
|
||||||
|
// Notify server of removals
|
||||||
|
let playlistDiff = playlistModel.items.difference(from: oldPlaylist) { $0.id == $1.id }
|
||||||
|
await withModificationsViaAPI { api in
|
||||||
|
for removal in playlistDiff.removals {
|
||||||
|
switch removal {
|
||||||
|
case .remove(let offset, _, _):
|
||||||
|
try await api.delete(index: offset)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let favoritesDiff = favoritesModel.items.difference(from: oldFavorites) { $0.id == $1.id }
|
||||||
|
await withModificationsViaAPI { api in
|
||||||
|
for removal in favoritesDiff.removals {
|
||||||
|
switch removal {
|
||||||
|
case .remove(_, let favorite, _):
|
||||||
|
try await api.deleteFavorite(mediaURL: favorite.filename)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
observePlaylistChanges()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainView: View
|
||||||
|
{
|
||||||
|
@Binding var model: MainViewModel
|
||||||
|
@State var isSettingsVisible: Bool = false
|
||||||
|
|
||||||
|
init(model: Binding<MainViewModel>) {
|
||||||
|
self._model = model
|
||||||
|
|
||||||
|
// If no servers are configured, make Settings the default tab.
|
||||||
|
if !Settings.fromDefaults().isConfigured {
|
||||||
|
model.wrappedValue.selectedTab = .settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $model.selectedTab) {
|
||||||
|
Tab(.playlist, systemImage: "list.bullet", value: .playlist) {
|
||||||
|
NavigationStack {
|
||||||
|
MediaListView(model: $model.playlistModel)
|
||||||
|
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
|
||||||
|
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() }
|
||||||
|
.displayingError(model.connectionError)
|
||||||
|
.withAddButton { model.onAddButtonTapped() }
|
||||||
|
.navigationTitle(.playlist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Tab(.favorites, systemImage: "heart.fill", value: .favorites) {
|
||||||
|
NavigationStack {
|
||||||
|
MediaListView(model: $model.favoritesModel)
|
||||||
|
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
|
||||||
|
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() }
|
||||||
|
.displayingError(model.connectionError)
|
||||||
|
.withAddButton { model.onAddButtonTapped() }
|
||||||
|
.navigationTitle(.favorites)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Tab(.settings, systemImage: "gear", value: .settings) {
|
||||||
|
SettingsView(onDone: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.sidebarAdaptable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NowPlayingMiniPlayerModifier: ViewModifier
|
||||||
|
{
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
@Binding var model: NowPlayingViewModel
|
||||||
|
@State var nowPlayingHeight: CGFloat = 0.0
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
ZStack {
|
||||||
|
content
|
||||||
|
.safeAreaPadding(.bottom, nowPlayingHeight)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
NowPlayingMiniView(model: $model, onTap: onTap)
|
||||||
|
.padding()
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: 800.0)
|
||||||
|
.onGeometryChange(for: CGSize.self) { $0.size }
|
||||||
|
action: { nowPlayingHeight = $0.height }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServerSelectionToolbarModifier: ViewModifier
|
||||||
|
{
|
||||||
|
@Binding var model: ViewModel
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .topBarLeading) {
|
||||||
|
Menu {
|
||||||
|
Section {
|
||||||
|
ForEach(model.selectableServers) { server in
|
||||||
|
Button {
|
||||||
|
model.selectedServer = server
|
||||||
|
} label: {
|
||||||
|
Text(server.displayName)
|
||||||
|
if model.selectedServer == server {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if false
|
||||||
|
// TODO
|
||||||
|
Section {
|
||||||
|
Button(.addServer) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
} label: {
|
||||||
|
Label(model.selectedServer?.displayName ?? "Servers", systemImage: "chevron.down")
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
|
}
|
||||||
|
.buttonBorderShape(.capsule)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.menuStyle(.button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class ViewModel
|
||||||
|
{
|
||||||
|
var selectableServers: [Server] = Settings.fromDefaults().configuredServers
|
||||||
|
var selectedServer: Server? = Settings.fromDefaults().selectedServer {
|
||||||
|
didSet {
|
||||||
|
Settings
|
||||||
|
.fromDefaults()
|
||||||
|
.selectedServer(selectedServer)
|
||||||
|
.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AddButtonToolbarModifier: ViewModifier
|
||||||
|
{
|
||||||
|
let onAdd: () -> Void
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
onAdd()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ErrorDisplayModifier: ViewModifier
|
||||||
|
{
|
||||||
|
let error: Error?
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.overlay {
|
||||||
|
if error != nil {
|
||||||
|
ZStack {
|
||||||
|
Rectangle()
|
||||||
|
.fill(.background)
|
||||||
|
|
||||||
|
contentPlaceholderView(
|
||||||
|
title: .connectionError,
|
||||||
|
subtitle: error?.localizedDescription,
|
||||||
|
systemImage: "exclamationmark.triangle.fill"
|
||||||
|
).tint(.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func displayingServerSelectionToolbar(model: Binding<ServerSelectionToolbarModifier.ViewModel>) -> some View {
|
||||||
|
modifier(ServerSelectionToolbarModifier(model: model))
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayingNowPlayingMiniPlayer(model: Binding<NowPlayingViewModel>, onTap: @escaping () -> Void) -> some View {
|
||||||
|
modifier(NowPlayingMiniPlayerModifier(onTap: onTap, model: model))
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAddButton(onAdd: @escaping () -> Void) -> some View {
|
||||||
|
modifier(AddButtonToolbarModifier(onAdd: onAdd))
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayingError(_ error: Error?) -> some View {
|
||||||
|
modifier(ErrorDisplayModifier(error: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
69
ios/QueueCube/Views/NowPlayingMiniView.swift
Normal file
69
ios/QueueCube/Views/NowPlayingMiniView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
173
ios/QueueCube/Views/NowPlayingView.swift
Normal file
173
ios/QueueCube/Views/NowPlayingView.swift
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
//
|
||||||
|
// NowPlayingView.swift
|
||||||
|
// QueueCube
|
||||||
|
//
|
||||||
|
// Created by James Magahern on 3/3/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class NowPlayingViewModel
|
||||||
|
{
|
||||||
|
var onPlayPause: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
|
var onStop: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
|
var onNext: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
|
var onPrev: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
|
var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
|
|
||||||
|
var isPlaying: Bool = false
|
||||||
|
var title: String? = ""
|
||||||
|
var subtitle: String? = ""
|
||||||
|
var volume: Double = 0.5
|
||||||
|
|
||||||
|
fileprivate var isSettingVolume: Bool = false
|
||||||
|
fileprivate var settingVolume: Double = 0.0 {
|
||||||
|
didSet { volume = settingVolume }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NowPlayingView: View
|
||||||
|
{
|
||||||
|
@State var model: NowPlayingViewModel
|
||||||
|
private var nothingQueued: Bool { model.title == nil && model.subtitle == nil }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 1.0)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
if let title = model.title {
|
||||||
|
Text(title)
|
||||||
|
.font(.title2)
|
||||||
|
.lineLimit(1)
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let subtitle = model.subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if nothingQueued {
|
||||||
|
Text(.notPlaying)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 24.0)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
ForEach(Buttons.allCases) { button in
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: button.action(model: model)) {
|
||||||
|
Image(systemName: button.imageName(isPlaying: model.isPlaying))
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(1.0, contentMode: .fit)
|
||||||
|
.scaleEffect(button.scale, anchor: .center)
|
||||||
|
.tint(button.tintColor)
|
||||||
|
}
|
||||||
|
.disabled(nothingQueued)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.imageScale(.large)
|
||||||
|
.frame(height: 34.0)
|
||||||
|
.tint(.label)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: model.isSettingVolume ? $model.settingVolume : $model.volume,
|
||||||
|
in: 0.0...1.0,
|
||||||
|
onEditingChanged: { editing in
|
||||||
|
if model.isSettingVolume != editing {
|
||||||
|
model.settingVolume = model.volume
|
||||||
|
model.isSettingVolume = editing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 18.0)
|
||||||
|
.padding(.bottom, -12.0) // intrinsic sizing bug workaround?
|
||||||
|
}
|
||||||
|
.padding(.vertical, 44.0)
|
||||||
|
.padding(.horizontal, 12.0)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14.0)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.stroke(Color.label.opacity(0.08))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
.padding(.horizontal, 15.0)
|
||||||
|
.padding(.bottom, 10.0)
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
model.onSheetDismiss(model)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.tint(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
private enum Buttons: Int, CaseIterable, Identifiable {
|
||||||
|
case backward
|
||||||
|
case stop
|
||||||
|
case playPause
|
||||||
|
case forward
|
||||||
|
|
||||||
|
var id: Int { rawValue }
|
||||||
|
|
||||||
|
var scale: Double {
|
||||||
|
switch self {
|
||||||
|
case .backward: 0.7
|
||||||
|
case .forward: 0.7
|
||||||
|
case .playPause: 1.0
|
||||||
|
case .stop: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tintColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .backward: .label.mix(with: .gray, by: 0.5)
|
||||||
|
case .forward: .label.mix(with: .gray, by: 0.5)
|
||||||
|
case .playPause: .label
|
||||||
|
case .stop: .label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageName(isPlaying: Bool) -> String {
|
||||||
|
switch self {
|
||||||
|
case .backward: "backward.fill"
|
||||||
|
case .stop: "stop.fill"
|
||||||
|
case .playPause: isPlaying ? "pause.fill" : "play.fill"
|
||||||
|
case .forward: "forward.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func action(model: NowPlayingViewModel) -> () -> Void {
|
||||||
|
switch self {
|
||||||
|
case .backward: { model.onPrev(model) }
|
||||||
|
case .stop: { model.onStop(model) }
|
||||||
|
case .playPause: { model.onPlayPause(model) }
|
||||||
|
case .forward: { model.onNext(model) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
ios/QueueCube/Views/PlaylistView.swift
Normal file
174
ios/QueueCube/Views/PlaylistView.swift
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
//
|
||||||
|
// PlaylistView.swift
|
||||||
|
// QueueCube
|
||||||
|
//
|
||||||
|
// Created by James Magahern on 3/3/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MediaListItem: Identifiable
|
||||||
|
{
|
||||||
|
let _id: String
|
||||||
|
let title: String
|
||||||
|
let filename: String
|
||||||
|
let index: Int?
|
||||||
|
let isCurrent: Bool
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
_id + filename // temporary: we get duplicate ids from the server sometimes...
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: String, title: String, filename: String, index: Int? = nil, isCurrent: Bool = false) {
|
||||||
|
self._id = id
|
||||||
|
self.title = title
|
||||||
|
self.filename = filename
|
||||||
|
self.index = index
|
||||||
|
self.isCurrent = isCurrent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MediaListMode {
|
||||||
|
case playlist
|
||||||
|
case favorites
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class MediaListViewModel
|
||||||
|
{
|
||||||
|
let mode: MediaListMode
|
||||||
|
var isPlaying: Bool = false
|
||||||
|
var items: [MediaListItem] = []
|
||||||
|
|
||||||
|
var onSeek: (MediaListItem) -> Void = { _ in }
|
||||||
|
var onPlay: (MediaListItem) -> Void = { _ in }
|
||||||
|
var onQueue: (MediaListItem) -> Void = { _ in }
|
||||||
|
var onEdit: (MediaListItem) -> Void = { _ in }
|
||||||
|
var onFavorite: (MediaListItem) -> Void = { _ in }
|
||||||
|
|
||||||
|
init(mode: MediaListMode) {
|
||||||
|
self.mode = mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MediaListView: View
|
||||||
|
{
|
||||||
|
@Binding var model: MediaListViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
if model.items.isEmpty {
|
||||||
|
let title: LocalizedStringKey = switch model.mode {
|
||||||
|
case .playlist: .playlistEmpty
|
||||||
|
case .favorites: .favoritesEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
contentPlaceholderView(title: title, systemImage: "list.bullet")
|
||||||
|
} else {
|
||||||
|
List($model.items, editActions: .delete) { item in
|
||||||
|
let item = item.wrappedValue
|
||||||
|
let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued
|
||||||
|
|
||||||
|
Button {
|
||||||
|
switch model.mode {
|
||||||
|
case .playlist:
|
||||||
|
model.onSeek(item)
|
||||||
|
case .favorites:
|
||||||
|
model.onPlay(item)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
MediaItemCell(
|
||||||
|
title: item.title,
|
||||||
|
subtitle: item.filename,
|
||||||
|
state: state
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.listRowBackground((model.mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.10) : nil)
|
||||||
|
.contextMenu {
|
||||||
|
Button(.copyTitle) {
|
||||||
|
UIPasteboard.general.string = item.title
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(.copyURL) {
|
||||||
|
if let url = URL(string: item.filename) {
|
||||||
|
UIPasteboard.general.url = url
|
||||||
|
} else {
|
||||||
|
UIPasteboard.general.string = item.filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.mode == .favorites {
|
||||||
|
Button(.edit) {
|
||||||
|
model.onEdit(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .leading) {
|
||||||
|
if model.mode == .favorites {
|
||||||
|
Button {
|
||||||
|
model.onQueue(item)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.square.on.square")
|
||||||
|
Text(.addToQueue)
|
||||||
|
}
|
||||||
|
.tint(.blue)
|
||||||
|
} else if model.mode == .playlist {
|
||||||
|
Button {
|
||||||
|
model.onFavorite(item)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "star")
|
||||||
|
Text(.favorite)
|
||||||
|
}
|
||||||
|
.tint(.yellow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct MediaItemCell: View
|
||||||
|
{
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let state: State
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let icon: String = switch state {
|
||||||
|
case .queued: "play.fill"
|
||||||
|
case .playing: "speaker.wave.3.fill"
|
||||||
|
case .paused: "speaker.fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.tint(Color.primary)
|
||||||
|
.frame(width: 15.0)
|
||||||
|
.padding(.trailing, 10.0)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(title)
|
||||||
|
.tint(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding([.top, .bottom], 4.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
case queued
|
||||||
|
case playing
|
||||||
|
case paused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
280
ios/QueueCube/Views/Settings View/AddServerView.swift
Normal file
280
ios/QueueCube/Views/Settings View/AddServerView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
16
ios/QueueCube/Views/Settings View/GeneralSettingsView.swift
Normal file
16
ios/QueueCube/Views/Settings View/GeneralSettingsView.swift
Normal 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
120
ios/QueueCube/Views/Settings View/ServerListSettingsView.swift
Normal file
120
ios/QueueCube/Views/Settings View/ServerListSettingsView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
ios/QueueCube/Views/Settings View/SettingsView.swift
Normal file
61
ios/QueueCube/Views/Settings View/SettingsView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
0
.gitignore → web/.gitignore
vendored
0
.gitignore → web/.gitignore
vendored
@@ -18,18 +18,25 @@ RUN npm run build --workspaces
|
|||||||
FROM --platform=$TARGETPLATFORM debian:testing-20250203
|
FROM --platform=$TARGETPLATFORM debian:testing-20250203
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install only production dependencies
|
# Install only production dependencies
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
|
COPY package-lock.json ./
|
||||||
|
|
||||||
|
RUN rm -rf node_modules/ # need to do a clean build
|
||||||
RUN npm ci --production
|
RUN npm ci --production
|
||||||
|
|
||||||
# Copy built files
|
# Copy built files
|
||||||
COPY --from=builder /app/backend/build ./build
|
COPY --from=builder /app/backend/build ./build
|
||||||
COPY --from=builder /app/frontend/dist ./dist/frontend
|
COPY --from=builder /app/frontend/dist ./dist/frontend
|
||||||
|
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY entrypoint.sh ./
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "build/server.js"]
|
CMD ["./entrypoint.sh"]
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node-fetch": "^2.6.12",
|
"@types/node-fetch": "^2.6.12",
|
||||||
|
"bonjour-service": "^1.3.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-ws": "^5.0.2",
|
"express-ws": "^5.0.2",
|
||||||
@@ -48,7 +48,7 @@ export interface ThumbnailResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const USE_INVIDIOUS = process.env.USE_INVIDIOUS || true;
|
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`;
|
const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
|
||||||
|
|
||||||
export const getInvidiousSearchURL = (query: string): string =>
|
export const getInvidiousSearchURL = (query: string): string =>
|
||||||
@@ -22,6 +22,8 @@ import { WebSocket } from "ws";
|
|||||||
import { getLinkPreview } from "link-preview-js";
|
import { getLinkPreview } from "link-preview-js";
|
||||||
import { PlaylistItem, LinkMetadata } from './types';
|
import { PlaylistItem, LinkMetadata } from './types';
|
||||||
import { FavoritesStore } from "./FavoritesStore";
|
import { FavoritesStore } from "./FavoritesStore";
|
||||||
|
import { Bonjour } from "bonjour-service";
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
interface PendingCommand {
|
interface PendingCommand {
|
||||||
resolve: (value: any) => void;
|
resolve: (value: any) => void;
|
||||||
@@ -37,9 +39,16 @@ enum UserEvent {
|
|||||||
MPDUpdate = "mpd_update",
|
MPDUpdate = "mpd_update",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Features {
|
||||||
|
video: boolean;
|
||||||
|
screenshare: boolean;
|
||||||
|
browserPlayback: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class MediaPlayer {
|
export class MediaPlayer {
|
||||||
private playerProcess: ChildProcess;
|
private playerProcess: ChildProcess | null = null;
|
||||||
private socket: Socket;
|
private socket: Promise<Socket>;
|
||||||
|
|
||||||
private eventSubscribers: WebSocket[] = [];
|
private eventSubscribers: WebSocket[] = [];
|
||||||
private favoritesStore: FavoritesStore;
|
private favoritesStore: FavoritesStore;
|
||||||
|
|
||||||
@@ -47,11 +56,61 @@ export class MediaPlayer {
|
|||||||
private requestId: number = 1;
|
private requestId: number = 1;
|
||||||
private dataBuffer: string = '';
|
private dataBuffer: string = '';
|
||||||
private metadata: Map<string, LinkMetadata> = new Map();
|
private metadata: Map<string, LinkMetadata> = new Map();
|
||||||
|
private bonjourInstance: Bonjour | null = null;
|
||||||
|
|
||||||
constructor() {
|
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 socketFilename = Math.random().toString(36).substring(2, 10);
|
||||||
const socketPath = `/tmp/mpv-${socketFilename}`;
|
const socketPath = `/tmp/mpv-${socketFilename}`;
|
||||||
const enableVideo = process.env.ENABLE_VIDEO || false;
|
const enableVideo = process.env.ENABLE_VIDEO || false;
|
||||||
|
const logfilePath = `/tmp/mpv-logfile.txt`;
|
||||||
|
|
||||||
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
||||||
this.playerProcess = spawn("mpv", [
|
this.playerProcess = spawn("mpv", [
|
||||||
@@ -59,22 +118,31 @@ export class MediaPlayer {
|
|||||||
"--fullscreen",
|
"--fullscreen",
|
||||||
"--no-terminal",
|
"--no-terminal",
|
||||||
"--idle=yes",
|
"--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", () => {
|
this.playerProcess.on("spawn", () => {
|
||||||
console.log(`Player process spawned, opening socket @ ${socketPath}`);
|
console.log(`Player process spawned, opening socket @ ${socketPath}`);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.connectToSocket(socketPath);
|
let socket = this.connectToSocket(socketPath);
|
||||||
|
socketReady(socket);
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.favoritesStore = new FavoritesStore();
|
this.playerProcess.on("error", (error) => {
|
||||||
this.favoritesStore.onFavoritesChanged = (favorites) => {
|
console.error("Player process error:", error);
|
||||||
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
|
console.log("Continuing without mpv player...");
|
||||||
};
|
});
|
||||||
|
|
||||||
|
return socketPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPlaylist(): Promise<PlaylistItem[]> {
|
public async getPlaylist(): Promise<PlaylistItem[]> {
|
||||||
@@ -92,8 +160,12 @@ export class MediaPlayer {
|
|||||||
public async getNowPlaying(): Promise<PlaylistItem> {
|
public async getNowPlaying(): Promise<PlaylistItem> {
|
||||||
const playlist = await this.getPlaylist();
|
const playlist = await this.getPlaylist();
|
||||||
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
||||||
const fetchMediaTitle = async (): Promise<string> => {
|
const fetchMediaTitle = async (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
return (await this.writeCommand("get_property", ["media-title"])).data;
|
return (await this.writeCommand("get_property", ["media-title"])).data;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (currentlyPlayingSong !== undefined) {
|
if (currentlyPlayingSong !== undefined) {
|
||||||
@@ -101,14 +173,14 @@ export class MediaPlayer {
|
|||||||
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
|
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
|
||||||
return {
|
return {
|
||||||
...currentlyPlayingSong,
|
...currentlyPlayingSong,
|
||||||
title: await fetchMediaTitle()
|
title: await fetchMediaTitle() || currentlyPlayingSong.filename
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentlyPlayingSong;
|
return currentlyPlayingSong;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaTitle = await fetchMediaTitle();
|
const mediaTitle = await fetchMediaTitle() || "";
|
||||||
return {
|
return {
|
||||||
id: 0,
|
id: 0,
|
||||||
filename: mediaTitle,
|
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"])
|
return this.writeCommand("get_property", ["stream-open-filename"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
});
|
}, (reject) => { return null; });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPauseState(): Promise<boolean> {
|
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> {
|
public async getIdle(): Promise<boolean> {
|
||||||
return this.writeCommand("get_property", ["idle"])
|
return this.writeCommand("get_property", ["idle"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -218,6 +311,10 @@ export class MediaPlayer {
|
|||||||
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
|
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) {
|
public subscribe(ws: WebSocket) {
|
||||||
this.eventSubscribers.push(ws);
|
this.eventSubscribers.push(ws);
|
||||||
}
|
}
|
||||||
@@ -246,8 +343,16 @@ export class MediaPlayer {
|
|||||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
|
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[] = []) {
|
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) {
|
if (fetchMetadata) {
|
||||||
this.fetchMetadataAndNotify(url).catch(error => {
|
this.fetchMetadataAndNotify(url).catch(error => {
|
||||||
@@ -262,10 +367,16 @@ export class MediaPlayer {
|
|||||||
// Notify all subscribers
|
// Notify all subscribers
|
||||||
this.handleEvent(event, {});
|
this.handleEvent(event, {});
|
||||||
return result;
|
return result;
|
||||||
|
}, (reject) => {
|
||||||
|
console.log("Error modifying playlist: " + reject);
|
||||||
|
return reject;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async writeCommand(command: string, args: any[]): Promise<any> {
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const id = this.requestId++;
|
const id = this.requestId++;
|
||||||
|
|
||||||
@@ -274,8 +385,13 @@ export class MediaPlayer {
|
|||||||
request_id: id
|
request_id: id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
this.pendingCommands.set(id, { resolve, reject });
|
this.pendingCommands.set(id, { resolve, reject });
|
||||||
this.socket.write(commandObject + '\n');
|
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
|
// Add timeout to prevent hanging promises
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -313,9 +429,12 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private connectToSocket(path: string) {
|
private connectToSocket(path: string): Socket {
|
||||||
this.socket.connect(path);
|
let socket = new Socket();
|
||||||
this.socket.on("data", data => this.receiveData(data.toString()));
|
socket.connect(path);
|
||||||
|
socket.on("data", data => this.receiveData(data.toString()));
|
||||||
|
|
||||||
|
return socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleEvent(event: string, data: any) {
|
private handleEvent(event: string, data: any) {
|
||||||
@@ -342,7 +461,12 @@ export class MediaPlayer {
|
|||||||
if (response.request_id) {
|
if (response.request_id) {
|
||||||
const pending = this.pendingCommands.get(response.request_id);
|
const pending = this.pendingCommands.get(response.request_id);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
|
if (response.error == "success") {
|
||||||
pending.resolve(response);
|
pending.resolve(response);
|
||||||
|
} else {
|
||||||
|
pending.reject(response.error);
|
||||||
|
}
|
||||||
|
|
||||||
this.pendingCommands.delete(response.request_id);
|
this.pendingCommands.delete(response.request_id);
|
||||||
}
|
}
|
||||||
} else if (response.event) {
|
} else if (response.event) {
|
||||||
@@ -43,6 +43,7 @@ const withErrorHandling = (func: (req: any, res: any) => Promise<any>) => {
|
|||||||
try {
|
try {
|
||||||
await func(req, res);
|
await func(req, res);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
console.log(`Error (${func.name}): ${error}`);
|
||||||
res.status(500).send(JSON.stringify({ success: false, error: error.message }));
|
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 pauseState = await mediaPlayer.getPauseState();
|
||||||
const volume = await mediaPlayer.getVolume();
|
const volume = await mediaPlayer.getVolume();
|
||||||
const idle = await mediaPlayer.getIdle();
|
const idle = await mediaPlayer.getIdle();
|
||||||
|
const timePosition = await mediaPlayer.getTimePosition();
|
||||||
|
const duration = await mediaPlayer.getDuration();
|
||||||
|
const seekable = await mediaPlayer.getSeekable();
|
||||||
|
|
||||||
res.send(JSON.stringify({
|
res.send(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -115,7 +119,10 @@ apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
|
|||||||
isPaused: pauseState,
|
isPaused: pauseState,
|
||||||
volume: volume,
|
volume: volume,
|
||||||
isIdle: idle,
|
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 }));
|
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) => {
|
apiRouter.ws("/events", (ws, req) => {
|
||||||
console.log("Events client connected");
|
console.log("Events client connected");
|
||||||
mediaPlayer.subscribe(ws);
|
mediaPlayer.subscribe(ws);
|
||||||
@@ -281,6 +294,11 @@ apiRouter.put("/favorites/:filename/title", withErrorHandling(async (req, res) =
|
|||||||
res.send(JSON.stringify({ success: true }));
|
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)
|
// Serve static files for React app (after building)
|
||||||
app.use(express.static(path.join(__dirname, "../dist/frontend")));
|
app.use(express.static(path.join(__dirname, "../dist/frontend")));
|
||||||
|
|
||||||
@@ -295,12 +313,18 @@ app.get("*", (req, res) => {
|
|||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
const server = app.listen(port, () => {
|
const server = app.listen(port, () => {
|
||||||
console.log(`Server is running on port ${port}`);
|
console.log(`Server is running on port ${port}`);
|
||||||
|
|
||||||
|
// Start zeroconf service advertisement
|
||||||
|
mediaPlayer.startZeroconfService(Number(port));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add graceful shutdown handling
|
// Add graceful shutdown handling
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
console.log('Received shutdown signal. Closing server...');
|
console.log('Received shutdown signal. Closing server...');
|
||||||
|
|
||||||
|
// Stop zeroconf service
|
||||||
|
mediaPlayer.stopZeroconfService();
|
||||||
|
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log('Server closed');
|
console.log('Server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
46
web/entrypoint.sh
Executable file
46
web/entrypoint.sh
Executable 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
|
||||||
0
flake.lock → web/flake.lock
generated
0
flake.lock → web/flake.lock
generated
@@ -86,7 +86,7 @@
|
|||||||
PORT = toString cfg.port;
|
PORT = toString cfg.port;
|
||||||
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
|
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
|
||||||
USE_INVIDIOUS = if cfg.invidious.enable 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;
|
STORE_PATH = cfg.store_path;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
'';
|
'';
|
||||||
|
|
||||||
# Let buildNpmPackage handle npm package hash
|
# Let buildNpmPackage handle npm package hash
|
||||||
npmDepsHash = "sha256-BqjJ4CxTPc14Od88sAm/ASwsLszkvcHHeNoZupotlFw=";
|
npmDepsHash = "sha256-kwbWqNqji0EcBeRuc/sqQUuGQkE+P8puLTfpAyRRzgY=";
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
meta = with pkgs.lib; {
|
||||||
description = "NodeJS application with media playback capabilities";
|
description = "NodeJS application with media playback capabilities";
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.18",
|
"eslint-plugin-react-refresh": "^0.4.18",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.22.0",
|
"typescript-eslint": "^8.22.0",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0"
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
@@ -5,6 +5,15 @@ export interface NowPlayingResponse {
|
|||||||
volume: number;
|
volume: number;
|
||||||
isIdle: boolean;
|
isIdle: boolean;
|
||||||
currentFile: string;
|
currentFile: string;
|
||||||
|
timePosition?: number;
|
||||||
|
duration?: number;
|
||||||
|
seekable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Features {
|
||||||
|
video: boolean;
|
||||||
|
screenshare: boolean;
|
||||||
|
browserPlayback: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Metadata {
|
export interface Metadata {
|
||||||
@@ -57,6 +66,11 @@ export enum ServerEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const API = {
|
export const API = {
|
||||||
|
async getFeatures(): Promise<Features> {
|
||||||
|
const response = await fetch('/api/features');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
async getPlaylist(): Promise<PlaylistItem[]> {
|
async getPlaylist(): Promise<PlaylistItem[]> {
|
||||||
const response = await fetch('/api/playlist');
|
const response = await fetch('/api/playlist');
|
||||||
return response.json();
|
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> {
|
async search(query: string): Promise<SearchResponse> {
|
||||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -4,11 +4,12 @@ import NowPlaying from './NowPlaying';
|
|||||||
import AddSongPanel from './AddSongPanel';
|
import AddSongPanel from './AddSongPanel';
|
||||||
import RenameFavoriteModal from './RenameFavoriteModal';
|
import RenameFavoriteModal from './RenameFavoriteModal';
|
||||||
import { TabView, Tab } from './TabView';
|
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 { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
|
||||||
import useWebSocket from 'react-use-websocket';
|
import useWebSocket from 'react-use-websocket';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useScreenShare } from '../hooks/useScreenShare';
|
import { useScreenShare } from '../hooks/useScreenShare';
|
||||||
|
import AudioPlayer from './AudioPlayer';
|
||||||
|
|
||||||
enum Tabs {
|
enum Tabs {
|
||||||
Playlist = "playlist",
|
Playlist = "playlist",
|
||||||
@@ -86,6 +87,9 @@ const App: React.FC = () => {
|
|||||||
const [isIdle, setIsIdle] = useState(false);
|
const [isIdle, setIsIdle] = useState(false);
|
||||||
const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null);
|
const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null);
|
||||||
const [nowPlayingFileName, setNowPlayingFileName] = 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 [volume, setVolume] = useState(100);
|
||||||
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
|
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
|
||||||
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
|
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
|
||||||
@@ -93,6 +97,8 @@ const App: React.FC = () => {
|
|||||||
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
|
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
|
||||||
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||||
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
|
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
|
||||||
|
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||||
|
const [features, setFeatures] = useState<Features | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
@@ -124,6 +130,12 @@ const App: React.FC = () => {
|
|||||||
setIsPlaying(!nowPlaying.isPaused);
|
setIsPlaying(!nowPlaying.isPaused);
|
||||||
setVolume(nowPlaying.volume);
|
setVolume(nowPlaying.volume);
|
||||||
setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true);
|
setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true);
|
||||||
|
setTimePosition(nowPlaying.timePosition);
|
||||||
|
setDuration(nowPlaying.duration);
|
||||||
|
setSeekable(nowPlaying.seekable);
|
||||||
|
|
||||||
|
const features = await API.getFeatures();
|
||||||
|
setFeatures(features);
|
||||||
}, [volumeSettingIsLocked]);
|
}, [volumeSettingIsLocked]);
|
||||||
|
|
||||||
const handleAddURL = async (url: string) => {
|
const handleAddURL = async (url: string) => {
|
||||||
@@ -170,6 +182,11 @@ const App: React.FC = () => {
|
|||||||
fetchNowPlaying();
|
fetchNowPlaying();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSeek = async (time: number) => {
|
||||||
|
await API.seek(time);
|
||||||
|
fetchNowPlaying();
|
||||||
|
};
|
||||||
|
|
||||||
const handleVolumeSettingChange = async (volume: number) => {
|
const handleVolumeSettingChange = async (volume: number) => {
|
||||||
setVolume(volume);
|
setVolume(volume);
|
||||||
await API.setVolume(volume);
|
await API.setVolume(volume);
|
||||||
@@ -198,7 +215,6 @@ const App: React.FC = () => {
|
|||||||
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
||||||
|
|
||||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`;
|
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`;
|
||||||
console.log('Connecting to WebSocket at', wsUrl);
|
|
||||||
useWebSocket(wsUrl, {
|
useWebSocket(wsUrl, {
|
||||||
onOpen: () => {
|
onOpen: () => {
|
||||||
console.log('WebSocket connected');
|
console.log('WebSocket connected');
|
||||||
@@ -241,6 +257,15 @@ const App: React.FC = () => {
|
|||||||
fetchFavorites();
|
fetchFavorites();
|
||||||
}, [fetchPlaylist, fetchNowPlaying, 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) => (
|
const AuxButton: React.FC<{ children: ReactNode, className: string, title: string, onClick: () => void }> = (props) => (
|
||||||
<button
|
<button
|
||||||
className={
|
className={
|
||||||
@@ -313,16 +338,24 @@ const App: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
|
<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">
|
<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
|
<NowPlaying
|
||||||
className="flex flex-row md:rounded-t-2xl"
|
className="flex flex-row md:rounded-t-2xl"
|
||||||
songName={nowPlayingSong || "(Not Playing)"}
|
songName={nowPlayingSong || "(Not Playing)"}
|
||||||
fileName={nowPlayingFileName || ""}
|
fileName={nowPlayingFileName || ""}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
isIdle={isIdle}
|
isIdle={isIdle}
|
||||||
|
timePosition={timePosition}
|
||||||
|
duration={duration}
|
||||||
|
seekable={seekable}
|
||||||
onPlayPause={togglePlayPause}
|
onPlayPause={togglePlayPause}
|
||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
onSkip={handleSkip}
|
onSkip={handleSkip}
|
||||||
onPrevious={handlePrevious}
|
onPrevious={handlePrevious}
|
||||||
|
onSeek={handleSeek}
|
||||||
onScreenShare={toggleScreenShare}
|
onScreenShare={toggleScreenShare}
|
||||||
isScreenSharing={isScreenSharing}
|
isScreenSharing={isScreenSharing}
|
||||||
volume={volume}
|
volume={volume}
|
||||||
@@ -330,6 +363,9 @@ const App: React.FC = () => {
|
|||||||
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
|
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
|
||||||
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
|
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
|
||||||
isScreenSharingSupported={isScreenSharingSupported}
|
isScreenSharingSupported={isScreenSharingSupported}
|
||||||
|
features={features}
|
||||||
|
audioEnabled={audioEnabled}
|
||||||
|
onAudioEnabledChange={setAudioEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
|
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
|
||||||
32
web/frontend/src/components/AudioPlayer.tsx
Normal file
32
web/frontend/src/components/AudioPlayer.tsx
Normal 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;
|
||||||
176
web/frontend/src/components/NowPlaying.tsx
Normal file
176
web/frontend/src/components/NowPlaying.tsx
Normal 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;
|
||||||
1678
package-lock.json → web/package-lock.json
generated
1678
package-lock.json → web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 196 KiB |
Reference in New Issue
Block a user